§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:
2026-06-20 01:38:20 +02:00
parent 9d22c75016
commit a8a018f3e5
13 changed files with 221 additions and 17 deletions

View File

@@ -286,6 +286,7 @@ export interface AdminUsersDeps {
kratosAdmin: KratosAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke (§9): kill the target's live tokens on deactivate/delete
}
function readUserInput(form: URLSearchParams): UserInput {
@@ -370,12 +371,15 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
if (method === "POST") {
if (seg[1] === "state") {
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, identity.state === "inactive" ? "active" : "inactive"));
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState));
if (nextState === "inactive") deps.revoke?.(targetId); // §9: a deactivation takes effect now, not after the JWT TTL
return { redirect: back };
}
if (seg[1] === "delete") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
await kratosAdmin.deleteIdentity(targetId);
deps.revoke?.(targetId); // §9: the account is gone — reject its live tokens immediately
return { redirect: ADMIN_USERS_BASE };
}
if (seg[1] === "recovery") {