@o3co/auth-provider-oauth
v0.7.0
Published
OAuth routes module for auth.provider
Downloads
1,228
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: {
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.
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();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.
TODO-F-5 changes — Logout endpoints
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;sidclaim identifies the sessionpost_logout_redirect_uri(optional) — must match one ofclient.postLogoutRedirectUrisexactlystate(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)
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_uribackchannelLogoutUri?: string— receiveslogout_tokenPOSTbackchannelLogoutSessionRequired?: boolean— defaulttrue; setfalseto excludesidfromlogout_tokenfrontchannelLogoutUri?: string— iframe src targetfrontchannelLogoutSessionRequired?: 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.
See Also
@o3co/auth-provider-session— session login / federation routes@o3co/auth-provider-core— shared types (Module,GrantHandlerResolver,ClientRepository,CodeRepository,KeyStore)
