Merge pull request 'appurl-canonical-host' (#1) from appurl-canonical-host into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-21 22:18:46 +02:00
19 changed files with 383 additions and 23 deletions
+51 -2
View File
@@ -148,6 +148,7 @@ auto-merged by `docker compose up`) turns them back off for live editing.
| Var | Default | Notes |
| --- | --- | --- |
| `APP_URL` | _unset_ (dev: `http://localhost:3000`) | the canonical public URL — the **single source** for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see [Canonical host](#canonical-host-one-public-url)) |
| `PORT` | `3000` | web listen port |
| `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) |
@@ -168,6 +169,39 @@ auto-merged by `docker compose up`) turns them back off for live editing.
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
### Canonical host (one public URL)
A site is often reachable at several URLs that resolve to the same place — `localhost` vs
`127.0.0.1`, an apex vs `www.`, an IP vs a domain. That matters here because **cookies are
host-scoped**: the themed login form POSTs to Kratos, and Kratos' CSRF cookie is set on the host the
browser is on. Reach the app on one host but let the form post from another and that cookie is lost —
Kratos rejects the flow and bounces to its error page. (The original symptom: open the banner's
`http://localhost:3000`, sign in, land on `http://127.0.0.1:3000/error` "Page not found".)
`APP_URL` is the **single source of truth** for the public host. Set it and the web app **redirects
any off-host GET/HEAD visitor to it** (308, path + query preserved) *before* a flow starts, so the
browser, the themed forms, and the cross-origin Kratos POST all share one cookie host. Static assets
under `/public/` are served on any host (so health checks don't bounce). Everything else derives from
the same `APP_URL`: the first-run banner, and — via compose — Kratos' browser-facing URLs
(`compose.override.yml` maps `${APP_URL}` onto every `ui_url`, return URL, and `allowed_return_urls`).
Set `APP_URL` and the whole stack follows; there is no second place to edit. A genuine Kratos flow
error now renders a themed **`/error`** page (a path back to sign-in), not the catch-all 404.
The redirect is an **explicit opt-in** (per the no-`NODE_ENV` rule): **unset ⇒ no redirect**, so a
deploy that forgets `APP_URL` never bounces real users to a stale default. The clean clone still works
with zero config because the bundled Kratos and the dev stack both default to `localhost` (the dev
override sets `APP_URL=http://localhost:3000`); browse `localhost:3000` and login just works, and
`127.0.0.1` is canonicalised onto it.
> **Behind a reverse proxy:** the proxy must pass the public `Host` through (or rewrite Kratos'
> `base_url`/`ui_url`s to match what the browser sees). If it rewrites `Host` to an internal upstream
> name while `APP_URL` is the public domain, the canonical redirect will loop — preserve `Host`.
>
> **Dev caveat (custom host).** Only if you point `APP_URL` at a non-default host (e.g. a LAN IP to
> test from a tablet) must you also point the dev-published Kratos port at that host: set
> `KRATOS_PUBLIC_BROWSER_URL=http://<that-host>:4433/` (it shares `APP_URL`'s host but keeps the Ory
> port, so it can't be `APP_URL` verbatim). In production Ory is fronted same-origin, so this doesn't arise.
### What you must supply (the only manual prep)
A clean clone needs **none** of the above — `docker compose up` brings up the whole
@@ -333,6 +367,21 @@ docker compose -f compose.yml -f compose.e2e-full.yml down -v #
`--build` rebuilds the runner so spec edits are always picked up (the image bakes in `e2e/`).
**Dev-stack login regression** (`devstack-login.spec.ts`) — drives the *plain* `docker compose up`
topology (not the same-origin gateway above) with the runner on the **host network**, so the browser
sees `http://localhost:3000` (web) and `http://127.0.0.1:4433` (Kratos public) exactly as a host
browser does. It signs in the seeded admin from the URL the first-run banner advertises
(`http://localhost:3000`) **and** from the wrong host (`http://127.0.0.1:3000`), asserting both reach
the dashboard signed in — the latter via the [canonical-host redirect](#canonical-host-one-public-url).
It guards against the regression where the advertised login URL dumps the user on the `/error` "Page
not found" page; the proxied full-flow suite can't catch this (it fronts web + Kratos on one origin).
Part of `scripts/ci.sh` — it needs host networking and the host ports `3000`/`4433` free (Linux).
```bash
docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml run --build --rm e2e # run it
docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml down -v # tear down
```
Screenshots + an HTML report land in `e2e/artifacts/` (git-ignored). Every user-facing flow
is covered end-to-end; tests are independent and run **fully in parallel** for speed
([AGENTS.md](AGENTS.md) §6) — keep new tests side-effect-free so the suite stays fast.
@@ -776,14 +825,14 @@ src/guards.ts requireSession()/can()/check(): in-handler authorization (
src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order (§2); no sandbox (a throwing hook fails loud), skipped when no plugin declares one
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
views/ Core EJS templates: home (public "/" landing), index (app-shell dashboard at /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
views/ Core EJS templates: home (public "/" landing), index (app-shell dashboard at /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), error (Kratos self-service flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
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 → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the §7 reference plugin (list/form over an upstream + permission-gated nav) you copy
examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service)
docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full].yml run them
e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full|-devstack].yml run them
html-css-foundation/ HTML design mockups — the source for the building-block
partials; reference the stylesheets in public/css/.
scripts/ci.sh The full CI gate (§8): typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash scripts/ci.sh`)
+1
View File
@@ -19,6 +19,7 @@ services:
# 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:
APP_URL: http://web:3000 # the runner calls web on this host → canonical-host redirect stays inert
CACHE_TEMPLATES: "true"
JWT_CLOCK_SKEW_SEC: "0"
REQUIRE_SECURE_SECRETS: "false"
+48
View File
@@ -0,0 +1,48 @@
# Dev-stack login regression — guards the from-scratch experience the banner advertises (login from
# http://localhost:3000 works, and entering on 127.0.0.1 is canonicalised). Unlike the proxied
# full-flow suite (which fronts web + Kratos on ONE origin and so can't see this class of bug), this
# runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so
# it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host
# browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`:
# docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml down -v # tear down
services:
web:
# Pin APP_URL so the regression is deterministic regardless of any APP_URL exported in the shell
# running ci.sh (overrides the dev override's ${APP_URL:-…}); the runner's BASE_URL matches it.
environment:
APP_URL: http://localhost:3000
# Base web has no healthcheck; add one so the runner waits for a ready app (deps come via base).
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s
timeout: 4s
retries: 30
# Pin Kratos' browser URLs to localhost too (literal, not ${APP_URL}) so the whole suite is
# hermetic — the 127.0.0.1 sub-test asserts canonicalisation onto localhost, which only holds if
# web AND Kratos agree on localhost regardless of the ambient shell env.
kratos:
environment:
SERVE_PUBLIC_BASE_URL: http://localhost:4433/
SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: http://localhost:3000/
SELFSERVICE_ALLOWED_RETURN_URLS: http://localhost:3000
SELFSERVICE_FLOWS_ERROR_UI_URL: http://localhost:3000/error
SELFSERVICE_FLOWS_LOGIN_UI_URL: http://localhost:3000/login
SELFSERVICE_FLOWS_LOGIN_AFTER_DEFAULT_BROWSER_RETURN_URL: http://localhost:3000/auth/complete
e2e:
build:
context: .
dockerfile: Dockerfile.e2e
command: ["npx", "playwright", "test", "devstack-login.spec.ts"]
# Host network: reach the host-published ports (web 3000, Kratos public 4433) at the very
# hostnames a user types — localhost / 127.0.0.1 — so the cross-host CSRF-cookie split reproduces.
network_mode: "host"
depends_on:
web:
condition: service_healthy
environment:
BASE_URL: http://localhost:3000
volumes:
- ./e2e/artifacts:/e2e/artifacts
+1
View File
@@ -19,6 +19,7 @@ services:
shifts-upstream:
condition: service_healthy
environment:
APP_URL: http://proxy # the browser reaches web through the same-origin gateway → canonical-host redirect stays inert
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored
+1
View File
@@ -9,6 +9,7 @@ services:
web:
# Dev throwaways are fine for the test stack; the runner hits web over http.
environment:
APP_URL: http://web:3000 # the runner/browser reach web on this host → canonical-host redirect stays inert
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false"
+1
View File
@@ -11,6 +11,7 @@ services:
depends_on: !reset []
# Dev throwaways are fine for tests; cache templates for production-like rendering.
environment:
APP_URL: http://web:3000 # the suite reaches web on this host → canonical-host redirect stays inert
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored
+26 -2
View File
@@ -5,8 +5,14 @@ services:
command: node --watch src/server.ts
# Dev overrides the base toggles: live template edits, dev-throwaway secrets allowed.
environment:
# Canonical public URL — the ONE knob. The web app redirects off-host visitors here, so
# localhost / 127.0.0.1 / any alias all funnel to one cookie host (Kratos' browser URLs below
# derive from it too). Override for a non-default host and the stack follows; see the note on
# kratos.SERVE_PUBLIC_BASE_URL for the single dev caveat (the published Ory port).
APP_URL: ${APP_URL:-http://localhost:3000}
CACHE_TEMPLATES: "false"
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # reference plugin → the dev mock backend
@@ -33,11 +39,29 @@ services:
restart: unless-stopped
# Ory Kratos dev: expose the public API so the browser can POST self-service flows to
# flow.ui.action (kratos.yml base_url = 127.0.0.1:4433). Prod fronts Ory same-origin,
# so the base file publishes no Ory ports.
# flow.ui.action. Prod fronts Ory same-origin, so the base file publishes no Ory ports.
kratos:
ports:
- "4433:4433"
# Every browser-facing Kratos URL derives from APP_URL — one knob, no second place to edit (the
# localhost-vs-127.0.0.1 disagreement that broke login was exactly this drift). Env overrides the
# kratos.yml defaults (Ory: env wins over config files).
environment:
# The public API the login form POSTs to. Its HOST must match APP_URL's (cookies are host-scoped,
# port-agnostic) but its PORT is the published Ory one (4433), so it can't be APP_URL verbatim.
# This is the ONE dev value to also change for a non-localhost APP_URL host (e.g. a LAN IP).
SERVE_PUBLIC_BASE_URL: ${KRATOS_PUBLIC_BROWSER_URL:-http://localhost:4433/}
SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/
SELFSERVICE_ALLOWED_RETURN_URLS: ${APP_URL:-http://localhost:3000}
SELFSERVICE_FLOWS_ERROR_UI_URL: ${APP_URL:-http://localhost:3000}/error
SELFSERVICE_FLOWS_LOGIN_UI_URL: ${APP_URL:-http://localhost:3000}/login
SELFSERVICE_FLOWS_LOGIN_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/auth/complete
SELFSERVICE_FLOWS_REGISTRATION_UI_URL: ${APP_URL:-http://localhost:3000}/registration
SELFSERVICE_FLOWS_SETTINGS_UI_URL: ${APP_URL:-http://localhost:3000}/settings
SELFSERVICE_FLOWS_RECOVERY_UI_URL: ${APP_URL:-http://localhost:3000}/recovery
SELFSERVICE_FLOWS_VERIFICATION_UI_URL: ${APP_URL:-http://localhost:3000}/verification
SELFSERVICE_FLOWS_VERIFICATION_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/
SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL: ${APP_URL:-http://localhost:3000}/login
# Ory Hydra dev: --dev permits the http issuer/redirect URLs; expose the public port
# so OAuth2 flows reach the host. Prod (base file) drops --dev for an https issuer.
+5
View File
@@ -9,6 +9,11 @@ services:
# Supply CSRF_SECRET via env; the dev-throwaway fallback boots a clean clone but
# REQUIRE_SECURE_SECRETS refuses it in prod (config.ts), so a forgotten secret fails loud.
environment:
# Canonical public URL — set it to your domain to enable the canonical-host redirect (off when
# empty/unset, so a forgotten value never bounces real users). Your reverse proxy must preserve
# the public Host (or forward it) or a Host-rewriting proxy can loop. Kratos browser URLs and
# the banner derive from the same APP_URL. The dev override sets it to localhost.
APP_URL: ${APP_URL:-}
CACHE_TEMPLATES: "true"
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
// lost it and Kratos redirected to its error sink.
//
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
// browser URLs derive from it, and a real /error page replaces the 404.
//
// This is faithful to the user's environment: the runner uses the host network
// (compose.e2e-devstack.yml) against the plain `docker compose up` topology, so it sees
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3)
const ADMIN_PASSWORD = "admin";
async function signIn(page: import("@playwright/test").Page): Promise<void> {
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click();
}
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
test.setTimeout(90_000);
// Open the app at the URL the first-run banner prints, then follow its "Log in" call to action.
await page.goto("/");
await page.getByRole("link", { name: "Log in" }).click();
await signIn(page);
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
await expect(page).not.toHaveURL(/\/error(\?|$)/);
await expect(page.locator("h1"), 'must not land on the "Page not found" 404 view').not.toHaveText("Page not found");
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL);
});
test("entering on the wrong host (http://127.0.0.1:3000) is canonicalised to APP_URL and login still works", async ({ page }) => {
test.setTimeout(90_000);
// The exact trigger from the bug report: a user types 127.0.0.1 instead of the advertised localhost.
// The canonical-host redirect sends them to localhost before the flow starts, so the CSRF cookie
// and the cross-origin Kratos POST share one host and login succeeds.
await page.goto("http://127.0.0.1:3000/login");
await expect(page).toHaveURL(/^http:\/\/localhost:3000\//); // 308'd onto the canonical host
await signIn(page);
await expect(page).not.toHaveURL(/\/error(\?|$)/);
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL);
});
+15 -12
View File
@@ -5,16 +5,19 @@
# key in tokenizer/jwks.json).
serve:
public:
base_url: http://127.0.0.1:4433/
base_url: http://localhost:4433/
cors:
enabled: false
admin:
base_url: http://kratos:4434/
selfservice:
default_browser_return_url: http://127.0.0.1:3000/
# Browser-facing URLs default to localhost (clean clone = APP_URL's default, so the host the web app
# canonicalises to matches the host the login form POSTs to — cookies share one host). Driven by
# APP_URL: compose overrides these from ${APP_URL} (compose.override.yml), so there's one knob.
default_browser_return_url: http://localhost:3000/
allowed_return_urls:
- http://127.0.0.1:3000
- http://localhost:3000
methods:
password:
enabled: true
@@ -33,37 +36,37 @@ selfservice:
providers: []
flows:
error:
ui_url: http://127.0.0.1:3000/error
ui_url: http://localhost:3000/error
login:
ui_url: http://127.0.0.1:3000/login
ui_url: http://localhost:3000/login
after:
# After authenticating, land on our completion route — it mints the session JWT
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie (§4).
default_browser_return_url: http://127.0.0.1:3000/auth/complete
default_browser_return_url: http://localhost:3000/auth/complete
registration:
ui_url: http://127.0.0.1:3000/registration
ui_url: http://localhost:3000/registration
after:
password:
hooks:
- hook: session # log in immediately after sign-up
- hook: show_verification_ui
settings:
ui_url: http://127.0.0.1:3000/settings
ui_url: http://localhost:3000/settings
privileged_session_max_age: 15m
required_aal: highest_available
recovery:
enabled: true
use: code
ui_url: http://127.0.0.1:3000/recovery
ui_url: http://localhost:3000/recovery
verification:
enabled: true
use: code
ui_url: http://127.0.0.1:3000/verification
ui_url: http://localhost:3000/verification
after:
default_browser_return_url: http://127.0.0.1:3000/
default_browser_return_url: http://localhost:3000/
logout:
after:
default_browser_return_url: http://127.0.0.1:3000/login
default_browser_return_url: http://localhost:3000/login
# Dev mail catcher (compose.override.yml). Prod overrides via COURIER_SMTP_CONNECTION_URI.
courier:
+10
View File
@@ -46,4 +46,14 @@ e2e compose.e2e-auth.yml # token timeout + silent re-mint
e2e compose.e2e-oauth.yml # OAuth2 login + consent
e2e compose.e2e-full.yml # full browser flow: login (password + SSO), menu, CRUD, plugin, logout
# Dev-stack login regression — runs against the PLAIN `docker compose up` topology (base + override)
# with the runner on the HOST network, so it can't use the shared e2e() helper (which merges only
# compose.yml + the suite). Needs host networking + the host ports 3000/4433 free (Linux CI).
step "E2E: compose.e2e-devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)"
devstack_files=(-f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml)
rc=0
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --build --rm e2e || rc=$?
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite compose.e2e-devstack.yml failed (exit $rc)"; exit "$rc"; }
step "ALL GREEN"
+86
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, randomUUID, sign, type JsonWebKey } from "node:crypto";
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { request as httpRequest } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -303,6 +304,65 @@ test("returns the 404 HTML page for unknown routes", async () => {
assert.match(await res.text(), /404/);
});
// Raw request so we can send an arbitrary Host (fetch derives Host from the URL); connect to the
// loopback server but present whatever host we want to exercise the canonical-host check.
function rawGet(port: number, path: string, host: string, method = "GET"): Promise<{ status: number; location: string | undefined; body: string }> {
return new Promise((resolve, reject) => {
const req = httpRequest({ host: "127.0.0.1", port, path, method, headers: { host } }, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => resolve({ status: res.statusCode ?? 0, location: res.headers.location, body }));
});
req.on("error", reject);
req.end();
});
}
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
// The fix for the localhost-vs-127.0.0.1 / multi-domain trap: reach the app on any host and it
// sends you to APP_URL's host, so the browser, the themed form, and the cross-origin Kratos POST
// all share ONE cookie host. Off-canonical only — same-host requests pass straight through.
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const port = (app.address() as AddressInfo).port;
// Off-canonical host → 308 to the canonical origin, path + query preserved.
const off = await rawGet(port, "/dashboard?q=x", `127.0.0.1:${port}`);
assert.equal(off.status, 308);
assert.equal(off.location, "http://canonical.example:3000/dashboard?q=x");
// On the canonical host → no canonicalisation (the gated dashboard 303s to /login, never 308).
const on = await rawGet(port, "/dashboard", "canonical.example:3000");
assert.notEqual(on.status, 308);
// Static assets are host-agnostic (served before the check) so health checks on any host still pass.
const asset = await rawGet(port, "/public/css/styles.css", `127.0.0.1:${port}`);
assert.equal(asset.status, 200);
// A 308 must not replay a cross-host POST — non-GET/HEAD is left alone (not canonicalised).
const post = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "POST");
assert.notEqual(post.status, 308);
});
test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-agnostic deploys unaffected)", async () => {
// The shared `server` is built without appUrl, so any Host is served as-is (no 308).
const r = await rawGet(Number(new URL(base).port), "/missing", "anything.example");
assert.equal(r.status, 404); // reaches the normal handler, not a redirect
});
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>. Without a
// handler it 404'd as "Page not found" (confusing). It must be a real, themed page now.
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
const html = await res.text();
assert.doesNotMatch(html, /Page not found/); // not the 404 view
assert.match(html, /sign in|sign-in|try again|something went wrong/i);
assert.match(html, /href="\/login"/); // a path back into auth
});
test("renders the 500 HTML page when a handler throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // the dashboard view
@@ -661,6 +721,32 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
assert.equal((await fetch(url + "/settings", signedIn)).headers.get("location"), "/settings?flow=new1");
});
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
// JWT from the live session), preserving return_to — never fall through to the catch-all 500.
const sessionRace = new KratosError("Kratos init login flow failed (400)", 400, JSON.stringify({ error: { id: "session_already_available" } }));
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw sessionRace; } } });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const recover = await fetch(url + "/login", { redirect: "manual" });
assert.equal(recover.status, 303);
assert.equal(recover.headers.get("location"), "/auth/complete");
// return_to is carried through so the deep link still lands after the JWT is minted.
const deep = await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
assert.equal(deep.headers.get("location"), "/auth/complete?return_to=%2Fadmin%2Fusers");
// A genuinely unexpected Kratos 400 is still surfaced as a 500 (not masked as a session race).
const app2 = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw new KratosError("bad", 400, JSON.stringify({ error: { id: "security_csrf_violation" } })); } } });
await new Promise<void>((r) => app2.listen(0, r));
t.after(() => app2.close());
const url2 = `http://localhost:${(app2.address() as AddressInfo).port}`;
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
});
// return_to (§9): a deep-link login lands back on the requested page. The gate redirects to
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
+40
View File
@@ -39,6 +39,7 @@ import { renderPluginView } from "./view-resolver.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
export interface AppOptions {
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
// Off by default so edits show live; the app itself never inspects the environment.
@@ -67,6 +68,11 @@ export function createApp(options: AppOptions = {}): Server {
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
const cache = options.cache ?? false;
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false;
const hydra = options.hydra;
@@ -136,6 +142,20 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
// otherwise the host-scoped Kratos CSRF cookie is lost and login dumps onto /error. Static
// assets above are served on any host (health checks). GET/HEAD only — a 308 must not replay a
// cross-host POST; first-party forms are always served from a canonical page anyway.
if (canonicalHost && (method === "GET" || method === "HEAD")) {
const host = req.headers.host;
if (host !== undefined && host !== canonicalHost) {
res.writeHead(308, { location: canonicalOrigin + (req.url ?? "/") }).end();
return;
}
}
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in" (§4): re-read roles from Keto, re-tokenize,
@@ -285,6 +305,16 @@ export function createApp(options: AppOptions = {}): Server {
res.writeHead(303, { location: pathname }).end();
return;
}
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500.
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
const local = localPath(ctx.url.searchParams.get("return_to"));
res.writeHead(303, { location: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" }).end();
return;
}
// Ory unreachable (Kratos 5xx / connection refused / timeout): "Ory down ⇒ no logins" is
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
if (!(err instanceof KratosError) || err.status >= 500) {
@@ -432,6 +462,16 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
// canonical-host redirect above prevents the common cause (a lost cross-host CSRF cookie); this
// is the honest fallback for any genuine flow error. The id is shown only for support reference.
if (pathname === "/error" && (method === "GET" || method === "HEAD")) {
sendHtml(res, 200, await render("error", { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }));
return;
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// The public landing (§10): ungated — anyone may see it. A plugin may fully own it via `home`
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
+2 -2
View File
@@ -51,8 +51,8 @@ test("web waits for kratos, keto and hydra to be healthy before starting", () =>
test("prod base publishes no internal Ory ports; dev exposes the host-facing ones", () => {
for (const p of [4433, 4434, 4444, 4445, 4466, 4467])
assert.ok(!compose.includes(`${p}:${p}`), `base does not publish :${p}`);
// Browser completes Kratos flows at kratos public (kratos.yml base_url 127.0.0.1:4433)
// and OAuth2 at hydra public — both reachable on the host only in dev.
// Browser completes Kratos flows at kratos public (kratos.yml base_url localhost:4433, shares
// APP_URL's host) and OAuth2 at hydra public — both reachable on the host only in dev.
assert.match(override, /"4433:4433"/, "dev publishes kratos public");
assert.match(override, /"4444:4444"/, "dev publishes hydra public");
});
+8
View File
@@ -28,6 +28,14 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.otlpEndpoint, undefined); // OTLP export opt-in; console-only by default
assert.equal(c.otlpProtocol, "http/json");
assert.equal(c.serviceName, "plainpages"); // OTLP service.name default; implementer-overridable
assert.equal(c.appUrl, undefined); // canonical-host redirect is an explicit opt-in (off unless APP_URL set)
});
test("APP_URL is the canonical public URL: opt-in (unset ⇒ no redirect), honoured when set, validated", () => {
assert.equal(loadConfig({}).appUrl, undefined); // unset ⇒ off, so a prod default can't misfire
assert.equal(loadConfig({ APP_URL: "" }).appUrl, undefined); // empty ⇒ off too (compose passes ${APP_URL:-})
assert.equal(loadConfig({ APP_URL: "https://admin.acme.com" }).appUrl, "https://admin.acme.com");
assert.throws(() => loadConfig({ APP_URL: "not a url" }), /APP_URL/);
});
test("SERVICE_NAME is overridable so an implementer brands their own logs/traces (§9)", () => {
+8
View File
@@ -13,6 +13,7 @@ export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly",
export type LogLevel = (typeof LOG_LEVELS)[number];
export interface Config {
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
cacheTemplates: boolean;
csrfSecret: string;
hydraAdminUrl: string;
@@ -124,6 +125,13 @@ function readPosInt(env: Env, key: string, devDefault: number): number {
export function loadConfig(env: Env = process.env): Config {
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
return {
// The canonical public URL — the single source for "where this deployment lives". When set, the
// canonical-host redirect (app.ts) sends a visitor who reached the app on any other host
// (localhost vs 127.0.0.1, a secondary domain) here, so the browser, the themed forms, and the
// cross-origin Kratos POST all share ONE cookie host. Explicit toggle (no magic default): unset ⇒
// no redirect (a prod operator can't accidentally bounce real users to a forgotten default). The
// dev stack sets it to localhost (compose.override.yml); Kratos' browser URLs derive from it too.
appUrl: readOptionalUrl(env, "APP_URL"),
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure),
// Hydra admin API — the OAuth2 login/consent challenge handshake (§6); not on the first-party path.
+8 -4
View File
@@ -37,17 +37,21 @@ test("kratos config wires the identity schema", () => {
assert.match(kratosYml, /identity\.schema\.json/);
});
// The five self-service flows return the browser to our own themed routes (§4 renders them).
// The five self-service flows return the browser to our own themed routes (§4 renders them). The
// host is `localhost` — the dev/clean-clone host the stack sets APP_URL to, so the web app's canonical
// host matches the host the login form POSTs to (cookies share one host). Compose overrides these
// from ${APP_URL} for a custom host.
const FLOW_PAGES = ["login", "registration", "recovery", "verification", "settings"];
test("self-service flows return to our themed pages", () => {
test("self-service flows return to our themed pages (on the localhost dev host)", () => {
for (const flow of FLOW_PAGES)
assert.match(kratosYml, new RegExp(`ui_url:\\s*http://127\\.0\\.0\\.1:3000/${flow}\\b`),
assert.match(kratosYml, new RegExp(`ui_url:\\s*http://localhost:3000/${flow}\\b`),
`${flow} flow points at our /${flow} page`);
assert.doesNotMatch(kratosYml, /127\.0\.0\.1/, "no 127.0.0.1 literal — it drifted from APP_URL (localhost) and broke login");
});
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/127\.0\.0\.1:3000\/auth\/complete/,
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete (§4)");
});
+4 -1
View File
@@ -39,6 +39,9 @@ log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) =>
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
const server = createApp({
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
// APP_URL is set, so the redirect is fully off — and costs nothing — when unconfigured.
...(config.appUrl ? { appUrl: config.appUrl } : {}),
auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
cache: config.cacheTemplates,
csrfSecret: config.csrfSecret,
@@ -53,7 +56,7 @@ const server = createApp({
plugins,
secureCookies: config.secureCookies,
}).listen(config.port, () => {
log.info("listening", { port: config.port, url: `http://localhost:${config.port}` });
log.info("listening", { port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
});
// Drain in-flight requests on container stop instead of cutting them mid-response, then flush any
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
</head>
<body>
<main>
<h1>Something went wrong</h1>
<p>We couldn't complete that sign-in step. It may have expired or been opened twice — please try again.</p>
<p><a href="/login">Back to sign in</a></p>
<% if (locals.id) { %><p><small>Reference: <%= id %></small></p><% } %>
</main>
</body>
</html>