Authentication
@bext-stack/framework/auth is the reusable core of the auth flow that ~31 bext
sites currently copy-paste into their own oidc.ts: cookie parsing, a
tamper-proof HMAC-signed session cookie, and OAuth/OIDC PKCE helpers. It
is the authentication half; Authorization (authz) is
the "can they do it" half.
The crypto is pure JavaScript — HMAC-SHA256, SHA-256 and base64url are
implemented in-module, validated against the canonical HMAC and RFC 7636 PKCE
vectors — so it runs identically on V8 and QuickJS with no node:crypto or
WebCrypto dependency (the copies mix both, which breaks in a pure isolate).
When To Use It#
- A signed session cookie for "who is this request" — without an external session store or a crypto dependency.
- Kicking off an OAuth2/OIDC login (PKCE + authorization URL).
- Anywhere you were hand-writing
parseCookies/ an HMAC signer.
Signed sessions#
import { createSession } from "@bext-stack/framework/auth";
const session = createSession<{ userId: string; role: string }>({
secret: process.env.SESSION_SECRET!, // read from env / a secrets store
cookie: "sid",
maxAgeSecs: 86400, // expiry is signed INTO the payload
});
// loader — read + verify (tampered or expired → null):
const s = session.read(request); // { userId, role } | null
// action — issue / clear:
return new Response(null, { status: 303, headers: {
location: "/",
"set-cookie": session.cookie({ userId, role }), // signed Set-Cookie
}});
// logout: session.clearCookie()
The token is base64url(json).signature; read/verify recompute the HMAC
(constant-time), reject a tampered payload, and enforce the exp baked into the
payload — so a token copied off the wire still expires server-side, not just in
the browser's Max-Age.
| Member | Does |
|---|---|
createSession<T>({ secret, cookie?, maxAgeSecs?, sameSite?, secure? }) |
build a session helper |
.read(request) |
verify the cookie → T | null |
.issue(data) / .verify(token) |
token ⟷ payload |
.cookie(data) / .clearCookie() |
Set-Cookie header values |
Cookies & the signer#
import { parseCookies, serializeCookie, createSigner } from "@bext-stack/framework/auth";
parseCookies(request.headers.get("cookie")); // { name: value, ... }
serializeCookie("flash", "hi", { maxAge: 60 }); // "flash=hi; Path=/; Max-Age=60; SameSite=Lax; HttpOnly; Secure"
const signer = createSigner(secret); // low-level HMAC
signer.verify(payload, signer.sign(payload)); // true (constant-time)
OAuth / OIDC (PKCE)#
Provider-agnostic helpers for the authorization-code + PKCE flow:
import { pkceChallenge, buildAuthorizeUrl, randomToken } from "@bext-stack/framework/auth";
const { verifier, challenge } = pkceChallenge(); // stash `verifier` in the session
const state = randomToken();
const url = buildAuthorizeUrl({
authorizationEndpoint: "https://idp/authorize",
clientId, redirectUri, scope: "openid email",
state, codeChallenge: challenge, // adds code_challenge_method=S256
});
// redirect the user to `url`; on callback, exchange the code + verifier at the token endpoint.
pkceChallengeFromVerifier(v) and the base64urlEncode/base64urlDecode
helpers are exported for building the token-exchange step yourself.
Store the verifier and state in a short-lived signed cookie (use
createSession) across the redirect, and verify state on the callback — that's
the CSRF defense for the login flow.
The full OIDC client (createOidcClient)#
The helpers above are the pieces. createOidcClient is the whole
authorization-code (public PKCE) flow in one configured object — the exact
shape ~30 bext sites used to copy-paste as a ~500-line lib/oidc.ts. You give
it config; it hands back PKCE, the authorize URL, token exchange, id-token
decode, a signed single-cookie flow envelope (PKCE verifier + CSRF state +
return path in one HMAC-signed cookie), an HMAC-signed session, and the
route gate.
import { createOidcClient } from "@bext-stack/framework/oidc";
const client = createOidcClient({
issuer: "https://auth.example.com", // default: the bext IdP
clientId: "my-app",
redirectUri: "https://my-app.com/auth/callback",
sessionSecret: () => process.env.SESSION_SECRET, // string OR lazy resolver
sessionCookieName: "my_session",
flowCookieName: "my_flow",
flowCarrier: "ret", // "ret" = 303 back to a path; "popup" = postMessage
});
Login kickoff — one signed cookie carries the whole flow across the redirect:
export async function action({ request }) {
const verifier = client.generateCodeVerifier();
const challenge = await client.generateCodeChallenge(verifier);
const state = client.generateState(), nonce = client.generateNonce();
const flow = client.buildFlowState({ pkce: verifier, state, nonce, ret: "/" });
return new Response(null, { status: 303, headers: {
Location: client.buildAuthorizeUrl({ codeChallenge: challenge, state, nonce }),
"set-cookie": client.flowEnvelopeCookie(flow),
}});
}
Callback — verify state, exchange the code with the PKCE verifier, sign the session:
export async function loader({ request }) {
const flow = client.readFlowEnvelope(request);
const url = new URL(request.url);
if (!flow || flow.state !== url.searchParams.get("state")) throw fail("state");
const tok = await client.exchangeCode({ code: url.searchParams.get("code"), codeVerifier: flow.pkce });
const c = client.decodeIdToken(tok.id_token);
const now = Math.floor(Date.now() / 1000);
const session = client.packSession({ sub: c.sub, name: c.name, email: c.email, iat: now, exp: now + 600 });
const headers = new Headers({ Location: flow.ret });
headers.append("Set-Cookie", client.sessionCookie(session));
headers.append("Set-Cookie", client.clearFlowEnvelope());
throw new Response(null, { status: 303, headers });
}
Read + gate any route:
const session = client.readSession(request); // verified payload | null
const gated = client.requireSession(request); // session | 303 to /auth/login?return=…
if (gated instanceof Response) return gated;
createOidcClient is byte-compatible with the hand-rolled copies it
replaces: same base64url, same HMAC-over-the-base64url-payload envelope, same
cookie attributes (session SameSite=Lax; Secure, not HttpOnly so a
display name can be read client-side; flow HttpOnly; SameSite=None). A
migrated site keeps validating already-issued live cookies — proven in
oidc.test.ts against an inlined copy of the sites' exact algorithm.
Migrating a copy-pasted oidc.ts#
Replace the file's body with a thin re-export shim so the hundreds of
import … from "../lib/oidc" call-sites never change:
// src/lib/oidc.ts
import { createOidcClient } from "@bext-stack/framework/oidc";
const client = createOidcClient<Session>({ /* this site's config */ });
export const {
buildAuthorizeUrl, exchangeCode, decodeIdToken,
packSession, unpackSession, readSession, sessionCookie, clearSessionCookie,
buildFlowState, flowEnvelopeCookie, clearFlowEnvelope, readFlowEnvelope,
requireSession, requireSessionJson, sanitizeReturnPath, parseCookies,
} = client;
export const SESSION_COOKIE_NAME = client.SESSION_COOKIE_NAME;
export interface Session { sub: string; email: string; /* … */ exp: number }
Pass sessionSecret as a function when the secret is read from a per-site
[env] that resolves lazily on a cold worker — a captured string would freeze
undefined. The resolver runs on every sign/verify.
Relationship to the Auth / Session capabilities#
The Rust Auth and Session capabilities are server-side seams. This module is the in-isolate TypeScript path a PRISM route uses directly — the signed cookie lives in your code, with no provider to configure and no crypto dependency to ship.
Try It#
The live signed-session demo signs you
in with a tamper-proof cookie and verifies it every request — edit the cookie in
DevTools and watch the session drop to anonymous. Source in
sites/demo/src/app/examples/session/page.tsx.
The OAuth / OIDC demo runs the full
createOidcClient flow — PKCE, signed flow envelope, token exchange, signed
session — against a mock IdP on the same site. Source in
sites/demo/src/app/examples/oauth-mock/.
See Also#
- Authorization — the "can they do it" half (
authz). - Auth / Session — the Rust capabilities.
- Application Toolkit — the rest of the TypeScript app layer.