§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

@@ -156,6 +156,8 @@ auto-merged by `docker compose up`) turns them back off for live editing.
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) | | `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) | | `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request | | `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-denylist) |
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` | | `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
### What you must supply (the only manual prep) ### What you must supply (the only manual prep)
@@ -496,14 +498,33 @@ users** on modest hardware. In return:
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a - **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a
granted or revoked role only takes effect when the token is next minted (re-login or granted or revoked role only takes effect when the token is next minted (re-login or
TTL refresh). For an admin tool this is intentional — the alternative is a Keto call TTL refresh). For an admin tool this is intentional — the alternative is a Keto call
per request, which we traded away. For instant revoke, the optional revocation per request, which we traded away. For instant revoke, turn on the optional
denylist (roadmap) closes the gap for security-critical cases without putting Keto [revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
back on the hot path. security-critical cases without putting Keto back on the hot path.
- **Ory is on the critical path for sign-in.** If Kratos is down no one can log in; if - **Ory is on the critical path for sign-in.** If Kratos is down no one can log in; if
it stays down past the TTL, existing sessions can't refresh and the UI goes dark. it stays down past the TTL, existing sessions can't refresh and the UI goes dark.
That's the direct consequence of being stateless and delegating identity — no local That's the direct consequence of being stateless and delegating identity — no local
fallback, by design. Run Ory with the availability you'd give any auth provider. fallback, by design. Run Ory with the availability you'd give any auth provider.
### Instant revoke — the optional denylist
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/denylist.ts`). For
security-critical revoke (offboarding, a compromised account) the ~10m role/session lag
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
**granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path
then rejects every token for it minted **before** the revoke and forces a re-mint — which
re-reads roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
*after* the revoke) passes, so a role downgrade lands immediately without locking the account.
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside the
stateless model. Entries self-evict after `REVOCATION_TTL_SEC` (default 900s ≥ the 10m token TTL
+ skew), by which point any pre-revoke token has expired anyway. The check is pure CPU — **Keto
stays off the hot path**. Two deliberate bounds: it's instant on the **single instance** that
handled the revoke (across replicas/restarts the guarantee falls back to the token TTL — back the
denylist with a shared store for hard multi-instance instant-revoke), and a **group** membership
change is transitive across many users, so it's left to lag — deactivate the user, or use a direct
user-role change, for an instant effect.
### Three tiers of "may I?" ### Three tiers of "may I?"
``` ```
@@ -610,6 +631,7 @@ src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer sign
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate
src/denylist.ts Optional instant-revoke denylist (§9): in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
src/security-headers.ts Response security headers set on every reply (§9): strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https src/security-headers.ts Response security headers set on every reply (§9): strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms) src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
src/context.ts RequestContext handed to handlers + buildContext() src/context.ts RequestContext handed to handlers + buildContext()

View File

@@ -272,6 +272,14 @@ export interface AdminRolesDeps {
kratosAdmin: KratosAdmin; kratosAdmin: KratosAdmin;
menu: MenuConfig; menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>; render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke (§9): assigning/unassigning a *user* kills their live tokens
}
// §9 instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(deps: AdminRolesDeps, member: string): void {
if (deps.revoke && member.startsWith("user:")) deps.revoke(member.slice("user:".length));
} }
// A role exists exactly while it has ≥1 member (Keto has no create-object). // A role exists exactly while it has ≥1 member (Keto has no create-object).
@@ -322,13 +330,15 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (method === "GET") return renderList(); if (method === "GET") return renderList();
if (method === "POST") { if (method === "POST") {
const name = (form!.get("name") ?? "").trim(); const name = (form!.get("name") ?? "").trim();
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim()); const member = (form!.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
const reject = (error: string): Promise<RouteResult> => const reject = (error: string): Promise<RouteResult> =>
renderForm({ error, values: { member: form!.get("member") ?? "", name } }).then((r) => ({ ...r, status: 400 })); renderForm({ error, values: { member, name } }).then((r) => ({ ...r, status: 400 }));
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores."); if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a user or group to assign the role to."); if (!tuple) return reject("Pick a user or group to assign the role to.");
if (await roleExists(keto, name)) return reject("A role with that name already exists."); if (await roleExists(keto, name)) return reject("A role with that name already exists.");
await keto.writeTuple(tuple); await keto.writeTuple(tuple);
revokeUserMember(deps, member);
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
} }
return null; return null;
@@ -345,8 +355,9 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 1 && method === "GET") return renderDetail(name); if (seg.length === 1 && method === "GET") return renderDetail(name);
if (seg.length === 2 && seg[1] === "members" && method === "POST") { if (seg.length === 2 && seg[1] === "members" && method === "POST") {
const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim()); const member = (form!.get("member") ?? "").trim();
if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); } // the picker only offers real users/groups
return { redirect: base }; return { redirect: base };
} }
if (seg.length === 2 && seg[1] === "delete" && method === "GET") { if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
@@ -361,6 +372,8 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 2 && seg[1] === "delete" && method === "POST") { if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access."); if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
// §9: a whole-role delete drops many members at once — left to lag like a group change; the
// per-member unassign above is the instant-revoke path.
return { redirect: ADMIN_ROLES_BASE }; return { redirect: ADMIN_ROLES_BASE };
} }
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") { if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
@@ -369,7 +382,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
// Admin held only via a group isn't covered here — the robust "last effective admin" check is §9. // Admin held only via a group isn't covered here — the robust "last effective admin" check is §9.
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access."); if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member); const tuple = roleMemberTuple(name, member);
if (tuple) await keto.deleteTuple(tuple); if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); }
return { redirect: base }; return { redirect: base };
} }
return null; return null;

