Files
plainpages/src/jwt-middleware.ts
T
lilleman 7bdeb24b7f §10 public pages + menu items, the blessed explicit alias (todo §10); a plugin may mark a page and its menu option public. A no-permission route/nav node is already anonymous-reachable, so per the human's pick this BLESSES that as a first-class, explicit choice (keep the default; add an explicit alias — not a secure-by-default flip). New optional public?: boolean on Route (src/plugin.ts) + NavNode (src/nav.ts) = "open to everyone, signed in or not", honored outright in isAuthorized (router.ts) + filterByRoles (nav.ts), and MUTUALLY EXCLUSIVE with permission — discovery shapeError recursively rejects a route/nav node setting both, failing the boot loud (never silently picks one). public is filter-only (toRenderNode never emits it). The shell (views/partials/shell.ejs) now renders a Sign in link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (ctx.chrome; ctx.user may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public /scheduling Overview route + a public "Overview" nav child (the "Scheduling" header now shows for everyone), the shifts list still behind scheduling:read. Hardened the latent gap the shell newly leans on: claimsToUser rejects an empty email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (public open + reject-both + loads), shell (anon → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: plugin-contract.md ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: visual.spec asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: APPROVE, no Critical/High/Medium (addressed its one Low — the empty-email hardening). typecheck + 354 units + full scripts/ci.sh gate (visual 10 · auth 1 · oauth 2 · full 7) green.
2026-06-20 18:12:46 +02:00

111 lines
5.7 KiB
TypeScript

// JWT session middleware (todo §4): verify our session cookie in-process on every request —
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
// check the signature (src/jwt.ts), validate the time/issuer/audience claims, project the
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
// (anonymous), so the route renders signed-out and the permission gate denies.
import type { User } from "./context.ts";
import { parseCookies } from "./cookie.ts";
import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.ts";
import type { JwksProvider } from "./jwks.ts";
import { SESSION_COOKIE } from "./login.ts";
// Leeway on exp/nbf for small clock drift between Kratos and web.
const DEFAULT_CLOCK_SKEW_SEC = 60;
export interface VerifyOptions {
audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
clockSkewSec?: number | undefined;
denylist?: Pick<Denylist, "isRevoked"> | undefined; // optional instant-revoke (§9); a revoked sub is rejected like an expiry
issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped)
now?: number | undefined; // unix seconds; injectable for tests
}
// A rejected token (bad signature, expired, wrong iss/aud, malformed claims). `authenticate`
// swallows it to anonymous; a caller wanting the reason can catch it. `expired` is set only for
// a lapsed-but-otherwise-intact token — the §4 re-mint trigger (see resolveSession).
export class TokenError extends Error {
expired: boolean;
constructor(message: string, expired = false) {
super(message);
this.expired = expired;
}
}
function num(payload: Record<string, unknown>, claim: string): number | undefined {
const v = payload[claim];
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
// Validate the time/issuer/audience claims of an already signature-verified payload.
export function validateClaims(payload: Record<string, unknown>, options: VerifyOptions = {}): void {
const skew = options.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
const now = options.now ?? Math.floor(Date.now() / 1000);
const exp = num(payload, "exp");
if (exp === undefined) throw new TokenError("token missing exp");
if (now > exp + skew) throw new TokenError("token expired", true);
const nbf = num(payload, "nbf");
if (nbf !== undefined && now < nbf - skew) throw new TokenError("token not yet valid");
if (options.issuer !== undefined && payload["iss"] !== options.issuer) throw new TokenError("token issuer mismatch");
if (options.audience !== undefined) {
const aud = payload["aud"];
const ok = typeof aud === "string" ? aud === options.audience : Array.isArray(aud) && aud.includes(options.audience);
if (!ok) throw new TokenError("token audience mismatch");
}
}
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
// keeps only string entries (defensive).
export function claimsToUser(payload: Record<string, unknown>): User {
const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"];
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
const roles = payload["roles"];
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
}
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
// claims, project the User. Throws TokenError / the underlying verify error on any failure.
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User> {
const { header } = decodeJws(token); // unverified — only to read `kid` for key selection
const jwk = await jwks.getKey(header.kid);
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options);
const user = claimsToUser(verified.payload);
// Instant revoke (§9): a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the §4 re-mint (fresh roles from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user;
}
export interface SessionAuth {
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate (§4)
user: User | null;
}
// The request middleware: read our session cookie, verify it → the User (fail-closed: any
// bad/expired/missing token ⇒ null). `expired` distinguishes a lapsed-but-intact token from
// no-cookie / tampered ones, so app.ts only pays an Ory round-trip to re-mint a genuinely
// expired session, never for anonymous or garbage requests.
export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionAuth> {
const token = parseCookies(cookieHeader)[SESSION_COOKIE];
if (!token) return { expired: false, user: null };
try {
return { expired: false, user: await verifyToken(token, jwks, options) };
} catch (err) {
return { expired: err instanceof TokenError && err.expired, user: null };
}
}
// Convenience for callers that don't re-mint: just the User, or null.
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
return (await resolveSession(cookieHeader, jwks, options)).user;
}