JWKS fetch + cache + rotation (todo §4); cachingJwks: TTL cache + rotation-on-miss reload (throttled, last-good on error), createJwksProvider routes file/base64/http + primes at boot

This commit is contained in:
2026-06-18 10:01:40 +02:00
parent c8b56b85eb
commit 24eb6b1c68
5 changed files with 154 additions and 18 deletions

View File

@@ -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/<id>/ → 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)

View File

@@ -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/);
});

View File

@@ -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<JsonWebKey | null>;
}
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<JsonWebKey[]> {
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<JsonWebKey[]>, opts: JwksCacheOptions = {}): JwksProvider & { prime: () => Promise<void> } {
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<void> | null = null;
const refresh = (): Promise<void> =>
(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<JwksProvider> {
if (jwksUrl.startsWith("base64://")) return staticJwks(loadJwks(jwksUrl));
const { protocol } = new URL(jwksUrl);
let load: () => Promise<JsonWebKey[]>;
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;
}

View File

@@ -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(", ")}` : ""}`);

View File

@@ -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 `<button type="submit" name=… value=…>` (posts `provider=<id>` to the same Kratos form, sharing its csrf hidden input); `href` still ⇒ `<a>`, neither ⇒ inert button. `auth.ejs` forwards `sso: { providers: flow.sso }`. Removed the mockup-only `body:not(:has(#sso-toggle:checked)) .sso{display:none}` rule from `auth.css` (`#sso-toggle` is a "remove for production" preview control in `html-css-foundation/Auth.html`) — visibility is now purely server-side. Tests-first: `flow-view.test.ts` (oidc→sso matrix + `sso:[]` when none), `auth-card.test.ts` (submit-provider markup), `app.test.ts` (live `/login` renders the SSO submit button in the form). README **Social sign-in (SSO)** updated (dropped the §4 forward-ref). typecheck + 181 units green. Boot-verified end-to-end: a real Kratos with the OIDC env emitted `{group:oidc, name:provider, value:google}``buildFlowView` derived `[{label:"Sign in with google", logo:"G", name:"provider", value:"google"}]`; clean-clone `/login` renders no `.sso` section; torn down.
- [x] Login completion: read roles from Keto → write `metadata_public` projection → tokenize → set JWT cookie. → `src/login.ts` (`completeLogin`/`readRoles`/`sessionCookie`, `SESSION_COOKIE`), wired into `app.ts` at `GET /auth/complete` — where `kratos.yml` now lands the browser after a successful login (`login.after.default_browser_return_url`). The route: `whoami(cookie)` → identity (id/email; no session ⇒ 303 `/login`); `readRoles` lists `Role:*#members@user:<id>` from Keto (one paged read, sorted/de-duped; group→role transitivity is §5); projects `{roles}` onto the identity; then `whoami(tokenize_as: plainpages)` → the signed JWT, stored as `plainpages_jwt` (HttpOnly + SameSite=Lax + 30d, `secure` deferred to §9). `server.ts` builds the kratos-admin + keto clients and passes all three to `createApp`. **Design bug caught in live boot-verify + fixed:** the projection had to move `metadata_admin``metadata_public` — Kratos *strips admin metadata* from the session the tokenizer reads, so `metadata_admin` yielded `roles:[]`; `metadata_public` is carried (and the user already reads these coarse roles in their own JWT, so nothing leaks). Touched `kratos-admin.ts` (`updateMetadataAdmin``updateMetadataPublic`, `/metadata_public` patch), the tokenizer jsonnet, and the kratos.yml/README rationale. Tests-first: `login.test.ts` (readRoles paging/dedup; completeLogin order whoami→project→tokenize; no-session⇒null; missing email⇒null; no-JWT⇒throw; cookie flags) + `app.test.ts` integration (`/auth/complete` projects roles, sets `plainpages_jwt`, 303→`/`; no session ⇒ 303 `/login`, no cookie) + `kratos.test.ts` (after-login URL + jsonnet metadata_public). Boot-verified the whole chain live: real admin login → `/auth/complete` → JWT `{sub, email, roles:["admin"], expiat=600}`, identity re-projected `metadata_public:{roles:["admin"]}` from Keto (wiped first to prove the write); no-session ⇒ 303 `/login`; torn down. The full-stack login Playwright E2E is owned by §8. typecheck + 189 units green.
- [x] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles). → `src/jwt-middleware.ts` (`authenticate`/`verifyToken`/`validateClaims`/`claimsToUser`) is the per-request hot path that never calls Ory: read the `plainpages_jwt` cookie → `decodeJws` the `kid` → resolve the verify key from the cached JWKS → `verifyJws` (§0 signature/alg-confusion guards) → validate claims → project the `User` (`sub`→id, email, roles). `src/jwks.ts` (`JwksProvider`, `loadJwks`, `staticJwks`) is the key-by-`kid` seam: `loadJwks` reads the mounted `file://` tokenizer key (dev default + prod mount) or a `base64://` inline set; `staticJwks` picks by `kid`, falling back to the sole key when a token carries none — **HTTP fetch + TTL cache + rotation-on-miss is the next §4 item (line 85)**; the interface lets it drop in without touching callers. Claim checks: `exp` required + `nbf` honoured, both with a 60s clock-skew leeway; `iss`/`aud` are **opt-in** — validated only when `JWT_ISSUER`/`JWT_AUDIENCE` are pinned (new optional `config.ts` fields), because the Kratos tokenizer sets neither (a clean clone must still verify). `authenticate` **fails closed**: any bad/expired/malformed token ⇒ `null` (anonymous), so the route renders signed-out and the §2 permission gate denies. Wired into `app.ts` — verify once per request (after the static short-circuit, before routing/hooks), thread `user` into both the base and route `RequestContext`, and feed `ctx.roles` (was `[]`) into the dashboard nav; `server.ts` loads the mounted JWKS at boot + passes the pinned iss/aud. Tests-first: `jwt-middleware.test.ts` (key-by-kid across a rotated set, exp/nbf + skew, iss/aud only-when-configured, bad-sig/unknown-kid, claimsToUser sub/email/roles, authenticate fail-closed matrix), `jwks.test.ts` (kid select/sole-key/miss + file/base64/reject-http), `config.test.ts` (iss/aud optional), `app.test.ts` (a verified cookie authorizes the gated `/demo/secret`; no-cookie/expired ⇒ 403). typecheck + 199 units + 7 E2E green; boot-smoked server.ts loading the mounted key. The live-stack token-refresh/timeout E2E is the §4 line 90 item; the full login E2E is §8.
- [ ] JWKS fetch + cache + rotation handling.
- [x] JWKS fetch + cache + rotation handling.`src/jwks.ts`: `cachingJwks(load, opts)` self-refreshing provider behind the existing `JwksProvider.getKey` seam (drop-in, callers untouched) — holds keys for `ttlMs` (5m), reloads on the next lookup past TTL, and on a `kid` miss reloads **once more** (rotation-on-miss → a freshly-prepended key verifies without a restart, README zero-downtime rotation), throttled by `minRefetchMs` (60s) so a stream of bogus kids can't hammer the source. A reload failure keeps the last-good set (transient resilience); only a cold cache propagates the error (→ middleware fails closed). Concurrent loads coalesce on one in-flight promise. `createJwksProvider(jwksUrl)` routes by scheme + primes at boot (fail loud): `base64://` → immutable `staticJwks`; `file://` → re-readable cache (rotation by remount/edit); `http(s)://` → new `fetchJwks` (Accept JSON, non-2xx throws). `server.ts` now `await createJwksProvider(config.jwksUrl)` (top-level await already present) — replaces `staticJwks(loadJwks(...))`. Tests-first (`jwks.test.ts`: TTL cache+expiry, rotation-on-miss + throttle, last-good-on-error vs cold-load-propagates, scheme routing + http prime/cache + fail-loud on non-2xx/missing-file/bad-scheme). README **Layout** line updated; the **JWT signing key & rotation** + flow-diagram cache notes already described this. typecheck + 203 units green; boot-smoked the file:// prime path. Guards/re-mint/logout/CSRF are the next §4 items.
- [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto).
- [ ] Session re-mint on TTL expiry (re-read roles from Keto).
- [ ] Logout: revoke Kratos session + clear cookie.