Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Type. Its type is registered and the detail validates against the type’s JSON Schema (Draft 2020-12). Remote $refs are never resolved.
  2. 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.
  3. 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

GrantDetails
client_credentials, jwt-bearerRequested directly with authorization_details on the token request; the three controls apply.
device_codeRequested 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_details claim (RFC 9396 §9.1).
  • Introspection: authorization_details member (§9.2), also for refresh tokens (their full grant).
  • Discovery: authorization_details_types_supported lists 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.