sc-idp/test/keyRotationGate.js
2026-06-01 16:40:54 -05:00

127 lines
4.3 KiB
JavaScript

// 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;
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 }, (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] || "";
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");
const before = await jwksKids();
ok(before.length >= 1, "JWKS serves at least one signing key before rotation (" + before.length + ")");
const csrf = csrfOf((await request("GET", "/admin/idp")).body);
const rot = await request("POST", "/admin/idp/rotate-key", { body: { _csrf: csrf } });
ok(rot.status === 302, "admin rotate-key returned 302 (HTTP " + rot.status + ")");
const after = await jwksKids();
const beforeSet = new Set(before);
const afterSet = new Set(after);
const newKids = after.filter((k) => !beforeSet.has(k));
const retained = before.every((k) => afterSet.has(k));
ok(retained, "ALL pre-rotation kids remain in JWKS (rotated-out key kept as RETIRING for the grace window)");
ok(newKids.length === 1, "exactly ONE new kid appeared in JWKS after rotation (" + newKids.join(",") + ")");
ok(after.length === before.length + 1, "JWKS grew by exactly one key (" + before.length + " -> " + after.length + ")");
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();