Skip to content

Design judgments ​

Every spec the OP touches is a layered set of MUSTs, SHOULDs, and "the authorization server may". Several places need an explicit reading because the literature disagrees with itself, or because a literal reading collides with another spec. This page records those calls, so you can tell whether a behaviour is an accident, a spec compromise, or a deliberate security posture.

How to read a design judgment
Each design judgment names the specification text involved, the point where two documents disagree, and the rule this library adopted — then says where that rule is actually enforced.The spec textMUST · SHOULD · MAYquoted, with the sectionWhere they conflicttwo documents give differentanswers to the same requestThe rule adopted hereone reading is chosen,and the reason is recordedenforced inop.New checks · the store contract · the internals
Every entry below follows this shape: Spec, Conflict, then the Decision, whose first sentence is the operative rule; an Implemented in line names the code that enforces it. The last row is what separates a judgment from an opinion. If a rule is not enforced somewhere concrete, it is not a rule — it is a note about what the author intended.
Terms used on this page
  • OP (OpenID Provider): the authorization server this library implements.
  • RP (Relying Party): the client application that signs users in through the OP.
  • RS (resource server): the API that accepts access tokens.
  • Grant: the OP's record of one subject's consent to one client. Several sessions and tokens can sit under one grant; see Consent.
  • Chain: a refresh token and every successor produced by rotation; see Refresh tokens.
  • Tombstone: a small revocation record keyed by grant (or by jti), which lets a JWT be rejected without keeping a row per token.

Decision map ​

Start with the group for the surface you are changing. Each line is the rule in one sentence; the number links to the full entry.

Protocol profiles and discovery

Tokens, grants, and revocation

Registration, issuer, and outbound fetch

Browser, session, and UI boundaries

JOSE and request objects

1. When a PAR request_uri is consumed ​

JOSE and request objects

Spec RFC 9126 §2.2 separates client use from AS handling:

RFC 9126 §2.2 (summary) A client MUST use each request_uri only once. An AS SHOULD treat it as one-time, MAY allow duplicates caused by a user-agent reload or refresh, and MUST reject an expired value.

Conflict A literal "consume on first sight" interpretation breaks multi-step interaction: consent → MFA → locale change can re-redirect to the same /auth?request_uri=… before the original interaction finishes.

The OFCS FAPI 2.0 Security Profile Final plan tests both sides: …-par-ensure-reused-request-uri-prior-to-auth-completion-succeeds requires the OP to accept a re-visit before authentication completes, and …-par-attempt-reuse-request_uri requires it to reject the re-visit afterwards.

Decision

request_uri is looked up at /auth entry (PARs.Find) and consumed at authorization-code issuance (PARs.Consume).

Re-visits during interaction succeed; re-visits after the code is emitted are rejected with invalid_request_uri.

The matching OFCS module fapi2-…-par-attempt-reuse-request_uri exercises the post-emission rejection path; see OFCS conformance.

2. Refresh rotation grace window ​

Tokens, grants, and revocation

Spec RFC 9700 §4.14.2 requires an AS to detect refresh-token replay for public clients with sender-constrained refresh tokens or rotation. FAPI 2.0 Security Profile Final §5.3.2.1(9) limits refresh-token rotation to extraordinary circumstances; when rotation is used, it allows a time-limited retry with the previous token after the new token is lost.

RFC 9700 §4.14.2 (summary) Rotation issues a new refresh token, invalidates the previous one while retaining the relationship, and revokes the active token when reuse reveals a compromise.

Conflict A strict "single-use, no grace" reading kills any client where the token rotation network round-trip is interrupted (mobile backgrounding, dropped TCP, retried HTTP/2 stream).

The OFCS FAPI 2.0 Final plans run against the FAPI profiles, which do not rotate refresh tokens, so their …-refresh-token module does not exercise a grace window; the window matters to the profiles that rotate.

Decision

Rotated refresh tokens stay accepted for the library's configurable GraceTTL (default 60 s), as long as the chain has not been revoked for reuse detection.

  • Within the grace window, presenting the old refresh token returns the saved successor without rotating again. A sender-constrained retry may receive a newly bound access token.
  • Reuse after the grace window — or any reuse on a chain that was already revoked — invalidates the entire chain (RFC 9700 §4.14.2) and retires the access tokens of its grant.

The 60-second default is an implementation choice for a bounded lost-response retry path, not an RFC requirement. op.WithRefreshGracePeriod rejects negative values and treats an explicit zero as no grace; otherwise a deployment may choose any non-negative window.

A rotation touches only its own chain. The successor keeps the scope of the token it replaces, even when the refresh request narrowed scope for that one access token. The rotation retires only the opaque access tokens minted from the same chain, so the user's other sessions on the same client keep theirs. store.RefreshToken.Revoked distinguishes "consumed by rotation" from "retired by chain compromise", and store.RefreshToken.ChainID names the chain.

The two FAPI 2.0 profiles do not rotate refresh tokens, as FAPI 2.0 Security Profile Final §5.3.2.1(9) requires, so they have no window: a refresh response carries no new refresh token, the presented one stays redeemable until it expires or is revoked, and op.New rejects the option under them. The rest of this judgment applies only to the profiles that rotate.

Implemented in internal/grants/refresh.Exchanger.tryGrace.

3. offline_access: signal, not gate ​

Tokens, grants, and revocation

Spec OIDC Core 1.0 §11 simultaneously asserts:

OIDC Core 1.0 §11 (a) the use of Refresh Tokens is not exclusive to the Offline Access use case.

OIDC Core 1.0 §11 (b) when offline_access is requested, the Authorization Server MUST ensure the prompt parameter contains consent.

Conflict Reading (a) suggests refresh tokens are issued whenever the client is granted the refresh_token grant; the scope only governs consent UX. Reading (b) suggests offline_access is the canonical scope for refresh issuance and the consent gate is secondary. Other OPs split — some treat offline_access as mandatory, others as a UX-only marker.

Decision

By default, offline_access is a UX and TTL signal, not the refresh-token issuance gate.

Refresh tokens are issued when the granted scope contains openid and the client's GrantTypes includes refresh_token. If the grant also carries offline_access, op.WithRefreshTokenOfflineTTL may place that chain in the offline lifetime bucket, and the consent/audit surface can reflect the offline reach the user authorised.

Embedders that want the strict §11 reading pass op.WithStrictOfflineAccess(). In that mode:

  • refresh-token issuance additionally requires offline_access;
  • grant_type=refresh_token rejects a token whose originating grant did not carry offline_access with invalid_grant.

The option is incompatible with op.WithOpenIDScopeOptional(), because §11 has no coherent meaning outside OIDC requests.

Implemented in op/options_features.go, internal/tokenendpoint/strict_offline_test.go, and the refresh issuance path.

4. Loopback redirect ports ​

Registration, issuer, and outbound fetch

Spec RFC 6749 §3.1.2.3 + OAuth 2.1 mandate byte-exact redirect_uri match. RFC 8252 §7.3 says native apps on loopback "MUST be allowed any port at request time".

Conflict The two readings disagree by construction. A CLI tool cannot pre-register every ephemeral OS-assigned port; a strict exact-match policy breaks the canonical native-app flow.

Decision

Default is exact-match (the strict OAuth 2.1 reading). The RFC 8252 §7.3 relaxation is opt-in per client, via a loopback URI in the client's registered redirect_uris.

The OP then ignores a port mismatch only when all four conditions hold:

  • the scheme is http;
  • the registered hostname is one of the loopback shapes: 127.0.0.1, ::1, or the textual localhost;
  • the requested host matches the registered host;
  • path, query, and fragment match exactly.

The textual localhost is admitted to keep the authorize-side rule aligned with OIDC Registration's native-client loopback carve-out. Without it, a native app that registered http://localhost/cb would pass registration but fail at /auth once the OS handed it an ephemeral port.

The localhost admission still depends on the embedder having opted in at registration: op.WithAllowLocalhostRedirectURIs() for web clients, or application_type=native for native clients. DNS-rebinding-sensitive deployments keep the strict literal-IP-only posture by leaving both opt-ins off.

