§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.
This commit is contained in:
56
src/denylist.ts
Normal file
56
src/denylist.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Optional revocation denylist (todo §9): instant role/session revoke without putting Keto
|
||||
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
||||
//
|
||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
|
||||
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
||||
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
||||
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
||||
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
||||
// re-reads roles from Keto, or clears a now-dead session).
|
||||
//
|
||||
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
||||
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
||||
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
|
||||
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
|
||||
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
|
||||
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
|
||||
// store for hard multi-instance instant-revoke.
|
||||
|
||||
export interface Denylist {
|
||||
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
|
||||
// after the latest revoke passes (a fresh re-login); a missing `iat` fails closed.
|
||||
isRevoked(sub: string, iat: number | undefined): boolean;
|
||||
// Record `sub` (a Kratos identity id) as revoked as of now: every token for it minted at or
|
||||
// before this instant is rejected until it would have expired anyway.
|
||||
revoke(sub: string): void;
|
||||
}
|
||||
|
||||
export interface DenylistOptions {
|
||||
now?: () => number; // unix seconds; injectable for tests
|
||||
ttlSec?: number; // entry lifetime; keep ≥ tokenizer TTL + clock skew (default 900 ≥ 10m + 60s)
|
||||
}
|
||||
|
||||
export function createDenylist(options: DenylistOptions = {}): Denylist {
|
||||
const ttl = options.ttlSec ?? 900;
|
||||
const clock = options.now ?? (() => Math.floor(Date.now() / 1000));
|
||||
const revokedAt = new Map<string, number>(); // sub → unix sec of its latest revoke
|
||||
|
||||
return {
|
||||
isRevoked(sub, iat) {
|
||||
const at = revokedAt.get(sub);
|
||||
if (at === undefined) return false;
|
||||
if (clock() - at > ttl) {
|
||||
revokedAt.delete(sub); // expired entry — any token it could match is long gone
|
||||
return false;
|
||||
}
|
||||
return iat === undefined || iat <= at; // pre-revoke token (or unknown iat) ⇒ revoked
|
||||
},
|
||||
revoke(sub) {
|
||||
const now = clock();
|
||||
// Full-scan prune (cheap, and only on a revoke — never the hot path) keeps the map bounded
|
||||
// to recently-revoked subjects.
|
||||
for (const [s, at] of revokedAt) if (now - at > ttl) revokedAt.delete(s);
|
||||
revokedAt.set(sub, now); // latest revoke wins; advances the cutoff
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user