Skip to content

Latest commit

 

History

History
1505 lines (1393 loc) · 89.2 KB

ARCHITECTURE.md

File metadata and controls

1505 lines (1393 loc) · 89.2 KB

Architecture

FAPIgo provides hardened, separately conformant FAPI client, authorization-server and resource-server engines built on one rigorously tested protocol core.

FAPI 2.0 defines requirements across clients, authorization servers and resource servers, and PAR and DPoP each impose role-specific behaviour on top of that (FAPI 2.0 Security Profile, RFC 9126, RFC 9449). The client and server roles in particular have different trust boundaries, state machines and failure modes, so this module does not expose one generic API that tries to behave as both.

FAPI is defined for high-value scenarios alongside an explicit attacker model (FAPI 2.0 Attacker Model), and RFC 9700 captures broader OAuth 2.0 security best current practice (RFC 9700). The guiding rule for every public API in this module, not just the protocol message formats, is:

Expose business decisions, not protocol mechanisms.

A caller decides who authenticated, what access was approved and which durable infrastructure to use. It must never be able to weaken request validation, bypass replay prevention, modify a signed output, or construct a security-sensitive protocol artefact by hand. See "Design rules" below.

The one rule everything else follows

Share protocol implementation, not role-level APIs.

Concretely:

  • client, server and resource are separate public packages with separate constructors (client.New, server.New, resource.NewVerifier), not one fapi.New(role, ...) behind a role enum. A role enum tends to produce one large config type with fields that only make sense in some modes, and makes it easy to accidentally wire up the wrong role.
  • They may all depend on the same internal/ protocol core (JOSE parsing, JWK validation, DPoP thumbprints, canonicalization, ...), but client must never import server or vice versa. Both public packages must stay independently usable.
  • Where client and server both touch the same wire format, the operation is asymmetric: signing lives on one side, verification on the other, as two distinct types even when they share JOSE encoding helpers (e.g. internal/jarm: server signs, client verifies; internal/requestobject: client signs, server verifies).

Package layout

fapigo/                    // package fapi: shared value types only
├── client/                // RP public API
│   └── sessioncookie/      // the session handle bound to the browser in one encrypted cookie
├── server/                // AS public API
│   └── interactioncookie/  // an interaction carried in one encrypted browser cookie
├── resource/              // RS verification API
├── serverresource/        // a resource.Verifier matching a server in the same process
├── federation/            // OpenID Federation 1.0 leaf-entity primitives — a shared
│                           // subsystem package like keys/storage, not a fourth role
├── extension/             // shared custom-parameter & RAR definitions
├── pairwise/              // pairwise subject (PPID) derivation, shared by server and resource servers
├── storage/                // role-specific storage contracts, shared replay primitive,
│                           // StoreAssurance capability checks + reusable contract test suite
│   └── memstore/           // in-memory reference implementation (dev/test only, never production)
├── keys/                  // operation-based signing/verification contracts (Sign, never Signer)
│   └── ephemeral/          // in-memory reference KeyManager/ClientKeySource (dev/test only, never production)
├── fapihttp/               // hardened HTTP transport used by all three roles
├── backchannelhttp/        // fapihttp-backed CIBA §10.2 ping-delivery Notifier
├── fapitest/               // in-process interop harness (test-only)
├── internal/
│   ├── jose/                 // JWT/JWS/JWK parsing & algorithm policy
│   ├── jwe/                   // JWE (RFC 7516) encryption/decryption
│   ├── dpop/                 // DPoP proof create (client) / verify (server, resource)
│   ├── mtls/                   // RFC 8705 mTLS certificate thumbprint (x5t#S256)
│   ├── pkce/                 // PKCE generate (client) / verify (server)
│   ├── par/                  // PAR wire format
│   ├── jarm/                  // JARM sign (server) / verify (client)
│   ├── requestobject/         // request object sign (client) / verify (server)
│   ├── clientassertion/       // client assertion create (client) / verify (server)
│   ├── clientattestation/      // OAuth 2.0 attestation-based client authentication
│   ├── federation/             // OpenID Federation 1.0 Entity Statement wire primitives
│   ├── token/                 // token issue (server) / validate (client, resource)
│   ├── metadata/               // AS/client metadata parsing
│   ├── httperror/               // shared error-type (server/resource/federation) mechanical bookkeeping
│   ├── resourceerr/             // the store-outage check and error builder resource and serverresource share
│   ├── nofollow/                // outbound requests that never follow a redirect or resend a body
│   ├── critical/                 // JWS/JWE "crit" header parameter check (RFC 7515/7516)
│   ├── authchallenge/           // WWW-Authenticate challenge parsing (RFC 9110 §11)
│   ├── grantrevocation/         // the grant_id claim and revocation key shared by server and resource
│   ├── canonical/               // URL/JSON canonicalization
│   ├── strictb64/               // canonical base64 decoding for JOSE segments, JWK members and x5c
│   ├── strictjson/              // case-sensitive JSON member names for JOSE/metadata decoding
│   ├── sealedcookie/            // the encrypted cookie behind interactioncookie and sessioncookie
│   └── validation/               // generic strict-parsing helpers
├── cmd/
│   ├── conformance-as/             // the AS (and its RS) the OIDF suite tests, built on server/resource
│   ├── conformance-client/         // the RP the OIDF suite tests, built on client
│   └── conformance-federation-trust-anchor/ // a Trust Anchor for the federation test plans
├── conformance/
│   ├── client/                  // OIDF RP/client test plan config + scripts
│   ├── server/                  // OIDF AS test plan config + scripts
│   ├── resource/                 // placeholder: the RS is tested by the resource package's own tests (not covered by OIDF)
│   └── scripts/                  // run-all.sh: every AS, RP and federation plan in one run
└── examples/                    // runnable demos, each its own module, public API only (see examples/README.md)
    └── internal/demokit/          // the demos' shared local TLS, CA and host routing

Design rules

1. Separate public constructors per role

rp, err := client.New(clientConfig, clientDeps)
as, err := server.New(serverConfig, serverDeps)
rs, err := resource.NewVerifier(resourceConfig, resourceDeps)

Not fapi.New(RoleClient, ...).

2. Shared value types only where semantics match

Identifiers and enums with one wire-level meaning regardless of role (ClientID, RegisteredRedirectURI, SignatureAlgorithm, KeyManagementAlgorithm, ContentEncryptionAlgorithm) live in the root fapi package. Anything whose meaning depends on trust state does not — client.BeginAuthorizationRequest (an instruction to construct a request) and server.InteractionRequest (a request the server has already validated, handed to the login UI) are deliberately different types, so code can never accidentally treat untrusted input as validated protocol state.

