Session re-mint on TTL expiry (todo §4); resolveSession flags a lapsed token, app.ts hot path re-mints via remintSession (roles re-read from Keto → fresh cookie) only when a live Kratos session backs it; a dead session clears the stale cookie
This commit is contained in:
2
todo.md
2
todo.md
@@ -84,7 +84,7 @@ everything via Docker.
|
||||
- [x] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles). → `src/jwt-middleware.ts` (`authenticate`/`verifyToken`/`validateClaims`/`claimsToUser`) is the per-request hot path that never calls Ory: read the `plainpages_jwt` cookie → `decodeJws` the `kid` → resolve the verify key from the cached JWKS → `verifyJws` (§0 signature/alg-confusion guards) → validate claims → project the `User` (`sub`→id, email, roles). `src/jwks.ts` (`JwksProvider`, `loadJwks`, `staticJwks`) is the key-by-`kid` seam: `loadJwks` reads the mounted `file://` tokenizer key (dev default + prod mount) or a `base64://` inline set; `staticJwks` picks by `kid`, falling back to the sole key when a token carries none — **HTTP fetch + TTL cache + rotation-on-miss is the next §4 item (line 85)**; the interface lets it drop in without touching callers. Claim checks: `exp` required + `nbf` honoured, both with a 60s clock-skew leeway; `iss`/`aud` are **opt-in** — validated only when `JWT_ISSUER`/`JWT_AUDIENCE` are pinned (new optional `config.ts` fields), because the Kratos tokenizer sets neither (a clean clone must still verify). `authenticate` **fails closed**: any bad/expired/malformed token ⇒ `null` (anonymous), so the route renders signed-out and the §2 permission gate denies. Wired into `app.ts` — verify once per request (after the static short-circuit, before routing/hooks), thread `user` into both the base and route `RequestContext`, and feed `ctx.roles` (was `[]`) into the dashboard nav; `server.ts` loads the mounted JWKS at boot + passes the pinned iss/aud. Tests-first: `jwt-middleware.test.ts` (key-by-kid across a rotated set, exp/nbf + skew, iss/aud only-when-configured, bad-sig/unknown-kid, claimsToUser sub/email/roles, authenticate fail-closed matrix), `jwks.test.ts` (kid select/sole-key/miss + file/base64/reject-http), `config.test.ts` (iss/aud optional), `app.test.ts` (a verified cookie authorizes the gated `/demo/secret`; no-cookie/expired ⇒ 403). typecheck + 199 units + 7 E2E green; boot-smoked server.ts loading the mounted key. The live-stack token-refresh/timeout E2E is the §4 line 90 item; the full login E2E is §8.
|
||||
- [x] JWKS fetch + cache + rotation handling. → `src/jwks.ts`: `cachingJwks(load, opts)` self-refreshing provider behind the existing `JwksProvider.getKey` seam (drop-in, callers untouched) — holds keys for `ttlMs` (5m), reloads on the next lookup past TTL, and on a `kid` miss reloads **once more** (rotation-on-miss → a freshly-prepended key verifies without a restart, README zero-downtime rotation), throttled by `minRefetchMs` (60s) so a stream of bogus kids can't hammer the source. A reload failure keeps the last-good set (transient resilience); only a cold cache propagates the error (→ middleware fails closed). Concurrent loads coalesce on one in-flight promise. `createJwksProvider(jwksUrl)` routes by scheme + primes at boot (fail loud): `base64://` → immutable `staticJwks`; `file://` → re-readable cache (rotation by remount/edit); `http(s)://` → new `fetchJwks` (Accept JSON, non-2xx throws). `server.ts` now `await createJwksProvider(config.jwksUrl)` (top-level await already present) — replaces `staticJwks(loadJwks(...))`. Tests-first (`jwks.test.ts`: TTL cache+expiry, rotation-on-miss + throttle, last-good-on-error vs cold-load-propagates, scheme routing + http prime/cache + fail-loud on non-2xx/missing-file/bad-scheme). README **Layout** line updated; the **JWT signing key & rotation** + flow-diagram cache notes already described this. typecheck + 203 units green; boot-smoked the file:// prime path. Guards/re-mint/logout/CSRF are the next §4 items.
|
||||
- [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.
|
||||
- [ ] Session re-mint on TTL expiry (re-read roles from Keto).
|
||||
- [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.
|
||||
- [ ] Logout: revoke Kratos session + clear cookie.
|
||||
- [ ] Secure cookie flags; CSRF for our own POST forms.
|
||||
- [ ] Make sure we have E2E tests for token timeouts and refresh (maybe by shorten the token lifetime to very low or something).
|
||||
|
||||
Reference in New Issue
Block a user