HTTPS loopback is not relaxed (no ACME on 127.0.0.1).

5. Iframe-based logout specs ​

Browser, session, and UI boundaries

Spec Session Management 1.0 and Front-Channel Logout 1.0 are both published OpenID Connect specs, separate from Core.

Conflict Both rely on a third-party iframe being able to read its own cookie from an embedded context. Modern browser defaults (Safari ITP since 2017, Firefox ETP since 2019, Chrome SameSite=Lax default since 2020, third-party-cookie phase-out across 2024–2025) have removed that capability for mainstream browsers.

Decision

Not implemented.

The discovery document does not advertise frontchannel_logout_supported or check_session_iframe. The library ships RP-Initiated Logout 1.0 + Back-Channel Logout 1.0 (server-to-server POST of a signed logout_token) as the modern substitutes.

Embedders that need iframe-based session signalling should pick a different library — this is a load-bearing scope decision, not a backlog item.

6. JAR request-object replay ​

JOSE and request objects

Spec RFC 9101 §10.8 RECOMMENDS rejecting replayed request objects within their exp window. It does not mandate a registry.

Conflict A naive "verify and accept" follows the minimum JAR rules but allows an intercepted request object to be replayed against a fresh /auth until exp. FAPI 2.0 Message Signing requires a signed request object at PAR, but does not make jti replay detection mandatory. The cache below is a library defense-in-depth choice.

Decision

A request object that carries a jti is deduplicated.

The OP marks jar:<use>:<clientID>:<jti> in ConsumedJTIStore for the verifier's max-age / max-lifetime window plus clock skew. The retention is clamped to that OP-side ceiling, so a client-supplied far-future exp cannot pin a marker indefinitely. The store is the one DPoP proofs and client assertions already use, so a deployment that shares it across instances gets all three defences.

Without a jti:

  • at /auth and /par, the request object is accepted: RFC 9101 §6.1 marks the claim optional, and the OFCS FAPI message-signing plans send request objects without one;
  • at /bc-authorize, FAPI-CIBA promotes jti to required, and a missing value is rejected (ErrJTIMissing).

A jti longer than 256 bytes is a malformed request object.

7. What a profile turns on ​

Protocol profiles and discovery

Spec FAPI profiles impose a mixture of requirements: some are routable features such as PAR, JAR, and JARM; others require deployment-owned collaborators. RFC 9700 §2.1.1 requires PKCE for public clients and recommends it for confidential clients. This library applies the stricter all-client rule by default, through profile.OAuth21, and under its FAPI profiles, while OIDC Core compatibility remains available for deployments that need it by declaring profile.OIDCCore.

Conflict Open-coded switch profile blocks in handlers silently diverge. Auto-enabling every requirement is also wrong: a missing feature can be selected safely, but mounting a grant without its store and resolver collaborators creates a half-wired endpoint.

Decision

Profiles accumulate their policy, but features and grants resolve differently: WithProfile auto-enables required features, and never auto-enables required grants.

Features:

  • FAPI 2.0 Baseline selects PAR and JAR; Message Signing also selects JARM.
  • A FAPI profile selects DPoP unless the embedder explicitly chose mTLS.
  • profile.OAuth21 has no routable feature.

PKCE: the library's profile.OAuth21, profile.FAPI2Baseline, and profile.FAPI2MessageSigning policies make PKCE mandatory for every authorization-code request, including confidential clients, and profile.OAuth21 is what an OP without WithProfile runs. Opting out is explicit: profile.OIDCCore keeps PKCE optional for confidential clients and cannot be combined with any other profile, so the weaker posture never arises as a side effect of an omitted option.

Grants: profile.FAPICIBA without the CIBA grant fails op.New and names WithCIBAGrant(CIBAGrant{HintResolver: ...}) as the wiring path. This preserves a useful construction error instead of mounting /bc-authorize without its collaborators.

The resolved profiles, features, grants, and policy are emitted once as the startup.profile audit event, so operators can verify the actual posture before the first request.

Implemented in op/profile/constraints.go; WithProfile and construction validation apply the constraint functions centrally.

8. ACR on the wire vs internal AAL ​

Protocol profiles and discovery

Spec OIDC Core 1.0 emits acr claims; RFC 6711 / 8485 govern the identifier registry; RFC 9470 standardises step-up via acr_values + WWW-Authenticate: error="insufficient_user_authentication".

Conflict Internal AAL ("which factors did the user complete?") is not the same vocabulary as the OIDC acr echoed on the wire. Mapping them naively (e.g. acr=urn:authn:aal=2 literal) ties the wire vocabulary to internal taxonomy, which embedders cannot evolve without breaking RPs.

Decision

A two-layer model: the internal AAL ladder and the wire-side acr mapping are kept apart.

RuleACR implements RFC 9470 step-up: an RP that asks for a higher acr_values than the session currently provides receives the insufficient_user_authentication challenge with the next step.

Implemented in op/aal.go (AAL ladder), op/acr.go (acr mapping), and op/rule.go (RuleACR).

9. Issuer canonicalization ​

Registration, issuer, and outbound fetch

Spec RFC 9207 says the OP MUST emit iss on authorization responses, and clients compare that value with the expected issuer using simple string comparison. OIDC Discovery 1.0 §3 and RFC 8414 §2 define the issuer as an HTTPS URL without query or fragment components and require the metadata issuer to match the issuer used for the configuration request. Discovery §4.1 (and RFC 8414 §3.1) removes a terminating / from a path issuer before constructing the well-known request. FAPI 2.0 Security Profile Final §5.3.2.1(1) requires the AS to distribute that discovery metadata.

Conflict URI syntax permits representations that differ by scheme or host case, default-port spelling, or a trailing slash, and Discovery explicitly removes a terminating slash when constructing the well-known request. These specifications do not require this library to reject every such representation, but RFC 9207 and the metadata specifications still require one exact issuer value at comparison boundaries. If an OP and an RP canonicalize that value differently, issuer validation fails.

Decision

op.WithIssuer deliberately applies a strict canonicalization policy, and op.New returns a build-time error rather than booting on a non-canonical issuer.

It rejects a URI carrying any of these:

  • a trailing slash
  • a mixed-case scheme
  • a mixed-case host
  • a default port (:443 for https, :80 for http)
  • a fragment
  • a query
  • a non-canonical path (.., ., or duplicate slashes detected via path.Clean)

Query and fragment rejection follows the issuer requirements; the case, port, trailing-slash, and path rules are additional library policy.

The same accepted form is reused for iss in every emitted artifact, in the discovery document's own issuer field, and in the authorization-response iss parameter — so simple string comparison under RFC 9207 mix-up defence has one value end-to-end.

10. Where sessions live ​

Browser, session, and UI boundaries

Spec None. This is an architecture decision.

Conflict Putting sessions in the same SQL cluster as auth codes / refresh tokens / clients (the transactional store) couples session mutation latency to the transactional path. Putting them on Redis loses durability — but session loss is recoverable (the user logs in again), whereas authorization-code loss is not (the RP loop breaks).

Decision

Sessions are routed through a separate substore (store.SessionStore) that the embedder may serve from a volatile store such as Redis.

/end_session takes the session snapshot before deleting it, and the BCL coordinator resolves its audience from grant-derived client IDs via GrantStore.ListClientIDsBySubject. If eviction happens before that snapshot, no fan-out starts and no BCL no-sessions event is emitted.

audit.BCLNoSessionsForSubject instead means that a session-bearing logout notice reached the coordinator but resolved to zero eligible grant-derived RP targets.

op.SessionDurabilityPosture records the embedder's declared durability, so operators can interpret logout-trigger and snapshot-retention risk without treating the event as a direct eviction signal.

11. JOSE allow-list and one signing algorithm ​

JOSE and request objects

Spec RFC 8725 ("JWT BCP") §3.1 requires libraries to let the caller specify an algorithm allow-list and to reject algorithms outside it. Section 3.2 makes algorithm selection an application decision, warns about HMAC/public-key confusion, and does not categorically prohibit none when another end-to-end protection exists; libraries SHOULD consume or generate it only when explicitly requested.

