§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:
@@ -1,5 +1,6 @@
|
||||
import { createApp } from "./app.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { createDenylist } from "./denylist.ts";
|
||||
import { discoverPlugins } from "./discovery.ts";
|
||||
import { withTimeout } from "./fetch-timeout.ts";
|
||||
import { runBootHooks } from "./hooks.ts";
|
||||
@@ -23,6 +24,9 @@ const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl, fetchImpl: oryFe
|
||||
// Session-JWT verify key: primed at boot from the configured JWKS (file mount, base64 inline,
|
||||
// or fetched http), then served from cache with TTL refresh + rotation-on-miss (§4).
|
||||
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
|
||||
// Optional instant-revoke (§9), off unless REVOCATION_DENYLIST=true: an in-memory denylist the
|
||||
// hot path consults and the admin screens populate on deactivate/delete/role-change.
|
||||
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
|
||||
|
||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
||||
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
|
||||
@@ -32,6 +36,7 @@ const server = createApp({
|
||||
auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
|
||||
cache: config.cacheTemplates,
|
||||
csrfSecret: config.csrfSecret,
|
||||
...(denylist ? { denylist } : {}),
|
||||
hydra,
|
||||
jwks,
|
||||
keto,
|
||||
|
||||
Reference in New Issue
Block a user