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:
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user