Conflict The underlying JOSE library accepts the full registry by default. Adding a runtime check is fragile — a future code path that imports the JOSE library directly bypasses it.

Decision

none and HS* are not in the algorithm type at all, and every artifact the OP signs uses one algorithm per OP: ES256 by default, RS256 on request.

internal/jose.Algorithm is a closed enum: RS256, PS256, ES256, EdDSA. depguard (lint) forbids importing the underlying JOSE package outside a short list of internal/ packages that already hold raw handles (jose, keys, dpop, jar, jarm, mtls, clientauth, and a few more), so a new code path has to be added to that list on purpose.

The verification allow-list is deliberately wider than the issuing policy. Client assertions and request objects may use the accepted asymmetric algorithms. Every artifact the OP signs — ID Tokens, JWT access tokens, signed UserInfo, JARM responses, logout tokens, introspection JWTs, ID-JAGs and Security Event Tokens — uses the one OP algorithm.

  • The default is ES256 with a P-256 key.
  • op.WithSigningAlgorithm(op.AlgRS256) selects RS256 with an RSA key of 2048 bits or more.
  • These are the only two values the option accepts, and a mismatch between the option and the active key makes op.New fail.

The choice is made once for the whole OP. There is no per-client or negotiated algorithm: *_signed_response_alg client metadata must equal the OP's algorithm (or none where the surface allows it), and dynamic registration fills id_token_signed_response_alg with the OP's algorithm. RS256 is refused together with FAPI2Baseline, FAPI2MessageSigning and FAPICIBA, because FAPI 2.0 Security Profile §5.4.1 permits only PS256, ES256 and EdDSA. An RP that requires PS256 for OP-issued tokens is unsupported.

The OP's own verifiers (access tokens, id_token_hint, token-exchange subject tokens) require the JWS header alg to equal the algorithm of the key that matched, in line with RFC 8725 §3.1. Keeping issuance to one algorithm removes algorithm negotiation and its downgrade paths without making the OP reject standards-compliant client assertions.

To move an existing OP between the two, see Switching the signing algorithm.

Implemented in internal/jose (Algorithm), with the import fence enforced by depguard.

12. Discovery narrowed by profile ​

Protocol profiles and discovery

Spec OIDC Discovery 1.0 + RFC 8414 enumerate the metadata fields. FAPI 2.0 Security Profile Final §5.3.2.1(6) restricts AS client authentication to mTLS or private_key_jwt, which narrows token_endpoint_auth_methods_supported. RFC 9101 §10.1 demands request_object_signing_alg_values_supported when JAR is on.

Conflict The _supported lists are produced by feature activation, then potentially narrowed by the active profile, then copied into the introspection / revocation auth-method lists. Doing this in handlers re-introduces drift; doing it at construction time forces a single point that must agree.

Decision

Discovery is built once at op.New time.

Profiles narrow the auth-method list via ProfileAllowedAuthMethods, and the introspection / revocation endpoints copy the post-narrowed list.

A discovery golden test in the repository asserts the document shape per profile, so silent drift is caught at PR time.

Implemented in internal/discovery/build.go.

13. client_assertion audience ​

Protocol profiles and discovery

Spec RFC 7523 §3 says the JWT bearer assertion's aud MUST be "a value identifying the authorization server" — without pinning which identifier. OIDC Core 1.0 §9 (private_key_jwt) reads that as the OP's token endpoint URL. FAPI 2.0 Security Profile Final §5.3.2.1(8) narrows the authorization-server requirement: the AS must accept only the issuer identifier, and that value must be a JSON string. FAPI clients have the matching obligation in §5.3.3.1(5). RFC 7519 generally permits a string or an array for aud, but FAPI narrows that shape.

Conflict The library's compatibility verifier accepts OIDC Core's token-endpoint form, FAPI's issuer form, and an array containing an accepted value. That is useful when one OP serves both dialects, but accepting the token endpoint URL or an aud array violates §5.3.2.1(8). A Final FAPI profile needs a stricter validation policy; the broad RFC 7523 interpretation does not override the profile requirement.

Decision

Under the two FAPI 2.0 profiles the verifier accepts only the issuer identifier as a JSON string; every other profile keeps the primary Audience plus AuxAudiences compatibility behavior.

  • Compatibility profiles: op.New populates AuxAudiences with the issuer for every OP, and the assertion path consumes jti once across the merged path, so neither accepted dialect can bypass replay defence.
  • profile.FAPI2Baseline and profile.FAPI2MessageSigning: the same verifier rejects the token-endpoint audience and the array form with invalid_client, as §5.3.2.1(8) requires. The profile is a switch on one verifier, not a second one.
  • profile.FAPICIBA follows CIBA Core §7.1 and keeps the wider set.

Implemented in internal/clientauth/assertion.go.

14. PKCE: S256 only ​

JOSE and request objects

Spec RFC 7636 §4.2 lists two transformations: plain and S256. §4.4.1 says clients SHOULD use S256, with plain retained for environments that cannot compute SHA-256. OAuth 2.1 (draft-ietf-oauth-v2-1 §4.1.1) and FAPI 2.0 Security Profile Final §5.3.2.2(5) forbid plain outright.

Conflict A literal RFC 7636 reading lets the OP accept whichever method the client requests. A strict reading of OAuth 2.1 / FAPI 2.0 requires rejection. A "per-profile gate" architecture would let the same client succeed against an OIDC-Core OP and fail against the same OP under a FAPI 2.0 profile — the kind of silent profile drift the rest of the codebase works hard to avoid.

Decision

The OP rejects plain regardless of profile.

internal/pkce.Method is the singleton constant "S256"; ValidateChallenge returns ErrChallengeMethodUnsupported for any other value, and code_challenge_methods_supported advertises only S256 in discovery.

Reasoning:

  1. plain provides no PKCE protection (the verifier IS the challenge).
  2. The SHOULD reading from RFC 7636 has aged out under OAuth 2.1.
  3. A uniform stance keeps client behaviour consistent across profiles.

Implemented in internal/pkce (Method, ValidateChallenge).

15. DPoP refresh binding: public vs confidential ​

Tokens, grants, and revocation

Spec RFC 9449 §5.0 / §5.4 say the AS MAY bind refresh tokens to the DPoP key supplied at the token endpoint. §5.4 adds that once bound, the binding MUST persist across rotations.

Conflict "MAY bind" is genuinely two-handed:

  • Always-bind locks confidential clients to a single DPoP key for the chain's lifetime, which clashes with FAPI 2.0 OFCS plans that explicitly rotate the DPoP key across refresh requests.
  • Never-bind leaves public-client refresh tokens (SPAs, native apps) as raw bearer secrets — exactly the threat model RFC 9449 §1 cites as motivating sender constraints.

Decision

Bind for public clients, leave unbound for confidential.

  • A client whose TokenEndpointAuthMethod is "none" (the public-client signal) gets its refresh chain DPoP-bound on first issue, and the binding propagates through every rotation per §5.4.
  • Confidential clients (private_key_jwt, client_secret_*) leave the refresh chain unbound, free to rotate DPoP keys per request. The access tokens minted on each refresh are still bound to whatever key was presented, so any holder of those access tokens still needs the matching private key.

Once a chain is bound, the §5.4 persistence rule kicks in and prevents an opportunistic upgrade from locking later key rotation.

The split applies to DPoP only. A refresh chain issued to an mTLS-bound request is bound to the client certificate for every client type, and each refresh must present the same certificate (requireMTLSMatch).

Implemented in internal/tokenendpoint.refreshDPoPJKT.

16. Who may introspect what ​

Tokens, grants, and revocation

Spec RFC 7662 §2.2:

RFC 7662 §2.2 an introspection response for a token the AS does not consider active MUST carry "active": false, and MAY contain only the active member.

§2.1 additionally lets the AS "differently respond depending on the audience". Cross-client refusal is therefore permitted, but not mandated.

