// Key-rotation gate (MAIN :3000). Validates the now-wired signing-key rotation // lifecycle: an admin rotation mints a NEW active key while KEEPING the prior // key in JWKS (status RETIRING) so id_tokens it signed keep verifying during the // grace window. Also checks the rotate endpoint is admin-gated. // Run: node idp/test/keyRotationGate.js (MAIN up; logs in as admin@local). const http = require("http"); const PORT = 3000; const ADMIN_EMAIL = "admin@local"; const ADMIN_PW = "AdminP@ss1"; const jar = {}; let pass = 0; let fail = 0; // MAIN runs a multi-worker cluster, and idp signing keys live in per-worker // config cache (single-node SQLite => no cross-worker config propagation). So // /admin/idp and /idp/jwks can be served by DIFFERENT workers with divergent key // views, which would make rotation assertions race. Pin every request in this gate // to ONE worker by funnelling them over a single keep-alive connection. const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); const ok = (cond, msg) => { if (cond) { pass++; console.log(" PASS " + msg); } else { fail++; console.log(" FAIL " + msg); } }; const storeCookies = (headers) => { const sc = headers["set-cookie"]; if (!sc) { return; } for (const line of sc) { const pair = line.split(";")[0]; const eq = pair.indexOf("="); if (eq > 0) { jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); } } }; const request = (method, path, opts) => { const options = opts || {}; return new Promise((resolve, reject) => { const headers = Object.assign({}, options.headers || {}); if (!options.noCookies && Object.keys(jar).length > 0) { headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; "); } let data = null; if (options.body) { data = new URLSearchParams(options.body).toString(); headers["Content-Type"] = "application/x-www-form-urlencoded"; headers["Content-Length"] = Buffer.byteLength(data); } const r = http.request({ host: "localhost", port: PORT, method: method, path: path, headers: headers, agent: agent }, (resp) => { storeCookies(resp.headers); let body = ""; resp.on("data", (c) => { body += c; }); resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body })); }); r.on("error", reject); if (data !== null) { r.write(data); } r.end(); }); }; const csrfOf = (h) => (h.match(/name="_csrf" value="([^"]+)"/) || [])[1] || ""; // The admin dashboard renders the ACTIVE signing kid (adminUi.js). Parse it so the // gate can assert rotation invariants (which key is active / retained) rather than // fragile absolute JWKS counts. const activeKidFrom = (html) => (html.match(/Active signing key \(kid\)<\/th>([^<]+)<\/code>/) || [])[1] || ""; const jwksKids = async () => { const r = await request("GET", "/idp/jwks"); let keys = []; try { keys = (JSON.parse(r.body).keys) || []; } catch (e) { keys = []; } return keys.map((k) => k.kid).filter(Boolean); }; const run = async () => { // rotate endpoint must be admin-gated. const anon = await request("POST", "/admin/idp/rotate-key", { noCookies: true, body: { _csrf: "x" } }); ok(anon.status === 403 || anon.status === 302 || anon.status === 401, "unauthenticated rotate-key is rejected (HTTP " + anon.status + ")"); const lp = await request("GET", "/auth/login"); await request("POST", "/auth/login", { body: { email: ADMIN_EMAIL, password: ADMIN_PW, _csrf: csrfOf(lp.body) } }); ok(/true/.test((await request("GET", "/auth/authenticated")).body), "admin session authenticated"); // Capture the ACTIVE kid before rotating. Visiting /admin/idp also opportunistically // reaps RETIRING keys whose grace window has elapsed (adminUi.js), so the ABSOLUTE // JWKS count is not stable across runs against a long-lived instance -- assert the // rotation INVARIANTS instead of count deltas. const adminBefore = await request("GET", "/admin/idp"); const beforeActive = activeKidFrom(adminBefore.body); const before = await jwksKids(); ok(before.length >= 1, "JWKS serves at least one signing key before rotation (" + before.length + ")"); ok(!!beforeActive && before.includes(beforeActive), "pre-rotation active kid is published in JWKS (" + beforeActive + ")"); const rot = await request("POST", "/admin/idp/rotate-key", { body: { _csrf: csrfOf(adminBefore.body) } }); ok(rot.status === 302, "admin rotate-key returned 302 (HTTP " + rot.status + ")"); const after = await jwksKids(); const afterActive = activeKidFrom((await request("GET", "/admin/idp")).body); const beforeSet = new Set(before); const afterSet = new Set(after); const newKids = after.filter((k) => !beforeSet.has(k)); // Invariants that hold regardless of how many already-expired keys were reaped: // * rotation mints exactly one brand-new kid, which becomes the active key; // * the rotated-out (previously active) key is RETAINED in JWKS for its grace // window (it is freshly demoted to RETIRING, so it cannot be reaped this pass). ok(newKids.length === 1, "exactly ONE new kid appeared in JWKS after rotation (" + newKids.join(",") + ")"); ok(!!afterActive && afterActive !== beforeActive && newKids[0] === afterActive, "rotation promoted a brand-new active kid (" + beforeActive + " -> " + afterActive + ")"); ok(afterSet.has(beforeActive), "rotated-out (previously active) key RETAINED in JWKS for the grace window (" + beforeActive + ")"); console.log("\nRESULT: " + pass + " passed, " + fail + " failed"); process.exit(fail ? 1 : 0); }; const main = async () => { try { await run(); } catch (e) { console.error("KEY ROTATION GATE ERROR:", e); process.exit(2); } }; main();