OIDC Login
s200's auth surface stops at the challenge-carrying basics (s200/auth, s200/jwt, s200/csrf) — a full OIDC client is deliberately a recipe, not a package entry. recipes/oidc.ts is repository-level TypeScript, the same deal as recipes/redis-stores.ts: vendor the one file, bring your own provider, inject. It implements the Authorization Code flow with PKCE (S256) — the flow current OIDC guidance recommends for every client, confidential or public — and composes the batteries instead of reinventing them: signed flow cookies (s200/cookies), id_token verification (s200/jwt's verifyJwt over a createJwksResolver on the discovery jwks_uri), and a token handoff to your session layer (s200/session).
Setup
Vendor recipes/oidc.ts into your project (adjust the two import specifier levels to wherever src/ lives for you), then wire two GET routes and the session middleware:
import { createApp, get, use } from 's200';
import { createSession, type Session } from 's200/session';
import { createOidc } from './oidc'; // vendored from recipes/oidc.ts
const sessions = createSession({ secret: process.env.SESSION_SECRET! });
const oidc = createOidc({
issuer: 'https://idp.example',
clientId: 'web-app',
clientSecret: process.env.OIDC_SECRET,
redirectUri: 'https://app.example/auth/callback',
cookieSecret: process.env.COOKIE_SECRET!,
scopes: ['openid', 'profile', 'email'],
onLogin: (ctx, tokens, claims) => {
const session = ctx.state.session as Session;
session.set('user', claims.sub);
session.set('idToken', tokens.idToken); // for the logout id_token_hint
},
});
const app = createApp();
use(app, sessions.middleware);
get(app, '/login', oidc.login);
get(app, '/auth/callback', oidc.callback); // must be the registered redirectUri
get(app, '/logout', async (ctx) => {
const session = ctx.state.session as Session;
const idToken = session.get('idToken') as string | undefined;
await session.destroy();
const target = await oidc.logoutUrl('https://app.example/', idToken);
return new Response(null, { status: 302, headers: { location: target ?? '/' } });
});If clientSecret is omitted the recipe runs as a public client — no provider-side client registration secret, PKCE carries the client authentication.
The flow, in words
The whole dance is one browser redirect out, one redirect back, one server-to-server POST:
GET /login— the handler draws three fresh 256-bit values from the platform CSPRNG: astate, anonce, and a PKCEcode_verifier. It answers302to the provider'sauthorization_endpointcarryingresponse_type=code, theclient_id/redirect_uri/scope, thestateandnoncein the clear, andcode_challenge=base64url(SHA-256(verifier))withcode_challenge_method=S256. The three secrets land in signed, HttpOnly,SameSite=Lax, ten-minute cookies (oidc.state,oidc.nonce,oidc.verifier, each with a.sigpartner under the cookie battery's convention).- At the provider — the user authenticates and consents. The provider never sees the verifier; the challenge is a one-way hash of it.
- Back to
/auth/callback— the provider redirects withcode+state(or anerror, which the recipe answers as a 401 carrying the provider's error code). The handler re-reads the three cookies, verifying signatures, and refuses with 401 unless the querystatematches the cookie exactly. - The exchange — the recipe POSTs the
token_endpointa form body ofgrant_type=authorization_code, thecode, theredirect_uri, and thecode_verifier; the provider re-hashes the verifier against the challenge it remembered, which is what binds the code to this browser. AclientSecretis sent as HTTP Basic authorization; without one,client_idrides the body. A failed exchange answers 502. - Verification — the returned
id_tokenis verified withverifyJwt: signature via acreateJwksResolverover the discoveryjwks_uri(kid rotation is the resolver's business),issagainst the configured issuer,audagainst theclientId, time claims with the configured clock tolerance, and thenonceclaim against the nonce cookie. Any failure answers 401. - Session —
onLoginruns with the tokens and the verified claims; the default response is a302topostLoginPath(or whereveronLoginanswered itself). The three flow cookies are expired alongside.
Discovery ({issuer}/.well-known/openid-configuration) is fetched once per issuer and cached for the process; a failed fetch is retried on the next request, not pinned.
Options
| Option | Default | Notes |
|---|---|---|
issuer | — | Provider issuer; trailing slashes are normalized. Discovery is cached per issuer. |
clientId | — | Also the required aud of the id_token. |
clientSecret | undefined | Present → confidential client, HTTP Basic on the token request. Absent → public client, client_id in the body. |
redirectUri | — | The callback URL registered with the provider. |
scopes | ['openid'] | openid is what makes the flow OIDC; add profile/email for claims. |
cookieSecret | — | HMAC secret for the three flow cookies (the cookies battery's convention). |
cookieNames | oidc.* | Override when several apps share one domain. |
postLoginPath | '/' | Where a successful callback redirects. |
clockTolerance | 60 | Seconds of id_token clock-skew tolerance. |
fetch | global fetch | Injection point for tests and proxies. An injected fetch gets a per-instance discovery cache — the module-level cache is only shared between instances on the global fetch. |
onLogin | — | Receives (ctx, tokens, claims); write your session here. |
Security notes
- PKCE is unconditional. Every authorization request carries
code_challenge_method=S256; the verifier exists only in a signed HttpOnly cookie and the one token request. The recipe never downgrades toplain, and there is no PKCE-off switch — for confidential clients it is defense in depth (a stolencodeis worthless without the verifier), for public clients it is the only client authentication there is. - Basic vs body client authentication. With a
clientSecret, credentials go in theAuthorization: Basicheader (RFC 6749 §2.3.1) — the default for confidential clients, and the kinder choice for logs and intermediaries, since bodies get logged far more often than headers.client_secret_post(secrets in the body) is not offered; if your provider demands it, that is a one-line vendored edit. - Clock skew.
verifyJwtchecksexp/nbf/iatwithclockToleranceseconds of leeway (default 60). Raise it for a provider on drifting clocks; set0for strictness. Thestate/nonce/verifierare single-purpose random values under a ten-minute cookie TTL, so they need no skew handling and no server-side storage. - Rotate the session at login. The recipe never touches your session;
onLoginis the place to callsession.regenerate()— the session battery's fixation defense — before writing the user in.
Limitations
- JWS only. Encrypted id_tokens (JWE — a token with an
encheader,alglikeRSA-OAEP-256) are unsupported and fail verification. Signed tokens are the provider default; request them if your provider offers both. - id_token claims only. The userinfo endpoint is never called — profile data must ride the id_token (request the scopes).
- No refresh automation.
tokens.refreshTokenis handed toonLoginuntouched; refreshing, storing, and rotating it are yours. - One redirect URI. Exactly the configured one, for login and the exchange alike; no per-request overrides.
- Discovery has no TTL. The document is fetched once per issuer per process; endpoint changes need a restart or an injected
fetchwith your own caching policy. - Logout needs provider support.
end_session_endpointis optional in discovery; without itlogoutUrl()resolvesundefinedand the guide's logout route falls back to destroying the local session.