diff --git a/README.md b/README.md index fd8d361..27d66ba 100644 --- a/README.md +++ b/README.md @@ -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_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 | +| `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` | ### 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 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 - per request, which we traded away. For instant revoke, the optional revocation - denylist (roadmap) closes the gap for security-critical cases without putting Keto - back on the hot path. + per request, which we traded away. For instant revoke, turn on the optional + [revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for + 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 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 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?" ``` @@ -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/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/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/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() diff --git a/src/admin-roles.ts b/src/admin-roles.ts index 096c4f2..5b7ead1 100644 --- a/src/admin-roles.ts +++ b/src/admin-roles.ts @@ -272,6 +272,14 @@ export interface AdminRolesDeps { kratosAdmin: KratosAdmin; menu: MenuConfig; render: (view: string, data: Record) => Promise; + 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:` member must take effect now, so revoke that +// user's live tokens (a re-mint then re-reads roles from Keto). A `group:` 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). @@ -322,13 +330,15 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d if (method === "GET") return renderList(); if (method === "POST") { 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 => - 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 (!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."); await keto.writeTuple(tuple); + revokeUserMember(deps, member); return { redirect: detailHref(name) }; } 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 === 2 && seg[1] === "members" && method === "POST") { - const tuple = roleMemberTuple(name, (form!.get("member") ?? "").trim()); - if (tuple) await keto.writeTuple(tuple); // the picker only offers real users/groups + const member = (form!.get("member") ?? "").trim(); + const tuple = roleMemberTuple(name, member); + if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); } // the picker only offers real users/groups return { redirect: base }; } 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 (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 + // §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 }; } 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. if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access."); const tuple = roleMemberTuple(name, member); - if (tuple) await keto.deleteTuple(tuple); + if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); } return { redirect: base }; } return null; diff --git a/src/admin-users.ts b/src/admin-users.ts index 619f465..dc41347 100644 --- a/src/admin-users.ts +++ b/src/admin-users.ts @@ -286,6 +286,7 @@ export interface AdminUsersDeps { kratosAdmin: KratosAdmin; menu: MenuConfig; render: (view: string, data: Record) => Promise; + 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") { diff --git a/src/app.test.ts b/src/app.test.ts index deb1436..daf0efd 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -8,6 +8,7 @@ import { after, before, test, type TestContext } from "node:test"; import { fileURLToPath } from "node:url"; import { createApp, type AppOptions } from "./app.ts"; import { readFormBody } from "./body.ts"; +import { createDenylist } from "./denylist.ts"; import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts"; import { can, check, GuardError, requireSession } from "./guards.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 }); +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((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) => { const identity: Identity = { id: "u1", traits: { email: "a@b.c" } }; 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 }), 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"); @@ -790,9 +808,10 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r assert.equal(updated.status, 303); 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}`); 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). 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 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"); @@ -917,6 +937,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces assert.equal(created.status, 303); 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.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. 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`); 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. assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/); const del = await post("/admin/roles/editor/delete", `_csrf=${token}`); diff --git a/src/app.ts b/src/app.ts index 1008127..04f1484 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import { readFormBody } from "./body.ts"; import { buildPluginChrome, type PageChrome } from "./chrome.ts"; import { buildContext, type User } from "./context.ts"; import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts"; +import type { Denylist } from "./denylist.ts"; import { buildDashboardModel } from "./dashboard.ts"; import { PLUGINS_DIR } from "./discovery.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. cache?: boolean; 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) jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4) @@ -55,7 +57,12 @@ export interface AppOptions { } 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 csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own 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 // 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. - 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 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. const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null; diff --git a/src/config.test.ts b/src/config.test.ts index eb9da10..dd7875f 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -21,6 +21,15 @@ test("loads dev defaults when the environment is empty", () => { assert.equal(c.hydraAdminUrl, "http://hydra:4445"); assert.match(c.csrfSecret, /dev-insecure/); 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", () => { diff --git a/src/config.ts b/src/config.ts index 1e532ae..c17121e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,8 @@ export interface Config { kratosPublicUrl: string; oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory) 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; } @@ -112,6 +114,11 @@ export function loadConfig(env: Env = process.env): Config { kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), 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. secureCookies: readBool(env, "SECURE_COOKIES", false), }; diff --git a/src/denylist.test.ts b/src/denylist.test.ts new file mode 100644 index 0000000..5d9c884 --- /dev/null +++ b/src/denylist.test.ts @@ -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); +}); diff --git a/src/denylist.ts b/src/denylist.ts new file mode 100644 index 0000000..c8b2dba --- /dev/null +++ b/src/denylist.ts @@ -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(); // 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 + }, + }; +} diff --git a/src/jwt-middleware.test.ts b/src/jwt-middleware.test.ts index aa0d4a0..bf1b00b 100644 --- a/src/jwt-middleware.test.ts +++ b/src/jwt-middleware.test.ts @@ -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(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 }); +}); diff --git a/src/jwt-middleware.ts b/src/jwt-middleware.ts index 944bd60..2e8ad7f 100644 --- a/src/jwt-middleware.ts +++ b/src/jwt-middleware.ts @@ -5,6 +5,7 @@ // (anonymous), so the route renders signed-out and the permission gate denies. import type { User } from "./context.ts"; import { parseCookies } from "./cookie.ts"; +import type { Denylist } from "./denylist.ts"; import { decodeJws, verifyJws } from "./jwt.ts"; import type { JwksProvider } from "./jwks.ts"; import { SESSION_COOKIE } from "./login.ts"; @@ -15,6 +16,7 @@ const DEFAULT_CLOCK_SKEW_SEC = 60; export interface VerifyOptions { audience?: string | undefined; // if set, the token `aud` must include it (else skipped) clockSkewSec?: number | undefined; + denylist?: Pick | 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) 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)"}`); const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg 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 { diff --git a/src/server.ts b/src/server.ts index 2052c7d..314a661 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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, diff --git a/todo.md b/todo.md index 8fa6e04..bbe07d2 100644 --- a/todo.md +++ b/todo.md @@ -127,7 +127,7 @@ everything via Docker. ## 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] 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 `