Canonical host via APP_URL: stop login dumping users on /error from a host mismatch
The from-scratch dev login was broken: open the banner's http://localhost:3000, sign in as the seeded admin, and you landed on http://127.0.0.1:3000/error "Page not found". Root cause: the banner/APP_URL said localhost but kratos.yml hard-coded 127.0.0.1, and a host-scoped Kratos CSRF cookie can't cross localhost<->127.0.0.1, so the cross-host login POST lost it; Kratos redirected to its error sink, which the app had no route for (404). Make APP_URL the single source of truth for the public host: - Canonical-host redirect (app.ts): when APP_URL is set, an off-host GET/HEAD visitor is 308'd to it (path+query kept) before a flow starts, so the browser, the themed forms and the cross-origin Kratos POST share one cookie host. After /public/ so static/health checks stay host-agnostic; GET/HEAD only so a 308 never replays a cross-host POST. - Opt-in (no NODE_ENV / no magic default): unset => no redirect, so a prod deploy that forgets APP_URL can't bounce real users to a stale default. The dev stack sets it. - kratos.yml browser URLs default to localhost (match APP_URL's dev value) and derive from ${APP_URL} via compose.override.yml; SERVE_PUBLIC_BASE_URL keeps the dev Ory port. - Real /error page (views/error.ejs) replaces the catch-all 404 for genuine flow errors. Tests-first: config (opt-in/validated), app (308 on mismatch, no-redirect on match, static host-agnostic, POST untouched, /error page), updated kratos.test host pins. New devstack regression (e2e/devstack-login.spec + compose.e2e-devstack.yml) drives the plain docker-compose-up topology on the host network: login from localhost works and 127.0.0.1 is canonicalised; wired into scripts/ci.sh. typecheck + 360 units + full ci.sh (visual 10 · auth 1 · oauth 2 · full 7 · devstack 2) green.
This commit is contained in:
+30
@@ -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=<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
|
||||
|
||||
Reference in New Issue
Block a user