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:
@@ -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`)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user