Canonical host via APP_URL: stop login dumping users on /error from a host mismatch

The from-scratch dev login was broken: open the banner's http://localhost:3000, sign
in as the seeded admin, and you landed on http://127.0.0.1:3000/error "Page not found".
Root cause: the banner/APP_URL said 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; Kratos redirected to its error sink, which the app had no route for (404).

Make APP_URL the single source of truth for the public host:
- Canonical-host redirect (app.ts): when APP_URL is set, an off-host GET/HEAD visitor is
  308'd to it (path+query kept) before a flow starts, so the browser, the themed forms and
  the cross-origin Kratos POST share one cookie host. After /public/ so static/health
  checks stay host-agnostic; GET/HEAD only so a 308 never replays a cross-host POST.
- Opt-in (no NODE_ENV / no magic default): unset => no redirect, so a prod deploy that
  forgets APP_URL can't bounce real users to a stale default. The dev stack sets it.
- kratos.yml browser URLs default to localhost (match APP_URL's dev value) and derive from
  ${APP_URL} via compose.override.yml; SERVE_PUBLIC_BASE_URL keeps the dev Ory port.
- Real /error page (views/error.ejs) replaces the catch-all 404 for genuine flow errors.

Tests-first: config (opt-in/validated), app (308 on mismatch, no-redirect on match, static
host-agnostic, POST untouched, /error page), updated kratos.test host pins. New devstack
regression (e2e/devstack-login.spec + compose.e2e-devstack.yml) drives the plain
docker-compose-up topology on the host network: login from localhost works and 127.0.0.1 is
canonicalised; wired into scripts/ci.sh. typecheck + 360 units + full ci.sh (visual 10 ·
auth 1 · oauth 2 · full 7 · devstack 2) green.
This commit is contained in:
2026-06-21 21:52:20 +02:00
parent 58398481ca
commit 1d198acc97
19 changed files with 346 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`)