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:
@@ -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/);
|
||||
});
|
||||
|
||||
90
src/jwks.ts
90
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<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;
|
||||
}
|
||||
|
||||
@@ -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(", ")}` : ""}`);
|
||||
|
||||
Reference in New Issue
Block a user