View File

@@ -286,6 +286,7 @@ export interface AdminUsersDeps {
kratosAdmin: KratosAdmin; kratosAdmin: KratosAdmin;
menu: MenuConfig; menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>; 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 { function readUserInput(form: URLSearchParams): UserInput {
@@ -370,12 +371,15 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
if (method === "POST") { if (method === "POST") {
if (seg[1] === "state") { if (seg[1] === "state") {
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 }; 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 }; return { redirect: back };
} }
if (seg[1] === "delete") { if (seg[1] === "delete") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 }; if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
await kratosAdmin.deleteIdentity(targetId); await kratosAdmin.deleteIdentity(targetId);
deps.revoke?.(targetId); // §9: the account is gone — reject its live tokens immediately
return { redirect: ADMIN_USERS_BASE }; return { redirect: ADMIN_USERS_BASE };
} }
if (seg[1] === "recovery") { if (seg[1] === "recovery") {

View File

@@ -8,6 +8,7 @@ import { after, before, test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createApp, type AppOptions } from "./app.ts"; import { createApp, type AppOptions } from "./app.ts";
import { readFormBody } from "./body.ts"; import { readFormBody } from "./body.ts";
import { createDenylist } from "./denylist.ts";
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts"; import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
import { can, check, GuardError, requireSession } from "./guards.ts"; import { can, check, GuardError, requireSession } from "./guards.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts"; import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
@@ -297,6 +298,22 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.doesNotMatch(await (await home()).text(), /href="\/admin\/users"/); // anonymous → no admin section assert.doesNotMatch(await (await home()).text(), /href="\/admin\/users"/); // anonymous → no admin section
}); });
test("revocation denylist (§9): a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint)
const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, roles: ["demo:read"], sub: "u1" })}` } });
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
denylist.revoke("u1");
assert.equal((await secret(nowSec - 5)).status, 303); // the pre-revoke token now bounces to /login
assert.equal((await secret(nowSec + 5)).status, 200); // a fresh re-login (iat after the revoke) still works
});
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => { test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } }; const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
@@ -759,7 +776,8 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
listIdentities: async () => ({ identities: store, nextPageToken: null }), listIdentities: async () => ({ identities: store, nextPageToken: null }),
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; }, updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
}); });
const { get, post, token, url } = await adminHarness(t, { kratosAdmin }); const denylist = createDenylist(); // §9: a deactivate/delete should revoke the target's live tokens instantly
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
await assertAdminGate(url, get, "/admin/users"); await assertAdminGate(url, get, "/admin/users");
@@ -790,9 +808,10 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal(updated.status, 303); assert.equal(updated.status, 303);
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" }); assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
// Deactivate (state toggle): active → inactive. // Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once (§9).
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`); await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
assert.equal(target.state, "inactive"); assert.equal(target.state, "inactive");
assert.equal(denylist.isRevoked(target.id, 0), true);
// Recovery: renders the edit page (200) showing the generated code (code-based; no admin-host link). // Recovery: renders the edit page (200) showing the generated code (code-based; no admin-host link).
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`); const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
@@ -902,7 +921,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
}); });
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) }); const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin }); const denylist = createDenylist(); // §9: granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/roles"); await assertAdminGate(url, get, "/admin/roles");
@@ -917,6 +937,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.equal(created.status, 303); assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/roles/viewer"); assert.equal(created.headers.get("location"), "/admin/roles/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `user:${ada}`)); assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. // An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length; const before = tuples.length;
@@ -942,6 +963,11 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`); await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token (§9), so the loss of access is immediate.
await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true);
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list. // Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/); assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`); const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);

