auth.provider API
    Preparing search index...

    Module @o3co/auth-provider-oauth

    @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.

    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
    
    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

    function 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.


    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.


    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.

    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.

    • AT cnf is mechanism-agnostic. Any binding's confirmation flows through unchanged — DPoP { jkt }, mTLS { "x5t#S256" }, or future mechanisms (all variants in the Confirmation union).
    • 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 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.

    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:

    • 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)
    • The code record must contain 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 claims
    • nonce — reflected verbatim from the code record when present (OIDC Core §3.1.3.7)
    • scope-filtered user claims (see claim mapping table below)
    GET /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:

    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; sid claim identifies the session
    • post_logout_redirect_uri (optional) — must match one of client.postLogoutRedirectUris exactly
    • state (optional) — round-tripped when redirecting to post_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/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.

    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.

    GET /.well-known/openid-configuration now advertises:

    • end_session_endpoint
    • backchannel_logout_supported: true
    • backchannel_logout_session_supported: truelogout_token includes sid by default
    • frontchannel_logout_supported: true
    • frontchannel_logout_session_supported: true — front-channel iframe URL includes sid by 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.

    Each Client supports five optional fields for logout behavior:

    • postLogoutRedirectUris?: string[] — allowlist for POST /oauth/logout's post_logout_redirect_uri
    • backchannelLogoutUri?: string — receives logout_token POST
    • backchannelLogoutSessionRequired?: boolean — default true; set false to exclude sid from logout_token
    • frontchannelLogoutUri?: string — iframe src target
    • frontchannelLogoutSessionRequired?: boolean — default true; set false to exclude sid from iframe URL

    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.

    • Bearer access_token minted by this auth.provider instance (typ: at+jwt).
    • The token's azp claim identifies the client; the client record MUST opt in via allowedAzpForFederationToken: true (see below).
    1. Verify the Bearer access_token.
    2. Deny if the family_id is revoked or the session no longer exists.
    3. Deny unless client.allowedAzpForFederationToken === true.
    4. Deny unless the federation is linked to the session.
    5. Return the cached upstream access_token if it has > 30 seconds of validity remaining.
    6. Otherwise, refresh it:
      • Acquire an advisory lock (when FederationTokenStore implements SupportsLock) 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.
    {
    "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.

    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-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 family
    • federation.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 IdP

    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.

    1. createOAuthRouter signature: the passport option is dropped. Pass clientRepository: ClientRepository directly. oauthModule({ config }) receives repositories through module requires from composition-root providers.
    2. /introspect error response: follows RFC 6749 §5.2 shape { error, error_description }.
    3. 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.

    Interfaces

    BroadcastBackchannelLogoutOptions
    BroadcastRP
    CascadeLogoutOptions
    FrontchannelRP
    IntrospectResponse
    RenderFrontchannelLogoutHtmlOptions

    Type Aliases

    CascadeLogoutResult

    Functions

    broadcastBackchannelLogout
    cascadeLogout
    createClientAuthMiddleware
    createOAuthRouter
    extractConfirmation
    oauthAuthorizationModule
    oauthModule
    oauthSessionModule
    renderFrontchannelLogoutHtml