§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:
2
todo.md
2
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 `<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.
|
||||
- [ ] JWT signing-key rotation runbook.
|
||||
- [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land.
|
||||
|
||||
Reference in New Issue
Block a user