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();
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)