From b5af4ba6cd52900a3d9b57da16e6b0f7642dc030 Mon Sep 17 00:00:00 2001 From: lilleman Date: Thu, 18 Jun 2026 11:32:23 +0200 Subject: [PATCH] =?UTF-8?q?E2E=20for=20token=20timeout=20+=20refresh=20(to?= =?UTF-8?q?do=20=C2=A74);=20full-stack=20auth-refresh.spec.ts=20(real=20Or?= =?UTF-8?q?y=20stack):=20a=20lapsed=20session=20JWT=20is=20silently=20re-m?= =?UTF-8?q?inted=20from=20the=20live=20Kratos=20session=20(roles=20re-read?= =?UTF-8?q?=20from=20Keto),=20and=20cleared=20once=20the=20session=20is=20?= =?UTF-8?q?revoked;=20ory/kratos/e2e.yml=20shortens=20the=20tokenizer=20tt?= =?UTF-8?q?l=20to=208s=20+=20adds=20JWT=5FCLOCK=5FSKEW=5FSEC=20config=20so?= =?UTF-8?q?=20re-mint=20fires=20at=20expiry;=20scope=20visual=20suite=20to?= =?UTF-8?q?=20visual.spec.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++++++-- compose.e2e-auth.yml | 40 +++++++++++++++ compose.e2e.yml | 2 + e2e/auth-refresh.spec.ts | 103 +++++++++++++++++++++++++++++++++++++++ ory/kratos/e2e.yml | 19 ++++++++ src/config.test.ts | 7 +++ src/config.ts | 12 +++++ src/server.ts | 2 +- todo.md | 2 +- 9 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 compose.e2e-auth.yml create mode 100644 e2e/auth-refresh.spec.ts create mode 100644 ory/kratos/e2e.yml diff --git a/README.md b/README.md index f1a29ca..6ebcb35 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ auto-merged by `docker compose up`) turns them back off for live editing. | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write | | `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT (§4) | | `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`) | | `COOKIE_SECRET` / `CSRF_SECRET` | dev throwaways | enforced by `REQUIRE_SECURE_SECRETS` | ### What you must supply (the only manual prep) @@ -217,15 +218,29 @@ otherwise drags up its `depends_on` services. ### End-to-end (Playwright) E2E runs in the official Playwright image (browsers preinstalled) against the live `web` -service — no Node/browsers on the host. It screenshots the live pages **and** the -`html-css-foundation` mockups, then asserts the live DOM computes the **same design-system -styles** as the reference (so a styling regression fails the build, independent of the row data). +service — no Node/browsers on the host. There are two suites: + +**Visual + design system** (`visual.spec.ts`) — Ory-free (mock-data dashboard), so it stays fast. +It screenshots the live pages **and** the `html-css-foundation` mockups, then asserts the live DOM +computes the **same design-system styles** as the reference (so a styling regression fails the +build, independent of the row data). ```bash docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e # run the suite docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after ``` +**Auth — token timeout + refresh** (`auth-refresh.spec.ts`) — the full-stack counterpart: it +boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL to +8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin and proves +the §4 "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the live Kratos +session (roles re-read from Keto), and once that session is revoked the stale cookie is **cleared**. + +```bash +docker compose -f compose.yml -f compose.e2e-auth.yml run --build --rm e2e # run the suite +docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after +``` + `--build` rebuilds the runner so spec edits are always picked up (the image bakes in `e2e/`). Screenshots + an HTML report land in `e2e/artifacts/` (git-ignored). Every user-facing flow @@ -533,7 +548,7 @@ config/menu.ts Central menu override + branding (optional; defaults apply ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs) + storage init (postgres/init/init.sql: one DB per service) plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned) docs/ Reference docs (plugin-contract.md — the authoritative plugin API) -e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it) +e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint, full Ory stack); Dockerfile.e2e + compose.e2e[-auth].yml run them html-css-foundation/ HTML design mockups — the source for the building-block partials; reference the stylesheets in public/css/. ``` diff --git a/compose.e2e-auth.yml b/compose.e2e-auth.yml new file mode 100644 index 0000000..6112141 --- /dev/null +++ b/compose.e2e-auth.yml @@ -0,0 +1,40 @@ +# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in", §4). The Ory-free +# visual suite (compose.e2e.yml) covers the design system; this is its full-stack counterpart: +# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml) +# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m. +# docker compose -f compose.yml -f compose.e2e-auth.yml run --build --rm e2e +# docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after +services: + web: + # Dev throwaways are fine for the test stack; the runner hits web over http; treat the JWT as + # expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly. + environment: + CACHE_TEMPLATES: "true" + JWT_CLOCK_SKEW_SEC: "0" + REQUIRE_SECURE_SECRETS: "false" + SECURE_COOKIES: "false" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] + interval: 2s + timeout: 4s + retries: 30 + + # Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml), + # merged after the base config. + kratos: + command: serve -c /etc/config/kratos/kratos.yml -c /etc/config/kratos/e2e.yml --watch-courier + + e2e: + build: + context: . + dockerfile: Dockerfile.e2e + depends_on: + web: + condition: service_healthy + environment: + BASE_URL: http://web:3000 + KRATOS_ADMIN_URL: http://kratos:4434 + KRATOS_PUBLIC_URL: http://kratos:4433 + command: ["npx", "playwright", "test", "auth-refresh.spec.ts"] + volumes: + - ./e2e/artifacts:/e2e/artifacts diff --git a/compose.e2e.yml b/compose.e2e.yml index adcc430..37db043 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -24,6 +24,8 @@ services: build: context: . dockerfile: Dockerfile.e2e + # Just the Ory-free visual suite; the full-stack auth spec runs via compose.e2e-auth.yml. + command: ["npx", "playwright", "test", "visual.spec.ts"] depends_on: web: condition: service_healthy diff --git a/e2e/auth-refresh.spec.ts b/e2e/auth-refresh.spec.ts new file mode 100644 index 0000000..66479a5 --- /dev/null +++ b/e2e/auth-refresh.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; + +// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in", §4). Runs against the +// real Ory stack via compose.e2e-auth.yml, where the session→JWT TTL is shortened to 8s and the +// web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from the +// still-live Kratos session. We drive the flow over HTTP (fetch, manual cookies) because Kratos +// and web sit on different hosts here; web's own server-side cookie relay is what we exercise. +// The browser-UI login is owned by §8; this proves the timeout/refresh server behaviour end-to-end. +const WEB = process.env.BASE_URL ?? "http://web:3000"; +const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433"; +const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434"; +const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3); admin role granted in Keto +const ADMIN_PASSWORD = "admin"; + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +// The full Set-Cookie line for `name`, or undefined. (cleared cookies carry Max-Age=0 + empty value.) +function setCookieLine(res: Response, name: string): string | undefined { + return res.headers.getSetCookie().find((c) => c.startsWith(`${name}=`)); +} +function cookieValue(line: string): string { + return line.split(";", 1)[0]!.slice(line.indexOf("=") + 1); +} +// Build a "name=value; …" Cookie header from a response's Set-Cookie lines (skips cleared ones). +function relayCookies(res: Response): string { + return res.headers + .getSetCookie() + .map((c) => c.split(";", 1)[0]!) + .filter((kv) => kv.split("=")[1] !== "") + .join("; "); +} +// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles). +function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } { + return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString()); +} + +// Authenticate the seeded admin via Kratos' browser login flow (JSON), return its session cookie value. +async function kratosLogin(): Promise { + const init = await fetch(`${KRATOS}/self-service/login/browser`, { headers: { accept: "application/json" } }); + expect(init.ok, `init login flow: ${init.status}`).toBeTruthy(); + const flow = await init.json(); + const csrf = flow.ui.nodes.find((n: { attributes?: { name?: string } }) => n.attributes?.name === "csrf_token"); + const submit = await fetch(flow.ui.action, { + body: JSON.stringify({ csrf_token: csrf?.attributes?.value ?? "", identifier: ADMIN_EMAIL, method: "password", password: ADMIN_PASSWORD }), + headers: { accept: "application/json", "content-type": "application/json", cookie: relayCookies(init) }, + method: "POST", + redirect: "manual", + }); + expect(submit.status, `login submit: ${await submit.text()}`).toBe(200); + const session = setCookieLine(submit, "plainpages_session"); + expect(session, "Kratos sets the session cookie on login").toBeTruthy(); + return cookieValue(session!); +} + +// Hit web's home with the given cookies (no redirect-follow so we can read its Set-Cookie). +function hitWeb(session: string, jwt: string): Promise { + return fetch(`${WEB}/`, { headers: { cookie: `plainpages_session=${session}; plainpages_jwt=${jwt}` }, redirect: "manual" }); +} + +// Poll web until it (re-)sets plainpages_jwt — i.e. the token has lapsed and the hot path acted: +// a live session re-mints a fresh token; a dead one clears the cookie. +async function awaitJwtSetCookie(session: string, jwt: string): Promise { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const line = setCookieLine(await hitWeb(session, jwt), "plainpages_jwt"); + if (line) return line; + await sleep(1000); + } + throw new Error("timed out waiting for web to act on the expired token"); +} + +test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => { + test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips + + // 1. Log in for real, then complete login on web → our session JWT (roles read from Keto). + const session = await kratosLogin(); + const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" }); + expect(complete.status, "auth/complete redirects home").toBe(303); + const jwt1Line = setCookieLine(complete, "plainpages_jwt"); + expect(jwt1Line, "auth/complete sets our session JWT").toBeTruthy(); + const jwt1 = cookieValue(jwt1Line!); + + const claims1 = jwtClaims(jwt1); + expect(claims1.email).toBe(ADMIN_EMAIL); + expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy(); + expect(claims1.roles, "roles are projected from Keto").toContain("admin"); + + // 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT. + const jwt2Line = await awaitJwtSetCookie(session, jwt1); + const jwt2 = cookieValue(jwt2Line!); + expect(jwt2, "a different token was minted").not.toBe(jwt1); + const claims2 = jwtClaims(jwt2); + expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp); + expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin"); + + // 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared. + const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" }); + expect([204, 404]).toContain(revoke.status); + + const clearedLine = await awaitJwtSetCookie(session, jwt2); + expect(cookieValue(clearedLine), "the stale JWT cookie is emptied").toBe(""); + expect(clearedLine, "and expired (Max-Age=0)").toMatch(/Max-Age=0/i); +}); diff --git a/ory/kratos/e2e.yml b/ory/kratos/e2e.yml new file mode 100644 index 0000000..1395d9e --- /dev/null +++ b/ory/kratos/e2e.yml @@ -0,0 +1,19 @@ +# E2E overlay (compose.e2e-auth.yml) — merged after kratos.yml via a second `-c`. Two changes +# that let the auth-refresh suite exercise token timeout + re-mint in seconds: +# 1. A very short session→JWT tokenizer TTL, so the JWT lapses while the Kratos session lives. +# 2. A public base_url on the compose-network hostname, so the Playwright runner can drive the +# self-service flow over `kratos:4433` (the default 127.0.0.1 base_url only works host-side). +# The full template is repeated (not just `ttl`) so it stays valid regardless of merge semantics. +serve: + public: + base_url: http://kratos:4433/ + +session: + whoami: + tokenizer: + templates: + plainpages: + ttl: 8s + subject_source: id + claims_mapper_url: file:///etc/config/kratos/tokenizer/plainpages.jsonnet + jwks_url: file:///etc/config/kratos/tokenizer/jwks.json diff --git a/src/config.test.ts b/src/config.test.ts index dd9ff1b..9d53277 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -21,6 +21,7 @@ test("loads dev defaults when the environment is empty", () => { assert.equal(c.ketoWriteUrl, "http://keto:4467"); assert.match(c.cookieSecret, /dev-insecure/); assert.match(c.csrfSecret, /dev-insecure/); + assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift }); test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => { @@ -59,6 +60,12 @@ test("rejects an invalid PORT", () => { for (const PORT of ["0", "70000", "abc", "3000.5"]) assert.throws(() => loadConfig({ PORT }), /PORT/); }); +test("JWT_CLOCK_SKEW_SEC: parses a non-negative integer, rejects junk (E2E shortens it to 0)", () => { + assert.equal(loadConfig({ JWT_CLOCK_SKEW_SEC: "0" }).jwtClockSkewSec, 0); + assert.equal(loadConfig({ JWT_CLOCK_SKEW_SEC: "120" }).jwtClockSkewSec, 120); + for (const v of ["-1", "1.5", "abc"]) assert.throws(() => loadConfig({ JWT_CLOCK_SKEW_SEC: v }), /JWT_CLOCK_SKEW_SEC/); +}); + test("rejects a malformed Ory URL", () => { assert.throws(() => loadConfig({ KETO_READ_URL: "not a url" }), /KETO_READ_URL/); }); diff --git a/src/config.ts b/src/config.ts index 6bb5d04..aa11af1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,7 @@ export interface Config { csrfSecret: string; jwksUrl: string; jwtAudience: string | undefined; + jwtClockSkewSec: number; jwtIssuer: string | undefined; ketoReadUrl: string; ketoWriteUrl: string; @@ -71,6 +72,15 @@ function readPort(env: Env): number { return port; } +// A non-negative integer count of seconds, with a default. Used for the JWT exp/nbf leeway. +function readNonNegInt(env: Env, key: string, devDefault: number): number { + const raw = env[key]; + if (raw === undefined) return devDefault; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) throw new Error(`config: ${key} must be a non-negative integer, got "${raw}"`); + return n; +} + export function loadConfig(env: Env = process.env): Config { const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false); return { @@ -83,6 +93,8 @@ export function loadConfig(env: Env = process.env): Config { jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"), // Optional, off by default: pin the session-JWT issuer/audience for a hardened deploy. jwtAudience: readOptional(env, "JWT_AUDIENCE"), + // exp/nbf leeway (s) for Kratos↔web clock drift; the auth E2E sets 0 to time tokens out fast. + jwtClockSkewSec: readNonNegInt(env, "JWT_CLOCK_SKEW_SEC", 60), jwtIssuer: readOptional(env, "JWT_ISSUER"), ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), diff --git a/src/server.ts b/src/server.ts index 261dc17..fd875a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,7 +23,7 @@ console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugi await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot const server = createApp({ - auth: { audience: config.jwtAudience, issuer: config.jwtIssuer }, + auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer }, cache: config.cacheTemplates, csrfSecret: config.csrfSecret, jwks, diff --git a/todo.md b/todo.md index caf4ce4..1048d37 100644 --- a/todo.md +++ b/todo.md @@ -87,7 +87,7 @@ everything via Docker. - [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 → `` (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. - [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 `.` (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 `
` 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). +- [x] Make sure we have E2E tests for token timeouts and refresh (maybe by shorten the token lifetime to very low or something). → New full-stack Playwright suite `e2e/auth-refresh.spec.ts` (run via `compose.e2e-auth.yml`): boots the **real** Ory stack (Postgres + Kratos + Keto + bootstrap + web), logs in the seeded admin, completes login on web → session JWT, then proves the §4 "stay signed in" hot path end-to-end — once the token lapses the next request is silently **re-minted** from the live Kratos session (fresh JWT, later `exp`, roles re-read from Keto = `["admin"]`); revoking the Kratos session (admin API) then makes the next lapsed request **clear** the stale cookie (→ anonymous). To make timeout/refresh observable in seconds not ~10m: `ory/kratos/e2e.yml` (merged via a second `-c`) shortens the tokenizer `ttl` to **8s** and points `serve.public.base_url` at `kratos:4433` (so the runner drives self-service over the compose network), and a new explicit **`JWT_CLOCK_SKEW_SEC`** config (default 60, the E2E sets `0`) makes web treat the JWT as expired the instant its ttl lapses instead of +60s. The flow is driven over HTTP (fetch + manual cookie relay) because Kratos/web sit on different hosts here — it exercises web's own server-side relay; the browser-UI login stays §8. Scoped the existing visual suite to `visual.spec.ts` (stays Ory-free/fast) so the two suites don't cross-run. Tests-first for the config knob (`config.test.ts`). Verified live: auth suite green (re-mint + clear), visual suite still 8/8 green; typecheck + 218 units green; both stacks torn down. - [ ] 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. - [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.