diff --git a/README.md b/README.md index 9644933..2d12a4f 100644 --- a/README.md +++ b/README.md @@ -502,13 +502,13 @@ src/server.ts Entry point — starts the HTTP server (reads PORT, default src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4) src/static.ts Static file serving (path-traversal protection) + routePublic(): /public// → a plugin's public/ src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK) -src/jwt-middleware.ts authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles (§4) +src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint (§4) src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload) src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, whoami, session→JWT tokenize (§4) src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4) src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4) src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4) -src/login.ts completeLogin(): /auth/complete login completion — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4) +src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4) src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation 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) diff --git a/src/app.test.ts b/src/app.test.ts index 65f0fee..08d3204 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -210,6 +210,32 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 403); }); +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); + const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" }); + const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session); + const keto = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) }); + const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`; + + // Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response. + const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] }); + await new Promise((r) => app.listen(0, r)); + t.after(() => app.close()); + const ok = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired } }); + assert.equal(ok.status, 200); + assert.equal(await ok.text(), "secret"); + assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=/); + + // Kratos session gone: no re-mint, the stale cookie is cleared, the gate denies. + const dead = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] }); + await new Promise((r) => dead.listen(0, r)); + t.after(() => dead.close()); + const denied = await fetch(`http://localhost:${(dead.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired } }); + assert.equal(denied.status, 403); + assert.match(denied.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/); +}); + test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => { const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters[0]; const guarded: Plugin = { diff --git a/src/app.ts b/src/app.ts index f8b444c..e9a73d9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,18 +2,18 @@ import { createServer, type Server, type ServerResponse } from "node:http"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as ejs from "ejs"; -import { buildContext } from "./context.ts"; +import { buildContext, type User } from "./context.ts"; import { buildDashboardModel } from "./dashboard.ts"; import { PLUGINS_DIR } from "./discovery.ts"; import { GuardError } from "./guards.ts"; import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts"; import { runRequestHooks, runResponseHooks } from "./hooks.ts"; import type { JwksProvider } from "./jwks.ts"; -import { authenticate, type VerifyOptions } from "./jwt-middleware.ts"; +import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts"; import type { KetoClient } from "./keto-client.ts"; import type { KratosAdmin } from "./kratos-admin.ts"; import { KratosError, type KratosPublic } from "./kratos-public.ts"; -import { completeLogin, sessionCookie } from "./login.ts"; +import { completeLogin, remintSession, sessionCookie } from "./login.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import type { Plugin, RouteResult } from "./plugin.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; @@ -81,7 +81,20 @@ export function createApp(options: AppOptions = {}): Server { } // Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous. - const user = jwks ? await authenticate(req.headers.cookie, jwks, authOptions) : null; + // If the token has lapsed but a live Kratos session still backs it (and we have the Ory + // clients), silently re-mint it — "stay signed in" (§4): re-read roles from Keto, re-tokenize, + // and set the fresh cookie via setHeader so it rides whatever response this request produces + // (a dead session clears the stale cookie). This is the only place the hot path touches Ory. + let user: User | null = null; + if (jwks) { + const auth = await resolveSession(req.headers.cookie, jwks, authOptions); + user = auth.user; + if (!user && auth.expired && keto && kratos && kratosAdmin) { + const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie); + user = reminted.user; + res.setHeader("set-cookie", reminted.setCookie); + } + } const ctx = buildContext(req, res, { user }); // base context (no route params yet); reused for onRequest // Plugin onRequest hooks run before routing and may short-circuit the request. diff --git a/src/jwt-middleware.test.ts b/src/jwt-middleware.test.ts index 2ca917e..4340f1d 100644 --- a/src/jwt-middleware.test.ts +++ b/src/jwt-middleware.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto"; import { test } from "node:test"; import { staticJwks } from "./jwks.ts"; -import { authenticate, claimsToUser, verifyToken } from "./jwt-middleware.ts"; +import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts"; import { SESSION_COOKIE } from "./login.ts"; const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url"); @@ -72,3 +72,17 @@ test("authenticate: a valid cookie → User; no cookie / invalid / expired → n assert.equal(await authenticate(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), null); assert.equal(await authenticate(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, exp: NOW - 999 })}`, jwks, { now: NOW }), null); }); + +test("resolveSession flags a lapsed token for re-mint, but not no-cookie / tampered tokens", async () => { + const ok = await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", valid)}`, jwks, { now: NOW }); + assert.deepEqual(ok, { expired: false, user: { email: "a@b.c", id: "u1", roles: ["admin"] } }); + + // Present but past exp → the §4 re-mint trigger. + const lapsed = await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, exp: NOW - 999 })}`, jwks, { now: NOW }); + assert.deepEqual(lapsed, { expired: true, user: null }); + + // No cookie / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip). + assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null }); + assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, user: null }); + assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "nope", valid)}`, jwks, { now: NOW }), { expired: false, user: null }); +}); diff --git a/src/jwt-middleware.ts b/src/jwt-middleware.ts index 6086f33..944bd60 100644 --- a/src/jwt-middleware.ts +++ b/src/jwt-middleware.ts @@ -20,8 +20,15 @@ export interface VerifyOptions { } // A rejected token (bad signature, expired, wrong iss/aud, malformed claims). `authenticate` -// swallows it to anonymous; a caller wanting the reason can catch it. -export class TokenError extends Error {} +// swallows it to anonymous; a caller wanting the reason can catch it. `expired` is set only for +// a lapsed-but-otherwise-intact token — the §4 re-mint trigger (see resolveSession). +export class TokenError extends Error { + expired: boolean; + constructor(message: string, expired = false) { + super(message); + this.expired = expired; + } +} function num(payload: Record, claim: string): number | undefined { const v = payload[claim]; @@ -35,7 +42,7 @@ export function validateClaims(payload: Record, options: Verify const exp = num(payload, "exp"); if (exp === undefined) throw new TokenError("token missing exp"); - if (now > exp + skew) throw new TokenError("token expired"); + if (now > exp + skew) throw new TokenError("token expired", true); const nbf = num(payload, "nbf"); if (nbf !== undefined && now < nbf - skew) throw new TokenError("token not yet valid"); @@ -71,14 +78,26 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve return claimsToUser(verified.payload); } -// The request middleware: read our session cookie, verify it → the User, or null for no -// cookie / any invalid token (fail-closed; the route then renders anonymous and gates deny). -export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise { +export interface SessionAuth { + expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate (§4) + user: User | null; +} + +// The request middleware: read our session cookie, verify it → the User (fail-closed: any +// bad/expired/missing token ⇒ null). `expired` distinguishes a lapsed-but-intact token from +// no-cookie / tampered ones, so app.ts only pays an Ory round-trip to re-mint a genuinely +// expired session, never for anonymous or garbage requests. +export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise { const token = parseCookies(cookieHeader)[SESSION_COOKIE]; - if (!token) return null; + if (!token) return { expired: false, user: null }; try { - return await verifyToken(token, jwks, options); - } catch { - return null; + return { expired: false, user: await verifyToken(token, jwks, options) }; + } catch (err) { + return { expired: err instanceof TokenError && err.expired, user: null }; } } + +// Convenience for callers that don't re-mint: just the User, or null. +export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise { + return (await resolveSession(cookieHeader, jwks, options)).user; +} diff --git a/src/login.test.ts b/src/login.test.ts index cd3f854..26fde05 100644 --- a/src/login.test.ts +++ b/src/login.test.ts @@ -6,7 +6,7 @@ import assert from "node:assert/strict"; import type { KetoClient, RelationTuple } from "./keto-client.ts"; import type { Identity, KratosAdmin } from "./kratos-admin.ts"; import type { KratosPublic, Session } from "./kratos-public.ts"; -import { completeLogin, readRoles, SESSION_COOKIE, sessionCookie } from "./login.ts"; +import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts"; const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55"; const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` }); @@ -85,6 +85,22 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/); }); +test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => { + const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; + const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session }); + const keto = ketoStub({ listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) }); + + // TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie. + const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s"); + assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] }); + assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/); + + // Kratos session also gone → clear the stale JWT so the next request falls through to anonymous. + const dead = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic: publicStub() }, undefined); + assert.equal(dead.user, null); + assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/); +}); + test("sessionCookie builds the HttpOnly/Lax JWT cookie; secure opt-in; JWT chars stay readable", () => { const jwt = "aaa.bbb-_.ccc"; assert.equal(sessionCookie(jwt), `${SESSION_COOKIE}=${jwt}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax`); diff --git a/src/login.ts b/src/login.ts index e515ace..b219ae7 100644 --- a/src/login.ts +++ b/src/login.ts @@ -6,6 +6,7 @@ // 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie // Order matters: the projection is written before tokenizing, because the claims mapper // reads only the identity, never Keto. +import type { User } from "./context.ts"; import { serializeCookie, type CookieOptions } from "./cookie.ts"; import type { KetoClient } from "./keto-client.ts"; import type { KratosAdmin } from "./kratos-admin.ts"; @@ -16,7 +17,7 @@ import type { KratosPublic } from "./kratos-public.ts"; export const SESSION_COOKIE = "plainpages_jwt"; // Mirrors kratos.yml session.lifespan (30d) so the cookie survives browser restarts; the -// JWT inside is short-lived (~10m) and re-minted by the §4 middleware on expiry. +// JWT inside is short-lived (~10m) and re-minted on expiry by the §4 hot path (remintSession). const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // The tokenizer template (kratos.yml session.whoami.tokenizer.templates.plainpages). @@ -67,9 +68,32 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined) return { email, identityId, jwt, roles }; } +export interface Reminted { + setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one + user: User | null; +} + +// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but +// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto, +// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead +// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to +// anonymous instead of re-hitting Ory on every one. +export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise { + const completed = await completeLogin(deps, cookie); + if (!completed) return { setCookie: clearSessionCookie(options), user: null }; + return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } }; +} + // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is // supplied by the caller (off in dev http; the §9 cookie hardening toggles it on for prod). export function sessionCookie(jwt: string, options: { secure?: boolean } = {}): string { const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) }; return serializeCookie(SESSION_COOKIE, jwt, opts); } + +// Expire our session cookie (Max-Age=0), with the same attributes sessionCookie sets so the +// browser deletes the right one. +export function clearSessionCookie(options: { secure?: boolean } = {}): string { + const opts: CookieOptions = { httpOnly: true, maxAge: 0, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) }; + return serializeCookie(SESSION_COOKIE, "", opts); +} diff --git a/todo.md b/todo.md index 0f92106..b20df60 100644 --- a/todo.md +++ b/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:`, 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).