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