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
claimsrequest, the coarser sibling)
Quick refresher
authorization_details— a JSON array; each element is an object with atypestring 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
typeis 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.
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.
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:
{
"authorization_details_types_supported": ["payment_initiation"]
}Driving it
The client sends authorization_details as a URL-encoded JSON array:
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/authThe same parameter is accepted on a PAR push. Once the user has consented, the token response echoes what was granted:
{
"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:
| Request | Effect of authorization_details |
|---|---|
authorization_code or refresh_token | Narrows. 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_credentials | The 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
| Condition | Error |
|---|---|
| The value is oversize | invalid_request |
The value is not a JSON array of objects, or an element has no registered type | invalid_authorization_details |
A registered type's Validate returns non-nil | invalid_authorization_details |
At /token, a requested element is not in the granted set | invalid_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.
Read next
- Claims request parameter — the per-claim sibling mechanism.
- Grant management — query and revoke the grant your
authorization_detailswere bound to. - Access token format — where the granted details land on a JWT access token.