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.
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.0
oauthModulefunction 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 |
oauthSessionModulefunction oauthSessionModule(params: {
clientRepository: ClientRepository;
}): 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.
oauthAuthorizationModulefunction 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.
createOAuthRouterfunction 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.
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();
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.
confirmation flows through unchanged — DPoP { jkt }, mTLS { "x5t#S256" }, or future mechanisms (all variants in the Confirmation union).(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.token_type: "DPoP" only when kind === "dpop" (RFC 9449 §5). mTLS keeps "Bearer" (RFC 8705 §3) — the cert IS the binding evidence, not the wire token type.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).
/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.
authorization_code grant — id_token issuanceWhen 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:
openid must appear in the granted scopes (set by GrantPolicyHook at /oauth/authorize time)AppOptions.userSessionStore must be wired (session is the source of truth for user claims)sid (written by login/federation wiring at authorize time)AppOptions.config.oauth.jwt.issuer must be set (prevents emitting a noncompliant iss: "" 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)/oauth/userinfo — OIDC Core §5.3GET /oauth/userinfo
Authorization: Bearer
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 |
/oauth/introspect cascading revoke. When the access token carries a family_id claim and AppOptions.refreshTokenStore is wired, the introspect endpoint calls RefreshTokenStore.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 a family_id claim bypass this check and are validated by signature only.family_id + sid data claims. Both access_token and refresh_token minted by the authorization_code and refresh_token grants carry family_id (token family for cascading revoke) and sid (session ID, when the code record contains it) as JWT claims.authorization_code grant — sid requirement. The grant reads sid from the CodeData record. Deployments must have the F-2/F-3 login wiring in place (local login or federation callback writing sid onto the code) for the sid claim to be present in issued tokens.refresh_token grant — session validation. When AppOptions.userSessionStore is wired and the refresh token carries a sid claim, the grant calls userSessionStore.get(sid) to verify the session is still active. A missing session returns 400 invalid_grant; a store error returns 503 temporarily_unavailable.The OAuth module exposes two logout-related routes when wired with userSessionStore, federationTokenStore, refreshTokenStore, and oauth.jwt.issuer:
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; sid claim identifies the sessionpost_logout_redirect_uri (optional) — must match one of client.postLogoutRedirectUris exactlystate (optional) — round-tripped when redirecting to post_logout_redirect_uriFlow: 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/html page with <iframe> per RP with frontchannelLogoutUri (when Accept: text/html wins q-weighted negotiation)303 to first-federation IdP end-session URL (when that federation's provider implements SupportsLogout)303 to post_logout_redirect_uri (when it matches the client's allowlist)200 {"logged_out": true} (fallback)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.
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.
GET /.well-known/openid-configuration now advertises:
end_session_endpointbackchannel_logout_supported: truebackchannel_logout_session_supported: true — logout_token includes sid by defaultfrontchannel_logout_supported: truefrontchannel_logout_session_supported: true — front-channel iframe URL includes sid by defaultThe 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.
Each Client supports five optional fields for logout behavior:
postLogoutRedirectUris?: string[] — allowlist for POST /oauth/logout's post_logout_redirect_uribackchannelLogoutUri?: string — receives logout_token POSTbackchannelLogoutSessionRequired?: boolean — default true; set false to exclude sid from logout_tokenfrontchannelLogoutUri?: string — iframe src targetfrontchannelLogoutSessionRequired?: boolean — default true; set false to exclude sid from iframe URLPOST /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.
typ: at+jwt).azp claim identifies the client; the client record MUST opt in via allowedAzpForFederationToken: true (see below).client.allowedAzpForFederationToken === true.FederationTokenStore implements SupportsLock) to prevent concurrent refresh fan-out.provider.refreshToken(refreshToken); persist the result.{
"access_token": "<upstream-IdP-access-token>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "<if-available>"
}
| 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.
allowedAzpForFederationTokenEach 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-in
Rationale: 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.
The following audit events fire on this endpoint:
federation.token.success — on token issuance (details include refreshed: boolean to 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.reason carries the classifier enum ("invalid_grant" | "rate_limited" | "network" | "unknown"); SIEM rules should group on this field. Pre-v0.5.1 the detail field was details.error: <raw message> — migrate dashboards.federation.token.reauthentication_required — on invalid_grant or invalid_token from IdPv0.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.
createOAuthRouter signature: the passport option is dropped. Pass clientRepository: ClientRepository directly. oauthModule({ config }) receives repositories through module requires from composition-root providers./introspect error response: follows RFC 6749 §5.2 shape { error, error_description }.req.oauthClient (typed as PublicClient | undefined) is attached to the express Request by createClientAuthMiddleware. Consumers composing this middleware onto their own routes can read it directly — types come via global Express namespace augmentation.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.
@o3co/auth-provider-session — session login / federation routes@o3co/auth-provider-core — shared types (Module, GrantHandlerResolver, ClientRepository, CodeRepository, KeyStore)