From 053438f96896a566d4ed20c2d805422aa6b6d534 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 12:59:28 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20product-owner=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=202=20=F0=9F=9F=A1=20(0=20misread);=20the=20submi?= =?UTF-8?q?t=20sends=20a=20same-origin=20Referer,=20Kratos'=20redirect=20t?= =?UTF-8?q?o=20this=20host=20keeps=20the=20chosen=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 ++++--- src/auth/kratos-public.ts | 8 ++++---- src/auth/routes.ts | 16 ++++++++++------ src/http/app-auth.test.ts | 9 +++++---- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d8466..4fc38df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ at boot. Entries start at 0.3.0. ### A verification or recovery code pasted with whitespace around it is accepted -The code field on the verification and recovery pages takes a code with spaces or tabs around it, as -a paste from an email often brings. Before, the browser refused it. Those two forms now post to -Plainpages, which trims the code and passes the form on to Kratos at `KRATOS_PUBLIC_URL`. +The code field on the verification and recovery pages takes a code with spaces or tabs around it. +Pasting a code from an email often adds them, and before, the browser refused the code. Those two +forms now post to Plainpages, which trims the code and passes the form on to Kratos at +`KRATOS_PUBLIC_URL`. A visitor who picked a language now keeps it through both flows. ### A filter shows its selected and focused choice in browsers without `:has()` diff --git a/src/auth/kratos-public.ts b/src/auth/kratos-public.ts index 59ef058..4bd50e6 100644 --- a/src/auth/kratos-public.ts +++ b/src/auth/kratos-public.ts @@ -21,7 +21,7 @@ export interface UiNode { } export interface FlowUi { - action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF) + action: string; // absolute Kratos URL the form submits to messages?: UiText[]; method: string; nodes: UiNode[]; @@ -117,9 +117,9 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof async submitFlow(type, flowId, opts) { const url = new URL(`${base}/self-service/${type}`); url.searchParams.set("flow", flowId); - // Asked for HTML, Kratos answers with the redirects a browser gets, so every outcome stays Kratos'. - // Its nosurf wants a Referer, absent here, only over https: valid while KRATOS_PUBLIC_URL is http. - const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded" }; + // Asked for HTML, Kratos answers with the redirect a browser posting to it would get. Its CSRF + // check demands a same-origin Referer on an https request, and this server-side POST has none. + const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded", referer: `${base}/` }; const res = await http(url, { body: opts.body, headers: h, method: "POST", redirect: "manual" }); if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text()); await res.body?.cancel(); diff --git a/src/auth/routes.ts b/src/auth/routes.ts index f250299..5938edc 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -114,10 +114,11 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole }; } -// A code flow's POST (flow-view.ts → CODE_FLOWS): trim the pasted one-time code, which Kratos would -// refuse with its surrounding whitespace, and relay the rest to Kratos and its redirect back. Kratos' -// own csrf_token field guards the POST, so the host checks no token of its own. -function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType): BuiltinRoute["handler"] { +// A code flow's POST (flow-view.ts → CODE_FLOWS). Trims the pasted one-time code, since Kratos +// refuses one with whitespace around it, then passes the form on to Kratos and sends the browser +// where Kratos redirects. Kratos' csrf_token field guards the POST, so the host's own CSRF token +// isn't checked here. +function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] { return async (ctx: RequestContext): Promise => { const pathname = ctx.url.pathname; const flowId = ctx.url.searchParams.get("flow"); @@ -133,7 +134,10 @@ function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType): BuiltinRoute[ return flowFailure(err, ctx, flowType); } if (submitted.setCookie.length) ctx.res.appendHeader("set-cookie", submitted.setCookie); - return { redirect: submitted.location ?? `${pathname}?flow=${encodeURIComponent(flowId)}` }; + // Kratos names our own pages by absolute URL; made host-relative, the redirect keeps a chosen locale. + const own = selfOrigin(ctx, secureCookies); + const location = submitted.location?.startsWith(`${own}/`) ? submitted.location.slice(own.length) : submitted.location; + return { redirect: location ?? `${pathname}?flow=${encodeURIComponent(flowId)}` }; }; } @@ -273,7 +277,7 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure if (kratos) { for (const [path, flowType] of Object.entries(AUTH_FLOWS)) { routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true }); - if (CODE_FLOWS.has(flowType)) routes.push({ handler: codeFlowSubmit(kratos, flowType), method: "POST", path, public: true }); + if (CODE_FLOWS.has(flowType)) routes.push({ handler: codeFlowSubmit(kratos, flowType, secureCookies), method: "POST", path, public: true }); } routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true }); } diff --git a/src/http/app-auth.test.ts b/src/http/app-auth.test.ts index 81558e7..1d68273 100644 --- a/src/http/app-auth.test.ts +++ b/src/http/app-auth.test.ts @@ -389,9 +389,6 @@ test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, o } }); - -// The code flows post to the host, which trims the code (a pasted one often carries whitespace that -// Kratos would refuse) and relays the rest of the browser's POST to Kratos verbatim. test("code-flow POST: the host trims the code, relays the POST to Kratos and Kratos' redirect + cookies back", async (t) => { const calls: { body: string; cookie: string | undefined; flowId: string; type: FlowType }[] = []; let answer: () => Promise = async () => @@ -423,6 +420,10 @@ test("code-flow POST: the host trims the code, relays the POST to Kratos and Kra assert.equal(noFlow.headers.get("location"), "/recovery"); assert.equal(calls.length, 2); + // Kratos' absolute redirect to this host becomes host-relative, so it keeps the chosen locale. + answer = async () => ({ location: `${url}/settings?flow=s1`, setCookie: [], status: 303 }); + assert.equal((await post("/recovery?flow=f1&locale=en-US", "code=1")).headers.get("location"), "/settings?flow=s1&locale=en-US"); + // Kratos answered without a redirect → back to the flow, whose page shows its state. answer = async () => ({ location: null, setCookie: [], status: 400 }); assert.equal((await post("/recovery?flow=f1", "code=1")).headers.get("location"), "/recovery?flow=f1"); @@ -433,6 +434,6 @@ test("code-flow POST: the host trims the code, relays the POST to Kratos and Kra answer = async () => { throw new TypeError("fetch failed"); }; assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503); - // Sign-in still posts straight to Kratos: the host takes no POST there. + // Sign-in posts straight to Kratos, so the host has no POST /login. assert.notEqual((await post("/login?flow=f1", "x=1")).status, 303); });