diff --git a/README.md b/README.md index b825ca7..d117620 100644 --- a/README.md +++ b/README.md @@ -502,7 +502,7 @@ src/app.ts Request routing + EJS rendering (incl. the themed Kratos se 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 (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/jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload) 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/jwks.test.ts b/src/jwks.test.ts index d311d64..7256968 100644 --- a/src/jwks.test.ts +++ b/src/jwks.test.ts @@ -3,7 +3,7 @@ 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"; +import { cachingJwks, createJwksProvider, 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"); @@ -27,3 +27,71 @@ test("loadJwks reads a file:// set and a base64:// inline set, rejects http", () assert.throws(() => loadJwks("http://keto:4466/keys"), /unsupported/); }); + +test("cachingJwks caches within TTL, reloads after expiry", async () => { + let clock = 0; + let calls = 0; + const k = jwk("k1"); + const c = cachingJwks(async () => (calls++, [k]), { minRefetchMs: 500, now: () => clock, ttlMs: 1000 }); + assert.equal(await c.getKey("k1"), k); // cold → loads + await c.getKey("k1"); // within TTL → cached + assert.equal(calls, 1); + clock = 1001; // past TTL + await c.getKey("k1"); + assert.equal(calls, 2); +}); + +test("cachingJwks reloads on a kid miss (rotation), throttled by minRefetchMs", async () => { + let clock = 0; + let calls = 0; + const [old, fresh] = [jwk("old"), jwk("new")]; + let set = [old]; + const c = cachingJwks(async () => (calls++, set), { minRefetchMs: 1000, now: () => clock, ttlMs: 100_000 }); + assert.equal(await c.getKey("old"), old); // cold load + set = [old, fresh]; // a new key rotates in at the source + clock = 500; // miss inside the throttle window → no reload + assert.equal(await c.getKey("new"), null); + assert.equal(calls, 1); + clock = 1001; // throttle elapsed → rotation-on-miss reload picks it up + assert.equal(await c.getKey("new"), fresh); + assert.equal(calls, 2); +}); + +test("cachingJwks keeps the last-good set when a reload fails, but a cold load propagates", async () => { + let clock = 0; + let fail = false; + const k = jwk("k"); + const c = cachingJwks(async () => { + if (fail) throw new Error("boom"); + return [k]; + }, { now: () => clock, ttlMs: 1000 }); + assert.equal(await c.getKey("k"), k); + fail = true; + clock = 2000; // TTL expired; the reload throws but the cached key still serves + assert.equal(await c.getKey("k"), k); + + await assert.rejects(() => cachingJwks(async () => { throw new Error("down"); }).getKey("x"), /down/); +}); + +test("createJwksProvider routes file/base64/http, primes + caches http, fails loud on a bad source", async () => { + // file:// primed at boot from the committed dev key. + assert.ok(await (await createJwksProvider(pathToFileURL(committed).href)).getKey(undefined)); + + // base64:// inline set. + const inline = `base64://${Buffer.from(JSON.stringify({ keys: [jwk("inl")] })).toString("base64")}`; + assert.equal((await (await createJwksProvider(inline)).getKey("inl"))?.kid, "inl"); + + // http(s):// fetched once at boot, then served from cache. + let calls = 0; + const k = jwk("h1"); + const fetchImpl = (async () => (calls++, new Response(JSON.stringify({ keys: [k] }), { status: 200 }))) as typeof fetch; + const http = await createJwksProvider("http://issuer/keys", { fetchImpl, ttlMs: 10_000 }); + assert.equal(calls, 1); // primed at boot + assert.equal((await http.getKey("h1"))?.kid, "h1"); + assert.equal(calls, 1); // cached + + // Fail loud at boot: non-2xx fetch, missing file, unsupported scheme. + await assert.rejects(() => createJwksProvider("http://issuer/keys", { fetchImpl: (async () => new Response("no", { status: 500 })) as typeof fetch }), /500/); + await assert.rejects(() => createJwksProvider("file:///nope/jwks.json"), /ENOENT/); + await assert.rejects(() => createJwksProvider("ftp://x/keys"), /unsupported/); +}); diff --git a/src/jwks.ts b/src/jwks.ts index f86abe8..5960d88 100644 --- a/src/jwks.ts +++ b/src/jwks.ts @@ -2,36 +2,104 @@ 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. +// JWKS provider: resolve the JWT verify key by the JWS `kid` (todo §4). The middleware calls +// `getKey` per request. `staticJwks` holds a fixed set; `cachingJwks` fetches over the network +// (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated- +// in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider` +// picks the right one from the configured URL scheme and primes it at boot (fail loud). export interface JwksProvider { getKey(kid: string | undefined): Promise; } +const TTL_MS = 5 * 60_000; // serve a fetched set this long before reloading +const MIN_REFETCH_MS = 60_000; // floor between rotation-on-miss reloads — a stream of bogus kids can't hammer the source + +export interface JwksCacheOptions { + fetchImpl?: typeof fetch; + minRefetchMs?: number; + now?: () => number; // unix ms; injectable for tests + ttlMs?: number; +} + 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. +// Load a JWKS synchronously from a local source: `file://` reads a mounted key, `base64://` +// decodes an inline set (README rotation). HTTP is `cachingJwks`'s job — fail loud here. 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}`); + throw new Error(`loadJwks: unsupported JWKS URL scheme (use cachingJwks for http): ${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. +async function fetchJwks(jwksUrl: string, fetchImpl: typeof fetch): Promise { + const res = await fetchImpl(jwksUrl, { headers: { accept: "application/json" } }); + if (!res.ok) throw new Error(`JWKS fetch ${jwksUrl}: HTTP ${res.status}`); + return parseJwks(await res.text()); +} + +function pick(keys: JsonWebKey[], kid: string | undefined): JsonWebKey | null { + // No `kid`: fall back to the sole key (single-key dev default), else ambiguous → null. + if (kid === undefined) return keys.length === 1 ? keys[0]! : null; + return keys.find((k) => k.kid === kid) ?? null; +} + +// A fixed in-memory key set — loaded once, never reloads. For immutable sources (base64 inline). export function staticJwks(keys: JsonWebKey[]): JwksProvider { + return { getKey: async (kid) => pick(keys, kid) }; +} + +// A self-refreshing provider over an async loader. Holds keys for `ttlMs`, then reloads on the +// next lookup; on a `kid` miss it reloads once more (rotation-on-miss), throttled by `minRefetchMs`. +// A reload failure keeps the last-good set (transient resilience); only a cold cache propagates it +// (→ the middleware fails closed). `prime()` does the eager boot load. Concurrent loads coalesce. +export function cachingJwks(load: () => Promise, opts: JwksCacheOptions = {}): JwksProvider & { prime: () => Promise } { + const ttlMs = opts.ttlMs ?? TTL_MS; + const minRefetchMs = opts.minRefetchMs ?? MIN_REFETCH_MS; + const now = opts.now ?? Date.now; + let keys: JsonWebKey[] = []; + let loadedAt = -Infinity; + let inflight: Promise | null = null; + + const refresh = (): Promise => + (inflight ??= load().then( + (k) => { keys = k; loadedAt = now(); inflight = null; }, + (e: unknown) => { inflight = null; throw e; }, + )); + return { + prime: refresh, getKey: async (kid) => { - if (kid === undefined) return keys.length === 1 ? keys[0]! : null; - return keys.find((k) => k.kid === kid) ?? null; + if (keys.length === 0 || now() - loadedAt > ttlMs) { + try { await refresh(); } catch (e) { if (keys.length === 0) throw e; } // else keep last-good + } + const hit = pick(keys, kid); + if (hit || kid === undefined) return hit; + if (now() - loadedAt >= minRefetchMs) { + try { await refresh(); } catch { /* keep last-good */ } + } + return pick(keys, kid); }, }; } + +// Build the verify-key provider from the configured JWKS URL and prime it at boot (fail loud): +// `base64://` → immutable inline set; `file://` → re-readable cache (rotation by remount/edit); +// `http(s)://` → fetched, cached, rotation-on-miss. The §4 middleware sees only `getKey`. +export async function createJwksProvider(jwksUrl: string, opts: JwksCacheOptions = {}): Promise { + if (jwksUrl.startsWith("base64://")) return staticJwks(loadJwks(jwksUrl)); + const { protocol } = new URL(jwksUrl); + let load: () => Promise; + if (protocol === "file:") load = async () => loadJwks(jwksUrl); + else if (protocol === "http:" || protocol === "https:") { + const fetchImpl = opts.fetchImpl ?? fetch; + load = () => fetchJwks(jwksUrl, fetchImpl); + } else throw new Error(`createJwksProvider: unsupported JWKS URL scheme: ${jwksUrl}`); + const provider = cachingJwks(load, opts); + await provider.prime(); + return provider; +} diff --git a/src/server.ts b/src/server.ts index 5a6890c..cfe81fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +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 { createJwksProvider } from "./jwks.ts"; import { createKetoClient } from "./keto-client.ts"; import { createKratosAdmin } from "./kratos-admin.ts"; import { createKratosPublic } from "./kratos-public.ts"; @@ -14,9 +14,9 @@ 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)); +// Session-JWT verify key: primed at boot from the configured JWKS (file mount, base64 inline, +// or fetched http), then served from cache with TTL refresh + rotation-on-miss (§4). +const jwks = await createJwksProvider(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(", ")}` : ""}`); diff --git a/todo.md b/todo.md index ac9b412..b2272cd 100644 --- a/todo.md +++ b/todo.md @@ -82,7 +82,7 @@ everything via Docker. - [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 `