From c8b56b85ebc24ad21ea07dae281550724d792ed1 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 18 Jun 2026 09:53:37 +0200 Subject: [PATCH] =?UTF-8?q?JWT=20session=20middleware=20(todo=20=C2=A74);?= =?UTF-8?q?=20authenticate():=20verify=20the=20session=20cookie=20via=20ca?= =?UTF-8?q?ched=20JWKS=20(key=20by=20kid)=20=E2=86=92=20exp/nbf/iss/aud=20?= =?UTF-8?q?claims=20(clock=20skew)=20=E2=86=92=20ctx.user/roles;=20iss/aud?= =?UTF-8?q?=20opt-in;=20fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++- src/app.test.ts | 30 ++++++++++++++ src/app.ts | 20 ++++++--- src/config.test.ts | 9 ++++ src/config.ts | 11 +++++ src/jwks.test.ts | 29 +++++++++++++ src/jwks.ts | 37 +++++++++++++++++ src/jwt-middleware.test.ts | 74 +++++++++++++++++++++++++++++++++ src/jwt-middleware.ts | 84 ++++++++++++++++++++++++++++++++++++++ src/server.ts | 15 ++++++- todo.md | 2 +- 11 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 src/jwks.test.ts create mode 100644 src/jwks.ts create mode 100644 src/jwt-middleware.test.ts create mode 100644 src/jwt-middleware.ts diff --git a/README.md b/README.md index 7a51703..b825ca7 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ auto-merged by `docker compose up`) turns them back off for live editing. | `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) | | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write | | `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT (§4) | +| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) | | `COOKIE_SECRET` / `CSRF_SECRET` | dev throwaways | enforced by `REQUIRE_SECURE_SECRETS` | ### What you must supply (the only manual prep) @@ -499,7 +500,9 @@ mid-response, so container restarts are clean. src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000) src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4) src/static.ts Static file serving (path-traversal protection) + routePublic(): /public// → a plugin's public/ -src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4 +src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK) +src/jwt-middleware.ts authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles (§4) +src/jwks.ts JwksProvider — loadJwks() (file://, base64://) + staticJwks(): resolve the verify key by kid; HTTP fetch/cache/rotation is the next §4 item src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, whoami, session→JWT tokenize (§4) src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4) src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4) diff --git a/src/app.test.ts b/src/app.test.ts index d1c10f1..3790dd4 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto"; import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; @@ -6,9 +7,11 @@ import { dirname, join } from "node:path"; import { after, before, test, type TestContext } from "node:test"; import { fileURLToPath } from "node:url"; import { createApp } from "./app.ts"; +import { staticJwks } from "./jwks.ts"; import type { KetoClient } from "./keto-client.ts"; import type { Identity, KratosAdmin } from "./kratos-admin.ts"; import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "./kratos-public.ts"; +import { SESSION_COOKIE } from "./login.ts"; import type { Plugin } from "./plugin.ts"; import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts"; @@ -179,6 +182,33 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per assert.equal((await fetch(url + "/demo/nope")).status, 404); }); +// JWT middleware (§4): a verified session cookie populates ctx.user/roles, which the gate reads. +const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); +const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" }; +const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url"); +function mintJwt(payload: Record): string { + const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`; + return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`; +} + +test("a verified session JWT authorizes a role-gated route; no cookie / expired token → 403", async (t) => { + const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] }); + await new Promise((r) => app.listen(0, r)); + t.after(() => app.close()); + const url = `http://localhost:${(app.address() as AddressInfo).port}`; + const nowSec = Math.floor(Date.now() / 1000); + const secret = (cookie?: string) => fetch(url + "/demo/secret", cookie ? { headers: { cookie } } : {}); + + // Token carrying the gating role → the handler runs (200). + const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`); + assert.equal(ok.status, 200); + assert.equal(await ok.text(), "secret"); + + // No cookie and an expired token both render anonymous → the gate denies (403). + assert.equal((await secret()).status, 403); + assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 403); +}); + test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => { const seen: string[] = []; const hooked: Plugin = { diff --git a/src/app.ts b/src/app.ts index 3692ac9..bf2f9ce 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,8 @@ import { buildDashboardModel } from "./dashboard.ts"; import { PLUGINS_DIR } from "./discovery.ts"; import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts"; import { runRequestHooks, runResponseHooks } from "./hooks.ts"; +import type { JwksProvider } from "./jwks.ts"; +import { authenticate, type VerifyOptions } from "./jwt-middleware.ts"; import type { KetoClient } from "./keto-client.ts"; import type { KratosAdmin } from "./kratos-admin.ts"; import { KratosError, type KratosPublic } from "./kratos-public.ts"; @@ -20,9 +22,11 @@ import { renderPluginView } from "./view-resolver.ts"; const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); export interface AppOptions { + auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks // Cache compiled templates; caller decides (server passes config.cacheTemplates). // Off by default so edits show live; the app itself never inspects the environment. cache?: boolean; + jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4) kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4) kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4) @@ -34,7 +38,9 @@ export interface AppOptions { } export function createApp(options: AppOptions = {}): Server { + const authOptions = options.auth ?? {}; const cache = options.cache ?? false; + const jwks = options.jwks; const keto = options.keto; const kratos = options.kratos; const kratosAdmin = options.kratosAdmin; @@ -63,16 +69,20 @@ export function createApp(options: AppOptions = {}): Server { return createServer(async (req, res) => { try { const method = req.method ?? "GET"; - const ctx = buildContext(req, res); // base context (no route params yet); reused for onRequest - const pathname = ctx.url.pathname; + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) { // /public//… serves a plugin's public/; everything else the core public/. + // Before auth: assets don't need a verified user, and the JWT cookie rides every request. const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds); await serveStatic(dir, subPath, res, method === "HEAD"); return; } + // Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous. + const user = jwks ? await authenticate(req.headers.cookie, jwks, authOptions) : null; + const ctx = buildContext(req, res, { user }); // base context (no route params yet); reused for onRequest + // Plugin onRequest hooks run before routing and may short-circuit the request. if (anyRequestHooks) { const short = await runRequestHooks(plugins, ctx); @@ -85,7 +95,7 @@ export function createApp(options: AppOptions = {}): Server { // Plugin routes (any method): gate on the route's permission, then run the handler. const match = matchRoute(plugins, method, pathname); if (match) { - const routeCtx = buildContext(req, res, { params: match.params }); + const routeCtx = buildContext(req, res, { params: match.params, user }); if (!isAuthorized(match.route, routeCtx.roles)) { sendHtml(res, 403, await render("403", { title: "Forbidden" })); return; @@ -134,8 +144,8 @@ export function createApp(options: AppOptions = {}): Server { } if (pathname === "/" && (method === "GET" || method === "HEAD")) { - // Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts. - sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, [], menu) })); + // Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts. + sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu) })); return; } diff --git a/src/config.test.ts b/src/config.test.ts index d9d50cc..e9d78e4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -31,6 +31,15 @@ test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an ht assert.match(url.pathname, /tokenizer\/jwks\.json$/); }); +test("JWT issuer/audience are optional: unset by default, pinned from the env", () => { + const def = loadConfig({}); + assert.equal(def.jwtIssuer, undefined); + assert.equal(def.jwtAudience, undefined); + const c = loadConfig({ JWT_AUDIENCE: "plainpages", JWT_ISSUER: "https://id.example.com" }); + assert.equal(c.jwtIssuer, "https://id.example.com"); + assert.equal(c.jwtAudience, "plainpages"); +}); + test("parses explicit boolean toggles and rejects non-boolean values", () => { assert.equal(loadConfig({ CACHE_TEMPLATES: "true" }).cacheTemplates, true); assert.equal(loadConfig({ CACHE_TEMPLATES: "false" }).cacheTemplates, false); diff --git a/src/config.ts b/src/config.ts index 3581053..15ea061 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,8 @@ export interface Config { cookieSecret: string; csrfSecret: string; jwksUrl: string; + jwtAudience: string | undefined; + jwtIssuer: string | undefined; ketoReadUrl: string; ketoWriteUrl: string; kratosAdminUrl: string; @@ -41,6 +43,12 @@ function readBool(env: Env, key: string, devDefault: boolean): boolean { throw new Error(`config: ${key} must be "true" or "false", got "${value}"`); } +// An optional pinned value: present only when set non-empty. Unset ⇒ the matching claim +// check is skipped (clean clone — the dev tokenizer sets no iss/aud; §4 verifier). +function readOptional(env: Env, key: string): string | undefined { + return env[key] || undefined; +} + // An absolute URL: defaults to the Ory service; validated so a typo fails at boot. function readUrl(env: Env, key: string, devDefault: string): string { const value = env[key] ?? devDefault; @@ -72,6 +80,9 @@ export function loadConfig(env: Env = process.env): Config { // Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS // mounted into web (compose.yml). Prod overrides with a real key (README: rotation). jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"), + // Optional, off by default: pin the session-JWT issuer/audience for a hardened deploy. + jwtAudience: readOptional(env, "JWT_AUDIENCE"), + jwtIssuer: readOptional(env, "JWT_ISSUER"), ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"), diff --git a/src/jwks.test.ts b/src/jwks.test.ts new file mode 100644 index 0000000..d311d64 --- /dev/null +++ b/src/jwks.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync, type JsonWebKey } from "node:crypto"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { loadJwks, staticJwks } from "./jwks.ts"; + +const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid }); +const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "ory/kratos/tokenizer/jwks.json"); + +test("staticJwks selects by kid, falls back to the sole key when none, misses cleanly", async () => { + const [a, b] = [jwk("k1"), jwk("k2")]; + const set = staticJwks([a, b]); + assert.equal(await set.getKey("k2"), b); + assert.equal(await set.getKey("nope"), null); + assert.equal(await set.getKey(undefined), null); // ambiguous with >1 key + assert.equal(await staticJwks([a]).getKey(undefined), a); // single-key dev default +}); + +test("loadJwks reads a file:// set and a base64:// inline set, rejects http", () => { + // The committed dev tokenizer key. + const fromFile = loadJwks(pathToFileURL(committed).href); + assert.equal(fromFile[0]?.kid, "42634591-3e04-49d5-a818-284d7021a85f"); + + const inline = JSON.stringify({ keys: [jwk("inline")] }); + assert.equal(loadJwks(`base64://${Buffer.from(inline).toString("base64")}`)[0]?.kid, "inline"); + + assert.throws(() => loadJwks("http://keto:4466/keys"), /unsupported/); +}); diff --git a/src/jwks.ts b/src/jwks.ts new file mode 100644 index 0000000..f86abe8 --- /dev/null +++ b/src/jwks.ts @@ -0,0 +1,37 @@ +import type { JsonWebKey } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// JWKS provider: resolve the JWT verify key by the JWS `kid` (todo §4). The middleware +// calls `getKey` per request. `staticJwks` holds a fixed set loaded once at boot from the +// mounted/dev key; HTTP fetch + TTL refresh + rotation-on-miss is the next §4 item. +export interface JwksProvider { + getKey(kid: string | undefined): Promise; +} + +function parseJwks(text: string): JsonWebKey[] { + const parsed = JSON.parse(text) as { keys?: unknown }; + if (!Array.isArray(parsed.keys)) throw new Error("JWKS: missing `keys` array"); + return parsed.keys as JsonWebKey[]; +} + +// Load a JWKS from the configured location: `file://` reads the mounted tokenizer key (the +// dev default + prod mount), `base64://` decodes an inline set (README rotation). `http(s)://` +// is the rotating-cache's job (next §4 item) — fail loud rather than silently no-fetch. +export function loadJwks(jwksUrl: string): JsonWebKey[] { + if (jwksUrl.startsWith("base64://")) return parseJwks(Buffer.from(jwksUrl.slice("base64://".length), "base64").toString("utf8")); + const url = new URL(jwksUrl); + if (url.protocol === "file:") return parseJwks(readFileSync(fileURLToPath(url), "utf8")); + throw new Error(`loadJwks: unsupported JWKS URL scheme (HTTP fetch lands with the §4 JWKS cache): ${jwksUrl}`); +} + +// A fixed in-memory key set. Pick by `kid`; with no `kid` fall back to the sole key (single- +// key dev default). Async so the §4 cache can drop in refetch-on-miss without touching callers. +export function staticJwks(keys: JsonWebKey[]): JwksProvider { + return { + getKey: async (kid) => { + if (kid === undefined) return keys.length === 1 ? keys[0]! : null; + return keys.find((k) => k.kid === kid) ?? null; + }, + }; +} diff --git a/src/jwt-middleware.test.ts b/src/jwt-middleware.test.ts new file mode 100644 index 0000000..2ca917e --- /dev/null +++ b/src/jwt-middleware.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto"; +import { test } from "node:test"; +import { staticJwks } from "./jwks.ts"; +import { authenticate, claimsToUser, verifyToken } from "./jwt-middleware.ts"; +import { SESSION_COOKIE } from "./login.ts"; + +const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url"); + +// Mint an ES256 session JWT the way the Kratos tokenizer would (kid in the header). +function mint(privateKey: KeyObject, kid: string, payload: Record): string { + const head = b64url(JSON.stringify({ alg: "ES256", kid, typ: "JWT" })); + const body = b64url(JSON.stringify(payload)); + const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: "ieee-p1363" }); + return `${head}.${body}.${b64url(sig)}`; +} + +const k1 = generateKeyPairSync("ec", { namedCurve: "P-256" }); +const k2 = generateKeyPairSync("ec", { namedCurve: "P-256" }); +const jwk1: JsonWebKey = { ...(k1.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "k1" }; +const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "k2" }; +const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys + +const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks +const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" }; + +test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => { + const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW }); + assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] }); +}); + +test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => { + const opts = { clockSkewSec: 60, now: NOW }; + await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/); + // exp 30s in the past but inside the 60s skew → still accepted. + await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts); + await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, nbf: NOW + 120 }), jwks, opts), /not yet valid/); +}); + +test("verifyToken checks issuer/audience only when configured", async () => { + const tok = (extra: Record) => mint(k1.privateKey, "k1", { ...valid, ...extra }); + // No iss/aud in the token and none expected (the dev tokenizer sets neither) → fine. + await verifyToken(tok({}), jwks, { now: NOW }); + // Issuer pinned: must match; absent or wrong → reject. + await verifyToken(tok({ iss: "https://id" }), jwks, { issuer: "https://id", now: NOW }); + await assert.rejects(verifyToken(tok({}), jwks, { issuer: "https://id", now: NOW }), /issuer/); + await assert.rejects(verifyToken(tok({ iss: "other" }), jwks, { issuer: "https://id", now: NOW }), /issuer/); + // Audience pinned: matches a string or an array membership; mismatch → reject. + await verifyToken(tok({ aud: "pp" }), jwks, { audience: "pp", now: NOW }); + await verifyToken(tok({ aud: ["x", "pp"] }), jwks, { audience: "pp", now: NOW }); + await assert.rejects(verifyToken(tok({ aud: "x" }), jwks, { audience: "pp", now: NOW }), /audience/); +}); + +test("verifyToken rejects a bad signature and an unknown kid", async () => { + // Signed with k1 but the header claims kid k2 → wrong verify key → bad signature. + await assert.rejects(verifyToken(mint(k1.privateKey, "k2", valid), jwks, { now: NOW }), /invalid signature/); + await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/); +}); + +test("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => { + assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/); + assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/); + assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent + assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]); +}); + +test("authenticate: a valid cookie → User; no cookie / invalid / expired → null (fail-closed)", async () => { + const cookie = `${SESSION_COOKIE}=${mint(k1.privateKey, "k1", valid)}`; + assert.deepEqual(await authenticate(cookie, jwks, { now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] }); + assert.equal(await authenticate(undefined, jwks, { now: NOW }), null); + assert.equal(await authenticate("other=1", jwks, { now: NOW }), null); + assert.equal(await authenticate(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), null); + assert.equal(await authenticate(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, exp: NOW - 999 })}`, jwks, { now: NOW }), null); +}); diff --git a/src/jwt-middleware.ts b/src/jwt-middleware.ts new file mode 100644 index 0000000..6086f33 --- /dev/null +++ b/src/jwt-middleware.ts @@ -0,0 +1,84 @@ +// 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 { 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; + 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. +export class TokenError extends Error {} + +function num(payload: Record, 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, 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"); + + 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 (the tokenizer always sets +// them); roles defaults to [] and keeps only string entries (defensive). +export function claimsToUser(payload: Record): User { + const sub = payload["sub"]; + if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub"); + const email = payload["email"]; + if (typeof email !== "string") 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 { + 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); + return claimsToUser(verified.payload); +} + +// The request middleware: read our session cookie, verify it → the User, or null for no +// cookie / any invalid token (fail-closed; the route then renders anonymous and gates deny). +export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise { + const token = parseCookies(cookieHeader)[SESSION_COOKIE]; + if (!token) return null; + try { + return await verifyToken(token, jwks, options); + } catch { + return null; + } +} diff --git a/src/server.ts b/src/server.ts index 2dc6797..5a6890c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { createApp } from "./app.ts"; import { loadConfig } from "./config.ts"; import { discoverPlugins } from "./discovery.ts"; import { runBootHooks } from "./hooks.ts"; +import { loadJwks, staticJwks } from "./jwks.ts"; import { createKetoClient } from "./keto-client.ts"; import { createKratosAdmin } from "./kratos-admin.ts"; import { createKratosPublic } from "./kratos-public.ts"; @@ -13,12 +14,24 @@ const menu = await loadMenuConfig(); // config/menu.ts override + branding — f const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl }); const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl }); const keto = createKetoClient({ readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }); +// Session-JWT verify key, loaded once from the mounted tokenizer JWKS (§4). HTTP fetch + +// TTL refresh + rotation-on-miss replace this static set in the next §4 item. +const jwks = staticJwks(loadJwks(config.jwksUrl)); const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`); await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot -const server = createApp({ cache: config.cacheTemplates, keto, kratos, kratosAdmin, menu, plugins }).listen(config.port, () => { +const server = createApp({ + auth: { audience: config.jwtAudience, issuer: config.jwtIssuer }, + cache: config.cacheTemplates, + jwks, + keto, + kratos, + kratosAdmin, + menu, + plugins, +}).listen(config.port, () => { console.log(`Listening on http://localhost:${config.port}`); }); diff --git a/todo.md b/todo.md index 10dba53..ac9b412 100644 --- a/todo.md +++ b/todo.md @@ -81,7 +81,7 @@ everything via Docker. - [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=`; `?flow=` ⇒ `getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8. - [x] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only. → `flow-view.ts` now collects the login/registration flow's `oidc`-group submit nodes into `FlowView.sso` (`{label, logo, name, value}` per provider; `logo` = provider initial, lucide ships no brand marks) instead of skipping them — so the button list *is* Kratos' live provider list (none configured ⇒ `sso: []` ⇒ no section; activate/remove a provider purely via the §3 OIDC env). `auth-card.ejs` gained a submit-provider branch: a provider with `name`/`value` renders `