OAuth2 / OIDC Provider
Make your app an identity provider — "Sign in with <yourapp>". authorization_code + mandatory PKCE, ES256 JWTs signed by a key you own (self-hosted JWKS), refresh-token rotation, DPoP sender-constrained tokens, and a complete OAuth2/OIDC surface (introspect, revoke, device flow, PAR, JAR, DCR, private_key_jwt, RP-initiated + back-channel logout, userinfo).
#Become an IdP
Configure the oidc block with a signing key, a relying-party client registry, and the code / refresh stores. The authorize endpoint reuses the package session, so the IdP login gets passkeys / MFA / SSO for free.
import {
auth,
createInMemoryOAuthClientStore,
createNeonAuthorizationCodeStore,
createNeonOidcRefreshTokenStore,
generateSigningKey
} from '@absolutejs/auth';
// Generate once; persist privateJwk (it signs tokens), serve publicJwk via JWKS.
const signingKey = await generateSigningKey();
await auth<User>({
providersConfiguration: {},
authSessionStore,
oidc: {
issuer: 'https://id.yourapp.com',
signingKey,
clientStore: createInMemoryOAuthClientStore([
{
clientId: 'partner-app',
name: 'Partner',
redirectUris: ['https://partner.example/callback'],
scopes: ['openid', 'profile']
}
]),
authorizationCodeStore: createNeonAuthorizationCodeStore(
process.env.DATABASE_URL
),
refreshTokenStore: createNeonOidcRefreshTokenStore(process.env.DATABASE_URL),
getUserId: (user) => user.sub,
getClaims: (user) => ({ email: user.email, name: user.name }),
loginUrl: '/signin'
}
});
// Mounts: /oauth2/authorize (authorization_code + mandatory PKCE),
// /oauth2/token (refresh-token rotation), /oauth2/jwks, /oauth2/userinfo,
// /oauth2/introspect, /oauth2/revoke, /oauth2/end_session, and
// /.well-known/openid-configuration — fully self-hosted (you own the keys, no
// api.workos.com). The authorize login reuses your session, so it gets
// passkeys / MFA / SSO for free.#Endpoints
Mounted automatically:
Optional endpoints opt in by configuring their store:
GET/PUT/DELETE {oidcRoute}/register/:idEverything is self-hosted — you own the keys, with no dependency on api.workos.com. Consent is yours via the getGrantedScopes hook (auto-granted for first-party clients).
#Custom access-token claims
getAccessTokenClaims lets you add per-token claims (email, name, org_id, tenant tier, …) to every issued access token.
iss, sub, aud, exp, iat, jti, client_id, scope, token_use, act, cnf) are stripped before merge, so the hook can't rewrite the token's identity, lifetime, or DPoP binding.// Add consumer claims (email/name/org_id/...) to every access token. Reserved keys
// — iss, sub, aud, exp, iat, jti, client_id, scope, token_use, act, cnf — are
// stripped before merge, so the hook can't rewrite the token's identity or lifetime.
oidc: {
// ...the rest of your OIDC config
getAccessTokenClaims: async ({ sub }) => {
const user = await getUserBySub(sub);
return { email: user.email, name: user.name, org_id: user.orgId };
}
}#UserInfo
The OIDC /userinfo endpoint lets RPs swap a valid access token for the user's profile claims — the missing piece that lets any generic OIDC client (Grafana, Tailscale, ArgoCD, etc.) onboard. Configure getUserInfo(sub) and the package mounts both GET and POST, with proper WWW-Authenticate: Bearer challenges on invalid tokens.
// OIDC /userinfo — RPs swap a valid access token for the user's profile claims
// (the same scope-driven set returned to getClaims at code issue time, minus
// reserved JWT envelope). Bearer-token authenticated, GET or POST.
oidc: {
// ...the rest of your OIDC config
getUserInfo: async (sub) => {
const user = await getUserBySub(sub);
return {
email: user.email,
email_verified: user.emailVerified,
name: user.name,
picture: user.avatarUrl
};
}
}
// Mounts:
// GET {oidcRoute}/userinfo (Authorization: Bearer <access_token>)
// POST {oidcRoute}/userinfo
// -> { sub, ...your claims }
//
// On invalid/missing token, replies 401 with a standard
// WWW-Authenticate: Bearer realm="userinfo", error="..." challenge.#Re-authentication signals
OIDC defines four standard ways for an RP to ask for a fresher or different login — prompt, max_age, id_token_hint, and (combined with the bot/abuse abuse and adaptive blocks) acr_values. The authorize endpoint honors them against the session's auth_time and bounces to loginUrl when needed, preserving the original return_to.
requireRecentAuth on sensitive RP actions for end-to-end freshness guarantees.#Introspection (RFC 7662)
Resource servers ask the AS whether a token is still live and who it belongs to. Required by most enterprise procurement reviews.
sub, scope, aud, exp, token_use, and cnf for DPoP binding){ active: false }| Method | Route | Description |
|---|---|---|
| POST | {oidcRoute}/introspect | Returns the live token state; revoked refresh tokens flip to { active: false }. Body: token=<access or refresh token>, token_type_hint=access_token (optional). Client auth: client_secret_basic, client_secret_post, or private_key_jwt — the same auth the token endpoint accepts. |
#Revocation (RFC 7009)
Clients voluntarily kill a refresh token — "Sign out of this device".
| Method | Route | Description |
|---|---|---|
| POST | {oidcRoute}/revoke | Voluntarily kill a refresh token, e.g. on "Sign out of this device". Body: token=<refresh or access token>, token_type_hint=refresh_token (optional). Same client auth as /token + /introspect. |
#Device Authorization Grant (RFC 8628)
Sign-in for TVs, CLIs, and IoT.
user_codeslow_down, authorization_pending, and access_denied/device page calls it after the user signs in// RFC 8628 Device Authorization Grant — sign-in on TVs / CLIs / IoT where typing a
// password is painful. The device polls /oauth2/token while the user authorizes on
// a phone (slow_down + authorization_pending statuses handled in-house).
oidc: {
// ...the rest of your OIDC config
deviceAuthorization: {
deviceCodeStore: createNeonDeviceCodeStore(process.env.DATABASE_URL),
verificationUri: 'https://app.example.com/device',
intervalSeconds: 5
}
}
// Mounts:
// POST {oidcRoute}/device_authorization -> { device_code, user_code, verification_uri }
// POST {oidcRoute}/token (grant_type=urn:ietf:params:oauth:grant-type:device_code)
// GET/POST /device (your page — call approveDeviceCode after login)#private_key_jwt client auth
The strongest client authentication (RFC 7521/7523). The client signs a JWT assertion with its private key; the AS holds only the public JWK. No shared secret means stolen logs can't leak credentials. The same wiring also accepts client_secret_jwt (HS256). A jti replay store enforces RFC 7523 §3 one-time use.
// RFC 7521/7523 private_key_jwt — the strongest client authentication. Instead of
// sending a shared client_secret to /token, the client signs a JWT assertion with
// its private key (the AS holds only the public JWK). Stolen logs no longer leak
// credentials. Same store change works for client_secret_jwt (HS256) and ships in
// the introspect / revoke / DCR endpoints too.
await clientStore.saveClient({
clientId: 'partner-app',
tokenEndpointAuthMethod: 'private_key_jwt',
jwks: { keys: [partnerPublicJwk] }, // or jwksUri: 'https://partner.example/jwks.json'
// ...standard fields
});
oidc: {
// ...the rest of your OIDC config
clientAssertionJtiStore: createNeonClientAssertionJtiStore(
process.env.DATABASE_URL
) // RFC 7523 §3: enforce one-time use of the assertion's jti
}
// Client side:
// POST {oidcRoute}/token
// grant_type=authorization_code, code=..., code_verifier=...
// client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
// client_assertion=<signed JWT with iss=sub=clientId, aud=tokenEndpoint, exp, jti>#Dynamic Client Registration (RFC 7591 + 7592)
Partners self-register at runtime without you minting clients by hand. Gate registration with an initial access token; the registered client receives a registration_access_token that lets it read/update/delete its own record. Match WorkOS' "Connect" flow without the SaaS.
// Dynamic Client Registration (RFC 7591 + 7592). Clients self-register at runtime;
// the package issues a clientId/secret + a registration_access_token that lets the
// client read/update/delete its own record. Gate registration with an initial
// access token (RFC 7591 §3) — without one, /register returns 401.
oidc: {
// ...the rest of your OIDC config
dynamicClientRegistration: {
initialAccessTokenStore: createNeonInitialAccessTokenStore(
process.env.DATABASE_URL
),
clientRegistrationTokenStore: createNeonClientRegistrationTokenStore(
process.env.DATABASE_URL
),
// Default policy: lock down redirect URIs + scopes the SaaS will allow:
allowScopes: ['openid', 'profile', 'email'],
requireHttps: true
}
}
// Issue an initial access token to a partner (one-shot, expires):
await createInitialAccessToken(initialAccessTokenStore, {
scope: 'register',
expiresInMs: 24 * 60 * 60_000
});
// Mounts: POST {oidcRoute}/register, GET/PUT/DELETE {oidcRoute}/register/:clientId.#Pushed Authorization Requests (RFC 9126)
The client posts its parameters to the AS over the authenticated back-channel and gets back a short-lived request_uri to hand to /authorize. Defeats browser-leak attacks on long query strings; required by FAPI profiles. Per-client opt-in via requirePushedAuthorizationRequests.
// RFC 9126 PAR — pushed authorization requests. The client POSTs its params to
// /par over an authenticated back-channel; the AS returns a request_uri the client
// then hands to /authorize. Defeats browser-leak attacks on long ?-strings and
// is required for FAPI profiles. Per-client opt-in via requirePushedAuthorizationRequests.
oidc: {
// ...the rest of your OIDC config
par: {
pushedAuthorizationRequestStore: createNeonPushedAuthorizationRequestStore(
process.env.DATABASE_URL
),
expiresInMs: 60_000
}
}
// Client side (one request, one URL):
// POST {oidcRoute}/par (client auth, same as /token)
// client_id, redirect_uri, scope, code_challenge, code_challenge_method, ...
// -> { request_uri: "urn:ietf:params:oauth:request_uri:...", expires_in }
//
// GET {oidcRoute}/authorize?client_id=...&request_uri=<above>#Signed Authorize Requests (RFC 9101 JAR)
Pass the entire /authorize request as a signed JWT (?request=<jwt>), proving the parameters originated from the registered client. Required by FAPI / Open Banking / FHIR; per-client opt-in via requireSignedRequestObject. Shares the same JWKS infrastructure as private_key_jwt.
import { signJwt } from '@absolutejs/auth';
// RFC 9101 JAR — sign the /authorize request itself (Request Object) instead of
// passing parameters as query strings. Required by FAPI profiles, used heavily by
// banking + healthcare. Per-client opt-in via requireSignedRequestObject; the AS
// verifies the JWT signature against the client's JWKS, then unwraps the params.
await clientStore.saveClient({
clientId: 'fapi-partner',
jwksUri: 'https://partner.example/.well-known/jwks.json',
requireSignedRequestObject: true,
// ...standard fields
});
// Client side: sign the request, then hit /authorize with ?request=<jwt>:
const requestObject = await signJwt({
payload: {
aud: 'https://id.yourapp.com',
client_id: 'fapi-partner',
iss: 'fapi-partner',
redirect_uri: 'https://partner.example/callback',
response_type: 'code',
scope: 'openid profile',
state,
code_challenge,
code_challenge_method: 'S256'
},
privateJwk: partnerPrivateKey
});
const url = `https://id.yourapp.com/oauth2/authorize?request=${requestObject}`;#Logout (RP-Initiated + Back-Channel)
post_logout_redirect_urilogout_token to every other RP that holds a session for the same sid, so one click logs the user out of every connected appDelivery uses Standard Webhooks signing + automatic retries; failures persist for manual replay.
// OIDC RP-Initiated Logout 1.0 + Back-Channel Logout 1.0. The end-session endpoint
// lets RPs sign the user out and bounce them back to a registered post-logout URI;
// back-channel logout pushes a signed logout_token to every other RP that has an
// open session for the same sid, so a single click logs the user out everywhere.
await clientStore.saveClient({
clientId: 'rp-app',
backchannelLogoutUri: 'https://rp.example/oidc/backchannel-logout',
postLogoutRedirectUris: ['https://rp.example/goodbye'],
// ...standard fields
});
oidc: {
// ...the rest of your OIDC config
logoutDeliveryStore: createNeonLogoutDeliveryStore(process.env.DATABASE_URL),
// Optional override; defaults to fetch() with retry + Standard Webhooks signing.
deliverLogoutToken: async ({ url, logoutToken }) => fetch(url, {
method: 'POST',
body: new URLSearchParams({ logout_token: logoutToken })
})
}
// Mounts:
// GET {oidcRoute}/end_session?id_token_hint=...&post_logout_redirect_uri=...
// On signout, the AS fans out a signed logout_token to every other RP's
// backchannelLogoutUri for the same sid. Deliveries retry; failures persist
// in the store so you can re-fire them.#DPoP (sender-constrained)
DPoP (RFC 9449) binds the access token to the client's proof key (cnf.jkt), so a stolen bearer token is useless without the private key — WorkOS doesn't offer this. It's automatic at the token endpoint; verify the proof on your resource server with verifyDpopProof. §8 nonces opt in via a dpopNonceStore to defeat pre-computed-proof replays. RFC 9470 acr_values pair with the adaptive + MFA gates to force step-up before issuing a code.
import { verifyDpopProof, verifyJwt } from '@absolutejs/auth';
// DPoP (RFC 9449) is automatic at the token endpoint: if the client sends a
// 'DPoP' proof header, the issued access token is bound to its key (cnf.jkt) and
// typed "DPoP" — a stolen bearer token is then useless without the private key.
// WorkOS doesn't offer this.
// On your resource server, verify the proof and match its thumbprint to the
// token's cnf.jkt:
const proof = await verifyDpopProof({
htm: request.method,
htu: request.url,
proof: request.headers.get('dpop') ?? undefined
});
const token = await verifyJwt(accessToken, publicJwk);
if (proof === undefined || proof.jkt !== token?.payload.cnf?.jkt) {
return new Response('invalid_token', { status: 401 });
}
// §8 nonces: opt in via dpopNonceStore so the AS challenges proofs with a
// short-lived nonce (returned as DPoP-Nonce: <value>). Mitigates pre-computed
// proof replays. RFC 9470 acr_values: pass acr_values=urn:mace:incommon:iap:silver
// to /authorize and require step-up before the code is issued.#AI-agent / MCP token exchange
The 2026 frontier. An agent trades a user's token for a narrower, short-lived, audience-bound on-behalf-of token via RFC 8693 token exchange — the issued token keeps the user's sub, binds aud to the resource (RFC 8707), and records the delegation in an act claim (DPoP-bindable). mcpProtectedResourceMetadata emits the MCP / RFC 9728 discovery doc. Matches Auth0's "Auth for MCP".
// AI-agent / MCP "on-behalf-of": an agent (a registered client) trades a user's
// access token for a narrower, short-lived, audience-bound delegated token.
//
// POST /oauth2/token
// grant_type=urn:ietf:params:oauth:grant-type:token-exchange
// subject_token=<the user's access token>
// subject_token_type=urn:ietf:params:oauth:token-type:access_token
// resource=https://api.example/mcp // RFC 8707 — binds the token's aud
// scope=documents:read // narrowed (must be a subset)
// -> { access_token, issued_token_type, scope, token_type }
//
// The issued token keeps the user's sub, sets aud to the resource, and adds an
// act claim ({ sub: <agent client id> }) recording the delegation. DPoP-bindable.
import { mcpProtectedResourceMetadata } from '@absolutejs/auth';
// Serve at /.well-known/oauth-protected-resource on your MCP / resource server so
// agent clients discover this authorization server (RFC 9728):
const metadata = mcpProtectedResourceMetadata({
issuer: 'https://id.yourapp.com',
resource: 'https://api.example/mcp',
scopes: ['documents:read']
});