Secure cookie flags + CSRF for our own POST forms (todo §4); SECURE_COOKIES toggle on session/CSRF cookies; csrf.ts signed double-submit token + body.ts form reader; logout is now a CSRF-guarded POST form
This commit is contained in:
@@ -145,6 +145,7 @@ auto-merged by `docker compose up`) turns them back off for live editing.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `PORT` | `3000` | web listen port |
|
| `PORT` | `3000` | web listen port |
|
||||||
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) |
|
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) |
|
||||||
|
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
|
||||||
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, the two secrets must be supplied and differ from the dev throwaways |
|
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, the two secrets must be supplied and differ from the dev throwaways |
|
||||||
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
||||||
| `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 |
|
||||||
@@ -512,6 +513,8 @@ src/login.ts completeLogin()/remintSession(): login completion + TTL re-
|
|||||||
src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
|
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/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)
|
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
|
||||||
|
src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate
|
||||||
|
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
|
||||||
src/context.ts RequestContext handed to handlers + buildContext()
|
src/context.ts RequestContext handed to handlers + buildContext()
|
||||||
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
|
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
|
||||||
src/dashboard.ts buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers)
|
src/dashboard.ts buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CACHE_TEMPLATES: "true"
|
CACHE_TEMPLATES: "true"
|
||||||
REQUIRE_SECURE_SECRETS: "false"
|
REQUIRE_SECURE_SECRETS: "false"
|
||||||
|
SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"]
|
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CACHE_TEMPLATES: "false"
|
CACHE_TEMPLATES: "false"
|
||||||
REQUIRE_SECURE_SECRETS: "false"
|
REQUIRE_SECURE_SECRETS: "false"
|
||||||
|
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CACHE_TEMPLATES: "true"
|
CACHE_TEMPLATES: "true"
|
||||||
REQUIRE_SECURE_SECRETS: "true"
|
REQUIRE_SECURE_SECRETS: "true"
|
||||||
|
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
||||||
# Wait for the services config.ts talks to (kratos + keto) + the one-shot bootstrap
|
# Wait for the services config.ts talks to (kratos + keto) + the one-shot bootstrap
|
||||||
# (admin + JWKS seed). Hydra is post-MVP (§6), not in config.ts, so web skips it.
|
# (admin + JWKS seed). Hydra is post-MVP (§6), not in config.ts, so web skips it.
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
|
|||||||
expect(offCanvas).toBe(true);
|
expect(offCanvas).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
||||||
|
const cookie = (await page.context().cookies()).find((c) => c.name === "plainpages_csrf");
|
||||||
|
expect(cookie?.value, "GET / issues a plainpages_csrf cookie").toBeTruthy();
|
||||||
|
const field = await page.locator('form[action="/logout"] input[name="_csrf"]').getAttribute("value");
|
||||||
|
expect(field).toBe(cookie!.value);
|
||||||
|
|
||||||
|
// A POST carrying the cookie but no form token is rejected before any Kratos call.
|
||||||
|
const res = await page.request.post("/logout", { form: {}, maxRedirects: 0 });
|
||||||
|
expect(res.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
||||||
const res = await page.goto("/no-such-page");
|
const res = await page.goto("/no-such-page");
|
||||||
expect(res?.status()).toBe(404);
|
expect(res?.status()).toBe(404);
|
||||||
|
|||||||
@@ -498,6 +498,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
color: var(--text); background: transparent; border: 0; cursor: pointer;
|
color: var(--text); background: transparent; border: 0; cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
.menu-item-form { display: contents; } /* form wraps the Sign-out button without changing layout */
|
||||||
.menu-item:hover { background: var(--surface-2); }
|
.menu-item:hover { background: var(--surface-2); }
|
||||||
.menu-item.danger { color: var(--neg); }
|
.menu-item.danger { color: var(--neg); }
|
||||||
.menu-item .ico { color: var(--text-faint); }
|
.menu-item .ico { color: var(--text-faint); }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
|
|||||||
import { after, before, test, type TestContext } from "node:test";
|
import { after, before, test, type TestContext } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createApp } from "./app.ts";
|
import { createApp } from "./app.ts";
|
||||||
|
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||||
import { staticJwks } from "./jwks.ts";
|
import { staticJwks } from "./jwks.ts";
|
||||||
import type { KetoClient } from "./keto-client.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
@@ -41,6 +42,13 @@ test("serves the home page: the app-shell People dashboard, filterable via the U
|
|||||||
assert.match(html, /<footer class="pager"/);
|
assert.match(html, /<footer class="pager"/);
|
||||||
assert.match(html, /Avery Kline/); // a mock person on page 1
|
assert.match(html, /Avery Kline/); // a mock person on page 1
|
||||||
|
|
||||||
|
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page (§4).
|
||||||
|
const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];
|
||||||
|
assert.ok(csrfCookie, "GET / issues a CSRF cookie");
|
||||||
|
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
|
||||||
|
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
|
||||||
|
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
|
||||||
|
|
||||||
// A search query filters server-side: a no-match query drops every row.
|
// A search query filters server-side: a no-match query drops every row.
|
||||||
const empty = await fetch(base + "/?q=zzz-no-such-person");
|
const empty = await fetch(base + "/?q=zzz-no-such-person");
|
||||||
assert.doesNotMatch(await empty.text(), /Avery Kline/);
|
assert.doesNotMatch(await empty.text(), /Avery Kline/);
|
||||||
@@ -409,25 +417,35 @@ test("login completion with no Kratos session redirects to /login and sets no co
|
|||||||
assert.equal(res.headers.get("set-cookie"), null);
|
assert.equal(res.headers.get("set-cookie"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("logout: bounces to Kratos to revoke the session and clears our JWT cookie; no session → /login", async (t) => {
|
test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clears our JWT; bad token → 403", async (t) => {
|
||||||
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
|
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
|
||||||
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie ? { logoutToken: "lt", logoutUrl } : null) };
|
// Real Kratos keys off its own session cookie (plainpages_session), not our always-present CSRF cookie.
|
||||||
const app = createApp({ kratos });
|
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie?.includes("plainpages_session") ? { logoutToken: "lt", logoutUrl } : null) };
|
||||||
|
const csrfSecret = "logout-secret";
|
||||||
|
const app = createApp({ csrfSecret, kratos });
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const token = issueCsrfToken(csrfSecret);
|
||||||
|
const post = (cookie: string, body: string) =>
|
||||||
|
fetch(url + "/logout", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie }, method: "POST", redirect: "manual" });
|
||||||
|
|
||||||
// Active session → redirect to Kratos' logout URL (it revokes + clears plainpages_session, then → /login).
|
// Valid double-submit (cookie token === form token) + active session → Kratos logout URL, JWT cleared.
|
||||||
const out = await fetch(url + "/logout", { headers: { cookie: `${SESSION_COOKIE}=x; plainpages_session=s` }, redirect: "manual" });
|
const out = await post(`${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=x; plainpages_session=s`, `_csrf=${token}`);
|
||||||
assert.equal(out.status, 303);
|
assert.equal(out.status, 303);
|
||||||
assert.equal(out.headers.get("location"), logoutUrl);
|
assert.equal(out.headers.get("location"), logoutUrl);
|
||||||
assert.match(out.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
|
assert.match(out.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
||||||
|
|
||||||
// No active Kratos session → clear our cookie and land on /login ourselves.
|
// No active Kratos session → clear our cookie and land on /login ourselves.
|
||||||
const none = await fetch(url + "/logout", { redirect: "manual" });
|
const none = await post(`${CSRF_COOKIE}=${token}`, `_csrf=${token}`);
|
||||||
assert.equal(none.status, 303);
|
assert.equal(none.status, 303);
|
||||||
assert.equal(none.headers.get("location"), "/login");
|
assert.equal(none.headers.get("location"), "/login");
|
||||||
assert.match(none.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
|
assert.match(none.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
||||||
|
|
||||||
|
// Missing field and a forged token are both refused (no Kratos call, no cookie cleared).
|
||||||
|
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "")).status, 403);
|
||||||
|
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "_csrf=forged.sig")).status, 403);
|
||||||
|
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||||
|
|||||||
43
src/app.ts
43
src/app.ts
@@ -1,8 +1,11 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { createServer, type Server, type ServerResponse } from "node:http";
|
import { createServer, type Server, type ServerResponse } from "node:http";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import * as ejs from "ejs";
|
||||||
|
import { readFormBody } from "./body.ts";
|
||||||
import { buildContext, type User } from "./context.ts";
|
import { buildContext, type User } from "./context.ts";
|
||||||
|
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
|
||||||
import { buildDashboardModel } from "./dashboard.ts";
|
import { buildDashboardModel } from "./dashboard.ts";
|
||||||
import { PLUGINS_DIR } from "./discovery.ts";
|
import { PLUGINS_DIR } from "./discovery.ts";
|
||||||
import { GuardError } from "./guards.ts";
|
import { GuardError } from "./guards.ts";
|
||||||
@@ -27,6 +30,7 @@ export interface AppOptions {
|
|||||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||||
// Off by default so edits show live; the app itself never inspects the environment.
|
// Off by default so edits show live; the app itself never inspects the environment.
|
||||||
cache?: boolean;
|
cache?: boolean;
|
||||||
|
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
|
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
|
||||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
||||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
|
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
|
||||||
@@ -35,12 +39,15 @@ export interface AppOptions {
|
|||||||
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
|
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
|
||||||
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||||
publicDir?: string;
|
publicDir?: string;
|
||||||
|
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
|
||||||
viewsDir?: string;
|
viewsDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(options: AppOptions = {}): Server {
|
export function createApp(options: AppOptions = {}): Server {
|
||||||
const authOptions = options.auth ?? {};
|
const authOptions = options.auth ?? {};
|
||||||
const cache = options.cache ?? false;
|
const cache = options.cache ?? false;
|
||||||
|
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
||||||
|
const secureCookies = options.secureCookies ?? false;
|
||||||
const jwks = options.jwks;
|
const jwks = options.jwks;
|
||||||
const keto = options.keto;
|
const keto = options.keto;
|
||||||
const kratos = options.kratos;
|
const kratos = options.kratos;
|
||||||
@@ -90,11 +97,14 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
||||||
user = auth.user;
|
user = auth.user;
|
||||||
if (!user && auth.expired && keto && kratos && kratosAdmin) {
|
if (!user && auth.expired && keto && kratos && kratosAdmin) {
|
||||||
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
|
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
|
||||||
user = reminted.user;
|
user = reminted.user;
|
||||||
res.setHeader("set-cookie", reminted.setCookie);
|
res.appendHeader("set-cookie", reminted.setCookie);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
|
||||||
|
// one (the form page below Set-Cookies it). Verified on our own state-changing routes (§4).
|
||||||
|
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
||||||
const ctx = buildContext(req, res, { user }); // base context (no route params yet); reused for onRequest
|
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.
|
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||||
@@ -128,7 +138,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
if (!flowId) {
|
if (!flowId) {
|
||||||
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
||||||
const { flow, setCookie } = await kratos.initBrowserFlow(flowType, cookie ? { cookie } : {});
|
const { flow, setCookie } = await kratos.initBrowserFlow(flowType, cookie ? { cookie } : {});
|
||||||
res.writeHead(303, { location: `${pathname}?flow=${flow.id}`, ...(setCookie.length ? { "set-cookie": setCookie } : {}) }).end();
|
if (setCookie.length) res.appendHeader("set-cookie", setCookie);
|
||||||
|
res.writeHead(303, { location: `${pathname}?flow=${flow.id}` }).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -152,24 +163,32 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
res.writeHead(303, { location: "/login" }).end();
|
res.writeHead(303, { location: "/login" }).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// secure: off in dev http; the §9 cookie hardening toggles it on for prod.
|
res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
|
||||||
res.writeHead(303, { location: "/", "set-cookie": sessionCookie(completed.jwt) }).end();
|
res.writeHead(303, { location: "/" }).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout: clear our local JWT and revoke the Kratos session. Kratos' own cookie lives on
|
// Logout: a state change, so a CSRF-guarded POST (the shell submits a form, not a GET link).
|
||||||
// its origin, so we can't clear it here — redirect the browser to Kratos' logout URL (it
|
// Clear our local JWT and revoke the Kratos session — Kratos' own cookie lives on its origin,
|
||||||
// revokes the session, clears plainpages_session, then lands on /login per kratos.yml).
|
// so redirect to its logout URL (it revokes the session, clears plainpages_session, then lands
|
||||||
// No active session ⇒ just clear our cookie and go to /login.
|
// on /login per kratos.yml). No active session ⇒ just clear our cookie and go to /login.
|
||||||
if (pathname === "/logout" && (method === "GET" || method === "HEAD") && kratos) {
|
if (pathname === "/logout" && method === "POST" && kratos) {
|
||||||
|
const form = await readFormBody(req);
|
||||||
|
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
|
||||||
|
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const flow = await kratos.createLogoutFlow(req.headers.cookie ? { cookie: req.headers.cookie } : {});
|
const flow = await kratos.createLogoutFlow(req.headers.cookie ? { cookie: req.headers.cookie } : {});
|
||||||
res.writeHead(303, { location: flow?.logoutUrl ?? "/login", "set-cookie": clearSessionCookie() }).end();
|
res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
|
||||||
|
res.writeHead(303, { location: flow?.logoutUrl ?? "/login" }).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||||
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts.
|
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts.
|
||||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu) }));
|
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||||
|
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||||
|
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token) }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
src/body.test.ts
Normal file
17
src/body.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { readFormBody } from "./body.ts";
|
||||||
|
|
||||||
|
const reqOf = (body: string): IncomingMessage => Readable.from([Buffer.from(body)]) as unknown as IncomingMessage;
|
||||||
|
|
||||||
|
test("readFormBody parses urlencoded fields, handles an empty body, and caps the size", async () => {
|
||||||
|
const form = await readFormBody(reqOf("_csrf=abc.def&name=Sam+Rivers"));
|
||||||
|
assert.equal(form.get("_csrf"), "abc.def");
|
||||||
|
assert.equal(form.get("name"), "Sam Rivers");
|
||||||
|
|
||||||
|
assert.equal([...(await readFormBody(reqOf("")))].length, 0); // empty body ⇒ no fields, no throw
|
||||||
|
|
||||||
|
await assert.rejects(() => readFormBody(reqOf("x".repeat(50)), { limit: 10 }), /limit/);
|
||||||
|
});
|
||||||
19
src/body.ts
Normal file
19
src/body.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Read an application/x-www-form-urlencoded request body (todo §4). Our own POST forms are
|
||||||
|
// tiny, so cap the size and reject anything larger rather than buffer unbounded. Consumes the
|
||||||
|
// stream once; never throws on an empty body. The CSRF gate + §5 admin forms read fields here.
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 1024 * 1024; // 1 MiB
|
||||||
|
|
||||||
|
export async function readFormBody(req: IncomingMessage, options: { limit?: number } = {}): Promise<URLSearchParams> {
|
||||||
|
const limit = options.limit ?? DEFAULT_LIMIT;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of req) {
|
||||||
|
const buf = chunk as Buffer;
|
||||||
|
size += buf.length;
|
||||||
|
if (size > limit) throw new Error("request body exceeds limit");
|
||||||
|
chunks.push(buf);
|
||||||
|
}
|
||||||
|
return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ test("loads dev defaults when the environment is empty", () => {
|
|||||||
const c = loadConfig({});
|
const c = loadConfig({});
|
||||||
assert.equal(c.port, 3000);
|
assert.equal(c.port, 3000);
|
||||||
assert.equal(c.cacheTemplates, false);
|
assert.equal(c.cacheTemplates, false);
|
||||||
|
assert.equal(c.secureCookies, false); // dev runs http; prod sets SECURE_COOKIES=true
|
||||||
assert.equal(c.kratosPublicUrl, "http://kratos:4433");
|
assert.equal(c.kratosPublicUrl, "http://kratos:4433");
|
||||||
assert.equal(c.kratosAdminUrl, "http://kratos:4434");
|
assert.equal(c.kratosAdminUrl, "http://kratos:4434");
|
||||||
assert.equal(c.ketoReadUrl, "http://keto:4466");
|
assert.equal(c.ketoReadUrl, "http://keto:4466");
|
||||||
@@ -43,6 +44,7 @@ test("JWT issuer/audience are optional: unset by default, pinned from the env",
|
|||||||
test("parses explicit boolean toggles and rejects non-boolean values", () => {
|
test("parses explicit boolean toggles and rejects non-boolean values", () => {
|
||||||
assert.equal(loadConfig({ CACHE_TEMPLATES: "true" }).cacheTemplates, true);
|
assert.equal(loadConfig({ CACHE_TEMPLATES: "true" }).cacheTemplates, true);
|
||||||
assert.equal(loadConfig({ CACHE_TEMPLATES: "false" }).cacheTemplates, false);
|
assert.equal(loadConfig({ CACHE_TEMPLATES: "false" }).cacheTemplates, false);
|
||||||
|
assert.equal(loadConfig({ SECURE_COOKIES: "true" }).secureCookies, true);
|
||||||
assert.throws(() => loadConfig({ CACHE_TEMPLATES: "yes" }), /CACHE_TEMPLATES/);
|
assert.throws(() => loadConfig({ CACHE_TEMPLATES: "yes" }), /CACHE_TEMPLATES/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface Config {
|
|||||||
kratosAdminUrl: string;
|
kratosAdminUrl: string;
|
||||||
kratosPublicUrl: string;
|
kratosPublicUrl: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
secureCookies: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Env = Record<string, string | undefined>;
|
type Env = Record<string, string | undefined>;
|
||||||
@@ -88,5 +89,7 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
|
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||||
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
||||||
port: readPort(env),
|
port: readPort(env),
|
||||||
|
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
|
||||||
|
secureCookies: readBool(env, "SECURE_COOKIES", false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/csrf.test.ts
Normal file
46
src/csrf.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { csrfCookie, ensureCsrfToken, issueCsrfToken, verifyCsrfRequest, verifyCsrfToken } from "./csrf.ts";
|
||||||
|
|
||||||
|
const SECRET = "test-csrf-secret";
|
||||||
|
|
||||||
|
test("issued tokens are signed: round-trip verifies; tamper/wrong-secret/garbage fail", () => {
|
||||||
|
const token = issueCsrfToken(SECRET);
|
||||||
|
assert.match(token, /^[\w-]+\.[\w-]+$/); // <nonce>.<hmac>, base64url
|
||||||
|
assert.ok(verifyCsrfToken(SECRET, token));
|
||||||
|
|
||||||
|
assert.equal(verifyCsrfToken(SECRET, token.replace(/.$/, (c) => (c === "a" ? "b" : "a"))), false); // tampered mac
|
||||||
|
assert.equal(verifyCsrfToken("other-secret", token), false);
|
||||||
|
assert.equal(verifyCsrfToken(SECRET, undefined), false);
|
||||||
|
assert.equal(verifyCsrfToken(SECRET, "nodot"), false);
|
||||||
|
assert.notEqual(issueCsrfToken(SECRET), issueCsrfToken(SECRET)); // random nonce each time
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensureCsrfToken reuses a valid cookie token, mints a fresh one when absent/invalid", () => {
|
||||||
|
const token = issueCsrfToken(SECRET);
|
||||||
|
const reused = ensureCsrfToken(`plainpages_csrf=${token}; other=x`, SECRET);
|
||||||
|
assert.deepEqual(reused, { fresh: false, token });
|
||||||
|
|
||||||
|
const minted = ensureCsrfToken(undefined, SECRET);
|
||||||
|
assert.equal(minted.fresh, true);
|
||||||
|
assert.ok(verifyCsrfToken(SECRET, minted.token));
|
||||||
|
|
||||||
|
const bad = ensureCsrfToken("plainpages_csrf=forged.value", SECRET);
|
||||||
|
assert.equal(bad.fresh, true); // a forged cookie is replaced, not trusted
|
||||||
|
});
|
||||||
|
|
||||||
|
test("csrfCookie builds the HttpOnly/Lax cookie; Secure is opt-in", () => {
|
||||||
|
assert.match(csrfCookie("tok"), /^plainpages_csrf=tok;.*HttpOnly; SameSite=Lax$/);
|
||||||
|
assert.match(csrfCookie("tok", { secure: true }), /; SameSite=Lax; Secure$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyCsrfRequest requires a genuine cookie that the submitted field echoes (double-submit)", () => {
|
||||||
|
const token = issueCsrfToken(SECRET);
|
||||||
|
const cookieHeader = `plainpages_csrf=${token}`;
|
||||||
|
assert.ok(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: token }));
|
||||||
|
|
||||||
|
assert.equal(verifyCsrfRequest({ cookieHeader: undefined, secret: SECRET, submitted: token }), false); // no cookie
|
||||||
|
assert.equal(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: null }), false); // no field
|
||||||
|
assert.equal(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: issueCsrfToken(SECRET) }), false); // field ≠ cookie
|
||||||
|
assert.equal(verifyCsrfRequest({ cookieHeader: "plainpages_csrf=forged.v", secret: SECRET, submitted: "forged.v" }), false); // matching but unsigned
|
||||||
|
});
|
||||||
58
src/csrf.ts
Normal file
58
src/csrf.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// CSRF protection for our own POST forms (todo §4). Stateless signed double-submit token:
|
||||||
|
// the token is `<nonce>.<HMAC(secret, nonce)>`, set as a cookie *and* echoed in a hidden form
|
||||||
|
// field. A request passes iff the cookie is a genuine signature (can't be forged without the
|
||||||
|
// secret) and the submitted field equals it. SameSite=Lax already blocks the cross-site POST
|
||||||
|
// from sending the cookie; the signature + double-submit defend the rest. Kratos' own flows
|
||||||
|
// carry Kratos' CSRF token — this guards only the routes we handle.
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import { parseCookies, serializeCookie } from "./cookie.ts";
|
||||||
|
|
||||||
|
export const CSRF_COOKIE = "plainpages_csrf";
|
||||||
|
export const CSRF_FIELD = "_csrf"; // hidden input name forms submit the token under
|
||||||
|
|
||||||
|
const MAX_AGE = 60 * 60 * 24 * 30; // 30d, mirrors the session cookie so the token survives restarts
|
||||||
|
const NONCE_BYTES = 18;
|
||||||
|
|
||||||
|
function sign(secret: string, nonce: string): string {
|
||||||
|
return createHmac("sha256", secret).update(nonce).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingEqual(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueCsrfToken(secret: string): string {
|
||||||
|
const nonce = randomBytes(NONCE_BYTES).toString("base64url");
|
||||||
|
return `${nonce}.${sign(secret, nonce)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True iff `token` is a `<nonce>.<hmac>` we signed (self-validating — no server state).
|
||||||
|
export function verifyCsrfToken(secret: string, token: string | null | undefined): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
const dot = token.indexOf(".");
|
||||||
|
if (dot <= 0) return false;
|
||||||
|
return timingEqual(token.slice(dot + 1), sign(secret, token.slice(0, dot)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token to embed in this request's forms: reuse a genuine cookie token, else mint one
|
||||||
|
// (`fresh` ⇒ the caller must Set-Cookie it). Reusing keeps every open tab/form on one token.
|
||||||
|
export function ensureCsrfToken(cookieHeader: string | undefined, secret: string): { fresh: boolean; token: string } {
|
||||||
|
const existing = parseCookies(cookieHeader)[CSRF_COOKIE];
|
||||||
|
if (existing && verifyCsrfToken(secret, existing)) return { fresh: false, token: existing };
|
||||||
|
return { fresh: true, token: issueCsrfToken(secret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csrfCookie(token: string, options: { secure?: boolean } = {}): string {
|
||||||
|
return serializeCookie(CSRF_COOKIE, token, { httpOnly: true, maxAge: MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate a state-changing request: the cookie must be a genuine signed token and the submitted
|
||||||
|
// field must equal it. Fail-closed on any missing/forged/mismatched part.
|
||||||
|
export function verifyCsrfRequest(args: { cookieHeader: string | undefined; secret: string; submitted: string | null | undefined }): boolean {
|
||||||
|
const cookieToken = parseCookies(args.cookieHeader)[CSRF_COOKIE];
|
||||||
|
if (!cookieToken || !args.submitted) return false;
|
||||||
|
if (!verifyCsrfToken(args.secret, cookieToken)) return false;
|
||||||
|
return timingEqual(cookieToken, args.submitted);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ test("dashboard default: page 1, mock data, nav + shell wired", () => {
|
|||||||
const m = buildDashboardModel(new URL("http://x/"));
|
const m = buildDashboardModel(new URL("http://x/"));
|
||||||
|
|
||||||
assert.equal(m.shell.title, "People");
|
assert.equal(m.shell.title, "People");
|
||||||
|
assert.equal(m.shell.csrfToken, ""); // default empty; app.ts passes the per-request token
|
||||||
|
assert.equal(buildDashboardModel(new URL("http://x/"), [], undefined, "tok.sig").shell.csrfToken, "tok.sig");
|
||||||
assert.ok(m.nav.length > 0); // composeNav produced a tree
|
assert.ok(m.nav.length > 0); // composeNav produced a tree
|
||||||
assert.equal(col0(m).label, "Name");
|
assert.equal(col0(m).label, "Name");
|
||||||
assert.equal(m.pagination.summary.total, 30); // full mock dataset
|
assert.equal(m.pagination.summary.total, 30); // full mock dataset
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function href(state: State, overrides: Partial<State> = {}): string {
|
|||||||
return qs ? `?${qs}` : "?";
|
return qs ? `?${qs}` : "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU) {
|
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "") {
|
||||||
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const status = query.filters.status?.[0] ?? "all";
|
const status = query.filters.status?.[0] ?? "all";
|
||||||
const team = query.filters.team?.[0] ?? "";
|
const team = query.filters.team?.[0] ?? "";
|
||||||
@@ -111,6 +111,7 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
|
|||||||
...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}),
|
...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}),
|
||||||
},
|
},
|
||||||
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
||||||
|
csrfToken, // hidden field for the shell's Sign-out POST form (§4)
|
||||||
...(menu.branding.theme != null ? { theme: menu.branding.theme } : {}),
|
...(menu.branding.theme != null ? { theme: menu.branding.theme } : {}),
|
||||||
title: "People",
|
title: "People",
|
||||||
user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4
|
user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ await runBootHooks(plugins); // plugin onBoot — after discovery, before listen
|
|||||||
const server = createApp({
|
const server = createApp({
|
||||||
auth: { audience: config.jwtAudience, issuer: config.jwtIssuer },
|
auth: { audience: config.jwtAudience, issuer: config.jwtIssuer },
|
||||||
cache: config.cacheTemplates,
|
cache: config.cacheTemplates,
|
||||||
|
csrfSecret: config.csrfSecret,
|
||||||
jwks,
|
jwks,
|
||||||
keto,
|
keto,
|
||||||
kratos,
|
kratos,
|
||||||
kratosAdmin,
|
kratosAdmin,
|
||||||
menu,
|
menu,
|
||||||
plugins,
|
plugins,
|
||||||
|
secureCookies: config.secureCookies,
|
||||||
}).listen(config.port, () => {
|
}).listen(config.port, () => {
|
||||||
console.log(`Listening on http://localhost:${config.port}`);
|
console.log(`Listening on http://localhost:${config.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
|
|||||||
const html = await render({
|
const html = await render({
|
||||||
title: "People",
|
title: "People",
|
||||||
brand: { name: "Acme Console", sub: "v2" },
|
brand: { name: "Acme Console", sub: "v2" },
|
||||||
|
csrfToken: "tok.sig",
|
||||||
nav: '<a id="nav-marker" href="/x">Overview</a>',
|
nav: '<a id="nav-marker" href="/x">Overview</a>',
|
||||||
body: '<section id="body-marker">page</section>',
|
body: '<section id="body-marker">page</section>',
|
||||||
actions: '<button id="action-marker">Add</button>',
|
actions: '<button id="action-marker">Add</button>',
|
||||||
@@ -30,8 +31,9 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
|
|||||||
assert.match(html, /<section id="body-marker">page<\/section>/); // content slot
|
assert.match(html, /<section id="body-marker">page<\/section>/); // content slot
|
||||||
assert.match(html, /<button id="action-marker"/); // topbar actions slot
|
assert.match(html, /<button id="action-marker"/); // topbar actions slot
|
||||||
|
|
||||||
// Sign out is wired to the logout route (the side-footer profile menu).
|
// Sign out is a CSRF-guarded POST form (state change, not a GET link), carrying the token.
|
||||||
assert.match(html, /<a class="menu-item danger" href="\/logout">/);
|
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
|
||||||
|
assert.match(html, /<input type="hidden" name="_csrf" value="tok\.sig" \/>/);
|
||||||
|
|
||||||
// Branding, document title, and the inlined icon sprite (so <use> resolves).
|
// Branding, document title, and the inlined icon sprite (so <use> resolves).
|
||||||
assert.match(html, /Acme Console/);
|
assert.match(html, /Acme Console/);
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -86,7 +86,7 @@ everything via Docker.
|
|||||||
- [x] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto). → `src/guards.ts`: in-handler authorization (imperative counterpart to the §2 declarative route `permission` gate; the JWT was already verified once by the §4 middleware → `ctx.user`/`ctx.roles`, so these never call Ory for the coarse tiers). `requireSession(ctx)` asserts a session → returns the `User`, else throws `GuardError(401, location:/login)`; `can(ctx, role)` is the coarse zero-I/O JWT-claim predicate (anonymous ⇒ false); `check(keto, ctx, {namespace, object, relation})` is the one live Keto call (fine-grained relationship tier, README) — subject = `user:<id>`, anonymous ⇒ false fail-closed (no call). New `GuardError {status, location?}`; `app.ts`'s request catch maps it (location ⇒ 303 redirect, else render the 403 page) **before** the 500 path, so a guard thrown anywhere in handling becomes the right response, never a 500. Tests-first: `guards.test.ts` (requireSession return/throw, `can` matrix, `check` subject + fail-closed) + an `app.test.ts` HTTP integration (anonymous → `/login`, `can`/`check` pass → 200 / fail → 403). README **Building blocks** + `docs/plugin-contract.md` Routes document them (dropped the "land with §4" marker). typecheck + 207 units green. Session re-mint / logout / CSRF are the next §4 items.
|
- [x] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto). → `src/guards.ts`: in-handler authorization (imperative counterpart to the §2 declarative route `permission` gate; the JWT was already verified once by the §4 middleware → `ctx.user`/`ctx.roles`, so these never call Ory for the coarse tiers). `requireSession(ctx)` asserts a session → returns the `User`, else throws `GuardError(401, location:/login)`; `can(ctx, role)` is the coarse zero-I/O JWT-claim predicate (anonymous ⇒ false); `check(keto, ctx, {namespace, object, relation})` is the one live Keto call (fine-grained relationship tier, README) — subject = `user:<id>`, anonymous ⇒ false fail-closed (no call). New `GuardError {status, location?}`; `app.ts`'s request catch maps it (location ⇒ 303 redirect, else render the 403 page) **before** the 500 path, so a guard thrown anywhere in handling becomes the right response, never a 500. Tests-first: `guards.test.ts` (requireSession return/throw, `can` matrix, `check` subject + fail-closed) + an `app.test.ts` HTTP integration (anonymous → `/login`, `can`/`check` pass → 200 / fail → 403). README **Building blocks** + `docs/plugin-contract.md` Routes document them (dropped the "land with §4" marker). typecheck + 207 units green. Session re-mint / logout / CSRF are the next §4 items.
|
||||||
- [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.
|
||||||
- [ ] Secure cookie flags; CSRF for our own POST forms.
|
- [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).
|
- [ ] Make sure we have E2E tests for token timeouts and refresh (maybe by shorten the token lifetime to very low or something).
|
||||||
- [ ] 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.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
body: filters + table + pager,
|
body: filters + table + pager,
|
||||||
brand: model.shell.brand,
|
brand: model.shell.brand,
|
||||||
breadcrumbs: model.shell.breadcrumbs,
|
breadcrumbs: model.shell.breadcrumbs,
|
||||||
|
csrfToken: model.shell.csrfToken,
|
||||||
nav,
|
nav,
|
||||||
theme: model.shell.theme,
|
theme: model.shell.theme,
|
||||||
title: model.shell.title,
|
title: model.shell.title,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
|
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
|
||||||
`actions` (topbar buttons), `body` (page content). Text locals: `title`, `brand`
|
`actions` (topbar buttons), `body` (page content). Text locals: `title`, `brand`
|
||||||
({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the
|
({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the
|
||||||
theme-switch), `user`, `breadcrumbs`. Branding comes from config/menu.ts; `user` from §4 auth.
|
theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field).
|
||||||
|
Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth.
|
||||||
%><%
|
%><%
|
||||||
const title = locals.title || "Plainpages";
|
const title = locals.title || "Plainpages";
|
||||||
const brand = locals.brand || { name: "Plainpages" };
|
const brand = locals.brand || { name: "Plainpages" };
|
||||||
@@ -53,7 +54,11 @@
|
|||||||
<div class="menu-pop left up" style="min-width:220px">
|
<div class="menu-pop left up" style="min-width:220px">
|
||||||
<div class="menu-head">Signed in as <%= user.name %></div>
|
<div class="menu-head">Signed in as <%= user.name %></div>
|
||||||
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg>Profile</button>
|
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg>Profile</button>
|
||||||
<a class="menu-item danger" href="/logout"><svg class="ico"><use href="#i-logout" /></svg>Sign out</a>
|
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
|
||||||
|
<form class="menu-item-form" method="post" action="/logout">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
|
||||||
|
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg>Sign out</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user