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.
Share protocol implementation, not role-level APIs.
Concretely:
client,serverandresourceare separate public packages with separate constructors (client.New,server.New,resource.NewVerifier), not onefapi.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, ...), butclientmust never importserveror 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).
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
rp, err := client.New(clientConfig, clientDeps)
as, err := server.New(serverConfig, serverDeps)
rs, err := resource.NewVerifier(resourceConfig, resourceDeps)Not fapi.New(RoleClient, ...).
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()andMarshalText()all redact;Reveal()is the only way to get the raw value out.client.TokenSetandserver.TokenResultuse it for every token they carry;resource.AuthorizationContextcarries no raw token value at all.URL(constructed viaParseIssuerURL/ParseEndpointURL/ParseRedirectURL, not a barestring) — 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 — seefapi.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.
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.
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.
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.
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.
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 fromBeginAuthorization.AuthorizationResult—AuthorizationRedirect|AuthorizationLocalError, returned fromCompleteAuthorization. Only the engine assembles the final redirect destination — the embedding app cannot replacestate, 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), andInteractionNeeded(need, reason)for aprompt=nonerequest the application can't complete without showing a page) passed toCompleteAuthorization, 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.Algorithmsand a fixed FAPI2 grant/response-type set (authorization_code[+refresh_tokenforoffline_access],response_type=code,private_key_jwtonly — RFC 7591 §2'stoken_endpoint_auth_method/grant_types/response_typesare not client-negotiable here) inserver, beforestorageis ever touched.storage.NewRegisteredClientonly checks structural validity today (is this a recognized algorithm at all) — it has no visibility into server-wide policy, and shouldn't gain any:storagesits belowserverin rule 14's dependency layering, so a policy check that needsConfig.Algorithmsbelongs inserver, the same layer that already runs the equivalent check lazily, per request, inPushAuthorizationRequest. RegisterClient/ReadClient/UpdateClient/DeleteClientfollow the same typed-request → typed-result/typed-error shape every other method here does — no genericHandleRequestfor this either.- Registration access tokens (RFC 7592 §1.2) are a new storage
primitive, not a repurposed
GrantStorerefresh 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.ClientKeySourceneeds its own write path, independent ofstorage.ClientRepository's. A client's key material (jwks/jwks_uri, RFC 7591 §2) is already modeled as a concern separate from its registration metadata (seekeys.ClientKeySourcevs.storage.ClientRepository); registration must keep writing to both without merging them into one interface.
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.
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.
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 bycheckExtensionsat 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 neithercheckExtensionsnorcheckBackchannelExtensionstells aRARPolicywhich grant it's being consulted for, so a shared field would give an integrator no way to permitauthorization_detailson one grant while refusing it on the other. The primary entitlement check for both grants remains the resource owner's own interactive decision atCompleteAuthorization/CompleteBackchannelAuthentication(GrantedAuthorization.AuthorizationDetails, checked byvalidateGrantedAuthorizationDetails— mirroringscope'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 byRequestClientCredentialsTokenat 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.
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.
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.
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.
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."
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.
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 registeredSubjectTypegive public or pairwise (Server.subjectTypeFor). An automatically registered federation Relying Party is offered whatConfig.AutomaticRegistration.SubjectTypesallows, pairwise alone under a pairwise default, andServer.EntityConfigurationadvertises 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.Deriverturns (issuer, sector, internal subject) into an opaque body, and the server prependsConfig.Subjects.Prefix. Whether a body can be mapped back is whether the deriver implementspairwise.Lookup, never a separate setting, so configuration can't claim more unlinkability than the deriver gives.server.Newprobes 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
subit 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'ssub. A prefix change does end the grant (invalid_grant), since resource servers resolve asubunder the current prefix. With an empty prefix, signed UserInfo checks a pairwisesubby 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'ssub. Records, audit events and the application's own interaction APIs keep the internal subject. - Inbound. A client's
id_token_hintorclaimssubis mapped back to the internal subject throughLookup(or a declaredPairwiseSubjectResolver) under that client's sector; with nothing to map it, the interaction carries the client'ssubfor the application to compare withServer.ClientSubject(a CIBAid_token_hint, which must identify the user, is refused). Alogin_hintis the application's to interpret, withServer.ResolveClientSubjectandServer.ClientSubjectfor one carrying asub. - Resource servers.
resource.AuthorizationContextcarries both:InternalSubject, which the API authorizes by and never returns, andClientSubject, which anything client-facing uses. An opaque access token's record holds the internal subject; a JWT access token's pairwisesubis mapped back under itspairwise_sectorby the OP's own resource servers (Dependencies.PairwiseSubjectsorPairwiseSubjectResolver). A resource server outside the OP setsConfig.PairwiseResolutiontoPairwiseClientSubjectsOnly, gets an emptyInternalSubject, and authorizes byClientSubject.
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.
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).
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).
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.