E2E for token timeout + refresh (todo §4); full-stack auth-refresh.spec.ts (real Ory stack): a lapsed session JWT is silently re-minted from the live Kratos session (roles re-read from Keto), and cleared once the session is revoked; ory/kratos/e2e.yml shortens the tokenizer ttl to 8s + adds JWT_CLOCK_SKEW_SEC config so re-mint fires at expiry; scope visual suite to visual.spec.ts

This commit is contained in:
2026-06-18 11:32:23 +02:00
parent 4b2173cb84
commit b5af4ba6cd
9 changed files with 204 additions and 6 deletions

View File

@@ -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 | | `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) | | `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_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` | | `COOKIE_SECRET` / `CSRF_SECRET` | dev throwaways | enforced by `REQUIRE_SECURE_SECRETS` |
### What you must supply (the only manual prep) ### What you must supply (the only manual prep)
@@ -217,15 +218,29 @@ otherwise drags up its `depends_on` services.
### End-to-end (Playwright) ### End-to-end (Playwright)
E2E runs in the official Playwright image (browsers preinstalled) against the live `web` 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 service — no Node/browsers on the host. There are two suites:
`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). **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 ```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 run --build --rm e2e # run the suite
docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after 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/`). `--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 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) 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) 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) 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 html-css-foundation/ HTML design mockups — the source for the building-block
partials; reference the stylesheets in public/css/. partials; reference the stylesheets in public/css/.
``` ```

40
compose.e2e-auth.yml Normal file
View File

@@ -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

View File

@@ -24,6 +24,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.e2e 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: depends_on:
web: web:
condition: service_healthy condition: service_healthy

103
e2e/auth-refresh.spec.ts Normal file
View File

@@ -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<void> => 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<string> {
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<Response> {
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<string> {
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);
});

19
ory/kratos/e2e.yml Normal file
View File

@@ -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

View File

@@ -21,6 +21,7 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.ketoWriteUrl, "http://keto:4467"); assert.equal(c.ketoWriteUrl, "http://keto:4467");
assert.match(c.cookieSecret, /dev-insecure/); assert.match(c.cookieSecret, /dev-insecure/);
assert.match(c.csrfSecret, /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", () => { 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/); 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", () => { test("rejects a malformed Ory URL", () => {
assert.throws(() => loadConfig({ KETO_READ_URL: "not a url" }), /KETO_READ_URL/); assert.throws(() => loadConfig({ KETO_READ_URL: "not a url" }), /KETO_READ_URL/);
}); });

View File

@@ -14,6 +14,7 @@ export interface Config {
csrfSecret: string; csrfSecret: string;
jwksUrl: string; jwksUrl: string;
jwtAudience: string | undefined; jwtAudience: string | undefined;
jwtClockSkewSec: number;
jwtIssuer: string | undefined; jwtIssuer: string | undefined;
ketoReadUrl: string; ketoReadUrl: string;
ketoWriteUrl: string; ketoWriteUrl: string;
@@ -71,6 +72,15 @@ function readPort(env: Env): number {
return port; 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 { export function loadConfig(env: Env = process.env): Config {
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false); const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
return { 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"), 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. // Optional, off by default: pin the session-JWT issuer/audience for a hardened deploy.
jwtAudience: readOptional(env, "JWT_AUDIENCE"), 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"), jwtIssuer: readOptional(env, "JWT_ISSUER"),
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"), ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),

View File

@@ -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 await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
const server = createApp({ const server = createApp({
auth: { audience: config.jwtAudience, issuer: config.jwtIssuer }, auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
cache: config.cacheTemplates, cache: config.cacheTemplates,
csrfSecret: config.csrfSecret, csrfSecret: config.csrfSecret,
jwks, jwks,

View File

@@ -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] 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. - [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.
- [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. - [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). - [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. - [ ] 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 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. - [ ] 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.