Conflict Three readings circulate:

  1. Liberal — let any authenticated client introspect any token the AS recognises, returning "active": true even when the calling client is not the issuer.
  2. Strict-distinguishable — refuse cross-client introspection but signal it (e.g. HTTP 403 or error: not_authorized).
  3. Audience-scoped — allow the resource server named by the token audience to introspect through an explicit allow-list, while refusing unrelated callers with the same {"active": false} shape used for unknown / expired / revoked tokens.

Reading 2 leaks token-existence information through the response shape: a curious client can probe whether a guessed token belongs to some live grant.

Decision

Only confidential callers may use /introspect, and cross-client access-token introspection is delegated only through the token audience.

  • A public client, or a client whose token-endpoint authentication method is none, receives 401 invalid_client before token resolution.
  • A confidential client may introspect its own access or refresh token.
  • Cross-client access-token introspection is allowed only when the calling client is listed in the token audience's protected resource IntrospectionClients; the audience check is the delegation boundary.
  • Refresh tokens are never delegated across clients.
  • An unauthorized cross-client access-token lookup returns the same {"active": false} shape as an unknown, expired, or revoked token.

Active opaque responses include iss. An active refresh-token response omits token_type, because RFC 7662 §2.2 defines that member as the access-token type.

Tests cover the confidential gate, audience delegation, and refresh-token refusal.

Implemented in internal/introspectendpoint and the protected-resource configuration.

17. /end_session cascade reach ​

Tokens, grants, and revocation

Spec OIDC RP-Initiated Logout 1.0 §6:

OIDC RP-Initiated Logout 1.0 §6 the OP MAY also revoke any active sessions, refresh tokens, and access tokens once the user signs out.

Back-Channel Logout 1.0 §2.3 governs RP-side fan-out but is silent on AT revocation reach.

Conflict A literal "MAY" admits postures from "delete the cookie and walk away" to "revoke every grant the subject holds". The blast radius differs:

  • Cookie-only: outstanding access tokens remain valid until their exp. A JWT that never returns to the OP cannot be retracted at all.
  • Revoke at the OP-served boundaries: /userinfo and /introspect see the token as inactive at once. A resource server that validates a JWT offline does not.
  • Opaque tokens reach every resource server: the token has no offline verification path, so the next introspection reports it inactive.

Decision

Cascade by default: /end_session lists every grant the subject holds and retires what was issued under each.

  • JWT access tokens, by the configured revocation strategy (a grant tombstone by default). Under the tombstone strategy the grant is also closed to further issuance.
  • Opaque access tokens.
  • Refresh tokens.

Each class goes through the substore the configured Store exposes. A class whose substore is absent is skipped, and a store that exposes no Grants leaves the cookie-only posture, where access tokens expire naturally. A failed class is reported in the audit stream and does not block the sign-out.

The table in #19 compares this reach with the other revocation triggers. The cascade-reach gap between JWT and opaque tokens (JWT → OP-served boundaries only, opaque → every RS via introspection) is documented separately on the access-token-format page; the choice of which form to issue is the lever embedders pull when this gap matters.

18. Access-token format ​

Tokens, grants, and revocation

Spec RFC 9068 standardises the JWT-shaped OAuth 2.0 access token. RFC 6749 itself treats the access token as an opaque bearer string. RFC 7662 introspection is optional. RFC 8707 lets a single client request tokens for multiple resources in one flow.

Conflict Two opposite defaults are defensible:

  • Default JWT — every RS validates locally, no /introspect round-trip, scales horizontally; but /end_session cannot retract a JWT that never sees the OP again until exp.
  • Default opaque — every RS hits the OP, latency-coupled with the OP path; but immediate revocation reach.

A single hard-coded default forces every embedder onto one trade-off. Per-audience selection (RFC 8707) lets a single OP issue different forms to different RSes inside the same authorization.

Decision

Default is op.AccessTokenFormatJWT; opaque is opt-in, globally or per audience.

JWT matches the typical horizontal-scale RS layout and keeps the wire shape compatible with off-the-shelf JWT verifiers.

  • op.WithAccessTokenFormat(op.AccessTokenFormatOpaque) flips the global default.
  • op.WithAccessTokenFormatPerAudience(map[string]op.AccessTokenFormat{...}) selects per RFC 8707 resource indicator, so high-revocation-reach audiences (admin APIs, payment APIs) can take opaque while general-purpose APIs keep JWT.

The full trade-off (load concentration, header size, cascade reach, storage shape) lives on the access-token-format page; this entry exists so the decision is recorded alongside the other deliberate spec reads.

19. JWT access-token revocation ​

Tokens, grants, and revocation

Spec RFC 6749 §4.1.2 says the AS "SHOULD revoke (when possible)" on code re-use. RFC 6819 §5.2.1.1 expects a server-side reuse-detection invariant. RFC 7009 §2.2 lets revocation be unsupported for self-contained tokens. FAPI 2.0 Security Profile Final §5.3.4(3) requires resource servers to verify the validity, integrity, expiration, and revocation status of access tokens. That requirement is enforced at the RS boundary; it does not prescribe an OP-side storage strategy.

Conflict The literal "shadow every jti so revocation can flip a row" model satisfies every spec but loads O(issuance_rate × AT_TTL) rows on the transactional store, and the cascade path on a logged-out user with N outstanding ATs becomes N row updates. The opposite extreme — "no revocation, JWT lives until exp" — leaves a resource server without revocation status unless the deployment supplies another check, so it cannot meet the FAPI 2.0 Security Profile Final §5.3.4(3) requirement.

The middle reading is to keep the JWT self-contained but bind it to a server-side grant rather than to a per-token row. One write per revoked grant fans out to every AT under that grant; issuance writes nothing.

Decision

Default is op.RevocationStrategyGrantTombstone: revocation state is kept per grant, and the default issuance path writes zero rows on /token.

Every JWT AT carries a gid private claim (the OP-side GrantID, RFC 7519 §4.3, omitempty). Verification at OP-served boundaries (/userinfo, /introspect) consults a small per-grant tombstone table keyed on gid.

  • Cascades (logout, code-replay, refresh compromise) write one tombstone row per revoked grant.
  • Single-AT /revoke writes one deny-list row per revoked jti.
  • Steady-state row count is O(revoked grants + revoked JTIs), not O(issued).

The other strategies:

  • op.WithAccessTokenRevocationStrategy(op.RevocationStrategyJTIRegistry) is for embedders who need a per-AT audit trail; it keeps one shadow row per issuance in store.AccessTokenRegistry.
  • op.RevocationStrategyNone is a third option for non-FAPI deployments that explicitly accept the RFC 6749 §4.1.2 "SHOULD" wiggle; op.New rejects it under any FAPI profile.

Both the tombstone and the registry strategies provide server-side revocation state at the OP-served boundaries, but selecting either OP strategy alone does not establish FAPI Final conformance: resource servers still have to satisfy §5.3.4(3).

The opaque AT path (op.WithAccessTokenFormat(op.AccessTokenFormatOpaque)) is unaffected by this enum: opaque verification needs the row, so storage is intrinsically per-token.

Revocation triggers. Every trigger routes through one owner (internal/grants/teardown), which states the blast radius per trigger. A grant is shared by all of one subject's sessions on one client, so most triggers deliberately stop short of the whole grant:

TriggerRefresh tokens retiredAccess tokens retiredGrant closed to further issuance
/end_session, for each grant the subject holdsall of the grant'sall of the grant's, JWT and opaqueyes
Authorization-code replayall of the grant'sall of the grant'syes
Refresh-token replay (reuse after the grace window)the replayed chain; all of the grant's if the chain root cannot be resolvedall of the grant'syes
/revoke of a refresh tokenthat chain; all of the grant's if the chain root cannot be resolvedall of the grant'sno: other chains and unredeemed codes keep redeeming
Refresh rotationthe previous tokenthe opaque ones minted from the same chainno

"Closed" holds under the tombstone strategy: the tombstone also refuses the grant's refresh and code redemption.

