Rich Authorization Requests (RFC 9396)
Scopes say what kind of access a client wants (“payments”). RFC 9396
authorization_details say exactly what: “a payment of 250 EUR to ACME”,
“read access to repository myorg/app”. Ahdapa accepts them in every grant,
shows them to the user on the consent page, checks them against
administrator-defined types and rules, and puts the granted details in the
access token.
The checks come from the authz-details-rs crate (bac-rules). This page
covers the setup and the protocol; the admin endpoints are in
Admin API — Authorization Details.
The three controls
A requested detail is granted only when all three allow it:
- Type. Its
typeis registered and the detail validates against the type’s JSON Schema (Draft 2020-12). Remote$refs are never resolved. - Client. The type is listed in the client’s
authorization_details_types(RFC 9396 §10). This is the client’s capability ceiling; a client without it cannot request any details. - Rules. An enabled rule of its type allows it. Rules are default deny: a type without rules cannot be granted. A rule without conditions allows every detail of its type.
HBAC still decides who may get tokens for which client; the rules decide what the details may contain. Both must allow the request.
Rules are evaluated when the request arrives (PAR, /device_authorization,
direct token requests) and again when each token is issued — at code
exchange, every refresh, and the device poll — so a rule with a time window
or one an administrator just disabled takes effect on the next token.
Configuration
Static types and rules live in ahdapa.toml and are seeded into the cluster
state at startup when they have never existed there. After that they are
managed like admin-created ones: edited through the admin API, ahdapactl
or the web UI, and replicated to every node. A static definition deleted
through the API is not seeded again.
[[authorization_details.types]]
type_id = "payment_initiation"
description = "Initiate a payment"
schema = { type = "object", required = ["instructedAmount", "creditorName"], properties = { instructedAmount = { type = "object", properties = { amount = { type = "number" }, currency = { type = "string" } } }, creditorName = { type = "string" } } }
[[authorization_details.rules]]
name = "small-eur-payments"
description = "Up to 1000 EUR, business hours"
type_id = "payment_initiation"
match_mode = "All" # or "Any"
conditions = [
{ field = { path = "$.instructedAmount.amount", operator = "LessThanOrEqual", value = 1000 } },
{ field = { path = "$.instructedAmount.currency", operator = "In", value = ["EUR"] } },
{ within = { start = "08:00", end = "18:00", days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] } },
]
Condition operators: Equals, NotEquals, LessThan, LessThanOrEqual,
GreaterThan, GreaterThanOrEqual, In, NotIn, Contains. Time windows
are daily and in UTC; an end before start crosses midnight. Invalid
schemas, JSONPaths or duplicate names fail startup.
A type may also carry a comparator describing how numeric and array fields
compare when a request narrows a grant (see Narrowing below).
Allow a client to use the type:
[[client]]
client_id = "banking-app"
# ...
authorization_details_types = ["payment_initiation"]
Dynamic registration accepts authorization_details_types too, limited to
types registered on the server.
Authorization code flow: PAR is required
Detail lists can be several kilobytes, too large for a front-channel URL. So
authorization_details is only accepted through a pushed authorization
request (RFC 9126). Sending it directly to /authorize returns an
invalid_request error to the redirect URI.
curl -s -X POST https://idp.example.com/par \
-u "banking-app:SECRET" \
-d response_type=code \
-d redirect_uri=https://bank.example/cb \
-d scope=openid \
-d code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM \
--data-urlencode 'authorization_details=[{"type":"payment_initiation","instructedAmount":{"amount":250,"currency":"EUR"},"creditorName":"ACME"}]'
# → {"request_uri":"urn:ietf:params:oauth:request_uri:…","expires_in":60}
Then send the user to /authorize?client_id=banking-app&request_uri=…. The
consent page lists each detail with its type’s description and fields.
The token response and the access token both carry the granted details:
{
"access_token": "eyJ…",
"token_type": "Bearer",
"scope": "openid",
"authorization_details": [
{"type": "payment_initiation", "instructedAmount": {"amount": 250, "currency": "EUR"}, "creditorName": "ACME"}
]
}
Narrowing
A token request for an earlier grant — code exchange, refresh, device poll —
may send authorization_details to ask for less than was granted. Every
requested entry must be covered by a granted entry of the same type (fields
equal, or within the comparator’s bounds); otherwise the request fails with
invalid_authorization_details and a description of the uncovered entry.
Without the parameter, the token carries the whole grant. The refresh token
always keeps the whole grant, so a later refresh may ask for a different
subset. A rejected refresh or device poll does not consume the refresh token
or device code.
Other grants
| Grant | Details |
|---|---|
client_credentials, jwt-bearer | Requested directly with authorization_details on the token request; the three controls apply. |
device_code | Requested at /device_authorization (a back-channel request, so no PAR), shown on the verification page, narrowed at the poll. |
token-exchange (agents) | See below. |
Agents and delegation (token exchange)
An agent acting for a user exchanges the user’s access token (RFC 8693). The
new token’s details are the delegated grant: the subject token’s details
restricted to the types listed in the agent’s authorization_details_types.
Details of other types are dropped. The agent may narrow the delegated grant
further with authorization_details, never widen it, and the rules apply as
usual. The act claim records the chain as before.
So a user who granted “pay 250 EUR to ACME” and “read my accounts” to a
banking app can let a payment agent act for them; if the agent is only
allowed payment_initiation, its token can pay ACME 250 EUR and nothing else.
Seeing the details
- Access token:
authorization_detailsclaim (RFC 9396 §9.1). - Introspection:
authorization_detailsmember (§9.2), also for refresh tokens (their full grant). - Discovery:
authorization_details_types_supportedlists the types compiled on the node. - Audit: token-issued and token-refreshed events add
authorization_details=<types>to the detail (types only, not contents).
Errors
invalid_authorization_details (RFC 9396 §5), with a description naming the
refused entry — malformed JSON, an empty list, a type the client may not use,
a schema violation, no rule allowing it, or (when narrowing) an entry the
grant does not cover. Descriptions never name the rule that refused.
Cluster behaviour
Types replicate like scopes (last writer wins). Rules replicate like HBAC rules: a delete or disable on one node wins over a concurrent edit or enable on another, so a partition can never re-open access an administrator closed. Because rules are default-deny, deleting or disabling a rule can only narrow access. A definition that does not compile on a node (for example one from a misconfigured peer) is not enforced there: its type’s details are rejected, or the rule allows nothing. The admin API and web UI mark such definitions as not active.