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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user