The /revoke row writes an access-token-only tombstone (store.GrantTombstone.AccessTokensOnly), and a store that implements the optional store.IssuanceRevocationChecker lets the token endpoint ignore that kind when it decides whether a chain or code may still mint. The chain-wide opaque retirement on rotation needs store.OpaqueAccessTokenChainRevoker. A store that implements neither, or drops ChainID, falls back to grant-wide behaviour, which over-revokes rather than under-revokes. /revoke records token.revoked with surface refresh_chain, or refresh_grant when it fell back to the grant's refresh tokens.

A code or refresh token presented by the wrong client, or one failing the redirect_uri or PKCE check, is not a replay: it consumes nothing and tears down nothing. Only a genuine replay triggers the rows above.

A grant revoked while a code exchange, refresh, token exchange, or device-code or CIBA poll is in flight leaves no live credential. After persisting what it issued, the token endpoint re-checks whether the grant is closed and revokes the credentials it just minted. Grant teardown records that closure under every access-token revocation strategy, not only the tombstone one.

Implemented in op/access_token_revocation.go, internal/tokens (gid claim), op/store/grant_revocation.go, internal/grants/teardown, and the backend adapters.

20. DCR client_secret storage ​

Registration, issuer, and outbound fetch

Spec RFC 7591 §3.2.1 marks client_secret as OPTIONAL in the registration response. RFC 7592 §2.1 (read) and §2.2 (update) both allow but do not require the OP to re-emit client_secret in the response body.

Conflict A "store the cleartext so we can re-emit it on every GET" reading is the simplest implementation and matches what some historic OPs do, but it places the cleartext in a recoverable form forever and turns the registration record into the most sensitive row in the database.

A "hash on POST and never re-emit" reading matches how every other credential in the OP is stored and removes the recoverable surface entirely, at the cost of asymmetry between the POST response (plaintext available once) and the GET response (plaintext gone forever).

Decision

Hash-only at rest. The plaintext is returned exactly once on POST /register; GET /register/{client_id} never re-emits client_secret.

PUT /register/{client_id} returns it again only when token_endpoint_auth_method changes from none to a confidential method. A confidential-to-confidential update keeps the existing hash, so editing metadata never rotates the secret.

Embedders that need recoverable access to the plaintext keep their own copy at the moment of the original POST response; the OP refuses to be the system of record for the cleartext.

Storage is governed by op/store/client.go (SecretHash-only), so the same posture applies to every store.ClientStore adapter.

Implemented in internal/registrationendpoint/manage.go and internal/registrationendpoint/register.go.

21. RFC 7592 PUT with omitted fields ​

Registration, issuer, and outbound fetch

Spec RFC 7592 §2.2 reads in two parts:

RFC 7592 §2.2 (sentence 1) the values of the metadata returned in the response MUST replace, not augment, the values previously associated with this client.

RFC 7592 §2.2 (sentence 2) the server MAY ignore any null or empty value in the request just as any other value.

Conflict A strict reading of the first sentence makes a missing grant_types field on PUT mean "delete grant_types", which leaves the client with no grant capability and breaks every subsequent /token request. The MAY clause in the second sentence is the escape hatch every reference OP uses to avoid that footgun, but the ecosystem has not converged on a single replacement policy.

Decision

Omitted defaulted fields reset to the server-side default. Omitted optional fields become empty.

  • Defaulted: grant_types, response_types, token_endpoint_auth_method, application_type, subject_type, and id_token_signed_response_alg. On PUT they fall back to the OP's documented defaults rather than vanishing from the record.
  • Optional metadata (client_uri, logo_uri, policy_uri, tos_uri, contacts, …) is genuinely cleared when omitted.
  • Server-managed fields — registration_access_token, registration_client_uri, client_secret_expires_at, client_id_issued_at — are rejected with 400 invalid_request if present in the body. A client_secret value that does not match the authenticated client is rejected with the same status.

The OP does not persist a separate "configured vs defaulted" bit for these fields; the on-the-wire behaviour is the same either way for every defaulted field.

Implemented in internal/registrationendpoint/manage.go (validateManageUpdateRequest) and internal/registrationendpoint/metadata.go (applyMetadataDefaults).

22. sector_identifier_uri fetch and loopback hosts ​

Registration, issuer, and outbound fetch

Spec OIDC Core 1.0 §8.1 makes the sector_identifier_uri fetch and the containment check on the registered redirect_uris a MUST:

OIDC Core 1.0 §8.1 the values registered in redirect_uris MUST be included in the elements of the array, or registration MUST fail.

The spec fixes neither a timeout nor a body cap. OIDC Registration §2 lists localhost, 127.0.0.1, and [::1] as valid loopback hosts for application_type=native. RFC 8252 §8.3 marks localhost as NOT RECOMMENDED in favour of the IP literals, citing DNS rebinding.

Conflict "Fetch with the language default" implicitly means "hold a goroutine open until the upstream eventually answers", and the request body is unbounded — both are footguns at registration time.

On localhost, OIDC Registration §2 and RFC 8252 §8.3 disagree: the OIDC text accepts it for native, the OAuth text warns against it. A web client that registers http://localhost/cb is a separate question from a native client that does the same.

Decision

The fetch is bounded to a 5 s timeout, a 64 KiB body cap, HTTPS only, no redirects, and a 24 h success cache.

Failures are not cached. A cache hit still re-runs the redirect-URI subset check against the currently registering client, so the cache never widens what a different client can register under the same sector.

When a later fetch observes that the remote document's canonical hash changed, the resolver returns ErrSectorContentChanged once and evicts the stale entry. The next valid registration for that URI repopulates the cache with the new document. Legitimate RP-side rotations recover without an OP restart, while unexpected sector changes still surface to operators.

Failure or containment mismatch produces 400 invalid_client_metadata. The cause goes to the audit log but never to the response body, so upstream details (host, TLS state, partial bytes) do not leak.

For loopback hosts, the registration layer splits on application_type:

  • Web clients (the default) accept 127.0.0.1 and [::1] over http. The textual localhost is rejected unless the embedder explicitly opts in via op.WithAllowLocalhostRedirectURIs().
  • Native clients (application_type=native) accept all three loopback hosts unconditionally per OIDC Registration §2, plus claimed https redirects and reverse-DNS custom URI schemes (com.example.app:/cb) per RFC 8252 §7.1. Custom schemes that lack a . are rejected because non-reverse-DNS schemes collide across applications.

