Skip to content

Use case — Rich authorization requests (RFC 9396) ​

Scopes are coarse: scope=payments says this client may move money, not move €40 from IBAN X to IBAN Y once. RFC 9396 (Rich Authorization Requests, "RAR") adds an authorization_details parameter — a JSON array of typed objects — so the client can describe exactly what it is asking for, the OP can bind that description to the grant, and every token minted from the grant carries it.

The library accepts authorization_details at /auth, /par, and /token, validates each element against a type you register, persists the granted details on the grant, and echoes them on JWT access tokens (RFC 9068 §2.2.3) and introspection (RFC 9396 §9). When mounted at /oidc, the authorization endpoint is /oidc/auth.

Use RAR when the resource server must decide from structured facts such as amount, account, location, action, or datatype. Skip it as a way to mint more scope names: if read:orders or payments:initiate is precise enough, scopes are simpler and better supported by existing clients.

Specs referenced on this page
  • RFC 9396 — OAuth 2.0 Rich Authorization Requests (§2 structure, §5 errors, §9 introspection, §10 discovery)
  • RFC 9068 — JWT access tokens (§2.2.3 carries authorization_details)
  • OpenID Connect Core 1.0 — §5.5 (the claims request, the coarser sibling)
Quick refresher
  • authorization_details — a JSON array; each element is an object with a type string plus type-specific members (actions, locations, datatypes, or whatever the type defines).
  • type — the RFC 9396 §2 identifier (e.g. payment_initiation). The OP only accepts types you have registered; an unrecognised type is rejected.
  • Validator — the OP checks the structure; the meaning of every member beyond type is yours to enforce in a per-type validator.

What the library checks, and what you check ​

The split is deliberate. The library owns the RFC 9396 §2.1 structure: the value is a JSON array of objects, each carries a non-empty string type the OP recognises, and the whole request is within conservative size limits. Everything type-specific — that a payment_initiation element carries a non-empty actions array, that an amount is positive, that a location is one this client may touch — is delegated to the Validate function you register with the type.

A type registered without a validator would accept arbitrary payloads under that type, so op.New refuses a nil Validate.

The OP checks the envelope; your code checks the contents
For one authorization_details element the OP checks the array shape and the type member; a validator you register checks every other member. The granted array is then echoed on the token response, inside a JWT access token, and from introspection.one array element"type": "payment_initiation","actions": ["initiate"],"amount": "40.00"The OP checksthe array shape, and that type is registeredYour Validate checksevery other member — it is your vocabularyonce granted, the array is echoed in three placesthe token responseas authorization_detailsa JWT access tokenRFC 9068 §2.2.3introspectionRFC 9396 §9
The split is the point of the feature. The OP has no opinion about what an amount or an action means, so it verifies the envelope and hands the contents to code that does.

Wiring ​

Register each type you accept with op.WithAuthorizationDetailTypes. This enables RAR: authorization_details becomes acceptable at the three endpoints, granted details are persisted and echoed, and discovery advertises authorization_details_types_supported. There is no separate feature flag; without registered types, the parameter is ignored as an unknown extension.

go
import (
  "context"
  "errors"

  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/grant"
  "github.com/libraz/go-oidc-provider/op/store"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

st := inmem.New()
loginFlow := op.LoginFlow{Primary: op.PrimaryPassword{Store: st.UserPasswords()}}

op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(st),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),
  op.WithLoginFlow(loginFlow),
  op.WithGrants(grant.AuthorizationCode, grant.RefreshToken),
  op.WithAuthorizationDetailTypes(op.AuthorizationDetailType{
    Type: "payment_initiation",
    Validate: func(_ context.Context, el map[string]any, _ *store.Client) error {
      if _, ok := el["actions"].([]any); !ok {
        return errors.New("payment_initiation requires an actions array")
      }
      return nil
    },
  }),
)

Validate receives the decoded element (el["type"] already equals the registered Type) and the authenticated *store.Client the request belongs to, so a validator can refuse a location the client is not entitled to. It MUST NOT mutate el. A non-nil return rejects the whole request with invalid_authorization_details.

Each Type must be non-empty and unique; a duplicate is rejected at construction. Repeated calls append, so a base set can be layered with a deployment-specific overlay.

The discovery document then advertises:

json
{
  "authorization_details_types_supported": ["payment_initiation"]
}

Driving it ​

The client sends authorization_details as a URL-encoded JSON array:

sh
DETAILS='[{"type":"payment_initiation","actions":["initiate"]}]'
curl -G --data-urlencode "authorization_details=$DETAILS" \
  --data-urlencode 'response_type=code' \
  --data-urlencode 'client_id=demo' \
  --data-urlencode 'redirect_uri=https://rp.example.com/callback' \
  --data-urlencode 'scope=openid' \
  --data-urlencode 'code_challenge_method=S256' \
  --data-urlencode "code_challenge=$CHALLENGE" \
  https://op.example.com/oidc/auth

The same parameter is accepted on a PAR push. Once the user has consented, the token response echoes what was granted:

json
{
  "access_token": "...",
  "token_type": "Bearer",
  "authorization_details": [{"type": "payment_initiation", "actions": ["initiate"]}]
}

A JWT access token carries the same array as an authorization_details claim (RFC 9068 §2.2.3), and introspecting the token returns it in the introspection response (RFC 9396 §9), so a resource server reaches the granted details whether it parses the JWT itself or asks the OP.

Every authorization code, refresh chain, and access token carries the details of the authorization that issued it. A later authorization by the same user and client never adds details to tokens that already exist, and a Grant Management replace can only take details away from opaque access tokens and refresh chains (see Grant management).

At the token endpoint ​

authorization_details on /token is validated by the same registered types, but it does not widen anything:

RequestEffect of authorization_details
authorization_code or refresh_tokenNarrows. Each requested element must deeply equal one the authorization granted, or the request fails with invalid_authorization_details. Omitted, the full granted set is kept.
client_credentialsThe validated elements go on the token as requested. There is no user and no consent record, so your Validate function is the only check on what the client may ask for.

Reconsent and detail coverage ​

The OP reuses an existing grant without showing consent only when every requested scope is already on it and every requested authorization_details element deeply equals an element already on it. A new element, or a changed value such as amount: "100" → "999", sends the user back through consent, and the ordinary consent path then adds the element to the grant. prompt=consent forces consent even when both sets are covered. A first-party client's consent skip never applies to a request that carries authorization_details.

The built-in consent step lists scopes only. It has no per-element display or approval: the user can untick scopes, but the requested details are persisted and echoed as a unit whatever they approve. A deployment that must show the terms (a payment amount, a payee) has to present them in its own consent UI, and Validate is the place to refuse an element outright.

Errors ​

ConditionError
The value is oversizeinvalid_request
The value is not a JSON array of objects, or an element has no registered typeinvalid_authorization_details
A registered type's Validate returns non-nilinvalid_authorization_details
At /token, a requested element is not in the granted setinvalid_authorization_details

RAR vs the claims parameter ​

Both go beyond what a coarse scope can say, and they answer different questions. The claims parameter asks for which claims appear where in the id_token and userinfo. authorization_details describes what the access token may do at a resource server. A request may carry both.