bext.dev
DocsApplication Toolkit
Application Toolkit5 min read
On this page

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#

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

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

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

Tip

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.

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

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

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

ts
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;
Note

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:

ts
// 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 }
Tip

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#

Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.