From 1d198acc9725e4fba36e13f09897831089c9c5ff Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 21 Jun 2026 21:52:20 +0200 Subject: [PATCH 1/2] Canonical host via APP_URL: stop login dumping users on /error from a host mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 53 +++++++++++++++++++++++++++++++-- compose.e2e-auth.yml | 1 + compose.e2e-devstack.yml | 48 ++++++++++++++++++++++++++++++ compose.e2e-full.yml | 1 + compose.e2e-oauth.yml | 1 + compose.e2e.yml | 1 + compose.override.yml | 27 +++++++++++++++-- compose.yml | 5 ++++ e2e/devstack-login.spec.ts | 51 ++++++++++++++++++++++++++++++++ ory/kratos/kratos.yml | 27 +++++++++-------- scripts/ci.sh | 10 +++++++ src/app.test.ts | 60 ++++++++++++++++++++++++++++++++++++++ src/app.ts | 30 +++++++++++++++++++ src/compose.test.ts | 4 +-- src/config.test.ts | 8 +++++ src/config.ts | 8 +++++ src/kratos.test.ts | 12 +++++--- src/server.ts | 5 +++- views/error.ejs | 17 +++++++++++ 19 files changed, 346 insertions(+), 23 deletions(-) create mode 100644 compose.e2e-devstack.yml create mode 100644 e2e/devstack-login.spec.ts create mode 100644 views/error.ejs diff --git a/README.md b/README.md index 379aaf4..a7115bd 100644 --- a/README.md +++ b/README.md @@ -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://: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//views/.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`) diff --git a/compose.e2e-auth.yml b/compose.e2e-auth.yml index 0f78e68..2f792ea 100644 --- a/compose.e2e-auth.yml +++ b/compose.e2e-auth.yml @@ -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" diff --git a/compose.e2e-devstack.yml b/compose.e2e-devstack.yml new file mode 100644 index 0000000..46fd8f8 --- /dev/null +++ b/compose.e2e-devstack.yml @@ -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 diff --git a/compose.e2e-full.yml b/compose.e2e-full.yml index e07847b..3d8770a 100644 --- a/compose.e2e-full.yml +++ b/compose.e2e-full.yml @@ -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 diff --git a/compose.e2e-oauth.yml b/compose.e2e-oauth.yml index f5d75a7..382d74e 100644 --- a/compose.e2e-oauth.yml +++ b/compose.e2e-oauth.yml @@ -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" diff --git a/compose.e2e.yml b/compose.e2e.yml index be9cf15..1f676c0 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -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 diff --git a/compose.override.yml b/compose.override.yml index d12f36f..f6072e6 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -5,6 +5,11 @@ 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) REQUIRE_SECURE_SECRETS: "false" @@ -33,11 +38,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. diff --git a/compose.yml b/compose.yml index bc6a7d4..b43612b 100644 --- a/compose.yml +++ b/compose.yml @@ -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 diff --git a/e2e/devstack-login.spec.ts b/e2e/devstack-login.spec.ts new file mode 100644 index 0000000..79eb5cb --- /dev/null +++ b/e2e/devstack-login.spec.ts @@ -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 { + 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); +}); diff --git a/ory/kratos/kratos.yml b/ory/kratos/kratos.yml index 6af6e69..af94197 100644 --- a/ory/kratos/kratos.yml +++ b/ory/kratos/kratos.yml @@ -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: diff --git a/scripts/ci.sh b/scripts/ci.sh index 34d9bcc..59073b1 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -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" diff --git a/src/app.test.ts b/src/app.test.ts index da8c6f9..b021628 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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((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=. 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 diff --git a/src/app.ts b/src/app.ts index 7af67c7..5ca4a24 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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, @@ -432,6 +452,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=. 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 diff --git a/src/compose.test.ts b/src/compose.test.ts index 83e64d6..41db6e8 100644 --- a/src/compose.test.ts +++ b/src/compose.test.ts @@ -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"); }); diff --git a/src/config.test.ts b/src/config.test.ts index 18c83d6..dd7459f 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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)", () => { diff --git a/src/config.ts b/src/config.ts index f42c7b6..4b1126f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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. diff --git a/src/kratos.test.ts b/src/kratos.test.ts index cd3afd1..0e7944c 100644 --- a/src/kratos.test.ts +++ b/src/kratos.test.ts @@ -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)"); }); diff --git a/src/server.ts b/src/server.ts index 2bc76f9..ab656c1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 diff --git a/views/error.ejs b/views/error.ejs new file mode 100644 index 0000000..b9a8a2f --- /dev/null +++ b/views/error.ejs @@ -0,0 +1,17 @@ + + + + + + <%= title %> + + + +
+

Something went wrong

+

We couldn't complete that sign-in step. It may have expired or been opened twice — please try again.

+

Back to sign in

+ <% if (locals.id) { %>

Reference: <%= id %>

<% } %> +
+ + -- 2.52.0 From c8b4c3c23bd699e0a0381de1649feaf8076b361c Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 21 Jun 2026 22:04:55 +0200 Subject: [PATCH 2/2] Fix 500 on /login when a Kratos session exists but no app JWT (session_already_available) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repro: register a new account, then click back from the password/verification step to /login → 500. Registration's `session` hook signs the user in at Kratos but routes to the verification UI, not /auth/complete, so they hold a Kratos session with NO app JWT — ctx.user is null, the "already signed in -> /dashboard" short-circuit can't fire, and initialising a login flow makes Kratos return 400 `session_already_available`. The flow-init catch only handled 403/404/410 and 5xx, so the 400 fell through to `throw` -> catch-all 500. Recover instead: on `session_already_available` (already authenticated at Kratos), 303 to /auth/complete to mint the JWT from the live session, preserving return_to. A genuinely unexpected Kratos 400 still surfaces as 500. Verified live: /login (Kratos session, no JWT) now 303s to /auth/complete, which mints plainpages_jwt and lands on /dashboard. Also default LOG_LEVEL to debug in the dev override (compose.override.yml) for verbose local logs. Tests-first: app.test asserts the session-race recovers to /auth/complete (return_to carried) while an unrelated 400 stays a 500. typecheck + 361 units green. --- compose.override.yml | 1 + src/app.test.ts | 26 ++++++++++++++++++++++++++ src/app.ts | 10 ++++++++++ 3 files changed, 37 insertions(+) diff --git a/compose.override.yml b/compose.override.yml index f6072e6..033727e 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -12,6 +12,7 @@ services: 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 diff --git a/src/app.test.ts b/src/app.test.ts index b021628..477c89d 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -721,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((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((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=; /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). diff --git a/src/app.ts b/src/app.ts index 5ca4a24..e99abfc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -305,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) { -- 2.52.0