@o3co/auth-provider-oauth
v0.15.0
Published
OAuth routes module for auth.provider
Readme
@o3co/auth-provider-oauth
OAuth 2.0 routes module for auth.provider.
Mounts POST /oauth/token, POST /oauth/introspect, and GET /oauth/authorize onto an Express app. Implements a registry-based grant dispatch model so additional grant types can be plugged in without modifying this package.
Install
This package is private — it is not published to npm and is only available within the auth.provider monorepo.
// packages/*/package.json
{
"dependencies": {
"@o3co/auth-provider-oauth": "workspace:*"
}
}Peer dependencies (install separately in the workspace root):
express@^5.0.0Public API
oauthModule
function oauthModule(params: {
clientRepository: ClientRepository;
codeRepository: CodeRepository;
express?: ExpressLike;
}): Module;Top-level module. Registers oauthSessionModule and oauthAuthorizationModule as sub-modules and mounts the OAuth router at /oauth. Use this as the single entry point unless you need to mount the sub-modules individually.
Routes mounted:
| Method | Path | Description |
|--------|--------------------|------------------------------------|
| POST | /oauth/token | Token endpoint — dispatches by grant_type |
| POST | /oauth/introspect | Token introspection (RFC 7662) |
| GET | /oauth/authorize | Authorization endpoint — PKCE auth code flow |
oauthSessionModule
function oauthSessionModule(params: {
config: AppConfig;
}): Module;Registers the "session" grant type in the grant registry. Activation is gated on config.oauth.grants.session.enabled. Use this sub-module directly when you need to compose the grant registry manually.
When a userSessionStore is wired, every session grant requires a non-empty
sid and a live UserSession before signing a token. Missing or revoked sessions
return 400 invalid_grant; store failures return 503 temporarily_unavailable.
The tracked session must have a non-empty subject matching the browser user;
malformed or inconsistent identities are refused before any token is signed.
Deployments without a session store retain the existing browser-session behavior.
Validated DPoP/mTLS bindings are retained in the access token's cnf;
DPoP responses use token_type=DPoP, while mTLS responses retain Bearer.
The resource server must support and verify the corresponding possession evidence.
oauthAuthorizationModule
function oauthAuthorizationModule(params: {
codeRepository: CodeRepository;
}): Module;Registers the "authorization_code" and "refresh_token" grant types in the grant registry. Use this sub-module directly when composing the grant registry manually.
createOAuthRouter
function createOAuthRouter(
express: ExpressLike,
options: {
registry: GrantHandlerResolver;
config: AppConfig;
clientRepository: ClientRepository;
codeRepository: CodeRepository;
keyStore: KeyStore;
}
): Promise<{ router: Router; registry: GrantHandlerResolver }>;Low-level factory. Creates the Express router and the fully-configured grant registry. Called internally by oauthModule; use directly when you need access to the registry instance after construction. Client authentication at /oauth/introspect is handled by createClientAuthMiddleware(clientRepository) — no Passport dependency required.
Usage Example
import express from "express";
import { createApp } from "@o3co/auth-provider-core";
import { oauthModule } from "@o3co/auth-provider-oauth";
const handle = await createApp({
modules: [
// composition-root modules that provide clientRepository, codeRepository,
// keyStore, and grant handlers go here
oauthModule({ config }),
],
bootstrapComponents: { config, pathResolver: import.meta.resolve },
});
const server = express();
server.use(handle.router);
server.listen(config.http.port);
await handle.dispose();The OIDC surface, stated (#284)
This is an OAuth 2.0 authorization server with the OIDC pieces a first-party deployment needs. Where it stops is deliberate, and saying so is part of the contract — an RP that discovers what is here should not have to find the edges by hitting them.
/oauth/authorize accepts GET and POST (OIDC Core §3.1.2.1). Both run the identical sequence of checks: the handler reads its parameters through one accessor, so a check cannot be mounted on one method and forgotten on the other.
redirect_uri is matched against client.allowedRedirectUris by exact string equality, with one carve-out (#483). When both the registered entry and the presented value are http: on a loopback IP literal (127.0.0.0/8, [::1]), the port is dropped from both before comparing — scheme, host, path and query are still compared exactly. The equality runs on the two original strings with the port removed, not on normalized URLs, so dot segments (/a/../cb), percent-encoding variants, a \ separator, a differing trailing slash and scheme case never widen it. A native app receiving the response on a loopback interface binds an ephemeral port the OS assigns at run time, so a registration cannot name it (RFC 8252 §7.3): http://127.0.0.1/cb admits http://127.0.0.1:49152/cb.
http://localhost/cbgets no carve-out — a loopback name moves the guarantee into the host's name resolution, and RFC 8252 §8.3 discourages it. Register the IP literal.https://gets no carve-out either, loopback host or not.- The presented URI is where the response goes, and it is what gets bound to the authorization code. The token endpoint's
redirect_uricheck (RFC 6749 §4.1.3) compares against that record with plain equality — port included — so a listener on a different port cannot redeem another one's code. - The comparison lives in
matchesRegisteredRedirectUri(@o3co/auth-provider-core), exported so a custom authorization endpoint matches the way this one does.
prompt=none is supported. No session answers login_required at the client's redirect_uri — which is the point, since a hidden renewal iframe cannot act on a login page. A session proceeds silently.
prompt=login re-authenticates (#481) — see Step-up and re-authentication below.
prompt=consent is honoured (#527): for a client that is not first-party it forces the consent page even when a recorded consent covers the request; for a first-party client it is a no-op — the deployment operates that client, so there is nothing to consent to. See Consent for third-party clients.
select_account is refused with invalid_request naming the value, not ignored: there is no account picker, and ignoring it would hand back a token the RP believes was freshly account-picked.
request and request_uri are refused with request_not_supported / request_uri_not_supported. Ignoring them was the pre-#284 behaviour and the dangerous one: a signed request object exists to make the parameters tamper-proof, so processing the query string instead gives an attacker precisely what the object was there to prevent while the RP believes it was honoured. The discovery document now says request_uri_parameter_supported: false for the same reason — OIDC Discovery defaults that field to true, so omitting it was a claim.
Not implemented: the claims parameter, and response_mode beyond the default. claims_parameter_supported and request_parameter_supported default to false when omitted, so the discovery document already tells the truth about them by saying nothing.
Step-up and re-authentication (#481)
A native app needs two things from the OP for a sensitive action: to force a fresh authentication (a payment, a credential change) and to know how the user authenticated (passkey, password, password plus a second factor), so it — or the resource server — can require a level. Both rest on what the session records at login.
What a session records. UserSession.authTime (already there) and, since #481, UserSession.amr — RFC 8176 values written by the login path: ["pwd"] for POST /session/login; the upstream IdP's amr (when the provider surfaces it on the profile) plus the deployment-defined fed for a federation callback; the WebAuthn grant, which mints tokens without a session, stamps amr: ["hwk"] on its access token directly. RFC 8176 registers no value for "federated", and OIDC Core §2 leaves amr values to the deployment, so fed is documented here rather than borrowed. A composition that resumes a login after POST /auth/mfa/verify (the MFA route is not composed in this repository; its resume handlers are the deployment's) records mfa — and the factor's own value, otp say — in the session it creates; CreateUserSessionInput.amr is the seam.
What the tokens carry. The id_token has auth_time always (it did before #481), amr when the session recorded one, and acr when /authorize satisfied an acr_values request. The access token mirrors amr and acr when present, so auth.policy-verifier or a resource server can gate on them without an id_token — and keeps mirroring them across refreshes: the authorization_code grant stamps both on the refresh token as well, and the refresh_token grant carries them from the presented token onto the access and refresh tokens it mints, since a refresh does not repeat the authentication (OIDC Core §12.2 treats auth_time the same way). The session grant mirrors the tracked session's amr (it has no acr_values negotiation, so no acr), and the passkey grant (@o3co/auth-provider-webauthn) stamps its amr: ["hwk"] on its refresh token as well as its access token. Every grant reads the claims in one shape — amr a non-empty array of non-empty strings, acr a non-empty string (core's wellFormedAmr / wellFormedAcr) — and omits anything else, so a session that recorded amr: [] stamps no amr on any token rather than one that vanishes at the first refresh. A refresh token minted before this carried neither, so the tokens refreshed from it carry neither.
max_age. A non-negative integer (anything else is invalid_request). A session whose auth_time is older than max_age seconds — max_age=0 is always older — is sent to the login page with the request round-tripped, exactly as an unauthenticated one is, plus one thing: the instant of the ask is recorded on the session, server-side. On the way back, a session authenticated strictly after that instant — compared to the millisecond — is the re-authentication that was asked for, and the request proceeds — max_age=0 included, which is what keeps it from looping; one authenticated before it is answered login_required rather than sent round again. Under prompt=none a stale session is login_required straight away: silent means silent. auth_time in the id_token is what an RP verifies, and it is always the truth.
The ask is a record in the session store, named by an opaque id the returned URL carries as reauth_ask. It is not the timestamp itself on the URL: a marker read straight off the request is the caller's to write, and reauth_after=0 would satisfy the check for any live session and skip the round trip it exists to force. A record cannot be forged (the id is 32 bytes from the CSPRNG, and naming one that does not exist is the same as naming none); it survives the session regeneration /session/login performs, which a field on the session would not; it is bound to the authorize request it was minted for, so an ask outstanding for one request cannot answer another's freshness requirement; and it is consumed when read, so a replay of the returned URL asks again rather than minting a second code. It expires after ten minutes.
The login page must return the browser to redirect_to verbatim: a page that rebuilds the authorize URL drops the ask id, and the request is asked to authenticate again. That has always been the contract of this round trip.
prompt=login uses the same mechanism with the staleness test replaced by "always": to the login page, the ask recorded, then satisfied by a session authenticated after it, else login_required. prompt=none login is still refused as OIDC Core §3.1.2.1 says.
acr_values is answered from a configured table and from nothing else:
oauth.authorize.acrValues {
"urn:example:pwd" = ["pwd"]
"urn:example:mfa" = ["pwd", "mfa"]
"urn:example:passkey" = ["hwk"]
}Each key is an Authentication Context Class Reference this deployment vouches for; its value is the amr set a session must carry to satisfy it. The first requested value the session satisfies becomes the acr of the code and of the id_token. None satisfied — or a value that is not in the table at all — is unmet_authentication_requirements at the redirect_uri, naming what was unmet; there is no silent acceptance, and no step-up redirect, because the login page cannot be told which factor to add. Discovery advertises the keys as acr_values_supported when the table is non-empty. An acr that requires nothing is refused at boot: every session would satisfy it, and it would vouch for nothing.
Both login paths must re-authenticate when asked. The login page the deployment serves receives redirect_to carrying prompt=login / max_age and the marker; a page that bounces an already-authenticated browser straight back gets login_required, never a loop. POST /session/login and the federation callback always establish a new session with a fresh auth_time, which is the re-authentication.
Client authentication: private_key_jwt (RFC 7523 §2.2)
Every client-authenticated endpoint here — /oauth/token, /oauth/introspect, /oauth/revoke — accepts, besides client_secret_basic / client_secret_post, a JWT the client signed with its own private key (#484). Nothing shared has to be distributed to every replica of a machine client and rotated everywhere at once: the private half stays with the client, rotation is a JWKS publish, and every assertion carries a jti the provider spends exactly once.
Registration. tokenEndpointAuthMethod: "private_key_jwt" with exactly one of jwks (the public keys, inline, RFC 7591 jwks — a key carrying a private member such as d, p, q or k, or a symmetric kty: "oct", is refused at registration, since the projection every middleware reads is public) or jwksUri (https, or http on a loopback host; fetched at verification time and cached, unknown kids trigger a refetch with a cooldown). No clientSecret — the schema refuses one next to this method, and refuses jwks / jwksUri next to any other.
The request. client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion=<JWT> in the form body, and nothing else that authenticates: an assertion next to a Basic header or a body client_secret is refused before either is examined (RFC 6749 §2.3, one method per request). A body client_id, if present, must match the assertion.
The assertion. iss and sub both equal to the client_id; aud naming the issuer or the token endpoint URL (RFC 7523 §3 — either form, so a client library that uses one or the other works); exp required and at most one hour ahead (MAX_CLIENT_ASSERTION_LIFETIME_SECONDS); jti required and single-use, recorded in the composition's replaySeenSet under client-assertion:<client_id> until the assertion expires; signed with an asymmetric algorithm (RS*, PS*, ES*, EdDSA — token_endpoint_auth_signing_alg_values_supported lists them; HS* and none are never accepted against a JWKS). nbf is validated when present, and iat when present must be neither ahead of the server's clock beyond the 30 s tolerance nor older than the lifetime ceiling.
Refusals are 401 invalid_client — a replayed jti, a wrong aud, an expired or over-long assertion, a signature under a key the JWKS does not hold, a kid it does not publish, a client registered for another method, an unknown client, or a jwks_uri that cannot be fetched (fail closed, logged as client_assertion_refused with the reason). A private_key_jwt request in a composition that wired no replaySeenSet is 500 server_error: a jti that cannot be recorded is one that could be replayed, so the path refuses rather than authenticating unchecked. The scaffold wires one (REPLAY_SEEN_SET_ADAPTER, Redis by default; the memory adapter is refused under DEPLOYMENT_MODE=multi because a captured assertion would replay once per replica).
Not shipped: client_secret_jwt. It would need the repository interface to hand the middleware the raw secret as an HMAC key — authenticate(clientId, secret) compares, it does not reveal — and a bcrypt-hashed clientSecret, which is what the scaffold recommends storing, cannot serve as one at all. The secret-based methods a deployment already has cover that case; the asymmetric one is the point of this feature.
# config/clients.yaml
orders-service:
tokenEndpointAuthMethod: "private_key_jwt"
jwksUri: "https://orders.example.com/.well-known/jwks.json"
allowedGrantTypes: ["client_credentials"]
allowedScopes: ["orders:read"]
defaultScopes: ["orders:read"]
allowedAudiences: ["https://api.example.com/orders"]POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjYtMDkifQ...Consent for third-party clients (#527)
/oauth/authorize mints a code for a client marked firstParty: true as soon as the session is authenticated — the deployment operates that client, and auto-consent is the honest model. Every other client goes through consent: the user is asked, on the deployment's own page, and the answer is recorded so they are not asked again for what they already allowed. Before #527 the only way to serve such a client was to mark it first-party, which minted with no consent step at all.
Wire a consentStore and a pendingConsentStore and point endpoints.consent.url at your page. Each bundled module provides both: memoryConsentStoreModule from @o3co/auth-provider-core (single replica — refused under deployment.mode = "multi") and redisConsentStoreModule from @o3co/auth-provider-redis (#561), which shares the consent records and the parked requests across replicas. In the standalone template that is consentStore.adapter = "memory" or "redis". Then, for a client that is not first-party:
/authorizeruns every request-shape check as usual, then looks up the consent record for (sub,client_id). A live record covering the requested scopes (a subset of what was granted) mints the code with no interaction.- Otherwise the request is parked under a 32-byte challenge in the
pendingConsentStore, bound to the session and the subject it was asked of, and the browser is redirected toendpoints.consent.url?challenge=<id>.prompt=nonegetsconsent_requiredat theredirect_uriinstead (OIDC Core §3.1.2.6);prompt=consentparks the request even when a record covers it. - The page calls
GET /oauth/consent?challenge=<id>(session cookie, uncacheable) and receivesclient_id,client_id_host(only for a client resolved from a Client ID Metadata Document — see below),client_name,client_uri(from the registration),scopes(what is asked),granted_scopes(what the user already agreed to, so the page can highlight the delta),redirect_uri(show its host — this is where the code goes) andexpires_in. - The page
POSTs/oauth/consentwith{ "challenge": "<id>", "decision": "accept" | "deny" }(JSON or a form).acceptrecords the union of what was granted and what is asked, emitsconsent.granted, and answers303to the parked/authorizeURL — which now finds the record and mints.denyemitsconsent.deniedand answers303to the client'sredirect_uriwitherror=access_deniedand thestate. Either way the challenge is spent.
The challenge is bound to the session that parked the request and reaches the page only through the redirect URL, which a cross-site page cannot read; a POST carrying the matching value was composed by same-origin code (the synchronizer-token pattern, with the session as the synchronizer). A foreign, replayed or expired (10 minutes) challenge is 400. The answer consumes the parked record in one step (PendingConsentStore.consume), so two answers in flight for one challenge — a duplicated tab, a double submit — apply exactly one, and the other is told there is no pending consent (#552). A consent-store outage at /authorize is temporarily_unavailable, never a code and never a refusal the user could act on. An operator revokes a consent by removing the record (consentStore.revoke(sub, clientId)); the next /authorize for that client asks again.
Register what the page will show: clientName (RFC 7591 client_name) and clientUri (client_uri) on the client record. A native client with a loopback redirect_uri is the case the MCP authorization spec asks the page to warn about — redirect_uri is in the response for exactly that.
A Client ID Metadata Document client names itself. Its client_name and client_uri come from a document whoever controls its host wrote, so "Google Drive" costs nothing to type. The one verified fact is the host its client_id URL names, which the response carries as client_id_host: show it prominently, as the draft asks, and never let client_name stand alone. Serve the page with Referrer-Policy: no-referrer (or strict-origin) so a client_uri link does not hand that host the page URL and its challenge.
Client ID Metadata Documents (#529)
A client may identify itself with the https URL of its own registration — a Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document), the registration model the MCP authorization spec (2026-07-28) makes the SHOULD for hosted clients now that Dynamic Client Registration is deprecated there. Off by default: oauth.clientIdMetadataDocuments.enabled = true (OAUTH_CIMD_ENABLED), and the discovery document then advertises client_id_metadata_document_supported: true beside the none it already lists in token_endpoint_auth_methods_supported — the two signals an MCP client selects on.
GET https://client.example/oauth/client-metadata.json is the registration:
{
"client_id": "https://client.example/oauth/client-metadata.json",
"client_name": "Acme Chat",
"client_uri": "https://client.example",
"redirect_uris": ["https://client.example/cb", "http://127.0.0.1/cb"],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "read write"
}What the server does with it:
- A pre-registered client with the same
client_idwins; the document is not fetched. - The URL must be a document URL:
https, a path, no fragment, credentials, dot segments or query string, a host name rather than an address and not loopback. Anything else is not a client. A host name ending in the DNS root dot (client.example.) is refused outright: it survives URL canonicalisation and TLS accepts the undotted certificate, so it would otherwise be a spelling the host lists below do not match. The operator may narrow hosts further (allowedHosts, exact or.suffix;deniedHostswins). - The name is resolved before the socket opens, and every address must be public: one inside an RFC 6890 special-use range — the cloud metadata endpoint, a private network, this host — refuses the lookup. That is the SSRF guard the draft requires; a rebinding between check and connect is the residual it accepts too, and the host lists are the lever against it.
- The fetch follows no redirect (a 3xx is an error), times out (
timeoutMs), caps the body onContent-Lengthand on the stream (maxBytes, 5 KB by default), and takes only200with JSON. A valid document is cached per URL for itsCache-Control: max-age, bounded bycacheMaxAgeMsand bymaxCacheEntries, and revalidated byETagwhen it expires. A5xxor a429from the client's server is read as their availability, not their registration — it takes the same path as a timeout or a DNS failure; a4xxor a refused redirect is the registration being absent or wrong, and takes the refusal path. A refusal is never cached as a client, but it is remembered as a refusal fornegativeCacheMs(a minute by default), so an inventedclient_iddoes not cost a DNS resolution and a socket on every request; a registration this server already validated is still served forstaleIfErrorMsthrough a revalidation that failed for a reason that is not the document's — a DNS blip, a 5xx, a timeout — because an outage at someone else's server is not a verdict on the client, while a document that was rejected is dropped at once.maxConcurrentFetchesbounds how many documents are in flight across every id. Concurrent lookups share one fetch. Every refusal logscimd_document_rejected/cimd_document_fetch_failed/cimd_host_not_allowedwith the reason. - The document must carry
client_idequal to the URL, a non-emptyredirect_uristhis server would accept at registration (exact match at/authorize, with the RFC 8252 §7.3 loopback-port carve-out), noclient_secret, and notoken_endpoint_auth_methodbutnone— a shared-secret method is forbidden by the draft, andprivate_key_jwtis refused because the keys would come from the same attacker-authored document that names them, so it would authenticate the document rather than the client (registered clients may use it — seeprivate_key_jwt).grant_typesmust includeauthorization_code;response_typesmust admitcode. - The client it becomes is public and not first-party (
tokenEndpointAuthMethod: none, PKCE S256 required,firstParty: false), so it goes through the consent step — wire a consent store, or the feature stays inert: without one/authorizecould not finish such a flow, so no document is fetched and a URL-shapedclient_idis simply an unknown client (the discovery document withholdsclient_id_metadata_document_supportedfor the same reason) — and the page shows the document'sclient_name,client_uriand theredirect_uri. Its scopes are the document'sscopeintersected with the operator'sallowedScopes; its audiences are the operator'sallowedAudiences— the resource servers this authorization server protects, which an MCP client names withresource. A document says who a client is, never what it may reach.
Introspection: which tokens a caller may ask about
POST /oauth/introspect authenticates its caller first (RFC 7662 §2.1 — public clients are refused), then answers only about tokens that caller is entitled to see. Two rules decide that, and both are stated here because both bit during live testing.
The audience pin is allowedAudiences ∪ {client_id}
When client authentication identified the caller, the token's aud must be one of:
- an entry in that client's registered
allowedAudiences, or - that client's own
client_id.
That is the same ceiling every issuing grant already derives an audience within (client_credentials, refresh_token, /authorize), so introspection admits exactly the audiences the registration already trusted this client to be associated with, and nothing beyond them.
The rule matters the moment RFC 8707 resource indicators are in use. Every access token then carries aud: <resource URI>, so a pin on client_id alone means a resource server cannot introspect its own tokens — it gets active: false unless it happens to be registered under a client_id that IS the resource URI. Register the resource URI as an allowed audience instead:
{
"clientId": "orders-api",
"tokenEndpointAuthMethod": "client_secret_basic",
"allowedAudiences": ["https://api.example.com/orders"]
}Everything the pin refused before, it still refuses: an audience outside that set, an unknown or expired token, one revoked through the jti denylist or the subject watermark, one from another issuer. The bearer self-introspection path — Authorization: Bearer <token> where the body token is that same value — establishes no calling-client identity, so there is no set to pin against; the verifier records the gap as jwt_verify_aud_skipped rather than inventing one.
A client_id with reserved characters must be percent-encoded in HTTP Basic
RFC 6749 §2.3.1 requires the client id and secret to be application/x-www-form-urlencoded-encoded before the id:secret pair is base64-encoded into the Authorization: Basic header. A resource URI is the case that makes this mandatory rather than pedantic: it contains : and /, and : is the field separator the header is split on.
# WRONG — split at the first colon, so the client id parses as "https"
Authorization: Basic base64("https://api.example.com/orders:s3cret")
# RIGHT — reserved characters percent-encoded first
Authorization: Basic base64("https%3A%2F%2Fapi.example.com%2Forders:s3cret")client_secret_post (credentials in the form body) avoids the question entirely — the body encoding already does it.
Session liveness
A token carrying a sid claim is checked against the UserSessionStore before it is vouched for — the same read /oauth/userinfo performs. A session that has been logged out, has expired, or was deleted out of band answers active: false and emits introspect.session_invalid. A store outage also answers active: false, because RFC 7662 defines no temporarily_unavailable for this endpoint and inactive is the only fail-closed answer available; it emits introspect.store_unavailable. A token with no sid (client credentials, jwt-bearer) does not pay for the read, and neither does a composition that wires no userSessionStore.
Token-binding cnf flow (Wave 2)
When a token-binding mechanism is installed (@o3co/auth-provider-dpop and/or @o3co/auth-provider-mtls), the grants here emit RFC 7800 cnf claims and the introspect handler echoes them back to resource servers.
Issuance
- AT cnf is mechanism-agnostic. Any binding's
confirmationflows through unchanged — DPoP{ jkt }, mTLS{ "x5t#S256" }, or future mechanisms (all variants in theConfirmationunion). - RT cnf is gated on
(bindingIsDpop || bindingIsMtls) && isPublicClient. Confidential clients always get plain RTs (RFC 9449 §5 rationale generalized: client_secret is the refresh-time authenticator). Public clients with a bound AT get a bound RT so the next refresh enforces continuity. - Wire-level
token_type:"DPoP"only whenkind === "dpop"(RFC 9449 §5). mTLS keeps"Bearer"(RFC 8705 §3) — the cert IS the binding evidence, not the wire token type.
Refresh-time matrix (5 outcomes — applied independently per mechanism)
refreshToken.mts runs a separate matrix per binding mechanism (one for DPoP cnf.jkt, one for mTLS cnf.x5t#S256). Each matrix has the same 5 outcomes, expressed below in mechanism-agnostic form:
| RT cnf | request binding | outcome |
| --- | --- | --- |
| plain | none | issue plain Bearer (legacy) |
| plain | bound | opt-in upgrade — bind new AT (RT bound only for public clients) |
| bound | none | reject invalid_grant |
| bound | bound, differs | reject invalid_grant (multi-key / cert-substitution attack) |
| bound | bound, matches | rotation preserves binding |
The proof field is extracted gated on kind === "<mechanism>" so a confirmation shape alone cannot satisfy a bound RT (mechanism-boundary regression from PR #185 / Codex Important #2). RT carrying BOTH cnf.jkt AND cnf.x5t#S256 is rejected with invalid_grant BEFORE either matrix runs (compound-cnf reject from Codex Critical #2).
Introspect
/oauth/introspect reads cnf from the AT claims and sets token_type based on whether jkt is present (DPoP) or not (Bearer for mTLS or unbound). The introspect response carries the full cnf so resource servers can require the right mechanism's proof at their boundary.
See ADR 2026-05-20-token-binding-first-class-abstraction.md for the design rationale.
TODO-F-4 changes
authorization_code grant — id_token issuance
When the openid scope is included in the granted scopes and a UserSessionStore is wired, the authorization_code grant issues an id_token alongside the access token and refresh token. The id_token is a signed JWT built by generateIdToken (from @o3co/auth-provider-core) and appended to the token response as the id_token field.
Conditions for id_token issuance:
openidmust appear in the granted scopes (set byGrantPolicyHookat/oauth/authorizetime)AppOptions.userSessionStoremust be wired (session is the source of truth for user claims)- The code record must contain
sid(written by login/federation wiring at authorize time) AppOptions.config.oauth.jwt.issuermust be set (prevents emitting a noncompliantiss: ""claim)
When any condition is not met, id_token is omitted from the response — the token endpoint still returns access_token and refresh_token normally.
Claim composition of the issued id_token:
iss,sub,aud,exp,iat,jti,auth_time,sid,azp— OIDC Core §2 standard claimsnonce— reflected verbatim from the code record when present (OIDC Core §3.1.3.7)- scope-filtered user claims (see claim mapping table below)
/oauth/userinfo — OIDC Core §5.3
GET /oauth/userinfo
Authorization: Bearer <access_token>Returns scope-filtered claims sourced from the durable UserSession. The endpoint is mounted by oauthModule alongside the existing /oauth/token, /oauth/introspect, and /oauth/authorize routes.
| Condition | Response |
| --- | --- |
| Missing / invalid Bearer token | 401 with WWW-Authenticate: Bearer realm="userinfo" |
| Invalid JWT signature | 401 invalid_token |
| family_id claim revoked (F-3 cascade) | 401 invalid_token |
| Session not found or store error | 401 invalid_token (fail-closed) |
| No userSessionStore wired or no sid claim | 200 { sub } (sub only, no durable claims) |
| Session active | 200 { sub, ...scope-filtered claims } |
All responses set Cache-Control: no-store and Pragma: no-cache (RFC 6750 §5.3).
Scope-to-claim mapping (OIDC Core §5.4 standard scopes):
| Scope | Emitted claims |
| --- | --- |
| openid | (governs id_token issuance; sub always included in userinfo response) |
| profile | name, picture |
| email | email, email_verified |
| groups | groups |
TODO-F-3 changes
/oauth/introspectcascading revoke. When the access token carries afamily_idclaim andAppOptions.refreshTokenStoreis wired, the introspect endpoint callsRefreshTokenStore.isFamilyRevoked(familyId)before returning an active response. If the family is revoked or the store is unreachable, the response is{ active: false }(fail-closed, per RFC 7009 §2.1 SHOULD). Tokens minted before F-3 that lack afamily_idclaim bypass this check and are validated by signature only.family_id+siddata claims. Bothaccess_tokenandrefresh_tokenminted by theauthorization_codeandrefresh_tokengrants carryfamily_id(token family for cascading revoke) andsid(session ID, when the code record contains it) as JWT claims.authorization_codegrant —sidrequirement. The grant readssidfrom theCodeDatarecord. Deployments must have the F-2/F-3 login wiring in place (local login or federation callback writingsidonto the code) for thesidclaim to be present in issued tokens.refresh_tokengrant — session validation. WhenAppOptions.userSessionStoreis wired and the refresh token carries asidclaim, the grant callsuserSessionStore.get(sid)to verify the session is still active. A missing session returns400 invalid_grant; a store error returns503 temporarily_unavailable.refresh_tokengrant — the rotation is reserved before anything is signed (#449). The new refresh token'sjtiand the instant its lifetime is measured from are chosen first, committed to the family store byRefreshTokenFamilyRotation.rotate, and signed only once that commit holds. A lost race — a replay, a revoked family, an unknown family underreject— therefore returns having produced no signature at all, which is what matters under a KMS-backedSigningKeyProviderwhere each signature is a billable remote call. The token that is issued carries exactly thejtithat was reserved and anexpno later than the ceiling the store committed —RefreshTokenFamilyRotationOutcome.cappedExpiresAtMs, less a one-second margin for the forward drift its contract documents, then floored to the second — so a refresh token can never outlive the family record that catches its replay. A ceiling that leaves no lifetime is400 invalid_grant("refresh token family has reached its lifetime"), not a200carrying an already-expired refresh token.The cost of that ordering, stated plainly: once
rotatecommits, the presented token is spent. A signer that fails after it — a KMS outage — therefore leaves a rotation nobody holds a token for. The grant answers503 temporarily_unavailableand logsrefresh_token_rotation_orphanedwith the family id, the spentjtiand the reserved one — only when the store actually committed the rotation, so a composition with no rotation wired, or an unknown family accepted underunknownFamilyPolicy, keeps the ordinary signer behaviour every other mint has; the client's retry presents the old token, which now reads as a replay, so the family is revoked and the user re-authenticates. That is the honest outcome, not a regression to hide: before #449 the signature came first, so a signer failure left the old token usable — and a lost race cost two signatures.
TODO-F-5 changes — Logout endpoints
The OAuth module exposes two logout-related routes when wired with userSessionStore, federationTokenStore, refreshTokenStore, and oauth.jwt.issuer:
There is a third logout endpoint, and it is not in this package.
POST /session/logout(@o3co/auth-provider-session) is the browser's own logout and the one a BFF /auth.proxytopology calls. It deletes theUserSessionrecord, the subject-index entry and the federation pair — so the liveness checks below do bite — but it revokes no refresh-token families, becausecascadeLogoutis not reachable across the package boundary.POST /oauth/logoutis the only endpoint that runs the full cascade. If a session holds a refresh token, that is the one to call. See the session package README.
POST /oauth/logout
OIDC RP-Initiated Logout 1.0 end_session_endpoint. Accepts application/x-www-form-urlencoded:
id_token_hint(required) — signed id_token from this provider;sidclaim identifies the sessionpost_logout_redirect_uri(optional) — must match one ofclient.postLogoutRedirectUrisexactly, byte for byte. A reverse-domain custom scheme is a legal entry (#498), and gets no relaxation for being one.state(optional) — round-tripped when redirecting topost_logout_redirect_uri
Flow: verifies id_token_hint → loads session → broadcasts OIDC Back-Channel Logout 1.0 logout_token to every RP with backchannelLogoutUri → executes store cascade (refresh-family revoke, federation-token delete, session delete) → responds with one of:
text/htmlpage with<iframe>per RP withfrontchannelLogoutUri(whenAccept: text/htmlwins q-weighted negotiation)303to first-federation IdP end-session URL (when that federation's provider implementsSupportsLogout)303topost_logout_redirect_uri(when it matches the client's allowlist)200 {"logged_out": true}(fallback)
On every one of those success shapes — and on the no-op answer for a session that is already gone — the endpoint also ends the browser's own express-session, but only when that session's sid is the one being logged out. RP-initiated logout is a request any party may make about any session, so a cookie naming a different sid, or naming none, is left alone rather than signing out an unrelated user. Without this the cascade emptied the stores while the cookie kept satisfying req.session.isAuthenticated at /authorize, which went on minting codes carrying a dead sid that /token then refused with invalid_grant — a login loop with no login page, for up to session.maxAge. A destroy the session store cannot complete is logged and does not turn a successful cascade into a 503; /authorize refuses the dead sid on its own account either way, by re-checking that an authenticated session's sid still resolves in the UserSessionStore before it mints anything (a store that cannot answer fails closed to the login page, or to login_required under prompt=none).
Cascade failure returns 503 {"error": "temporarily_unavailable"}. The cascade order is fixed per the spec: step 1 (refresh-family revoke) and step 3 (session delete) fail hard; step 2 (federation-token delete) is best-effort and logs a warning on failure without aborting the cascade.
POST /oauth/federation/:name/logout
Provider-scoped federation disconnect. Authorization: Bearer <access_token> with typ: at+jwt. Optional body: post_logout_redirect_uri, state.
Flow: verifies access_token → checks family not revoked → loads session → verifies federation is linked → deletes federation token → removes federation from session → if the provider implements SupportsLogout, redirects to the IdP end-session URL; otherwise returns 200 {"disconnected": true}.
If the IdP end-session call throws, local state is already cleared; the response is 200 {"disconnected": true} and an audit event federation.logout.idp_unreachable is emitted for operator visibility.
Returns 404 {"error": "federation_not_linked"} when the named federation is not in the session.
Discovery metadata
GET /.well-known/openid-configuration now advertises:
end_session_endpointbackchannel_logout_supported: truebackchannel_logout_session_supported: true—logout_tokenincludessidby defaultfrontchannel_logout_supported: truefrontchannel_logout_session_supported: true— front-channel iframe URL includessidby default
The session_supported defaults of true intentionally deviate from OIDC Back-Channel Logout 1.0 §2.2 (spec default: false). Clients that require the spec-default behavior must set backchannelLogoutSessionRequired: false or frontchannelLogoutSessionRequired: false on their client record.
Client record logout metadata
Each Client supports five optional fields for logout behavior:
postLogoutRedirectUris?: string[]— allowlist forPOST /oauth/logout'spost_logout_redirect_uri. Held to the same grammar asallowedRedirectUrissince #498:https:,http:for a loopback host, or an RFC 8252 §7.1 reverse-domain custom scheme (com.example.app:/signout), and never a fragment, userinfo or executable scheme. Registering the custom scheme is what lets a native app be returned to itself after logout instead of landing on a JSON body.backchannelLogoutUri?: string— receiveslogout_tokenPOST.http/httpsonly — this server dispatches the POST itself, and it has no way to reach a custom scheme.backchannelLogoutSessionRequired?: boolean— defaulttrue; setfalseto excludesidfromlogout_tokenfrontchannelLogoutUri?: string— iframe src target.http/httpsonly — the browser resolves this value in a document context, where a custom scheme is at best inert and at worst a handler invocation the RP never asked for.frontchannelLogoutSessionRequired?: boolean— defaulttrue; setfalseto excludesidfrom iframe URL
TODO-F-6 changes — Federation token endpoint
POST /oauth/federation/:name/token retrieves the upstream IdP access_token for the caller's session, so consumers can make server-side API calls to Google Calendar / GitHub API / etc. on the user's behalf.
Authentication
- Bearer access_token minted by this auth.provider instance (
typ: at+jwt). - The token's
azpclaim identifies the client; the client record MUST opt in viaallowedAzpForFederationToken: true(see below).
Flow
- Verify the Bearer access_token.
- Deny if the family_id is revoked or the session no longer exists.
- Deny unless
client.allowedAzpForFederationToken === true. - Deny unless the federation is linked to the session.
- Return the cached upstream access_token if it has > 30 seconds of validity remaining.
- Otherwise, refresh it:
- Acquire an advisory lock (when
FederationTokenStoreimplementsSupportsLock) to prevent concurrent refresh fan-out. - Re-read after the lock — another waiter may have refreshed during the wait.
- Call
provider.refreshToken(refreshToken); persist the result. - Release the lock.
- Acquire an advisory lock (when
Response
{
"access_token": "<upstream-IdP-access-token>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "<if-available>"
}Error responses
| Status | Error | Meaning |
| --- | --- | --- |
| 401 | invalid_token | Bearer missing, invalid, wrong type (not at+jwt), or family revoked |
| 403 | forbidden | Client not opted in via allowedAzpForFederationToken |
| 404 | federation_not_linked | The named federation isn't linked to this session |
| 410 | refresh_token_absent | Stored tokens have no refresh_token (upstream didn't return one at login, or post-lock re-read found a record without one) |
| 410 | re_authentication_required | IdP returned invalid_grant / invalid_token — session federation is cleared; user must re-authenticate with the IdP |
| 429 | rate_limited | Upstream IdP rate limit exceeded (status: 429 or error: "too_many_requests"); retry later |
| 500 | refresh_failed | Generic / unclassified error from the IdP refresh path; SIEM should group on the details.reason audit field |
| 503 | refresh_not_supported | Provider doesn't implement SupportsRefresh |
| 503 | lock_timeout | Advisory lock could not be acquired within the wait window |
| 503 | temporarily_unavailable | Store outage, IdP 5xx, or upstream network failure (ECONNREFUSED / ENOTFOUND / ETIMEDOUT — including codes wrapped on error.cause.code of a fetch TypeError) |
All error responses set Cache-Control: no-store and Pragma: no-cache. 401 responses include WWW-Authenticate: Bearer error="invalid_token" per RFC 6750.
Opt-in: allowedAzpForFederationToken
Each Client carries an optional allowedAzpForFederationToken: boolean flag. Default is false — clients do NOT get federation-token access automatically. Operators explicitly opt in for clients that need it:
clients:
- clientId: my-backend-api
clientSecret: ...
allowedRedirectUris: [...]
allowedScopes: [openid, profile, email]
allowedAzpForFederationToken: true # explicit opt-inRationale: federation access_tokens grant access to the user's external resources (Google Drive, GitHub API, etc.). Deny-by-default prevents accidental exposure when a generic OAuth client registration only needs auth.
Audit events
The following audit events fire on this endpoint:
federation.token.success— on token issuance (details includerefreshed: booleanto distinguish cache hits from refresh path)federation.token.forbidden— on 403 (client not opted in)federation.token.family_revoked— on 401 via revoked familyfederation.token.refresh_failed— on provider.refreshToken throwing with an unclassified error. SF-13 (v0.5.1):details.reasoncarries the classifier enum ("invalid_grant" | "rate_limited" | "network" | "unknown"); SIEM rules should group on this field. Pre-v0.5.1 the detail field wasdetails.error: <raw message>— migrate dashboards.federation.token.reauthentication_required— oninvalid_grantorinvalid_tokenfrom IdP
Migrating from v0.3.x to v0.4.0
v0.4.0 removes passport from this package. The /oauth/introspect endpoint now uses createClientAuthMiddleware(clientRepository) — a self-hosted RFC 6749 §2.3.1 HTTP Basic + form-encoded client-auth middleware.
Breaking changes
createOAuthRoutersignature: thepassportoption is dropped. PassclientRepository: ClientRepositorydirectly.oauthModule({ config })receives repositories through modulerequiresfrom composition-root providers./introspecterror response: follows RFC 6749 §5.2 shape{ error, error_description }.req.oauthClient(typed asPublicClient | undefined) is attached to the expressRequestbycreateClientAuthMiddleware. Consumers composing this middleware onto their own routes can read it directly — types come via global Express namespace augmentation.
For consumers
If you consume @o3co/auth-provider-oauth via its public API (oauthModule, createOAuthRouter), no code changes beyond updating your config are required — the module internally wires the new middleware.
If you extend or replace the middleware for custom client-auth schemes, import createClientAuthMiddleware from @o3co/auth-provider-oauth as a reference, or write a drop-in replacement that attaches a compatible PublicClient to req.oauthClient.
jwt-bearer: which issuers are trusted (#525)
The RFC 7523 grant (urn:ietf:params:oauth:grant-type:jwt-bearer) accepts a signed assertion from an issuer this deployment trusts and hands the verified handle to the Store. Which issuers, on what keys, on what terms, is a trust registry of AssertionIssuerEntry records, and the bundled verifier is built over it:
import {
createMemoryAssertionIssuerRegistry,
createRegistryAssertionVerifier,
} from "@o3co/auth-provider-core";
const registry = createMemoryAssertionIssuerRegistry([
{
issuer: "https://devices.example",
keys: { type: "jwks_uri", uri: "https://devices.example/.well-known/jwks.json" },
algorithms: ["EdDSA"],
allowedClients: ["mobile-app"], // who may present its assertions
allowedScopes: ["read", "write"], // ceiling on the issued scope
allowedAudiences: ["https://api.example"], // ceiling on the issued aud
},
{
issuer: "https://legacy.example",
keys: { type: "key", key: legacyPublicKey },
algorithms: ["ES256"],
expiresAt: new Date("2026-12-31T00:00:00Z"),
},
]);
const assertionVerifier = createRegistryAssertionVerifier({
registry,
audience: ["https://auth.example", "https://auth.example/oauth/token"], // what the assertion's aud must name
});What an entry says, and what it means at /oauth/token:
- Keys come from one public key (
type: "key"), a static JWK set (type: "jwks"), or a JWKS endpoint (type: "jwks_uri",httpsrequired outside loopback). A remote set is fetched on first use and cached (10 minutes by default;cacheMaxAgeMs,cooldownMs,timeoutMson the entry tune it); an unknownkidtriggers a refetch, so a rotation at the issuer is picked up without a restart. The fetch is the verifier'sfetchoption when given — an egress proxy — as it is for aprivate_key_jwtclient'sjwksUri; both are core'screateRemoteKeySetCache. An endpoint that is down is an outage: the grant answers503, notinvalid_grant. - An unregistered
issis refused before any signature work. No key is fetched and no signature is checked for an issuer nobody registered; "signed by A, claiming to be B" fails on B's keys. allowedClientsrestricts who may present the issuer's assertions; a list refuses an unauthenticated presenter. Absent, anyone may.allowedScopesis intersected with the assertion's ownscopeclaim (or stands alone when the assertion names none) and becomes the scope ceiling the request and the client registration are further bounded by.allowedAudiencesbounds the issuedaudwhatever chose it — agrantPolicy, an RFC 8707resource, the client registration (itsallowedAudiencesnarrowed to the issuer's, its client id only if the issuer admits it). With no authenticated client it is also the source: the token names the issuer's first audience instead of this server. A client and an issuer that admit no audience in common isinvalid_grantand logsjwt_bearer_issuer_audience_mismatch.expiresAtis the one field that changes in place (registry.setExpiresAt); everything else is immutable — remove and re-add — so the history of what was trusted is the history of adds and removes.add,list,removeare the rest of the admin surface. On the memory registry that surface reaches one process: an issuer revoked withsetExpiresAton one replica stays trusted on the others, a restart rebuilds the registry from the composition's entries — restoring the issuer even where it was revoked — anddeployment.mode = "multi"cannot catch it, because the registry lives inside theassertionVerifieryou hand in rather than on a module. Entries supplied when the registry is built are identical everywhere; with several replicas, change the entry list and redeploy, or implement the registry over a shared store.
createJwtAssertionVerifier({ key, issuer, audience, algorithms }) — the static one-key shape — is a one-entry registry and keeps working unchanged. A deployment that registers issuers at runtime and needs them to survive a restart implements AssertionIssuerRegistry (findIssuer) over its own store. An entry is data a store can hold: every field survives a JSON round trip (revive expiresAt as a Date), except keys: { type: "key" }, a live key object — a store-backed entry uses jwks (a one-key set is fine) or jwks_uri.
How claims are read is code, so it is the verifier's, not the entry's. With several issuers, namespace the handle unless every issuer's sub values are known to be disjoint — the Store receives the handle alone:
const assertionVerifier = createRegistryAssertionVerifier({
registry,
audience: "https://auth.example",
readersFor: (entry) =>
entry.profile === "id-jag"
? undefined // keep the ID-JAG default, <iss>#<tenant>#<sub>
: {
readSubjectHandle: (claims) =>
typeof claims.sub === "string" && claims.sub.length > 0
? `${entry.issuer}#${claims.sub}`
: null, // never namespace a missing or empty sub
},
});readersFor runs for every entry, ID-JAG ones included, so return undefined where the default is the right answer.
An entry that carries readSubjectHandle or readScope itself is refused when it is registered, rather than having the reader silently ignored.
The ID-JAG profile (#526)
An entry with profile: "id-jag" accepts the Identity Assertion JWT Authorization Grant — what an enterprise IdP mints for a client so that this server, as the resource's authorization server, can issue it an access token (the MCP "Enterprise Managed Authorization" flow, Cross-App Access). The client sends it as a plain jwt-bearer request, with client authentication:
const assertionVerifier = createRegistryAssertionVerifier({
registry: createMemoryAssertionIssuerRegistry([
{
issuer: "https://idp.example",
keys: { type: "jwks_uri", uri: "https://idp.example/.well-known/jwks.json" },
algorithms: ["RS256"],
profile: "id-jag",
allowedClients: ["mcp-client"],
allowedScopes: ["read", "write"],
allowedAudiences: ["https://mcp.example"],
},
]),
audience: "https://auth.example",
issuerIdentifier: "https://auth.example", // the only aud an ID-JAG may name
replaySeenSet, // each jti is accepted once
});On top of the registry's checks, an ID-JAG must carry typ: oauth-id-jag+jwt, aud exactly this server's issuer identifier (the token endpoint URL is not an alias), a client_id naming the authenticated client (an unauthenticated presenter is refused), and jti, iat, sub — iat no more than an hour old, as for private_key_jwt; each jti is accepted once for the assertion's lifetime. scope and resource travel as claims: the scope ceiling is the claim ∩ allowedScopes, the audience ceiling is resource ∩ allowedAudiences (a resource the entry does not admit is refused), and the grant then bounds both by the client's registration. The handle handed to the Store is <iss>#<sub> (or <iss>#<tenant>#<sub>) — sub is unique only within its issuer — and an identity the Store has not linked is refused there. No refresh token is issued: the assertion is the refresh mechanism, and the access token lives no longer than it (below).
The issued token never outlives the assertion
The access token's lifetime is min(oauth.accessToken.defaultExpiresIn, exp − now): exp is the verified assertion's, reported by the verifier as expiresAt (epoch seconds), and the remainder is rounded down to whole seconds at the moment the token is minted. expires_in in the response is that minted lifetime. This is the rule token exchange applies to its subject token (security note 16), and it holds for every jwt-bearer request, RFC 7523 and ID-JAG alike:
- A short-lived assertion yields a short-lived access token. An ID-JAG's
iatmay be at most an hour old and IdPs commonly give it minutes of lifetime; the token exchanged from it lives no longer. No refresh token is issued, so when the token expires the client re-exchanges a fresh assertion. It cannot present the same ID-JAG again — eachjtiis accepted once. - An assertion with no whole second left is refused with
invalid_grant/assertion did not verify— the answer every failed verification gets, so it tells a caller nothing about the handle behind it — and logged for the operator asjwt_bearer_assertion_expired. That covers an assertion past itsexpthat the entry'sclockToleranceSeconds(default 60) still let verify: the tolerance absorbs clock skew for verification, but leaves no lifetime for a token to inherit. A steady rate of that line from one issuer is a clock out of step with this server's, or clients presenting assertions at the last moment. - A custom
AssertionVerifierreportsexpiresAtwhenever its credential expires. The field is optional so a verifier written before it existed still compiles, but omitting it asserts a credential with no expiry, and the configured lifetime then stands uncapped. Present, it must be a finite number: a numeric string,null,NaNorInfinityis refused asinvalid_grant, never read as an expiry or as none.createRegistryAssertionVerifierandcreateJwtAssertionVerifieralways report it, from theexpthey require.
See Also
@o3co/auth-provider-session— session login / federation routes@o3co/auth-provider-core— shared types (Module,GrantHandlerResolver,ClientRepository,CodeRepository,KeyStore)