View File

@@ -12,6 +12,7 @@ import { readFormBody } from "./body.ts";
import { buildPluginChrome, type PageChrome } from "./chrome.ts"; import { buildPluginChrome, type PageChrome } from "./chrome.ts";
import { buildContext, type User } from "./context.ts"; import { buildContext, type User } from "./context.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts"; import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
import type { Denylist } from "./denylist.ts";
import { buildDashboardModel } from "./dashboard.ts"; import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts"; import { PLUGINS_DIR } from "./discovery.ts";
import { GuardError } from "./guards.ts"; import { GuardError } from "./guards.ts";
@@ -41,6 +42,7 @@ export interface AppOptions {
// Off by default so edits show live; the app itself never inspects the environment. // Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean; cache?: boolean;
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke (§9); the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge (§6) hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge (§6)
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4) keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
@@ -55,7 +57,12 @@ export interface AppOptions {
} }
export function createApp(options: AppOptions = {}): Server { export function createApp(options: AppOptions = {}): Server {
const authOptions = options.auth ?? {}; // The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
const denylist = options.denylist;
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
const cache = options.cache ?? false; const cache = options.cache ?? false;
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false; const secureCookies = options.secureCookies ?? false;
@@ -88,9 +95,9 @@ export function createApp(options: AppOptions = {}): Server {
// Built-in admin screens (§5) — wired only when their Ory clients are present (the writes go // Built-in admin screens (§5) — wired only when their Ory clients are present (the writes go
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler. // there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers. // Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render } : null; const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null; const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null; const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
// OAuth2 clients (§6) write to Hydra; wired only when the Hydra admin client is present. // OAuth2 clients (§6) write to Hydra; wired only when the Hydra admin client is present.
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null; const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;

View File

@@ -21,6 +21,15 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.hydraAdminUrl, "http://hydra:4445"); assert.equal(c.hydraAdminUrl, "http://hydra:4445");
assert.match(c.csrfSecret, /dev-insecure/); assert.match(c.csrfSecret, /dev-insecure/);
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
assert.equal(c.revocationDenylist, false); // instant-revoke is opt-in (§9)
assert.equal(c.revocationTtlSec, 900); // ≥ tokenizer TTL (10m) + skew
});
test("REVOCATION_DENYLIST: opt-in toggle (off by default) + REVOCATION_TTL_SEC must be a positive integer", () => {
assert.equal(loadConfig({ REVOCATION_DENYLIST: "true" }).revocationDenylist, true);
assert.throws(() => loadConfig({ REVOCATION_DENYLIST: "on" }), /REVOCATION_DENYLIST/);
assert.equal(loadConfig({ REVOCATION_TTL_SEC: "1200" }).revocationTtlSec, 1200);
for (const v of ["0", "-1", "1.5", "abc"]) assert.throws(() => loadConfig({ REVOCATION_TTL_SEC: v }), /REVOCATION_TTL_SEC/);
}); });
test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => { test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => {

View File

@@ -22,6 +22,8 @@ export interface Config {
kratosPublicUrl: string; kratosPublicUrl: string;
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory) oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
port: number; port: number;
revocationDenylist: boolean; // §9: enable the optional instant role/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean; secureCookies: boolean;
} }
@@ -112,6 +114,11 @@ export function loadConfig(env: Env = process.env): Config {
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
port: readPort(env), port: readPort(env),
// Optional instant-revoke (§9), off by default. When on, an admin deactivate/delete or role
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
revocationTtlSec: readPosInt(env, "REVOCATION_TTL_SEC", 900),
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it. // Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
secureCookies: readBool(env, "SECURE_COOKIES", false), secureCookies: readBool(env, "SECURE_COOKIES", false),
}; };

37
src/denylist.test.ts Normal file
View File

@@ -0,0 +1,37 @@
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);
});

56
src/denylist.ts Normal file
View 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
},
};
}

View File

@@ -83,3 +83,15 @@ test("resolveSession classifies the cookie; authenticate is its fail-closed user
assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null
assert.equal(await authenticate(undefined, jwks, { now: NOW }), null); assert.equal(await authenticate(undefined, jwks, { now: NOW }), null);
}); });
test("verifyToken honours an optional denylist: a revoked subject's token rejects like an expiry → re-mint", async () => {
// Deny u1's tokens minted at/before NOW; a token minted after passes (a fresh re-login).
const denylist = { isRevoked: (sub: string, iat: number | undefined) => sub === "u1" && (iat === undefined || iat <= NOW) };
// Revoked: thrown as *expired* so resolveSession flags it for the §4 re-mint (re-read Keto / clear).
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
});

