Files
plainpages/src/denylist.test.ts
T
lilleman a8a018f3e5 §9 optional revocation denylist (todo §9); closes the documented ~10m role/session lag for security-critical revoke, off by default (REVOCATION_DENYLIST, zero hot-path cost + zero behaviour change when off). New pure src/denylist.ts (createDenylist({ttlSec})): an in-memory, auto-evicting Map<sub, revokedAt> — revoke(sub) records now, isRevoked(sub, iat) rejects a subject's tokens minted at/before the revoke (iat <= revokedAt; missing iat fails closed), so a fresh re-login (iat after the revoke) passes while a downgrade lands immediately. Entries self-evict after REVOCATION_TTL_SEC (default 900 ≥ the 10m tokenizer TTL + skew), so it stays a bounded cache like JWKS — no database, Keto stays off the hot path. Wired: jwt-middleware.ts takes the denylist in VerifyOptions and throws TokenError(expired) on a revoked sub, so resolveSession routes it through the existing §4 re-mint (live session → fresh post-revoke JWT with current Keto roles; dead/deactivated → cleared cookie). app.ts merges it into authOptions (the same resolveSession hot-path call) and hands a bound revoke to the Users + Roles admin deps; admin-users.ts revokes on deactivate/delete, admin-roles.ts revokes a direct user: member on assign/unassign (a group:/whole-role change is transitive → left to lag, documented). server.ts builds it only when the toggle is on. Tests-first: denylist.test.ts (iat semantics, cutoff-advance, TTL eviction), jwt-middleware.test.ts (revoked→expired→re-mint, fresh passes), config.test.ts (toggle + posint TTL), app.test.ts (hot-path reject + fresh-login pass; admin deactivate/role-assign/unassign record the revoke). Stability-reviewer on the diff: APPROVE, no Critical/High/Medium (addressed its one Low). Per the §9 security-headers precedent, covered by unit + app-HTTP integration (no new browser E2E — no new user-facing page). README (Auth trade-off + new "Instant revoke" subsection, config table, Layout) updated. typecheck + 317 units green.
2026-06-20 01:38:20 +02:00

38 lines
1.6 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { createDenylist } from "./denylist.ts";
test("createDenylist: revokes a subject's pre-revoke tokens, lets a fresh re-login through", () => {
let clock = 1000;
const dl = createDenylist({ now: () => clock, ttlSec: 600 });
// An un-revoked subject is never revoked.
assert.equal(dl.isRevoked("u1", 990), false);
// Revoke at t=1000. A token minted at/before the revoke is rejected; one minted after passes
// (a fresh re-login, whose JWT already reflects the new Keto state).
dl.revoke("u1");
assert.equal(dl.isRevoked("u1", 990), true); // before
assert.equal(dl.isRevoked("u1", 1000), true); // exactly at the revoke instant
assert.equal(dl.isRevoked("u1", 1001), false); // after → fresh token, not revoked
assert.equal(dl.isRevoked("u2", 990), false); // a different subject is unaffected
// A missing iat fails closed (better to force a re-mint than honour a maybe-revoked token).
assert.equal(dl.isRevoked("u1", undefined), true);
});
test("createDenylist: a later revoke advances the cutoff; entries self-evict after the TTL", () => {
let clock = 1000;
const dl = createDenylist({ now: () => clock, ttlSec: 600 });
dl.revoke("u1"); // cutoff = 1000
clock = 1500;
dl.revoke("u1"); // cutoff advances to 1500
assert.equal(dl.isRevoked("u1", 1400), true); // minted before the latest revoke
assert.equal(dl.isRevoked("u1", 1600), false); // minted after
// Past the TTL the entry is gone — any pre-revoke token has long since expired anyway.
clock = 1500 + 601;
assert.equal(dl.isRevoked("u1", 1400), false);
});