Two more value types belong here because every role needs them identically:

  • Secret — wraps any token, code or credential value that could end up in a log line by accident. String(), GoString() and MarshalText() all redact; Reveal() is the only way to get the raw value out. client.TokenSet and server.TokenResult use it for every token they carry; resource.AuthorizationContext carries no raw token value at all.
  • URL (constructed via ParseIssuerURL / ParseEndpointURL / ParseRedirectURL, not a bare string) — enforces absolute, HTTPS (except an explicitly enabled loopback exception, or, for a native app's redirect, a private-use scheme), no fragment, no embedded credentials, normalized host. Registered redirect URIs are compared under OAuth registration semantics, never generic URL equivalence or automatic normalization — see fapi.RegisteredRedirectURI — but for a native client's loopback redirect URI, which matches on any port (RFC 8252 §7.3, §8.4).

SignatureAlgorithm is a closed enum (ES256, PS256, ...), never a bare string accepted from a caller or read directly out of a JWT header before the engine has decided which algorithms are even permitted for that operation.

3. Client: workflow methods, not primitives

BeginAuthorization → AuthorizationSession (opaque, carries the authorization URL and a session handle) → HandleAuthorizationResponse → ExchangeCode, or the combined CompleteAuthorization so a caller cannot skip callback validation before exchanging a code; then RefreshTokens for a new access token from the same grant, and RevokeToken to tell the server it's no longer needed (RFC 7009). CIBA (BeginBackchannelAuthentication → PollBackchannelAuthentication) and RequestClientCredentialsToken are the other two flows. The intermediate ValidatedAuthorizationResponse is opaque, can only be constructed by HandleAuthorizationResponse, and is redeemed only by the client that validated it; a BackchannelAuthenticationSession is polled only by the client that began it.

BeginAuthorization internally: generates state/nonce/PKCE, builds and signs the request object when required, authenticates to and calls the PAR endpoint (RFC 9126), receives request_uri, builds the browser URL, and persists correlation state.

HandleAuthorizationResponse internally validates: that the callback belongs to the user agent that began the flow — the caller passes back the SessionHandle it bound to that browser (an HttpOnly cookie; client/sessioncookie sets and reads one), and a callback whose state doesn't match it is rejected before anything is consumed, closing login CSRF (RFC 9700 §4.7); a native app whose session store is its own on-device storage has that binding already, and with Config.CallbackBinding set to CallbackBindingDeviceLocalStore completes from the callback alone, state taken from the verified signed response under Message Signing — then correlation state, issuer, JARM signature and claims, audience, expiry, response mode, authorization-code presence, error-response integrity, and replay.

The response is a closed sum type, not one struct with optional fields — a caller cannot assume every callback carries a code, and cannot forget to branch on an error case that was silently left zero-valued.

Calling a protected resource is a generic capability, not something special-cased to UserInfo. Client.ProtectedResource(tokens) returns a *ResourceClient bound to that token set; its Do(ctx, req) takes any *http.Request and sender-constrains it — a fresh DPoP proof plus the RFC 9449 §9 nonce-challenge retry under SenderConstrainDPoP, or a plain Bearer credential under SenderConstrainMTLS — entirely from Config.SenderConstrain, with no caller branching on which mode is active. It sends only to an https URL (loopback http only under AssuranceDevelopment) and follows no redirect, so the credential never reaches an origin the caller didn't name. FetchUserInfo is just this method's first caller, not a special path: it builds a GET Config.Endpoints.UserInfo request and hands it to the same Do. A caller with its own protected resource (a FAPI 2.0 "accounts" endpoint, for instance) reuses ProtectedResource(tokens).Do directly rather than reimplementing proof creation and nonce retry against internal/dpop itself.

4. Client session storage is atomic-consume, not CRUD

storage.SessionStore.Create / Consume — no GetSession / DeleteSession. Consume atomically looks up and retires a session by its state, returning its expiry and the opaque, versioned Record the client wrote — the nonce, PKCE verifier, expected issuer, redirect URI and response mode, and any max_age — in one step, to prevent callback replay and race conditions. As with the server's grant records, a store never interprets the record, so a new client feature never needs a store change; the client refuses a session whose record is missing.

5. Keys are handles and operations, never raw private keys

KeyManager.Sign / KeyManager.PublicKey, keyed by purpose (ClientAuthentication, RequestObjectSigning, DPoPProofSigning). A client's DPoP proofs are signed through its KeyManager under the DPoPProofSigning purpose, never with a crypto.PrivateKey this module holds — DPoP's value depends on the private key never leaving its holder (RFC 9449).

keys.KeyManager never hands back a crypto.Signer, only a Sign operation and a public JWK — the same model server uses for its own signing keys (KeyManager.Sign, published through Server.PublicJWKS), so both a client's and a server's key material can be backed by an HSM or a remote signing service without the module ever holding private key material in process. client.PublicJWKS is this same publication surface on the RP side — its ClientAuthentication key always, its RequestObjectSigning key under message-signing, and its IDTokenDecryption/UserInfoDecryption encryption key when configured, never its DPoPProofSigning key (RFC 9449 embeds that one directly in each proof, not via a discoverable JWKS). Resolving a client's verification keys (for request-object and client-assertion signatures) is a separate concern from KeyManager — it belongs in server's client-lookup path, prefers administratively pre-resolved/registered keys over live JWKS fetches in the request-handling path, and where it does fetch JWKS applies the same SSRF, size-limit, content-type, bounded-redirect and stale-key rules as any other outbound fetch (see rule 6).

keys.Decrypter (UnwrapContentEncryptionKey / EncryptionPublicKey) is the same rule applied to recovering an encrypted ID token's or UserInfo response's content-encryption key: never a raw private key, only an operation and a public JWK. keys.ECDHAgreer / KeyDecrypter go one step further, splitting that operation into the one private-key primitive a real HSM/KMS actually exposes (raw ECDH agreement, or an RSA-OAEP-256 decrypt) so an embedder implements only that primitive, never the JOSE Concat-KDF/AES-unwrap machinery this module already owns. keys.NewKeyManagerFromSigners and keys.NewDecrypter / NewSingleKeyDecrypter are this package's own on-ramps for either rule — the first adapts any crypto.Signer (what most HSM/KMS Go wrappers already implement) into a KeyManager; the others assemble a Decrypter from an ECDHAgreer/KeyDecrypter backend — so a production HSM/KMS integration often needs zero custom glue code, not just an interface it's free to satisfy. See keys/doc.go.

Rotation is the seam these interfaces leave open on purpose — this module never prescribes a key's lifecycle — but two places needed explicit support, not just permission: keys.RotatingKeyManager is KeyManager's optional extension for publishing more than one currently-valid key per purpose (an outgoing key alongside an incoming one) during a rotation's overlap window, which server.PublicJWKS uses automatically when a configured KeyManager implements it; and Decrypter's UnwrapRequest carries the JWE's own "kid" through to ECDHAgreer/KeyDecrypter so a backend holding more than one registered decryption key can tell which one a given ciphertext was actually wrapped to, instead of only ever being able to guess "the current one."

Publishing a JWK Set is where this module makes one deliberate, narrow exception to "no shared role-level types" (rule 1, and the package-layout note above): client.PublicJWKS/server.PublicJWKS are both thin callers over keys.PublicJWKS, and client.PublicKeySet/ server.PublicKeySet (and their PublicJWK) are type aliases of keys.PublicKeySet/keys.PublicJWK, not each role's own redefinition. A JWK Set's wire shape (RFC 7517) genuinely doesn't differ by who's publishing it, unlike a session handle or a verification request — so sharing the type here removes real duplication (keys.PublicJWKS resolves every keys.SigningKeyUse/keys.EncryptionKeyUse it's given, honoring RotatingKeyManager, publishing a key several uses share once and refusing a kid that names two different keys — logic client and server had each independently written once already) without reintroducing a generic cross-role API: the caller still decides which purposes belong in the set, so all the role-specific business logic (DPoP's key excluded, RequestObjectSigning only under message-signing, and so on) stays exactly where it was. The same function is what lets an integrator publish a real JWKS from key material and a declared algorithm alone, with no client.Client or server.Server constructed at all — the seam a from-scratch onboarding flow needs and neither role's own PublicJWKS method can offer by itself, since both require a fully validated Config/Dependencies first.

6. HTTP is a narrow interface, hardened internally

Callers supply a Do(*http.Request) (*http.Response, error); fapihttp wraps it with strict TLS verification, response-size limits, bounded/no redirects (an *http.Client is copied with its own redirect following turned off, a redirect another client followed itself is refused, and a request body is never handed over in a form any client could resend across a 307/308), endpoint origin validation, timeouts, body-read deadlines, SSRF restrictions on discovery/JWKS fetches, and content-type checks. The public API never asks a caller to hand-build a PAR body or similar wire-level payload.

7. Server: a state machine over client-generated artefacts

PushAuthorizationRequest → BeginAuthorization → CompleteAuthorization → ExchangeAuthorizationCode → RefreshAccessToken, plus RevokeGrant to withdraw a grant, RevokeToken for a client to revoke its own refresh token (RFC 7009), the CIBA and client credentials grants, Metadata and PublicJWKS. AuthenticateAttestedClient and VerifyTokenRequestBinding let an embedder serve its own grant type at the same token endpoint; they fit this rule because each is one endpoint's own check, run exactly as the server's own grants run it, not a generic primitive. IssueRefreshToken then issues a refresh token for such a grant, which RefreshAccessToken redeems as it does its own. The server only ever verifies; it must not expose client's request-building functionality, and it must not expose a generic HandleRequest(map[string]any) or bare ValidateJWT(token string) — which claims, algorithms, audiences, replay checks and key sources apply is determined by the protocol context, not left for the caller to assemble correctly.

Raw request boundary. PushAuthorizationRequest and AuthorizationCodeExchangeRequest carry an HTTP FormRequest — an ordered list of FormParameter{Name, Value} plus method, URL, content type, body size, client certificate and DPoP proofs — not a pre-parsed url.Values or map[string]string. A map can silently collapse duplicate parameters before the engine ever sees them; server needs the lossless form to detect duplicate/conflicting values, malformed names, parameter-count abuse and oversized values itself. server.FormRequestFromHTTP(r *http.Request) builds one faithfully — an optional convenience for a caller already using net/http to serve these endpoints, so every such adapter doesn't reimplement the same order/duplicate-preserving parse (cmd/conformance-as and fapitest both did, byte-for-byte, before this was centralized). A caller fronted by something other than *http.Request builds a FormRequest by hand instead.

A net/http adapter rarely calls those helpers one by one. Each request type has a constructor that reads everything it needs from the *http.Request at once — the form, every DPoP header, the client attestation headers and the TLS client certificate: server.PushAuthorizationRequestFromHTTP, TokenEndpointRequestFromHTTP, BeginBackchannelAuthenticationRequestFromHTTP, BeginAuthorizationRequestFromHTTP and resource.VerifyRequestFromHTTP. Filling a request struct field by field compiles just as well with one left out, and then fails only for the clients that need it — typically those using mTLS or attestation — so the constructors are the recommended path; the fields stay public for adapters fronted by something else. A request they can't read (a malformed or oversized body, a repeated client_id) is a 400 invalid_request *Error, never a 500; more than one Authorization header is marked by VerifyRequestFromHTTP for Verify to refuse the same way.

The same reasoning applies to the DPoP header specifically: DPoPProofs []string on every request type that accepts a DPoP proof (server's six token/PAR/CIBA-begin request types, and resource.VerifyRequest) takes every raw "DPoP" header value a request carried, not a single already-resolved string — a caller passes http.Header.Values("DPoP") directly, never Header.Get, which silently returns only the first of several values instead of letting the request be rejected. RFC 9449 §4.3 (check 1) requires a request carry at most one DPoP header; internal/dpop.ResolveHeaderValues (used by both server and resource) enforces that itself, rejecting more than one value regardless of whether any individual value would otherwise have verified, rather than depending on every HTTP adapter to remember its own check.

mTLS trusts the TLS-terminating adapter, not a CA store of its own. PeerCertificate — on PushAuthorizationRequest and its siblings, and on resource.VerifyRequest — carries whatever *x509.Certificate the connection's TLS handshake already produced. server.PeerCertificateFromHTTP/resource.PeerCertificateFromHTTP are optional net/http conveniences for reading it off an *http.Request.TLS, the same "no reason for every adapter to reimplement it" precedent as FormRequestFromHTTP — deliberately duplicated between the two packages rather than shared, since they never import each other (design rule 14) and the helper is small enough that a shared package would cost more than it saves. server/resource compare it — thumbprint for ClientAuthMethodSelfSignedTLSClientAuth and SenderConstrainMTLS (RFC 8705 §3), subject DN or a SAN field for ClientAuthMethodTLSClientAuth/...SANDNS/...SANURI/...SANIP/ ...SANEmail (RFC 8705 §2.1) — against the client's registration in storage.RegisteredClientConfig. Neither package owns a TLS listener, so by default chain trust can only come from whichever tls.Config.ClientCAs/ClientAuth mode the embedding HTTP server configures before a request ever reaches this library — a RegisteredClientConfig entry authenticates a client's identity claim, not its certificate's trustworthiness, unless something else also checks the chain. server.Dependencies.ClientCertificateTrust (required — no default) is that something for server: server.TrustedClientCAs{Roots: ...} has authenticateClientViaCertificate independently re-verify the presented certificate before any subject/SAN field-match, for deployments that can't rely on their own TLS termination alone — e.g. mTLS terminated by a gateway in front of this process that forwards the presented certificate without having verified its chain itself; server.NoPKIClientAuthentication{} refuses tls_client_auth and its SAN siblings outright (and keeps automatic registration from registering them), for a server with no such client; server.ClientCertificateChainVerifiedUpstream{} skips the check while still accepting them, declaring that the deployment's own TLS termination verifies the chain; and server.NoClientCertificateChainTrust{} accepts them with no chain trust anywhere, which server.New refuses under AssuranceProduction — the same "conscious, visible choice, not a silent default" pattern NoRevocation{} uses for Dependencies.Revocation. TrustedClientCAs verifies at Dependencies.Clock's time and then asks its required Revocation whether the verified chain has been revoked: server.ClientCertificateCRLs{...} checks it against Certificate Revocation Lists, a custom server.ClientCertificateRevocation can use OCSP or anything else, and server.NoClientCertificateRevocationCheck{} declines the same explicit way. A CA in Roots is a trust anchor and is never itself checked for revocation. An issuing intermediate goes in TrustedClientCAs.Intermediates instead, so its own revocation is checked too. Revocation is checked when a client authenticates, so an access token already bound to a revoked certificate stays usable until it expires. resource has no equivalent field; a protected-resource deployment still depends entirely on its own TLS termination for chain trust. cmd/conformance-as makes the unconfigured case concrete: its listener sets tls.RequestClientCert with no ClientCAs, and its wiring passes server.NoClientCertificateChainTrust{} under development assurance, so that reference binary performs zero chain verification — a production deployment must configure one or the other in its own adapter.

Opaque identifiers. PushAuthorizationResult.RequestURI and the InteractionHandle handed to the embedding application's login UI have no public constructor — only server can produce one. The interaction handle is high-entropy, short-lived, bound to one authorization transaction, single-use, and deliberately unsuitable as an authorization code; the PAR identifier or any storage primary key must never reach the UI.

Closed sum types for results, so a caller can't assume a shape that isn't there — e.g. an unvalidated redirect_uri must produce a LocalErrorResponse/AuthorizationLocalError, never something that looks like a normal redirect:

  • AuthorizationAction — InteractionRequired | RedirectResponse | LocalErrorResponse, returned from BeginAuthorization.
  • AuthorizationResult — AuthorizationRedirect | AuthorizationLocalError, returned from CompleteAuthorization. Only the engine assembles the final redirect destination — the embedding app cannot replace state, edit a JARM response, change the redirect URI, or leak a code into logs.
  • InteractionResult — built only via constructor functions (Authorize(subject, authContext, grant), Deny(reason), AuthenticationFailed(reason), and InteractionNeeded(need, reason) for a prompt=none request the application can't complete without showing a page) passed to CompleteAuthorization, so invalid combinations of subject/grant/denial can't be constructed.

Untrusted hints stay untrusted: AuthenticationHints.LoginHint is a plain string-wrapping type, never a SubjectID — only the application's own authentication result (an AuthenticatedSubject passed to Authorize) produces a verified subject.

Assurance levels. Config.Assurance is AssuranceDevelopment or AssuranceProduction. New fails construction unless every security-critical dependency is present and valid — no implicit in-memory fallback for clocks, randomness, replay storage, signing keys, client lookup or authorization-code storage. Under AssuranceProduction, New additionally requires every dependency to declare what production depends on — stores durable and atomically consuming (storage.StoreAssurance), key sources with hardened live fetches (keys.KeySourceAssurance), signing keys and decryption keys held durably (keys.KeyCustodyAssurance; an HSM or KMS is not required, only that the keys survive a restart), each also cross-instance consistent under DeploymentHorizontallyScaled — plus an audit sink, crypto/rand.Reader itself as Dependencies.Random (an io.Reader can't declare that it is a CSPRNG) and, with CIBA, a ping notifier declaring hardened outbound sending (server.BackchannelNotifierAssurance), and rejects loopback http issuer and endpoint URLs. Production also requires Config.Deployment — single instance or horizontally scaled — so a fleet can't be checked as one instance by omission. The assurance level is itself a required Config.Assurance choice with no default, so a caller can never end up on the development level by omission. resource.NewVerifier applies the same rule to the resource server's own key source and stores (serverresource.NewVerifier takes the server's level).

Policy is a bounded deployment decision, not a bypass. The application's decision is a GrantedAuthorization passed to Authorize, made against an already-validated InteractionRequest: the granted scope (which CompleteAuthorization rejects if it isn't a subset of what was requested), approved AuthorizationDetails (each an acceptable narrowing of a requested object, per RARDefinition.ValidateGrant), and extra ID-token claims (server-managed names rejected, total size bounded by Limits.MaxIDTokenClaimsBytes). A client may request only the RAR types it is registered for (storage.RegisteredClientConfig.AuthorizationDetailsTypes, RFC 9396 §10's authorization_details_types; none by default), and an optional RARPolicy narrows what it may request before a resource owner ever sees it. None of this can disable PAR, PKCE, sender constraint, redirect URI validation, client authentication, replay protection, required signed request objects, or profile algorithm restrictions.

Audit is a typed dependency, required under AssuranceProduction: Dependencies.Audit records structured AuditEvents (type, time, client ID, outcome and a short safe description such as an error code — never a bare map[string]any, a raw internal error, or anything that could carry a token, key or assertion value). Recording is best-effort: a failing AuditSink.Record never fails the request it describes, so a sink that must not lose events has to provide its own durability (a local outbox, for example).

Dynamic client registration (RFC 7591 / RFC 7592), if added, extends this state machine — it doesn't bypass it. Not committed to: FAPI 2.0 Security Profile Final §5.4.2 recommends but does not require it, as one of two options for client key distribution — the other, a static jwks_uri per client, is already fully supported, and the OIDF conformance suite's plain FAPI2 Security Profile Final test plan (unlike its separate, Brazil-Open-Banking- specific DCR plan) has no DCR module at all. If it is added:

  • A registration request is validated against Config.Algorithms and a fixed FAPI2 grant/response-type set (authorization_code [+refresh_token for offline_access], response_type=code, private_key_jwt only — RFC 7591 §2's token_endpoint_auth_method/ grant_types/response_types are not client-negotiable here) in server, before storage is ever touched. storage.NewRegisteredClient only checks structural validity today (is this a recognized algorithm at all) — it has no visibility into server-wide policy, and shouldn't gain any: storage sits below server in rule 14's dependency layering, so a policy check that needs Config.Algorithms belongs in server, the same layer that already runs the equivalent check lazily, per request, in PushAuthorizationRequest.
  • RegisterClient/ReadClient/UpdateClient/DeleteClient follow the same typed-request → typed-result/typed-error shape every other method here does — no generic HandleRequest for this either.
  • Registration access tokens (RFC 7592 §1.2) are a new storage primitive, not a repurposed GrantStore refresh token: they authenticate the management endpoint itself, not a resource, and their rotate-on-read/update semantics (RFC 7592 §5, a MAY, never a MUST) don't match this codebase's existing single-use (authorization code) or reuse-until-expiry (refresh token) lifecycles.
  • keys.ClientKeySource needs its own write path, independent of storage.ClientRepository's. A client's key material (jwks/ jwks_uri, RFC 7591 §2) is already modeled as a concern separate from its registration metadata (see keys.ClientKeySource vs. storage.ClientRepository); registration must keep writing to both without merging them into one interface.

8. Resource server verifies in HTTP context, not in isolation

Verifier.Verify(ctx, VerifyRequest{Method, URL, Authorization, DPoPProofs, PeerCertificate}) → AuthorizationContext{InternalSubject, ClientSubject, ClientID, Scopes, Claims}, with the request usually built by resource.VerifyRequestFromHTTP(r, target) — target being the endpoint's own fixed external URL, never one built from the Host header. Token verification is inseparable from method/URL/DPoP context, so there is no bare VerifyJWT entry point — and, symmetrically, no bare VerifyDPoP entry point either, since a DPoP proof can't be judged valid without the HTTP method, target URI, access-token hash, expected nonce and JTI replay state alongside it. AuthorizationContext carries the token's validated claims, never the raw token itself, and a verification failure is a typed Error (see rule 16), not a bare error the caller has to string-match.

Access-token format is pluggable, verification is not. server.AccessTokenIssuer and resource.AccessTokenResolver let an integrator choose a self-contained JWT (RFC 9068 — JWTAccessTokens, the default) or an opaque, storage-backed token (OpaqueAccessTokens) — FAPI 2.0 doesn't mandate a format, so this isn't the library's own opinion to impose. But AccessTokenResolver only resolves a token's claims and its own claimed DPoP-binding thumbprint — it cannot check sender-constraint binding, ordinary expiry, or revocation, and ResolveAccessTokenRequest carries no expected thumbprint for it to compare against even if it tried. Verify() enforces all three itself, once, uniformly, regardless of which format is wired in, so the two formats can't disagree on FAPI2/RFC 9449-mandated behavior by construction — an earlier design let each implementation check binding and expiry itself, which meant CI had to run the full live conformance suite once per format to catch divergence; the current shape makes that unnecessary (see conformance/README.md's "Access-token format coverage"). A returned error is a typed *Error per rule 16 either way, so Verify() propagates the right exposure without the resolver needing to anticipate how it'll be used.

9. Internal protocol core is organized around asymmetric operations

See the internal/ tree above — create.go/verify.go (or, for tokens, issue.go/validate.go) pairs per concern, each side used by exactly the role(s) that need it, sharing JOSE encoding but not signing/verification policy.

10–11. Extension and RAR parameters are defined once, used by both sides

An extension.Definition[T] captures wire name, cardinality, allowed sources (a plain parameter, an integrity-protected request object, or both), max size, sensitivity, validator, and whether it may be returned in token claims — once. Client sets a value against the definition with extension.Set(&req.Extensions, Definition, value); server registers the same definition in server.Config.Extensions (an *extension.Registry) and reads the validated value back out through a typed accessor — extension.Get(validated.Extensions, Definition) — never a generic map[string]any, which would allow name collisions and invalid type assertions. An authorization request parameter without a registered definition is ignored, not rejected: RFC 6749 §3.1, RFC 9126 §2.1 and OIDC Core §3.1.2.2 all require an authorization server to accept an otherwise-valid request in spite of one, and a live OIDF HAIP conformance run against this package surfaced exactly this — a deliberately injected, unrecognized parameter failing an otherwise-valid request that should have succeeded. "Ignored" is not "silently preserved", though: extension.Registry.Parse deletes the name from the caller's own params map as it goes, so its value never reaches storage, an audit sink, or a token claim. There is still no production option to silently forward an unrecognized parameter's value anywhere; a caller who genuinely needs that (a compatibility gateway, a test server) must opt in explicitly and separately rather than being one missed check away from it. request_uri is the one name PAR-2.1 requires rejecting outright regardless of whether it's "recognized" in this sense — server/par.go enforces that with two dedicated checks (one per source: a sibling form parameter, or a claim inside a request object) rather than relying on Registry.Parse's now-permissive default.

Rich Authorization Requests (RFC 9396) uses a structurally distinct sibling in the same package — extension.RARDefinition[T], registered into a server.Config.RAR (*extension.RARRegistry), separate from Config.Extensions because authorization_details is validated as a whole bounded array of typed detail objects (total size, nesting depth, per-type object count and size, duplicate/unknown JSON members), not parameter-by-parameter. FAPIgo's AS has three primary token-acquisition grants — Authorization Code (browser-based; PAR is mandatory for it under FAPI 2, not a distinct grant of its own — see the FAPI 2 Security Profile's own authorization-endpoint requirements), CIBA (backchannel, also user-involved), and client_credentials (RFC 6749 §4.4, machine-to-machine, no end user) — and authorization_details is structurally validated identically across all three: the same RARDefinitions, the same parseRequestedAuthorizationDetails helper (server/rar.go). Before any policy, every requested type must be one the client is registered for (storage.RegisteredClientConfig.AuthorizationDetailsTypes; an empty list allows none), or the request is refused with ErrorInvalidAuthorizationDetails — so a policy only ever sees types the client may use, and server.AllowRequestedAuthorizationDetails is enough where that is the only rule. The entitlement decision on top takes one of three shapes, all implementing the same server.RARPolicy interface, one per Dependencies field:

  • Authorization Code (Dependencies.AuthorizationCodeRARPolicy, consulted by checkExtensions at PAR submission time — the only place this grant's own request ever arrives, since FAPI 2 makes PAR mandatory for it) and CIBA (Dependencies.CIBARARPolicy, checkBackchannelExtensions's own equivalent, at backchannel authentication request time): two independent defense-in-depth, request-time gates on what a client may even ask for on each grant — deliberately not one field shared between them, since neither checkExtensions nor checkBackchannelExtensions tells a RARPolicy which grant it's being consulted for, so a shared field would give an integrator no way to permit authorization_details on one grant while refusing it on the other. The primary entitlement check for both grants remains the resource owner's own interactive decision at CompleteAuthorization/CompleteBackchannelAuthentication (GrantedAuthorization.AuthorizationDetails, checked by validateGrantedAuthorizationDetails — mirroring scope's own request → interaction → GrantedAuthorization → storage → token-claim lifecycle, so a resource owner may grant a narrower array than was requested exactly the way a granted scope may narrow a requested one); the two request-time policies narrow what's ever put in front of that resource owner in the first place.
  • client_credentials (Dependencies.ClientCredentialsRARPolicy, consulted by RequestClientCredentialsToken at token-issuance time): this grant has no resource owner at all, so this policy is the only entitlement check it ever gets.

All three fields are optional, but none's absence is permissive: a request naming authorization_details with no policy configured for the applicable field is refused with ErrorInvalidAuthorizationDetails (RFC 9396 §6's own dedicated error code) — RARRegistry.Parse only confirms a request is well-formed and registered, it is deliberately not treated as the entitlement decision itself, the same "unconfigured is not permissive" stance Config.RAR itself takes. All three roles funnel through one shared helper, applyRARPolicy (server/rar.go): call the configured RARPolicy.Authorize, then run its result back through validateGrantedAuthorizationDetails — the same "is this an acceptable subset of what was requested" check a resource owner's decision gets, just fed a programmatic decision instead of an interactive one. An empty result (no error) is a legitimate "deny everything requested" decision, surfaced as ErrorInvalidAuthorizationDetails rather than silently proceeding with no authorization_details at all — RFC 9396 §6's own "the AS refuses the request" framing.

Whether a narrower grant is acceptable is, per type, decided by RARDefinition.ValidateGrant(requested, granted T) error — nil requires byte-for-byte (canonically re-encoded) equality; set, it can permit real field-level narrowing (e.g. a lower payment amount). RARRegistry.ValidateGrant does the per-type matching this drives. RARGet/RARSet are the RAR counterparts of Get/Set — a client builds one detail object with extension.RARSet(Definition, value), which also stamps the definition's own Type into the result, so a caller's value type never needs its own redundant Type field; a server (or a consent UI) reads validated objects back out, typed, with extension.RARGet, and a resource server reads what a token was granted the same way, after extension.ParseGrantedRAR on its authorization_details claim.

12. Configuration is per-role, not one shared struct

client.Config, server.Config and resource.Config are separate types. They may reference the same validated value types (fapi.URL, algorithm types) but are not merged into one struct with role-conditional fields.

13. Storage contracts are per-role, with one shared replay primitive

storage.SessionStore (client), storage.TransactionStore and storage.GrantStore (server) are distinct, and none of them expose generic CRUD (GetSession, GetCode, UpdateCode, DeleteCode, ...) — every method is a named security operation. GrantStore.RedeemAuthorizationCode in particular must atomically look up and consume a code by its hash, in one step; server then checks client ID, expiry, redirect URI, PKCE verifier and sender-binding against what it returns. A store given the presenting client's ID (AuthorizationCodeRedemption.ClientID, and likewise PollBackchannelAuthentication.ClientID for CIBA) refuses another client's record without consuming it, so a leaked code or auth_req_id can't be spent by someone else. A store only understands the fields it acts on (lookup hash, ClientID, ExpiresAt, and CIBA's decision and delivery state): everything else server needs later — the request's parameters, the granted scope, subject, authentication context and claims — is one opaque, versioned JSON value (Request or Grant) the store persists and returns without interpreting, so a feature that changes what a grant carries never changes a store. storage.ReplayStore stores only a digest and expiry per use (ReplayUse{Namespace, Digest, ExpiresAt}) — never a complete client assertion, DPoP proof or other sensitive payload — and must keep each until at least that expiry (one forgotten sooner can be replayed); callers must assign it a namespaced identifier per role/subsystem (server:client-assertion, server:request-object, server:dpop, resource:dpop, ...) so different subsystems can never collide on the same use-once token.

Because a storage backend's atomicity/durability guarantees are self-asserted, storage also defines a StoreAssurance.Capabilities interface (durable, atomic-consume, serializable-redemption, cross-instance-consistent, encrypted-at-rest) that the server, client and resource AssuranceProduction levels check at construction time, and a reusable contract test suite (e.g. a storage.TestGrantStoreContract(t, factory) helper) that any storage implementation — first-party or downstream — runs for single-use redemption under concurrency, field round-tripping, unknown-key handling and revocation, rather than relying on the capability declaration alone. What it can't observe through the interface (cross-instance atomicity, expiry eviction, encryption at rest) stays the backend's own responsibility — see storage/doc.go.

14. No cross-role dependency cycles

client ─────┐
server ─────┼──▶ internal protocol packages
resource ───┘

extension ──▶ shared value definitions
storage   ──▶ shared storage primitives
keys      ──▶ signing contracts

client and server never import each other, and neither do server and resource. serverresource, which builds a resource.Verifier from a server.Server's own configuration for endpoints hosted in the same process, imports both, so neither role package has to.

15. fapitest is a real-HTTP interop harness, not a shortcut

fapitest wires a client.Client, server.Server and resource.Verifier together through httptest.Server so tests exercise real wire encoding and HTTP semantics — catching parameter serialization bugs, duplicate handling, header issues, URI canonicalization mismatches, content-type handling and redirect behaviour that in-process shortcuts would hide. It is test-only and must never be reachable from a production, customer-deployed application's own code paths.

This repo's own cmd/conformance-as/cmd/conformance-client/ cmd/conformance-federation-trust-anchor binaries import fapitest directly (fapitest.SelfSignedServerCert, fapitest.PeerTLSConfig, ...) without violating this rule: those binaries exist solely to drive OIDF conformance suites against this module's own client/server implementations — they are dev/test tooling themselves, never deployed as a production service, the same category fapitest and keys/ephemeral/storage/memstore already occupy. The rule's actual boundary is "a real integrator's own production code," not "this repo's own non-test Go files."

16. Errors carry their own exposure — the caller doesn't decide

client, server and resource all return a typed Error — Code(), PublicDescription() and Unwrap() everywhere, HTTPStatus() on server and resource, and on client a ServerResponse() carrying the error response a server sent, if any. The engine, not the embedding application, decides whether a failure is safe to put in a redirect query string versus a response body versus neither, and it does so through the result's shape: server's authorization-endpoint methods return a local-error variant (LocalErrorResponse, AuthorizationLocalError) distinct from a redirect, and a token or PAR failure is written with Error.WriteJSON. Internal diagnostic detail is never copied into a public error_description-style field. This is what makes rule 7's "an unvalidated redirect_uri must produce a local error, never a redirect" enforceable in the type system rather than by convention.

server.Error goes one step further for its own token-endpoint-exposure case: WriteJSON(w http.ResponseWriter) also owns how to encode that exposure once it's decided — the RFC 6749 §5.2 JSON body, status code, and (RFC 9449 §8) DPoP-Nonce header, all in one call — plus NewError(code, httpStatus, description) *Error for the one case this package's own methods can't produce an *Error for: an HTTP adapter's own request routing rejecting something (a malformed body, an unrecognized grant_type) before it can even decide which Server method to call. Before this, an adapter had to reach into internal/par (unavailable outside this module) to encode that body itself, one more manual step rule 16 exists specifically to avoid leaving to the embedding application.

17. One internal subject in, one sub per client out

The application authenticates a user and hands the server their internal subject (server.SubjectID); it never computes a client's sub itself. The server decides, per client, which sub that client sees:

  • Type. Config.Subjects (Default, Enforcement) and the client's registered SubjectType give public or pairwise (Server.subjectTypeFor). An automatically registered federation Relying Party is offered what Config.AutomaticRegistration.SubjectTypes allows, pairwise alone under a pairwise default, and Server.EntityConfiguration advertises that.
  • Sector. A pairwise client's sector is its operator-set Sector, or the single https host of a web client's redirect URIs. None is derived for a client permitted CIBA or attestation, whose tokens arrive without a redirect proving that host is theirs; a federation Relying Party's is its Entity Identifier's host. A pairwise client with no sector is refused at PAR and the backchannel authentication endpoint, before its user signs in.
  • Derivation. pairwise.Deriver turns (issuer, sector, internal subject) into an opaque body, and the server prepends Config.Subjects.Prefix. Whether a body can be mapped back is whether the deriver implements pairwise.Lookup, never a separate setting, so configuration can't claim more unlinkability than the deriver gives. server.New probes the deriver (deterministic, varies by sector and by user, no internal subject in the body) and refuses one that fails.
  • Continuity. The grant stores the sub it was issued with (ClientSubject), with the sector and prefix it was derived under; refreshes and CIBA polls reuse it rather than re-deriving, so a later sector change doesn't change an existing grant's sub. A prefix change does end the grant (invalid_grant), since resource servers resolve a sub under the current prefix. With an empty prefix, signed UserInfo checks a pairwise sub by mapping it back under the client's current sector, so it fails closed for grants issued before a sector change.
  • Outbound. ID tokens, access tokens (with the sector in pairwise_sector) and signed UserInfo carry the client's sub. Records, audit events and the application's own interaction APIs keep the internal subject.
  • Inbound. A client's id_token_hint or claims sub is mapped back to the internal subject through Lookup (or a declared PairwiseSubjectResolver) under that client's sector; with nothing to map it, the interaction carries the client's sub for the application to compare with Server.ClientSubject (a CIBA id_token_hint, which must identify the user, is refused). A login_hint is the application's to interpret, with Server.ResolveClientSubject and Server.ClientSubject for one carrying a sub.
  • Resource servers. resource.AuthorizationContext carries both: InternalSubject, which the API authorizes by and never returns, and ClientSubject, which anything client-facing uses. An opaque access token's record holds the internal subject; a JWT access token's pairwise sub is mapped back under its pairwise_sector by the OP's own resource servers (Dependencies.PairwiseSubjects or PairwiseSubjectResolver). A resource server outside the OP sets Config.PairwiseResolution to PairwiseClientSubjectsOnly, gets an empty InternalSubject, and authorizes by ClientSubject.

Whatever maps a sub back (the reversible deriver's keys, the mapping store, a resolver) belongs only to the OP and its own resource servers: OpenID Connect Core §8.1 forbids a pairwise identifier being reversible by anyone else.

Conformance strategy

Certification is tracked separately per role under conformance/. The server runs the OIDF AS test plan; the client runs the applicable RP/client tests; the resource server is verified by the resource package's own tests (valid and invalid DPoP proofs, replay, method/URL and cnf binding, mTLS, revocation, and a fuzz target), since OIDF does not cover that role. One role passing its suite is not evidence the other role conforms, even where both share internal JOSE code — protocol behaviour and negative-test expectations differ per role.

Current results. conformance/scripts/run-all.sh runs 24 test configurations, and the daily conformance.yml run executes all of them. The 24th, AS pairwise (added for v0.55.0), runs the baseline plan against an AS that gives every client pairwise subject identifiers. As of the 2026-10-13 run (v0.55.0), all 15 AS configurations (baseline, message-signing, mtls, message-signing-mtls, client-auth-mtls, client-auth-mtls-and-mtls, four CIBA poll/ping variants, four client-credentials variants, and pairwise) pass with 0 failures and 0 warnings; all 6 FAPI RP configurations pass every module; the federation RP plan passes 6/10, exactly matching its list of known suite-side failures; and the federation deployed-entity plan passes 5/5 against both the automatic-registration AS and the Trust Anchor. The per-profile counts in the history below are from each profile's first clean run; the suite has added modules since, so the daily run's own report is the source of truth for today's numbers.

CIBA (server.BeginBackchannelAuthentication/CompleteBackchannelAuthentication/ ExchangeBackchannelAuthentication) was initially left out of this automated loop. It implements base OIDC CIBA and FAPI-CIBA's other requirements (a mandatory signed authentication request with jti/nbf, poll and ping delivery, DPoP- or mTLS-bound tokens) — but the OIDF suite's own fapi-ciba-id1-test-plan requires MTLS-bound access tokens unconditionally, even under client_auth_type=private_key_jwt (confirmed directly from the suite's own AbstractFAPICIBAID1.setupPrivateKeyJwt, not inferred): "FAPI requires the use of MTLS sender constrained access tokens, so we must use the MTLS version of the token endpoint even when using private_key_jwt client authentication."

This module now supports mTLS sender-constrained access tokens (RFC 8705 §3, storage.SenderConstrainMTLS, -mtls) as an alternative to DPoP, and — as a separate, orthogonal capability — tls_client_auth/self_signed_tls_client_auth (RFC 8705 §2, storage.ClientAuthMethod) as client authentication methods alongside private_key_jwt; dynamic client registration remains out of scope (see cmd/conformance-as's own doc comment). This made the CIBA wall above worth a genuine live re-attempt rather than a permanent limitation: against conformance/server/oidf-config/ciba-mtls.config.json: 34/34 PASS. The last holdout, fapi-ciba-id1-ensure-authorization-request-with-potentially-bad-binding-message, was initially misdiagnosed as unfixable by construction — disassembling ExpectBindingMessageCorrectDisplay.evaluate() in isolation showed it unconditionally throws whenever automated_ciba_approval_url is set, with no branch for that case, and concluding from that alone that this one check and an automated run were mutually exclusive. That was wrong: disassembling the test module itself (FAPICIBAID1EnsureAuthorizationRequestWithPotentiallyBadBindingMessage.performAuthorizationFlow) shows ExpectBindingMessageCorrectDisplay is only reached along the accept path — if the backchannel authentication endpoint's response carries an error field, the module instead runs CheckErrorFromBackchannelAuthenticationEndpointErrorInvalidBindingMessage (asserting error == "invalid_binding_message") and finishes right there. CIBA Core 1.0 §13 gives an AS exactly this option — reject a binding_message it can't safely or faithfully display, rather than accept it and hope. The test's actual value (confirmed from the live log, not assumed) is 456 Unicode code points of emoji, CJK text, and a Lorem Ipsum paragraph — clearly disqualified on length/renderability grounds alone, nothing content-specific. server/backchannel_authentication.go's isAcceptableBindingMessage now rejects a binding_message over 100 Unicode code points or containing a control/line-separator character, returning the new ErrorInvalidBindingMessage ("invalid_binding_message") — length and renderability only; ordinary punctuation, emoji, and non-Latin scripts remain fully accepted, confirmed by a dedicated unit test using the exact same emoji/CJK content the failing probe sends, just under the length bound. (The 3 modules requiring an RS256-signed probe — ...-signature-algorithm-is-RS256-fails and its two client-assertion counterparts — self-skip unless the plan's own client is registered with an RSA/PS256 key, since the suite reuses that same registered key, just forcing its JWS header to RS256, rather than provisioning a separate one; switching client 1 from ES256 to PS256/RSA — already an allowed algorithm here — turned all three from SKIPPED to PASSED with zero regressions elsewhere.) Getting here fixed six real library/harness gaps: server.Metadata was missing tls_client_certificate_bound_access_tokens (RFC 8705 §3.3); authenticateClient's client-assertion aud acceptance was far narrower than RFC 7523 §3 actually requires (see server/par.go's acceptableClientAssertionAudiences, not mTLS-specific despite being found here); this plan's own TLS check needed both a narrower cipher list and an RSA (not ECDSA) server certificate — cmd/conformance-as's conformance cert is now RSA (conformance/server/scripts/generate-server-cert.sh), shared across every profile in docker-compose.yml (the change is TLS-layer only, no application code touched, treated as low-risk to already-passing baseline/message-signing but not independently re-confirmed live end-to-end — that needs the official run-test-plan.py/unblock-implicit-callback.py tooling, a real suite checkout, that an ad-hoc REST-API driver can't substitute for on a browser-driven flow); CIBA's own error vocabulary had incorrectly borrowed PAR's invalid_request_object convention (CIBA Core 1.0 §13 defines no such code — every request-object negative test uniformly expects plain invalid_request, confirmed by disassembling the suite's own check); a CIBA backchannel authentication request's iat claim was never required at all — new requestobject.VerifyPolicy.RequireIssuedAt, mirroring RequireNotBefore/RequireJTI's own pattern, set true only for CIBA (PAR's stays false); and, as detailed above, binding_message had no acceptability check at all, so an AS-side rejection option CIBA §13 explicitly provides for was simply never exercised. Full breakdown in conformance/server/oidf-config/README.md's own CIBA section, which also covers the DPoP-only config's manual (non-automated) setup.

The aud widening above was itself corrected later, caught by the daily conformance.yml run rather than anything CIBA-specific: acceptableClientAssertionAudiences had widened to accept any of this server's own endpoint URLs (Token, PAR, BackchannelAuthentication) at every endpoint, but FAPI2 Security Profile Final's own par-test-{par,token}-endpoint-url-as-audience-fails modules require PAR to reject both — PAR was never granted a URL-audience carve-out by RFC 7523 in the first place, unlike Token's own explicit sanction. Fixed by scoping authenticateClient's accepted audiences to the specific physical endpoint authenticating each request rather than a blanket set, which in turn surfaced that CIBA Core 1.0 §7.1 separately, explicitly widens the backchannel authentication endpoint specifically ("the OP MUST accept its Issuer Identifier, Token Endpoint URL, or Backchannel Authentication Endpoint URL") — an initial overly-narrow first pass at the endpoint-scoped fix (BackchannelAuthentication's own URL only) briefly regressed the already-passing AS ciba-mtls/ciba-ping legs before this was caught by a second full local conformance run and corrected. Confirmed live: all nine test configurations clean after both fixes.

The token endpoint's own URL-audience allowance was narrowed further later still, again caught by the daily conformance.yml run: FAPI 2.0 Security Profile Final §5.3.2.1 says the authorization server "shall only accept its issuer identifier value ... as a string in the aud claim received in client authentication assertions", overriding RFC 7523 §3's token-endpoint-URL allowance, and the suite's ensure-invalid-client-assertions-fail module began enforcing it at the token endpoint for the client-credentials variant. Endpoint-URL audiences are now accepted only for a client registered for CIBA (BackchannelAuthenticationRequestAlgorithm set), which keeps CIBA Core 1.0 §7.1's widened set at the token and backchannel authentication endpoints — the FAPI-CIBA-ID1 suite signs every token-endpoint assertion, refresh included, with the token endpoint URL. Every other client is issuer-only at every endpoint.

client's own CIBA support (BeginBackchannelAuthentication/PollBackchannelAuthentication) was genuinely attempted against the OIDF suite's RP-side plan (fapi-ciba-id1-client-test-plan, cmd/conformance-client -profile=ciba), unlike the AS side's plan being ruled out ahead of time — the suite's own FAPICIBARPProfileBehavior.requiresMtlsForBackchannelEndpoint() returns false for plain_fapi, so the backchannel authentication step itself doesn't share the AS-side plan's unconditional MTLS mandate. A live run confirmed this: 3 of 22 modules genuinely PASS (fapi-ciba-id1-client-invalid-missing-authreqid-test, -invalid-missing-expiresin-test, -invalid-unknown-user-id-test — each one a negative test for the backchannel response itself, which never reaches token exchange). Every other module FAILs for one uniform, confirmed reason: AbstractFAPICIBAClientTest.handleHttp's own routing hardcodes case "token": to throw "Token endpoint must be called over an mTLS secured connection" unconditionally — regardless of client_auth_type variant, unlike setupPrivateKeyJwt on the methods that actually vary per-variant. So the token endpoint carries the AS-side plan's same MTLS wall even though the backchannel endpoint doesn't; once PollBackchannelAuthentication reaches token exchange (every happy-path and most negative-test modules do), it hits this the same way. This live attempt also surfaced and fixed a real, separate bug along the way: client.Discover/client.Config assumed every client also drives the browser PAR+authorize flow (authorization_endpoint/pushed_authorization_request_endpoint were unconditionally required both in internal/metadata.ParseAndValidate and in client.validateConfig), which made a CIBA-only client (exactly this suite's own CIBA-only mock AS, and a legitimate real deployment shape) impossible to construct at all. Fixed by making the pair conditionally required — mirroring Endpoints.BackchannelAuthentication's own optionality — with client.New now requiring at least one of the two flows to be configured.

Once client.Config gained storage.SenderConstrainMTLS support, the token-endpoint MTLS wall above was worth a genuine re-attempt too (cmd/conformance-client -profile=ciba -mtls, a throwaway self-signed client certificate in place of a DPoP proof). Confirmed live: 22/22 PASS. Getting there past the initial re-attempt's own partial result took four more fixes, found by tracing each non-PASS module's own suite-side log rather than reasoning about this driver's code in isolation: this driver discarded the issued tokens outright and never called the plan's required "accounts" resource endpoint (so several modules sat in WAITING forever, no matter how long the driver waited); the plan config never registered this driver's own mTLS client certificate with the suite (EnsureClientCertificateMatches had nothing to compare the presented one against); clientID was a fixed constant, unlike the already-randomized alias, letting an orphaned instance from an earlier killed run corrupt a fresh run's own certificate registration; and one genuine client/internal/token gap, not a driver issue — IDToken.Validate never checked iat staleness at all (OIDC Core §3.1.3.7 step 10), fixed with a new ErrIssuedAtTooOld reusing the same MaxLifetime bound that already governs exp, applied symmetrically to the past. Full breakdown in conformance/client/scripts/README.md's own CIBA section, which also covers running this driver (with or without -mtls) for exploratory use. Both CIBA directions — AS (34/34) and client (22/22) — are now wired into conformance/scripts/run-all.sh's own automated gate ("AS ciba-mtls"/"RP ciba-mtls" legs) and the daily .github/workflows/conformance.yml run, alongside baseline and message-signing on both sides.

CIBA §10.2 ping delivery mode (storage.BackchannelTokenDeliveryModePing, server.BackchannelNotifier) followed the same library-then-conformance pattern: implemented and unit-tested first, then given its own AS-side conformance re-attempt (conformance/server/oidf-config/ciba-ping-plan.json, generated by setup-config, two extra clients on the same conformance-as-ciba-mtls container as AS ciba-mtls). This surfaced two more real bugs no amount of unit-testing against this module's own mocked notifier would have caught: a client's poll immediately following a ping notification was being rejected with slow_down (the interval throttle has no concept of "a notification just told you to poll now"), and cmd/conformance-as's outbound notifier followed an HTTP redirect from the notification endpoint when CIBA requires treating it as the final, non-2xx response instead. Confirmed live: the core fapi-ciba-id1 module plus all five ping-specific modules PASS. Full breakdown, including why client.hint_type/client.hint_value turned out to be required despite ping mode not touching the approval flow, in conformance/server/oidf-config/README.md's own ping-mode section. Wired into run-all.sh as its own "AS ciba-ping" leg — no RP-side counterpart, since cmd/conformance-client only ever plays a poll-mode CIBA client.

The RFC 8705 §2 client-authentication methods themselves (as opposed to §3 sender-constraining, covered by CIBA above) got their own genuine live re-attempt too, against conformance/server/oidf-config/client-auth-mtls.config.json — client_auth_type=mtls on the same fapi2-security-profile-final-test-plan baseline/message-signing already use, with sender_constrain left at dpop to keep the two axes isolated. Confirmed live: 47/47 PASS. This was the first plan to combine a DPoP-sender-constrained client with an mTLS-requesting listener at all, and it surfaced two real gaps: (1) server.Metadata's token_endpoint_auth_methods_supported never advertised either RFC 8705 §2 method, hardcoded to private_key_jwt alone since the feature was first added — fixed by appending both whenever Config.MTLSEndpoints is configured, the suite's own EnsureServerConfigurationSupportsMTLS accepting either value (confirmed by disassembly); (2) a DPoP proof's htu claim was checked against this server's plain endpoint URL only, in both verifyTokenRequestDPoP (server/token.go) and reconcileParDPoPBinding (server/par.go) — but a client authenticating via certificate must call the mTLS-alias URL to present it at all, so its DPoP proof legitimately names that alias instead; confirmed live via the token endpoint's dpop: htu does not match request URI rejection. Fixed by verifyDPoPAtEitherEndpoint, which retries once against the alias on a pure htu mismatch — safe since internal/dpop.Verify checks htu strictly before consuming replay state. Full breakdown, including a genuine suite-config quirk this surfaced (unrelated to either fix) and the one apparent failure traced to a suite-side timing artifact rather than this server, in conformance/server/oidf-config/README.md's own client-authentication mTLS section. Wired into run-all.sh as its own "AS client-auth-mtls" leg (a fourth conformance-as container).

The RP side got the same treatment immediately after, via cmd/conformance-client -profile=baseline -client-auth-mtls against the suite's own fapi2-security-profile-final-client-test-plan, same client_auth_type=mtls/sender_constrain=dpop split. Confirmed live: 22/22 PASS, on the first attempt — the only mTLS re-attempt in this repo's conformance history that needed no fix at all, since both sides' library groundwork (client.Config.ClientAuthMethod from PR #182, and verifyDPoPAtEitherEndpoint from the AS side's own re-attempt just above) were already in place before this driver flag existed. Reuses cmd/conformance-client/mtls.go's existing -mtls machinery (selfSignedClientCert, mtlsSuiteHTTPClient, registering the certificate as the plan config's client.certificate value) almost unchanged, plus one addition — applyMTLSEndpointAliasesForClientAuth overrides the PushedAuthorizationRequest alias too, not just Token, since certificate-based client authentication (unlike sender-constraining) is checked at PAR as well. (That driver helper, and the applyMTLSEndpointAliases and callProtectedResourceBearer helpers mentioned below, have since become the library's own client.MTLSEndpoints.ApplyForClientAuth/ApplyForSenderConstrain and Client.ProtectedResource.) Full breakdown in conformance/client/scripts/README.md's own client-authentication mTLS section. Wired into run-all.sh as "RP client-auth-mtls" (no separate container — a driver flag, like RP ciba-mtls).

That left one real combination never tested at all: §3 sender -constraining (mTLS-bound access tokens) through the ordinary PAR/authorize/token flow, as opposed to CIBA's backend-to-backend one. AS ciba-mtls exercises mTLS-bound tokens but never touches PAR/authorize (CIBA has no browser hop); AS client-auth-mtls exercises the full PAR/authorize/token flow under mTLS but only for §2 client authentication, keeping sender_constrain=dpop. Confirmed live before building anything: fapi2-security-profile-final-test-plan genuinely accepts sender_constrain=mtls as its own variant value. Closed via two more AS-side profiles, mtls.config.json and its message-signing counterpart message-signing-mtls.config.json — generalizing the existing baseline/message-signing profile/writePlanConfig machinery in conformance/server/scripts/setup-config/main.go with one senderConstrainMTLS bool flag rather than duplicating setupCIBAMTLS-shaped standalone functions, since the plan shape (browser/consent/override) is otherwise identical to baseline's own — only an mtls/mtls2 suite-side certificate pair needed adding. Surfaced one real bug: the shared Resource.ResourceURL construction defaulted to the plain (:8443) endpoint unconditionally, but an mTLS-bound access token's resource call must go over the mTLS listener (:8444) — a client presenting its certificate to the plain listener (which never asks for one) gets a token the resource endpoint's own binding check then rejects with 400, exactly the same reasoning ciba-mtls's own resource URL already accounts for. Confirmed live: the core happy-flow, holder-of-key, PAR-audience, and RS256-negative modules PASS for both profiles, including the message-signing variant's own signed-request-object override, confirming mTLS sender-constraining composes correctly with JARM/signed request objects too. Full breakdown in conformance/server/oidf-config/README.md's own section. Wired into run-all.sh as "AS mtls"/"AS message-signing-mtls" (two more conformance-as containers).

That still left two FAPI2/FAPI-CIBA AS register profiles genuinely untested: FAPI2SP OP MTLS + MTLS (§2 client authentication and §3 sender-constraining together on the same client — AS client-auth-mtls covers §2 alone, AS mtls covers §3 alone, nothing combined them) and FAPI-CIBA OP Poll/Ping w/ MTLS (§2 client authentication for the backchannel authentication endpoint — every CIBA client this repo had registered before this used private_key_jwt; "AS ciba-mtls"/"AS ciba-ping" are actually the register's "...+ Private Key" profiles despite their names, which describe §3 token binding, not client authentication). Both closed the same way as everything above: one shared setupClientAuthMTLSVariant function (a senderConstrainMTLS bool parameter, same shape as writePlanConfig's own) now covers both AS client-auth-mtls and this new profile — one shared certificate satisfies both axes at once, since storage.RegisteredClientConfig's ClientAuthMethod and SenderConstrain fields are already fully orthogonal, nothing library-side stops registering both on one client — its own new conformance-as-client-auth-mtls-and-mtls container; the two CIBA profiles extend the same running conformance-as-ciba-mtls container via one shared setupCIBAClientAuthMTLSVariant, whose appendCIBAClientAuthMTLSClients helper adds client_auth_method/ expected_certificate_thumbprint clients alongside the existing private_key_jwt ones — jwks/backchannel_authentication_request_algorithm stay regardless of client authentication method, since FAPI-CIBA always requires a signed backchannel authentication request independent of how the client authenticates itself (server/backchannel_authentication.go's own doc comment).

Confirmed live, all three on the first attempt after one real process fix: AS client-auth-mtls-and-mtls 38/38 PASS, AS ciba-client-auth-mtls 34/34 PASS, AS ciba-ping-client-auth-mtls 39/39 PASS, all 0 failures/0 warnings. The one real snag wasn't in this AS at all — manually running run-test-plan.py for an AS-side browser-flow plan without also running its own required companion, unblock-implicit-callback.py, alongside it produces exactly the suite's own long-documented HtmlUnit/Bootstrap-JS implicit-callback bug (unblock-implicit-callback.py's own doc comment: "not optional convenience — every suite version that supports fapi2-security-profile-final-test-plan also carries this bug"), non-deterministically stalling whichever module currently owns the plan's shared alias — every one of this repo's other AS legs already runs both together via run_as_plan's own two-process shape (run-all.sh); a one-off manual verification run has to do the same. Wired into run-all.sh as "AS client-auth-mtls-and-mtls", "AS ciba-client-auth-mtls", "AS ciba-ping-client-auth-mtls". Full breakdown in conformance/server/oidf-config/README.md's own sections.

The RP side got the same generalization: cmd/conformance-client's -mtls flag, previously hardcoded to -profile=ciba only, now also works with -profile=baseline, and combines with -client-auth-mtls (one shared certificate covers both RFC 8705 §2 and §3, since the suite's own EnsureClientCertificateMatches condition reads the same client.certificate plan-config value regardless of which axis triggered the check). This closes the two FAPI2SP RP register profiles that were unreachable before — "private key + MTLS" and "MTLS + MTLS" — alongside the already-covered "private key + DPoP" (-profile=baseline) and "MTLS + DPoP" (-client-auth-mtls), so all four FAPI2SP auth×sender-constrain combos now have a driver. Confirmed live: 20/20 PASS for both, on the first attempt — no driver fixes needed, since all the underlying library groundwork (client.Config.SenderConstrain/ClientAuthMethod, applyMTLSEndpointAliases/ForClientAuth, callProtectedResourceBearer) already existed from the CIBA -mtls and client-auth-mtls re-attempts above. Wired into run-all.sh as "RP mtls" and "RP client-auth-mtls-and-mtls". Full breakdown in conformance/client/scripts/README.md's own "Sender-constrain mTLS" section.

RP certification also needs evidence, per test, of what the client itself did — a bare suite-graded PASSED/FAILED isn't sufficient submission evidence on its own, since the suite can't see inside the client under test. cmd/conformance-client's new -evidence-dir flag writes one named log file per test module (evidence.go) instead of the one combined stdout log this driver has always produced, opt-in and unused by daily CI — see conformance/client/scripts/README.md's own "Certification evidence" section for the format and the actual certification-run workflow (against certification.openid.net by hand, never automated, same as every other suite this repo touches). This workflow has since produced real, listed results — FAPIgo holds official OpenID Foundation certification for FAPI 2.0 OP, FAPI 2.0 RP (both Security Profile Final + Message Signing Final) and FAPI-CIBA OP; see conformance/README.md#oidf-certification for the full list and links to each listing.

Rich Authorization Requests (RFC 9396) has no OIDF conformance plan at all, unlike CIBA (which at least has fapi-ciba-id1 to eventually re-attempt) — so it's deliberately outside this automated live-suite loop entirely, not a temporary gap, and is instead validated by extension/rar_test.go, server/rar_test.go and cmd/conformance-as's own end-to-end smoke tests (conformance/README.md#rar).

One more entry in that matrix review turned out not to be a gap at all on inspection: the register's separate "...OpenID Connect" AS/RP profiles aren't a distinct plan to add — they're the openid (AS)/fapi_client_type (RP) variant value every non-client_credentials leg this repo already runs selects (openid_connect/oidc, never plain_oauth). Confirmed live via GET /api/plan/{id}'s own certificationProfileName field against the already-running AS and RP baseline plans: ["FAPI2SP OP OpenID Connect", "FAPI2SP OP private key + DPoP"] and ["FAPI2SP RP OpenID Connect", "FAPI2SP RP private key + DPoP"] respectively — the same openid_connect/oidc value run-all.sh and cmd/conformance-client's profiles map already request on every leg. Every "...OpenID Connect" register-profile entry this matrix covers is earned this way already, as a byproduct of the plans this repo runs for other reasons — the only variant that deliberately opts out is openid=plain_oauth, required by (and only by) the client_credentials grant below, since that grant has no end-user to ever produce an ID token for.

The RFC 6749 §4.4 client_credentials grant closed the last real AS-side conformance gap this repo's own 15-profile OIDF matrix review surfaced — a genuinely missing grant type, not a conformance-setup gap like everything above. server.RequestClientCredentialsToken (server/client_credentials.go) reuses nearly everything already built: client authentication (every ClientAuthMethod), sender -constrain binding (verifyTokenRequestBinding, unchanged), scope authorization (RegisteredClient.AllowsScope, the same check PAR already runs, plus a refusal of openid and offline_access, which this grant can never honour), and AccessTokenIssuer.IssueAccessToken — with Subject set to the client's own ID (RFC 9068 §2.2 requires a sub claim; there's no end user for this grant to name instead). No PAR, no storage.GrantStore redemption call (there's no grant to redeem — a structural difference from every other token-issuing path), no ID token, no refresh token (RFC 6749 §4.4.3 says a refresh token "SHOULD NOT" be issued). It does support Rich Authorization Requests (RFC 9396 §6) when Config.RAR is configured — see the RAR section above for how this differs structurally from PAR/CIBA's own narrowing-grant shape. Two new opt-ins gate the grant itself, deliberately kept separate: Config.ClientCredentialsGrant (server-wide — false by default, mirroring the "zero value disables the feature" idiom Endpoints.BackchannelAuthentication/Config.RAR already use, since this grant arrives at the existing token endpoint via a grant_type value rather than a new endpoint to gate on) and storage.RegisteredClientConfig.AllowsClientCredentialsGrant (per-client, mirroring BackchannelAuthenticationRequestAlgorithm's own "explicit capability, not inferred" precedent) — enabling the grant server-wide does not implicitly permit any specific client to use it.

Confirmed live against all four FAPI2SP OP "Client Credentials Grant" register profiles (MTLS+MTLS, MTLS+DPoP, private key+MTLS, private key+DPoP): 15/15, 11/11, 10/10, 6/6 modules, 0 failures/0 warnings each — reusing the same four already-running conformance-as-{baseline,mtls,client-auth-mtls,client-auth-mtls-and-mtls} containers (no PAR/authorize/browser hop at all, so no new container topology was needed), with one small addition to cmd/conformance-as itself: a second, non-OIDC protected-resource endpoint (/accounts, accountsHandler in cmd/conformance-as/resource.go) alongside the existing /userinfo — confirmed live that the suite's own openid=plain_oauth variant (the only legal value for fapi_profile=fapi_client_credentials_grant; openid_connect demands an ID token this grant can never produce) forbids requesting the openid scope at all, which collided with /userinfo's own real OIDC contract requiring exactly that scope. Full breakdown, including the client2-is-structurally-required finding this surfaced (not specific to this profile — a fapi2-security-profile-final-test-plan-wide requirement, confirmed live), in conformance/server/oidf-config/README.md#client-credentials-grant.

OpenID Federation 1.0 (federation, plus cmd/conformance-as's self-issuance/automatic-registration wiring and cmd/conformance-federation-trust-anchor) got the same live-conformance treatment, against all three OIDF Federation test plans. "Deployed federation entity" (server-to-server Entity Statement/Trust Chain checks, no browser hop) is fully automated, as two run-all.sh legs: "Federation federation-deployed-entity" runs it against conformance-as-federation, where every module passes except two that test the entity's own fetch endpoint and skip, since a leaf entity has none; "Federation federation-trust-anchor" runs it against cmd/conformance-federation-trust-anchor, which serves fetch, list and resolve, so those modules run for real there. The other two plans — "Entity joined to test federation OP test" and its RP-side counterpart — exercise automatic registration (§12.1) live, with the suite playing the opposite role from this repo in each one, and landed in two different, both genuinely confirmed, suite-side gaps:

The OP plan (suite plays the RP, tests this AS) was long assumed suite-blocked by a deterministic test-runner bug ("Illegal test state change: CREATED -> RUNNING") — disproven by reconstructing the plan's full config from scratch (real RP/Trust Anchor JWKS, via the suite's own REST API) and driving it live: that failure never reproduced once the config was actually complete. Two real, previously-unknown gaps surfaced and were fixed instead — the suite self-hosts a fresh Trust Anchor per run (a random path segment, no static config can name it in advance) and this AS's own outbound federation fetcher lacked fapihttp.Config.AllowedPrivateHosts/TLS peer trust for reaching it. Both fixed (cmd/conformance-as -federation-trust-anchor-admin, a runtime POST /internal/federation/trust-anchors admin endpoint — gated behind that flag and a bearer token, -federation-trust-anchor-admin-token, and never appropriate outside a conformance run, see dynamicFederationClients's own doc comment) and driven all the way to a real PAR call every time. The plan's actual blocker is that the suite's RP is a plain OpenID Connect client, not a FAPI 2.0 one: it uses no PKCE and no DPoP or mTLS sender-constraining, all of which this AS requires. Its self-hosted RP entity config also never sets token_endpoint_auth_method, so this AS refuses to auto-register it at all (no implicit client_secret_basic default — both this module's own "no implicit defaults" design and FAPI 2.0's own prohibition on client-secret-based methods), but declaring a method wouldn't be enough. Of the plan's other modules, most negative ones send their Request Object by value to the authorization endpoint, which a PAR-only AS never accepts regardless, and the JAR-direct positive ones don't apply under FAPI 2.0. Not wired into run-all.sh.