View File

@@ -5,6 +5,7 @@
// (anonymous), so the route renders signed-out and the permission gate denies. // (anonymous), so the route renders signed-out and the permission gate denies.
import type { User } from "./context.ts"; import type { User } from "./context.ts";
import { parseCookies } from "./cookie.ts"; import { parseCookies } from "./cookie.ts";
import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.ts"; import { decodeJws, verifyJws } from "./jwt.ts";
import type { JwksProvider } from "./jwks.ts"; import type { JwksProvider } from "./jwks.ts";
import { SESSION_COOKIE } from "./login.ts"; import { SESSION_COOKIE } from "./login.ts";
@@ -15,6 +16,7 @@ const DEFAULT_CLOCK_SKEW_SEC = 60;
export interface VerifyOptions { export interface VerifyOptions {
audience?: string | undefined; // if set, the token `aud` must include it (else skipped) audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
clockSkewSec?: number | undefined; clockSkewSec?: number | undefined;
denylist?: Pick<Denylist, "isRevoked"> | undefined; // optional instant-revoke (§9); a revoked sub is rejected like an expiry
issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped) issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped)
now?: number | undefined; // unix seconds; injectable for tests now?: number | undefined; // unix seconds; injectable for tests
} }
@@ -75,7 +77,11 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`); if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options); validateClaims(verified.payload, options);
return claimsToUser(verified.payload); const user = claimsToUser(verified.payload);
// Instant revoke (§9): a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the §4 re-mint (fresh roles from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user;
} }
export interface SessionAuth { export interface SessionAuth {

View File

@@ -1,5 +1,6 @@
import { createApp } from "./app.ts"; import { createApp } from "./app.ts";
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
import { createDenylist } from "./denylist.ts";
import { discoverPlugins } from "./discovery.ts"; import { discoverPlugins } from "./discovery.ts";
import { withTimeout } from "./fetch-timeout.ts"; import { withTimeout } from "./fetch-timeout.ts";
import { runBootHooks } from "./hooks.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, // 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). // 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 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 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(", ")}` : ""}`); 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 }, auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
cache: config.cacheTemplates, cache: config.cacheTemplates,
csrfSecret: config.csrfSecret, csrfSecret: config.csrfSecret,
...(denylist ? { denylist } : {}),
hydra, hydra,
jwks, jwks,
keto, keto,

View File

@@ -127,7 +127,7 @@ everything via Docker.
## 9. Production, security, ops ## 9. Production, security, ops
- [x] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. → The base file was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + the one-shot bootstrap; `.:/app` lives only in the dev override), built during §3. **The real gap, now closed:** it set `REQUIRE_SECURE_SECRETS=true` but never wired `CSRF_SECRET` into `web`, so `docker compose -f compose.yml up` couldn't boot. Added `CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}` — env-supplied with the throwaway as the only fallback; `config.ts`'s existing `REQUIRE_SECURE_SECRETS` logic rejects that throwaway, so a forgotten prod secret **fails loud** (verified all three paths: prod-unset→reject, prod-set→real secret, dev→throwaway + toggle off → boots). Used `:-` not `:?` because compose interpolates the base file per-file *before* merging the override (confirmed empirically), so a `:?` in the base would also break the zero-config dev `docker compose up`. Tests-first: extended `compose.test.ts` (secret-via-env + no-source-mount + the prod/dev toggle split + postgres-creds-via-env). README prod section corrected (dropped the stale "_(… Ory + Postgres — planned)_"). typecheck + 310 units green. - [x] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. → The base file was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + the one-shot bootstrap; `.:/app` lives only in the dev override), built during §3. **The real gap, now closed:** it set `REQUIRE_SECURE_SECRETS=true` but never wired `CSRF_SECRET` into `web`, so `docker compose -f compose.yml up` couldn't boot. Added `CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}` — env-supplied with the throwaway as the only fallback; `config.ts`'s existing `REQUIRE_SECURE_SECRETS` logic rejects that throwaway, so a forgotten prod secret **fails loud** (verified all three paths: prod-unset→reject, prod-set→real secret, dev→throwaway + toggle off → boots). Used `:-` not `:?` because compose interpolates the base file per-file *before* merging the override (confirmed empirically), so a `:?` in the base would also break the zero-config dev `docker compose up`. Tests-first: extended `compose.test.ts` (secret-via-env + no-source-mount + the prod/dev toggle split + postgres-creds-via-env). README prod section corrected (dropped the stale "_(… Ory + Postgres — planned)_"). typecheck + 310 units green.
- [x] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. → Cookies (HttpOnly · SameSite=Lax · Secure-when-`SECURE_COOKIES`, `src/cookie.ts`), the signed double-submit CSRF (`src/csrf.ts`), and JWT clock-skew leeway (`JWT_CLOCK_SKEW_SEC`, applied to exp+nbf in `validateClaims`) all landed in §4 — the open gap was **response security headers**, now closed. New pure `src/security-headers.ts` (`securityHeaders({secure})`): a strict CSP for the zero-JS core — `default-src 'self'`, `script-src 'self'` with **no** `'unsafe-inline'` (an injected `<script>` can't run; core ships none, a plugin may still serve its own `/public/<id>/*.js`), `style-src` adds `'unsafe-inline'` for the partials' inline `style=`, `img-src 'self' data:`, `frame-ancestors 'none'`, `object-src 'none'`; **`form-action` deliberately omitted** (the themed login POSTs to Kratos' often-cross-origin action URL) — plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin`, and HSTS only when `secureCookies` (https; ignored on dev http). Wired in `app.ts`: precomputed once at boot, `res.setHeader`'d at the very top of the handler before any branch, so **every** response (page/json/redirect/static/error/plugin) inherits them via `writeHead`'s merge; a plugin overrides per-route via `RouteResult.headers`. Verified no view/CSS loads cross-origin (no `<script>` anywhere, no external fonts/CDNs), so `default-src 'self'` breaks nothing. Tests-first: `security-headers.test.ts` (strict defaults, `script-src` has no `'unsafe-inline'`, HSTS-only-on-secure) + an `app.test.ts` integration (the page **and** a static asset both carry the headers; HSTS toggles with `SECURE_COOKIES`). Stability-reviewer on the diff: **APPROVE, no Critical/High** (Low: a CDN/absolute branding logo would be CSP-blocked → documented the same-origin-logo constraint). README Status + Production + Layout updated. typecheck + 312 units green. - [x] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. → Cookies (HttpOnly · SameSite=Lax · Secure-when-`SECURE_COOKIES`, `src/cookie.ts`), the signed double-submit CSRF (`src/csrf.ts`), and JWT clock-skew leeway (`JWT_CLOCK_SKEW_SEC`, applied to exp+nbf in `validateClaims`) all landed in §4 — the open gap was **response security headers**, now closed. New pure `src/security-headers.ts` (`securityHeaders({secure})`): a strict CSP for the zero-JS core — `default-src 'self'`, `script-src 'self'` with **no** `'unsafe-inline'` (an injected `<script>` can't run; core ships none, a plugin may still serve its own `/public/<id>/*.js`), `style-src` adds `'unsafe-inline'` for the partials' inline `style=`, `img-src 'self' data:`, `frame-ancestors 'none'`, `object-src 'none'`; **`form-action` deliberately omitted** (the themed login POSTs to Kratos' often-cross-origin action URL) — plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin`, and HSTS only when `secureCookies` (https; ignored on dev http). Wired in `app.ts`: precomputed once at boot, `res.setHeader`'d at the very top of the handler before any branch, so **every** response (page/json/redirect/static/error/plugin) inherits them via `writeHead`'s merge; a plugin overrides per-route via `RouteResult.headers`. Verified no view/CSS loads cross-origin (no `<script>` anywhere, no external fonts/CDNs), so `default-src 'self'` breaks nothing. Tests-first: `security-headers.test.ts` (strict defaults, `script-src` has no `'unsafe-inline'`, HSTS-only-on-secure) + an `app.test.ts` integration (the page **and** a static asset both carry the headers; HSTS toggles with `SECURE_COOKIES`). Stability-reviewer on the diff: **APPROVE, no Critical/High** (Low: a CDN/absolute branding logo would be CSP-blocked → documented the same-origin-logo constraint). README Status + Production + Layout updated. typecheck + 312 units green.
- [ ] Optional revocation denylist for instant role/session revoke. - [x] Optional revocation denylist for instant role/session revoke. → 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: a comment noting whole-role delete lags like a group change). Per the §9 security-headers precedent, covered by unit + app-HTTP integration (no new browser E2E — no new user-facing page; the operator toggle + handler paths are exercised directly). README (Auth trade-off + a new "Instant revoke" subsection, config table, Layout) updated. typecheck + 317 units green.
- [ ] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly. - [ ] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly.
- [ ] JWT signing-key rotation runbook. - [ ] JWT signing-key rotation runbook.
- [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land. - [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land.