The authorize-time port wildcard for loopback URIs is governed by a separate per-client opt-in (#4) and composes with this rule without overlap.

Implemented in internal/registrationendpoint/sector_identifier.go and internal/registrationendpoint/metadata.go (validateRedirectURI / validateNativeRedirectURIScheme).

23. Outbound fetch SSRF boundary ​

Registration, issuer, and outbound fetch

Spec OIDC Dynamic Registration, JAR, private_key_jwt, pairwise subjects, and Back-Channel Logout all let RP-controlled metadata point the OP at outbound URLs. Those specs define what has to be fetched, but they do not define a common SSRF envelope for private networks, loopback, DNS rebinding, or custom TLS roots.

Conflict A deployment often needs a custom transport for conformance runners, internal CAs, or RP networks that are not publicly trusted. Letting a caller replace the HTTP client wholesale would also let them accidentally remove the dial-time SSRF guard. Refusing custom transports keeps the guard simple but makes otherwise valid deployments impossible.

Decision

Outbound trust and outbound reachability are separate knobs. A custom transport alone never widens the network boundary.

op.WithJWKSHTTPTransport lets embedders supply TLS trust and transport settings for RP JWKS fetches. What the SSRF gate still sees depends on the transport:

  • *http.Transport: the package replaces its dial functions with the gated dialer, so the SSRF deny-list still runs at connect time.
  • Any other http.RoundTripper: checked on the request URL of every request and redirect hop only, which does not stop a DNS-rebinding peer.
  • Through a proxy: the dial-time gate sees the proxy's address, and the target URL is checked once before the hand-off.

Private-network admission remains controlled by the explicit WithAllowPrivateNetwork(op.OutboundJWKS, op.OutboundJAR, ...) policy knob.

The same bounded-fetch posture is used for JAR JWKS, client-auth JWKS, sector_identifier_uri, and Back-Channel Logout destinations: URL-shape validation first, dial-time network checks second, bounded response handling third.

Implemented in op/options_session.go, internal/netsec, internal/securefetch, internal/jar, internal/endpointsupport/clientauth.go, and internal/registrationendpoint/sector_identifier.go.

24. Open DCR without scope ​

Registration, issuer, and outbound fetch

Spec RFC 7591 makes scope optional in registration metadata. In an IAT-bound registration flow, the issuer of the Initial Access Token is operator code and may attach an explicit AllowedScopes policy. In open registration, there is no such operator-issued per-request envelope.

Conflict Treating omitted scope as "all public scopes" makes a no-body open-registration POST surprisingly powerful. Treating it as "no scopes" is safer, but a client then has to ask for scope explicitly, or the embedder has to configure a default.

Decision

Open registration defaults omitted scope to an empty registered scope set.

A later /auth request that asks for scopes the client did not register is rejected as invalid_scope. Embedders that intentionally want a public default opt in via DynamicRegistration.OpenRegistrationDefaultScopes, which is validated against the OP scope registry at construction time.

The IAT-bound path keeps the broader operator-trusted default: if the IAT has no AllowedScopes, the request can default from the public scope registry.

Implemented in op/registration.go, op/options_validate.go, internal/registrationendpoint/handler.go, internal/registrationendpoint/register.go, and internal/registrationendpoint/metadata_validate.go.

25. DPoP nonce before assertion replay ​

Protocol profiles and discovery

Spec RFC 9449 lets an AS challenge a token request with use_dpop_nonce; the client then retries with a fresh DPoP proof. RFC 7523 requires replay protection for JWT client assertions through jti / time-window checks.

Conflict If the token endpoint authenticates the client first, the first request consumes the client_assertion jti and then returns use_dpop_nonce. A client that retries the same form body with only the DPoP proof changed then fails as invalid_client / assertion replay, even though the nonce challenge is the expected RFC 9449 recovery path.

Decision

DPoP nonce validation runs before client authentication on token and PAR paths.

A missing or stale DPoP nonce produces the nonce challenge without burning the form-borne client_assertion. Once the DPoP proof is acceptable, the normal private_key_jwt replay cache consumes the assertion exactly once.

Implemented in internal/tokenendpoint/authcode.go, the refresh-token token path, and internal/parendpoint/par.go; pinned by internal/tokenendpoint/refresh_dpop_nonce_pkjwt_test.go.

26. CIBA polling and errors ​

Protocol profiles and discovery

Spec CIBA Core 1.0 defines poll, ping, and push modes, slow_down, and a general §13 error vocabulary. It leaves the poll-abuse lockout threshold implementation-defined. FAPI-CIBA layers signed request-object requirements on top of CIBA but conformance modules still expect CIBA-shaped wire errors.

Conflict A strict poll ladder without a cap lets a client ignore slow_down until auth_req_id expiry. A hard-coded low cap protects production but can break OFCS modules that intentionally exercise repeated polling.

Separately, surfacing detailed JAR parser errors from /bc-authorize would be helpful for debugging but leaks a JOSE-specific taxonomy through a CIBA endpoint that expects invalid_request.

Decision

The library implements CIBA poll mode only and applies a bounded slow-down strike counter.

  • The default cap is 5 violations. CIBAGrant.MaxPollViolations raises or lowers it; 0 means the library default, and 255 effectively disables lockout because the counter is uint8. Production deployments should keep a finite cap.
  • op.New also refuses a negative CIBAGrant.DefaultRequestTTL or CIBAGrant.PollInterval, and under FAPI-CIBA a default or maximum expiry above 10 minutes.

At /bc-authorize, duplicate single-valued parameters are rejected and request-object failures map to invalid_request per CIBA Core §13. Under FAPI-CIBA, the JAR verifier requires iat, caps request-object lifetime at 60 minutes, and accepts RP JWKS fetched through the same SSRF-bounded fetcher as the rest of the OP.

Implemented in op/options_ciba.go, internal/ciba/polling.go, internal/cibaendpoint, internal/jar, and op/op_builders.go.

27. JWE allow-list and nesting cap ​

JOSE and request objects

Spec RFC 7516 and RFC 7519 allow a broad set of JWE algorithms and nested JWT shapes. In principle, a JWT can be a JWE wrapping another JWE wrapping another JWT, and so on.

Conflict Accepting every JOSE shape supported by a general-purpose library widens the cryptographic surface beyond what the OP needs. A per-layer plaintext cap protects memory for each decryption, but it does not by itself bound an attacker-controlled nesting chain.

Decision

JWE is closed by policy and nested JOSE traversal is capped.

The OP:

  • accepts only its explicit alg and enc allow-lists;
  • rejects unknown crit;
  • caps decrypted plaintext at 1 MiB;
  • rejects the 11th JOSE layer with ErrJWENestingTooDeep.

A normal encrypted request object is two layers at most (JWE wrapping JWS), so the 10-layer ceiling leaves room for future protocol shapes without making recursion unbounded.

Implemented in internal/jose/jwe.go, wired through JAR / PAR verification; pinned by internal/jar/verify_jwe_test.go plus scenario coverage for deeply nested encrypted request objects.

28. Custom-grant refresh tokens ​

Tokens, grants, and revocation

Spec RFC 6749 §6 treats a refresh token as a credential issued by the authorization server. RFC 9700 §4.14.2 requires the AS to detect refresh-token replay for public clients with either sender-constrained refresh tokens or rotation; rotation issues a new token, invalidates the previous token while retaining the relationship, and revokes the active token when reuse reveals a compromise.

Conflict Custom grants are intentionally handler-defined. Letting a handler return an arbitrary refresh-token string is tempting because it mirrors AccessToken, but the OP cannot rotate, bind, or cascade-revoke a credential whose value and lineage it did not create. Rejecting refresh tokens entirely is simpler, but it prevents legitimate custom grants and token-exchange policies from using the OP's existing rotation machinery for long-lived service chains.

Decision

Handlers signal intent; the OP owns the refresh-token value.

CustomGrantResponse.IssueRefreshToken asks the OP to mint and persist the refresh token through RefreshTokenStore. The record shares the issued access token's grant identity and DPoP / mTLS confirmation, so the normal rotation, replay cascade, grant revocation, and introspection semantics apply. A handler never supplies the refresh-token string directly. Token exchange carries the same policy shape through TokenExchangeDecision.IssueRefreshToken.

Issuance is a five-gate decision:

  1. the provider must serve refresh_token;
  2. the client must be registered for it;
  3. the response subject (or the bound access token's subject) must be non-empty;
  4. the response scope must be non-empty;
  5. the response may contain at most one audience.

If any gate fails, the access-token response and any otherwise eligible ID Token still succeed, the refresh token is omitted, and custom_grant.refresh_dropped records the drop reason. An empty scope therefore drops only the refresh token.

The handler, not a grant record, vouches for the subject of a generic custom grant's credentials. Provider.RevokeSubject therefore leaves them live until they expire or are revoked individually, and pairwise /userinfo answers invalid_token for them, unless the handler records a grant. Revoking them when an account is removed is the handler's job. Provider.ForgetSubject calls RevokeSubject and also deletes the subject's browser sessions, lockout record and authentication-factor records, so it does not reach these credentials either.

Token exchange differs: its credentials live under the subject_token's grant, so revoking that grant revokes them, and a refresh token obtained through exchange stops rotating once the subject is deleted.

Implemented in internal/tokenendpoint/customgrant.go.

29. Device-code revocation cascade ​

Tokens, grants, and revocation

Spec RFC 8628 defines the device authorization state machine but does not spell out what should happen to access tokens already issued from a device authorization when the user later removes that device. RFC 7009 gives the AS a revocation endpoint, but device unenrolment is an embedder-owned UX action rather than a standard wire request.

Conflict If revocation only flips the device-code row to denied, future polls stop but already-issued access tokens remain usable until exp. If every embedder must subscribe to an audit event and run RevokeByGrant itself, the security posture depends on remembering out-of-band glue code in each deployment.

Decision

The public helper owns the cascade. devicecodekit.Revoke:

  1. denies the device-code row;
  2. recomputes the grant identity the device flow allocated for it — a one-way derivation of the device-code ID, so the device code itself is never republished — and retires what was issued under that grant: JWT access tokens through Deps.RevocationStrategy, opaque access tokens when Deps.OpaqueAccessTokens is set, and refresh-token chains when Deps.RefreshTokens is set;
  3. when Deps.Grants is set, deletes the consent record last, after the cascade completed, so that consent no longer satisfies a later authorization request.

Every class is attempted even if an earlier one fails. A partial failure returns a joined error and records cascade_complete=false, and repeating the call finishes the job.

A substore left nil is skipped. JWT-only deployments, and deployments that drive revocation out of band, still get the denied row and the audit event, and the helper does not pretend the skipped cascade happened.

device_code.revoked carries one extra per attempted class (revoked_access_tokens, grant_tombstone_written, revoked_opaque_access_tokens, refresh_token_cascade_complete, grant_record_deleted) plus cascade_complete, so operators can alert on a cascade that fell short.

Implemented in op/devicecodekit.Revoke.

Browser, session, and UI boundaries

Spec OIDC Core and OAuth 2.0 define the authorization and token protocols, but they do not mandate that an OP use browser cookies at all. Cookie encryption is an implementation detail of this library's interaction/session binding.

Conflict Two implementation postures are plausible:

  • Always require cookie keys — simpler documentation and fewer branches, but a client_credentials-only OP or other no-browser deployment must configure a secret it will never use.
  • Require cookie keys only when browser authorization is enabled — more precise and friendlier to machine-to-machine deployments, but the option layer must know which grants need encrypted cookies before any handler is mounted.

Decision

WithCookieKeys is required when the enabled grant set contains authorization_code, which is true for the default grant set.

WithIssuer, WithStore, and WithKeyset are unconditional construction requirements. The authorization-code flow uses encrypted session / CSRF cookies to bind browser interaction state; client_credentials-only deployments that explicitly remove authorization_code can boot without cookie keys.

The rule is centralised in validateCookieKeysRequired, so future grants that need authorize-endpoint cookies can opt into the same check without changing handler code. Individual keys must still be exactly 32 bytes, matching AES-256-GCM.

Implemented in op/options_validate.go; documented in Required options.

31. Grant Management stays opt-in ​

Tokens, grants, and revocation

Spec OAuth 2.0 Grant Management is still an IETF draft. It defines stable grant_id values plus create, replace, merge, query, and revoke actions, but the wire shape may still change before publication.

Conflict A long-lived consent OP benefits from naming grants and letting clients query or revoke them later. Enabling a draft feature by default, however, would expose a moving wire contract to every deployment and make the discovery document advertise semantics the operator may not be ready to support.

Decision

Grant Management is off by default and explicitly marked experimental.

op.WithGrantManagement(op.GrantManagement{Actions, ActionRequired}) must name the exact action set the OP accepts. The same set is advertised in discovery and enforced at PAR / authorize-time (create / replace / merge) and endpoint-time (query / revoke). Unknown actions, duplicate actions, an empty action set, or an action/grant_id mismatch fail at construction or request validation.

When enabled, token responses carry grant_id and the /grant_management query / revoke endpoint is mounted:

  • a confirmed missing or foreign grant returns 404;
  • a store error that leaves existence uncertain returns 500 server_error without attempting a cascade;
  • a cascade failure also returns 500, emits the failure audit event, and leaves the grant retryable/queryable;
  • a final delete racing with another successful delete is the idempotent 204 case.

The draft remains isolated behind this option, so a future wire-incompatible draft bump can be handled as an explicit migration rather than an accidental default change.

Implemented in op/grant_management.go, internal/parendpoint, internal/authorizeendpoint, internal/grantmgmtendpoint, and internal/tokenendpoint.

32. Per-authorization state on the credential ​

Tokens, grants, and revocation

Spec OIDC Core 1.0 defines auth_time, acr and amr as properties of the authentication event behind an ID Token, and scopes a claims request (§5.5) to the authorization request that carried it. RFC 9396 ties authorization_details to what the resource owner granted in that request.

Conflict The library keeps one store.Grant per (subject, client) pair and reuses it across authorizations, because it carries the consent. Reading auth_time, claims or details from that shared record when a credential is used lets a later authorization rewrite what an earlier one reports. A code redeemed after a second login by the same user would show the second login in its ID Token, release the claims the second request asked for, and pick up authorization_details it was never granted.

Decision

The credential carries the state of the authorization that issued it.

store.AuthorizationCode, store.RefreshToken and store.OpaqueAccessToken hold AuthTime, ACR, AMR, AuthorizationDetails and the claims request. A refresh rotation copies them forward, so the ID Token, access token and introspection response of a chain describe its own authorization.

  • A Grant Management replace still withdraws details from live tokens; nothing a later authorization adds reaches them.
  • An authorization served silently without a claims parameter releases only what its own request asked for, never what an earlier request on the same grant asked for.

JWT access tokens issued from a code or its refresh chain also carry a userinfo_claims private claim: the userinfo member of the claims request, or {} when it requested none. /userinfo releases claims from it; resource servers should ignore it. The name is reserved, so a custom grant cannot set it through its extra claims.

AuthorizationRecorded marks a row that carries the state. A row without the marker, such as one written by a store that does not persist these fields, falls back to the grant, so a bring-your-own store keeps working with the shared-grant behaviour until it persists them.

Implemented in internal/tokenendpoint, internal/tokens and op/store.

33. Step-up on a reused session ​

Browser, session, and UI boundaries

Spec OIDC Core 1.0 §3.1.2.1 defines prompt and max_age, and RFC 9470 defines step-up through acr_values. Neither says how an OP's own policy rules interact with single sign-on, where the user already holds a session.

Conflict A LoginFlow rule such as RuleClient or RuleScope guards a client or scope behind an extra factor. If rules are evaluated only while the factor chain runs, a session established with the primary factor alone reaches the guarded client or scope through a cached grant that covers the request, or through the first-party consent skip, without ever meeting the rule.

Decision

Rules and the Decider are evaluated before minting from a session, as if the chain had just completed Primary.

When one demands a step, /auth runs the chain from the session's subject instead of minting, and prompt=none answers interaction_required.

A rule's step counts as done when the session's amr already records what that step records:

  • pwd for a password;
  • otp for TOTP, recovery codes and email OTP;
  • hwk for a passkey (hwk or swk when the step does not require user verification).

The three otp factors stand in for each other because the amr does not tell them apart. An op.StepOneOf counts when any of its steps would, and an op.ExternalStep or op.StepCaptcha is never satisfied by the session and is asked again. A relying party that needs a fresh factor sends max_age or prompt=login, or requests an essential acr above the session's level. Risk-driven rules still fire only inside the chain.

A session likewise satisfies acr_values and an essential acr claim when the configured op.ACRPolicy accepts one of the requested values for the session's level. With op.ACRLevels{"urn:example:mfa": op.AAL2}, an AAL2 session is reused and an AAL1 session still re-authenticates. The policy that picks the acr a response reports is the one that decides whether a session may serve it.

After a step-up, the issued tokens keep the session's acr, amr and auth_time rather than reporting only the step-up factors. Risk assessors see the session's methods in AMRSoFar, and step-up factors are audited as mfa.* events rather than login.*.

The evaluation fails closed:

  • a panicking Rule.When counts as a match;
  • a Decider that panics, returns nil, a nil pointer or an unknown shape denies;
  • a factor whose interaction.Result names a subject other than the one an earlier factor or the account chooser bound ends the chain with a server error, and the same check covers a factor that completes in Begin without a prompt.

Implemented in internal/authorizeendpoint, op/rule.go and op/decider.go.

34. ID-JAG signing algorithm ​

Tokens, grants, and revocation

Spec The Identity Assertion JWT Authorization Grant draft (draft-06) makes RS256 mandatory-to-implement for both the identity provider and the resource authorization server, with ES256 a SHOULD for the resource side.

Conflict This OP signs every artifact it issues with one algorithm (#11). Adding a separate algorithm for one token type would reopen the single-algorithm policy for a draft that is still moving. With the default ES256, a resource authorization server that implements only the mandatory algorithm cannot verify this OP's ID-JAGs.

Decision

An ID-JAG is signed with the OP's issuing algorithm, and there is no separate ID-JAG setting.

  • With the default ES256, the identity provider role is reported as partial in the RFC compliance matrix.
  • An OP built with op.WithSigningAlgorithm(op.AlgRS256) signs its ID-JAGs with RS256, the draft's mandatory algorithm, and the same switch moves every other issued token to RS256 as well.

The optional id_jag_signing_alg_values_supported discovery member is not published under either algorithm. Verification of incoming assertions accepts RS256, PS256, ES256 and EdDSA, so the resource authorization server role is not affected.

An embedder whose counterpart requires RS256 for ID-JAGs while other relying parties require ES256 needs external or multi-algorithm signing, which is a separate decision.

Implemented in internal/customgrant/tokenexchange and internal/tokens.

35. Single-use assertions ​

Tokens, grants, and revocation

Spec RFC 7523 §3 makes jti optional and treats single use as a server choice. The ID-JAG draft allows a client to submit the same grant again within its lifetime to obtain further access tokens.

Conflict Resubmission lets a client renew an access token without going back to the identity provider, but it also turns a leaked assertion into a bearer credential that works for its whole lifetime. A short assertion lifetime narrows that window without closing it.

Decision

jti is mandatory and every assertion is redeemable once, for the plain and the ID-JAG profile alike.

The jti is recorded in the consumed-jti store under its own namespace, and the replay check runs last, so a rejected assertion does not burn its jti. The accepted lifetime is capped at five minutes plus a 60 second leeway, and there is no knob to relax either.

A client that needs a new access token after the first expires obtains a new assertion. In a multi-node deployment the store must be shared; otherwise the replay window is as long as the assertion lifetime.

Implemented in internal/assertiongrant and internal/tokenendpoint.

36. client_id URL fetch limits ​

Registration, issuer, and outbound fetch

Spec The Client ID Metadata Document draft lets any HTTPS URL act as a client_id, and the authorization server fetches that URL on the strength of a request from an unauthenticated party. The draft names the SSRF risk and suggests limits, but leaves the values to the implementation.

Conflict The fetch is triggered by an attacker-chosen URL, so every limit is an attack-surface setting. Making the limits configurable invites a deployment to loosen them for one awkward client. Fixed limits can refuse a client whose document is unusually large or slow.

Decision

The fetch uses the same dial-time SSRF gate as the other outbound fetches, and its limits are fixed. None of these values is an option.

  • URL: only https URLs with a path and no query, fragment, userinfo or dot segments are fetched (255 bytes at most, ASCII host). Anything else is an unknown client and is never fetched.
  • Response: must be a 200 with application/json or application/*+json. Redirects are not followed, the body is capped at 5 KiB, and the request times out after 5 seconds.
  • Network: private, loopback, link-local and metadata addresses are refused when the connection is made.
  • Cache: a document is cached for its Cache-Control lifetime clamped to between 60 seconds and 24 hours. A failed or invalid fetch is remembered for 5 seconds, so the same URL is not refetched on every request.

op.WithAllowPrivateNetwork(op.OutboundClientIDMetadata) admits private targets and http for relying parties on a private network. It does not affect the document's jwks_uri, which stays under op.OutboundJWKS.

The reason for a failed fetch goes to the audit stream only; the client sees an unknown client. A registered client with the same ID always wins and nothing is fetched.

Implemented in internal/cimd and internal/securefetch.

Registration, issuer, and outbound fetch

Spec The MCP authorization specification requires showing the redirect URI host on the authorization screen and recommends warning when it is a loopback address. The draft recommends showing the host of the client_id URL.

Conflict A URL-identified client has no registration step where an operator reviewed its redirect URIs. Anyone can reuse a well-known client_id URL and send the user to a redirect URI the document legitimately lists, such as a loopback address on the attacker's machine. The client_id host alone cannot tell the two apart. Showing a logo is the usual way to build trust, but fetching it from the consent page lets the client's host track who is looking.

Decision

The consent screen shows the host of the client_id URL, the host of the requested redirect_uri, and a warning when that redirect is a loopback address. The logo is never shown.

  • A URL-identified client is never covered by the first-party consent skip, and putting such a URL in op.WithFirstPartyClients is refused at construction.
  • PKCE is required for these clients even with private_key_jwt.
  • Custom consent drivers receive the same values in interaction.ClientView (MetadataDocumentHost, RedirectHost, RedirectLoopback) and are expected to display them.

Implemented in internal/authorizeendpoint, op/interaction and internal/authorize.

38. Changed metadata documents ​

Registration, issuer, and outbound fetch

Spec The draft says an authorization server may need to invalidate existing grants when a client's metadata changes, such as its redirect URIs or keys.

Conflict Revoking on change needs the OP to compare each fresh document with the previous one, keep stale copies, and decide which edits count. Without it, a document whose jwks is replaced lets the new key redeem refresh tokens issued before the change.

Decision

A new document applies from the next fetch, and existing grants and refresh tokens are not revoked.

To exploit this, an attacker would have to take over the host that serves the document and also hold a stolen refresh token for that client. Both are needed, and the refresh token alone is already a bearer secret for a public client.

Redirect URIs and keys are re-read from the latest document on every use, so a removed redirect_uris entry stops working as soon as the cache expires. If the document disappears, the next refresh finds an unknown client and fails with invalid_client.

Revisit this if the draft makes revocation on change mandatory.

39. Metadata fetch volume ​

Registration, issuer, and outbound fetch

Spec The draft warns that an authorization server can be made to fetch many URLs and suggests rate limiting, without prescribing where.

Conflict A per-origin budget would be a second rate limiter inside a library that deliberately leaves generic HTTP rate limiting to the embedder. Having no bound at all would turn the OP into a request amplifier.

Decision

Fetches are bounded by the shared outbound gate, and there is no per-origin budget; ClientIDMetadataDocuments.Allow is the throttle.

  • Fetches share the process-wide outbound load gate with JAR and client JWKS fetches (64 concurrent).
  • Concurrent requests for one URL are merged into a single fetch.
  • Failures are cached for 5 seconds.

The worst case is therefore 64 concurrent fetches, each capped at 5 seconds and 5 KiB. When the gate is full, the request fails with server_error and the failure is not cached.

ClientIDMetadataDocuments.Allow receives the client_id URL on every resolution, cache hits included, before any fetch. An embedder narrows exposure there with an allow-list or its own limiter, and a tightened list takes effect immediately.

Implemented in internal/cimd and internal/remotecache.

40. DPoP proof error codes ​

Protocol profiles and discovery

Spec RFC 9449 §7 defines the error code invalid_dpop_proof for a proof the server rejects, and §8 defines use_dpop_nonce for a proof that lacks the nonce the server requires.

Conflict /token, /par and the other form-post endpoints already answer every malformed request with the RFC 6749 §5.2 codes. A separate code for one header makes every relying-party library add a branch for DPoP alone, although its handling of a malformed proof is the same as for any other malformed request.

Decision

A form-post endpoint returns 400 invalid_request for a malformed, mis-signed, stale, replayed or mismatched proof.

The description names the failure in RFC 9449 wording; the underlying cause is not echoed.

Two codes stay as the RFC defines them, because a client acts on each differently:

  • a missing or stale nonce returns use_dpop_nonce with a fresh value in the DPoP-Nonce header, so the client retries with it;
  • a protected resource that rejects a proof or a token presented under the wrong scheme returns 401 with WWW-Authenticate: DPoP error="invalid_token" (see DPoP).

Implemented in internal/dpop.