The RP plan (suite plays the OP, tests this repo's own RP driver, cmd/conformance-client -profile=federation) needed cmd/conformance-client to become a real inbound HTTPS listener for the first time — every other profile it drives only ever makes outbound calls. The plan's own server_metadata variant name (static vs discovery) initially suggested a static config value could avoid this; reading the suite's own source disproved that too — the suite's live authorize/PAR handling unconditionally fetches the RP's Entity Configuration over HTTP no matter which variant is selected. Confirmed live (2026-09-24): 6 of 10 modules PASS. The remaining 4 (every module that reaches the token endpoint) hit two suite-side bugs there: the suite checks the client assertion's aud against the RP's own Entity Identifier rather than the OP's, and its token response carries only id_token (no access_token/token_type, which RFC 6749 §5.1 requires). Until the suite added issuer to its mock OP metadata (2026-09-23) every module stopped at discovery instead, which had made three negative tests pass for the wrong reason. See conformance/client/scripts/README.md's Federation section for the request-object and host.docker.internal details this plan needs. Wired into run-all.sh as the "RP federation-rp" leg via a new cmd/conformance-client -expected-failures=<file> allowlist mechanism (conformance/client/expected-failures-federation.json names the failing modules), the RP-side counterpart to the AS side's expected-skips/expected-warnings JSON files, checking for drift in both directions — an allowlisted module unexpectedly passing (e.g. the suite shipping a fix upstream) is flagged just as reliably as a new regression. Full breakdown of both plans in conformance/server/scripts/README.md and conformance/client/scripts/README.md's own Federation sections.

Building and driving this live surfaced four hand-rolled pieces of crypto/HTTP boilerplate a genuine third-party OpenID Federation integrator would also need, later promoted out of the conformance binaries into the public library: federation.WriteEntityStatement (the Content-Type-header-plus-body-write every Entity Statement HTTP response needs, existed independently 4 times across the three binaries), federation.OpenIDRelyingPartyMetadata (a typed openid_relying_party metadata struct — closes off the exact token_endpoint_auth_method-omission bug class above by construction), and fapitest.SelfSignedServerCert/fapitest.PeerTLSConfig (the server-cert and verify-except-an-explicit-allowlist tls.Config patterns every local multi-service integration test with no shared CA needs — see design rule 15 above for why importing fapitest from these binaries' own non-test code doesn't violate that rule).

What is and isn't shared

Shared (via internal/, extension, storage's replay primitive and contract test suite, keys, fapihttp, backchannelhttp, federation, and the root fapi package's value types): strict parsers, canonicalization rules, JOSE implementation, algorithm policy primitives, Secret, typed URL, key representations, extension/RAR definitions, replay machinery, storage contract tests, OpenID Federation 1.0 Trust Chain resolution and self-issuance (federation — a shared subsystem package like keys/storage, not a fourth role: see federation/doc.go), CIBA ping-delivery notification (backchannelhttp).

Not shared: role-level configuration, workflow APIs, transaction types, untrusted vs. validated request types, storage interfaces where semantics differ, generic JWT/DPoP verification methods, and audit (only server has an audit sink).

No public JOSE utility package

None of client, server or resource exposes a general-purpose JWT/JWS/JWK helper. If one did, callers would eventually reach for it outside the exact protocol context it was validated for and end up with a verification path missing an issuer, audience, lifetime or replay check. Everything JOSE-shaped stays under internal/jose (plus internal/jwe for encryption) and its asymmetric callers (internal/dpop, internal/mtls, internal/jarm, internal/requestobject, internal/clientassertion, internal/clientattestation, internal/federation, internal/token); the public surface is limited to keys.KeyManager, registered public key types, the closed SignatureAlgorithm enum, and each role's own signed protocol outputs. The one narrow exception is client.Client.VerifyIssuerJWS: it checks only that this client's own configured authorization server signed a compact JWS, with the client's issuer key source and its configured Algorithms.UserInfo (never the header's alg), and hands back the payload for the caller's own claim checks.