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:
23
README.md
23
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 |
|
| `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
40
compose.e2e-auth.yml
Normal 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
|
||||||
@@ -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
103
e2e/auth-refresh.spec.ts
Normal 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
19
ory/kratos/e2e.yml
Normal 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
|
||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
2
todo.md
2
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] 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user