Secure cookie flags + CSRF for our own POST forms (todo §4); SECURE_COOKIES toggle on session/CSRF cookies; csrf.ts signed double-submit token + body.ts form reader; logout is now a CSRF-guarded POST form

This commit is contained in:
2026-06-18 11:12:32 +02:00
parent dec55f85a6
commit 4b2173cb84
21 changed files with 241 additions and 26 deletions

View File

@@ -86,7 +86,7 @@ everything via Docker.
- [x] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto). → `src/guards.ts`: in-handler authorization (imperative counterpart to the §2 declarative route `permission` gate; the JWT was already verified once by the §4 middleware → `ctx.user`/`ctx.roles`, so these never call Ory for the coarse tiers). `requireSession(ctx)` asserts a session → returns the `User`, else throws `GuardError(401, location:/login)`; `can(ctx, role)` is the coarse zero-I/O JWT-claim predicate (anonymous ⇒ false); `check(keto, ctx, {namespace, object, relation})` is the one live Keto call (fine-grained relationship tier, README) — subject = `user:<id>`, anonymous ⇒ false fail-closed (no call). New `GuardError {status, location?}`; `app.ts`'s request catch maps it (location ⇒ 303 redirect, else render the 403 page) **before** the 500 path, so a guard thrown anywhere in handling becomes the right response, never a 500. Tests-first: `guards.test.ts` (requireSession return/throw, `can` matrix, `check` subject + fail-closed) + an `app.test.ts` HTTP integration (anonymous → `/login`, `can`/`check` pass → 200 / fail → 403). README **Building blocks** + `docs/plugin-contract.md` Routes document them (dropped the "land with §4" marker). typecheck + 207 units green. Session re-mint / logout / CSRF are the next §4 items.
- [x] Session re-mint on TTL expiry (re-read roles from Keto). → "stay signed in": the ~10m JWT lapses but the 30d Kratos session lives, so the hot path silently re-mints instead of dropping to anonymous. `jwt-middleware.ts` now classifies the cookie via `resolveSession``{user, expired}` (`TokenError.expired` set only on a lapsed-but-intact token); `authenticate` delegates to it. `login.ts` adds `remintSession` (reuses `completeLogin`: whoami → re-read roles from Keto → re-project → re-tokenize → fresh cookie + refreshed user — the one moment authz recomputes) + `clearSessionCookie` (Max-Age=0). `app.ts` hot path: only when the token is *expired* (not absent/garbage) **and** the Ory clients are wired does it re-mint, setting the cookie via `res.setHeader` so it rides whatever response follows; a dead Kratos session clears the stale cookie so later requests fall straight through to anonymous (no per-request Ory hit). Tests-first: `jwt-middleware.test.ts` (resolveSession lapsed-vs-absent/tampered matrix), `login.test.ts` (remintSession live→fresh / dead→clearing), `app.test.ts` (expired+live session → gated route runs + fresh cookie; expired+dead session → 403 + cleared cookie). typecheck + 210 units green. Live-stack token-timeout/refresh Playwright E2E is the §4 line 90 item.
- [x] Logout: revoke Kratos session + clear cookie. → `GET /logout` (`app.ts`): clears our local `plainpages_jwt` (`clearSessionCookie`, Max-Age=0) **and** revokes the Kratos session. Kratos' own cookie lives on its origin, so we can't expire it from here — instead `kratos.createLogoutFlow(cookie)` (new `KratosPublic` method, `GET /self-service/logout/browser``{logoutToken, logoutUrl}`, 401⇒null) and 303 the browser to `logoutUrl`; Kratos revokes the session, clears `plainpages_session`, and lands on `/login` (`kratos.yml` `logout.after`, already configured). No active session ⇒ just clear our cookie + 303 `/login`. Wired the inert shell "Sign out" button → `<a href="/logout">` (zero-JS, matches the menu's existing link items). Tests-first: `kratos-public.test.ts` (logout flow 200→urls / 401→null + cookie forwarded), `app.test.ts` integration (active session → Kratos logout URL + cleared JWT; no session → `/login` + cleared JWT), `shell.test.ts` (sign-out link wired). typecheck + 212 units green. Boot-verified live: admin login → `/logout` 303s to the real `…/self-service/logout?token=ory_lo_…` with `plainpages_jwt` cleared, following it revokes the session (`whoami` 200→401) and redirects to `/login`; no-session `/logout``/login`; torn down.
- [ ] Secure cookie flags; CSRF for our own POST forms.
- [x] Secure cookie flags; CSRF for our own POST forms.**Secure flag:** new explicit `SECURE_COOKIES` toggle (`config.ts`, default off — dev is http; `compose.yml` sets it `true`, `compose.override.yml`/`compose.e2e.yml` `false`), threaded through every first-party Set-Cookie (session JWT, clear, re-mint, CSRF). **CSRF:** `src/csrf.ts` — stateless **signed double-submit** token `<nonce>.<HMAC-SHA256(CSRF_SECRET, nonce)>` (node:crypto, no dep): `issueCsrfToken`/`verifyCsrfToken` (self-validating, timing-safe), `ensureCsrfToken` (reuse a genuine `plainpages_csrf` cookie, else mint — one token across tabs), `csrfCookie` (HttpOnly+Lax, secure opt-in), `verifyCsrfRequest` (cookie genuine **and** field echoes it). `src/body.ts` `readFormBody` (size-capped urlencoded reader; §5 forms reuse it). Applied to our one first-party form: **logout is now a CSRF-guarded `POST`**`shell.ejs`'s Sign-out is a `<form method=post action=/logout>` with a hidden `_csrf` (semantic win: a state change is a form, not a GET link), `app.ts` issues the token cookie on `GET /` and verifies it on `POST /logout` (bad/missing → 403, before any Kratos call); `dashboard.ts``index.ejs`→shell thread the token. Kratos' own flows keep Kratos' CSRF; the host does **not** auto-gate plugin routes (they own their body/safety per the contract). Switched the cookie-setting sites to `appendHeader` so the CSRF cookie coexists with others. Tests-first: `csrf.test.ts`/`body.test.ts` + extended `config`/`dashboard`/`shell`/`app` tests (logout POST: valid→Kratos logout + cleared JWT, no-session→/login, missing/forged→403) + an Ory-free E2E (GET / issues the cookie + matching form token; tokenless POST→403). typecheck + 217 units + 8 E2E green. Boot-verified live on the full stack: GET / double-submit token matches; admin login → `POST /logout` 303s to the real `…/self-service/logout?token=ory_lo_…` with the JWT cleared; no-session→/login; forged/missing→403; torn down.
- [ ] Make sure we have E2E tests for token timeouts and refresh (maybe by shorten the token lifetime to very low or something).
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.