From 2a4ed30e93015754e74c1d4bbc97d85e946bbbf8 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 09:04:55 +0100 Subject: [PATCH 1/7] Redirect an anonymous hit on /settings to sign-in with the page as return_to --- README.md | 2 +- docs/reading-cost.md | 17 +++++++ e2e-tests/full-flow.spec.ts | 13 ++++++ src/auth/routes.ts | 88 +++++++++++++++++++++---------------- src/function-limits.test.ts | 2 - src/http/app.test.ts | 18 ++++++++ 6 files changed, 98 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0f1871b..5ad575f 100644 --- a/README.md +++ b/README.md @@ -1506,7 +1506,7 @@ no Node/browsers on the host. Five suites, each on its own stack: | `visual.spec.ts` + `language.spec.ts` | `e2e-tests/compose.visual.yml` | Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and [language switching](#languages-i18n) | | `auth-refresh.spec.ts` | `e2e-tests/compose.auth.yml` | The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared | | `oauth-login.spec.ts` | `e2e-tests/compose.oauth.yml` | Another app logging in through us — register a client, start an authorization flow, `/oauth2/login` accept, `/oauth2/consent` Allow → authorization code | -| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout | +| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, an anonymous `/settings` visit signing in first, logout | | `devstack-login.spec.ts` | `compose.override.yml` + `e2e-tests/compose.devstack.yml` | The plain `docker compose up` topology on the **host network**: sign-in works both from the banner's `localhost:3000` and from `127.0.0.1:3000` via the [canonical-host redirect](#canonical-host-one-public-url) | ```bash diff --git a/docs/reading-cost.md b/docs/reading-cost.md index b454c82..18c02a0 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -105,3 +105,20 @@ Read most (opens and searches): - `src/ui/chrome.ts` 1 - `views/index.ejs` 1 - `views/partials/shell.ejs` 1 + +## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z + +154733 new input tokens before the first commit. + +Read most (opens and searches): + +- `todo.md` 10 +- `README.md` 6 +- `package.json` 3 +- `.gitignore` 1 +- `.npmrc` 1 +- `AGENTS.md` 1 +- `ci.sh` 1 +- `public/css/styles.css` 1 +- `renovate.json` 1 +- `src/e2e-console-guard.test.ts` 1 diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index b043e7d..d0ee3eb 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -240,6 +240,19 @@ test("return_to: a deep link while logged out returns to that page after login", await expect(page.locator("h1")).toHaveText("Users"); }); +test("settings: an anonymous visit signs in first, then lands on the settings page", async ({ page }) => { + test.setTimeout(90_000); + // /settings is public (recovery lands there with a Kratos session but no app JWT), so Kratos' 401 + // for an anonymous settings flow must send the visitor to sign in, not to the 500 page. + await page.goto("/settings"); + await expect(page).toHaveURL(/\/login(\?|$)/); + 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(); + await expect(page).toHaveURL(/\/settings(\?|$)/); + await expect(page.locator("h1")).toHaveText("Account settings"); +}); + test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => { test.setTimeout(90_000); await page.goto("/login"); diff --git a/src/auth/routes.ts b/src/auth/routes.ts index df810e0..6003879 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -38,11 +38,56 @@ const FORBIDDEN: RouteResult = { status: 403, view: "403" }; const selfOrigin = (ctx: RequestContext, secure: boolean): string => `${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`; +// Where Kratos lands the browser after a freshly initialised flow, or undefined for its default +// completion route. A first-party deep link (host-relative, from the gate's return_to) is wrapped +// through /auth/complete so the session JWT is minted before the user reaches the page; an absolute +// target (the OAuth2 login challenge) is passed as-is — Kratos allow-lists it. localPath rejects an +// off-origin "//evil.com". The target is the host's, not Kratos', so the language the visitor picked +// on the sign-in page survives the round-trip and lands on the page after it; without this the +// most-travelled path in the product (pick Swedish → sign in) drops back to Accept-Language. +function flowReturnTo(ctx: RequestContext, secureCookies: boolean): string | undefined { + const raw = ctx.url.searchParams.get("return_to"); + const local = localPath(raw); + const chosen = chosenLocale(ctx); + if (!local && !chosen) return raw ?? undefined; + const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`); + if (local) complete.searchParams.set("return_to", ctx.localeHref(local)); + if (chosen) complete.searchParams.set("locale", chosen); + return complete.toString(); +} + +// What a failed Kratos call renders instead of the flow; rethrows what is genuinely unexpected. +function flowFailure(err: unknown, ctx: RequestContext, flowType: FlowType): RouteResult { + const pathname = ctx.url.pathname; + // Expired/unknown flow → restart by re-initialising (drop the stale ?flow=). + if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname }; + // No Kratos session on a page that needs one (/settings is public, so the gate never sent them + // to sign in): sign in, then come back. Not for /login itself, which would redirect to itself. + if (err instanceof KratosError && err.status === 401 && flowType !== "login") { + return { redirect: `/login?return_to=${encodeURIComponent(pathname)}` }; + } + // 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 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")); + return { redirect: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" }; + } + // 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) { + ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname }); + return { status: 503, view: "503" }; + } + throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected) +} + // Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/ // verification/settings). function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] { return async (ctx: RequestContext, csrf: RequestCsrf): Promise => { - const pathname = ctx.url.pathname; // Already signed in? Re-authenticating / re-registering is pointless — send them to the app // dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.) if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" }; @@ -54,49 +99,14 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole try { if (!flowId) { // No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=. - // A `return_to` is baked into the flow so Kratos lands there after login instead of the - // default completion route. A first-party deep link (host-relative, from the gate's - // return_to) is wrapped through /auth/complete so the session JWT is minted before the - // user reaches the page; an absolute target (the OAuth2 login challenge) is passed - // as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com". - const raw = ctx.url.searchParams.get("return_to"); - const local = localPath(raw); - const chosen = chosenLocale(ctx); - let returnTo: string | undefined; - if (local || chosen) { - // The flow's return target is the host's, not Kratos' — so the language the visitor picked - // on the sign-in page survives the round-trip through Kratos and lands on the page after - // it. Without this the most-travelled path in the product (pick Swedish → sign in) drops - // straight back to Accept-Language. - const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`); - if (local) complete.searchParams.set("return_to", ctx.localeHref(local)); - if (chosen) complete.searchParams.set("locale", chosen); - returnTo = complete.toString(); - } else if (raw) returnTo = raw; + const returnTo = flowReturnTo(ctx, secureCookies); const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) }); if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie); - return { redirect: `${pathname}?flow=${initiated.id}` }; + return { redirect: `${ctx.url.pathname}?flow=${initiated.id}` }; } flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {}); } catch (err) { - // Expired/unknown flow → restart by re-initialising (drop the stale ?flow=). - if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname }; - // 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")); - return { redirect: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" }; - } - // 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) { - ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname }); - return { status: 503, view: "503" }; - } - throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected) + return flowFailure(err, ctx, flowType); } // Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the // shell's Sign-out form (shown on /settings, where the user is signed in) needs the token. diff --git a/src/function-limits.test.ts b/src/function-limits.test.ts index c7d067d..131157a 100644 --- a/src/function-limits.test.ts +++ b/src/function-limits.test.ts @@ -15,8 +15,6 @@ const LIMITS = { lines: 50, complexity: 20 }; const grandfathered: Record = { "src/http/app.ts createApp": { lines: 318, complexity: 28 }, "src/http/app.ts createApp > handleRequest": { lines: 168, complexity: 43 }, - "src/auth/routes.ts flowPage": { lines: 64 }, - "src/auth/routes.ts flowPage > anonymous": { lines: 62, complexity: 23 }, "src/auth/flow-view.ts buildFlowView": { complexity: 24 }, "src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 }, "src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 }, diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 591f059..81ae7e9 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -792,6 +792,24 @@ test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500); }); +test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sign-in with the page as return_to, never 500", async (t) => { + // /settings stays public (recovery lands there with a Kratos session but no app JWT), so Kratos' 401 + // for a settings flow with no session is the signal to sign in. /login itself must not redirect to + // itself, so a 401 there stays the catch-all 500. + const unauthorized = new KratosError("Kratos init settings flow failed (401)", 401, ""); + const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => { throw unauthorized; }), initBrowserFlow: async () => { throw unauthorized; } } }); + await new Promise((r) => app.listen(0, r)); + t.after(() => app.close()); + const url = `http://localhost:${(app.address() as AddressInfo).port}`; + + const init = await fetch(url + "/settings", { redirect: "manual" }); + assert.equal(init.status, 303); + assert.equal(init.headers.get("location"), "/login?return_to=%2Fsettings"); + const existing = await fetch(url + "/settings?flow=f1", { redirect: "manual" }); + assert.equal(existing.headers.get("location"), "/login?return_to=%2Fsettings"); + assert.equal((await fetch(url + "/login", { redirect: "manual" })).status, 500); +}); + // return_to: a deep-link login lands back on the requested page. The gate redirects to // /login?return_to=; /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). -- 2.52.0 From dbd8af08ba6a89feee989103cbfd5f9f7708fb99 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 09:05:26 +0100 Subject: [PATCH 2/7] =?UTF-8?q?Answer=20security=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20no=20findin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0 From 8d1e8be6fcd2addd8c821894c08546840c14dadf Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 09:10:57 +0100 Subject: [PATCH 3/7] =?UTF-8?q?Answer=20stability=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=202=20=F0=9F=9F=A1=20(0=20misread);=20pin=20the?= =?UTF-8?q?=20503=20on=20a=20non-401=20failure=20and=20the=20language-only?= =?UTF-8?q?=20return=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/http/app.test.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 81ae7e9..a3cbb59 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -793,11 +793,9 @@ test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via }); test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sign-in with the page as return_to, never 500", async (t) => { - // /settings stays public (recovery lands there with a Kratos session but no app JWT), so Kratos' 401 - // for a settings flow with no session is the signal to sign in. /login itself must not redirect to - // itself, so a 401 there stays the catch-all 500. - const unauthorized = new KratosError("Kratos init settings flow failed (401)", 401, ""); - const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => { throw unauthorized; }), initBrowserFlow: async () => { throw unauthorized; } } }); + let failure = new KratosError("Kratos init settings flow failed (401)", 401, ""); + const throwing = async (): Promise => { throw failure; }; + const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(throwing), initBrowserFlow: throwing } }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; @@ -807,7 +805,11 @@ test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sig assert.equal(init.headers.get("location"), "/login?return_to=%2Fsettings"); const existing = await fetch(url + "/settings?flow=f1", { redirect: "manual" }); assert.equal(existing.headers.get("location"), "/login?return_to=%2Fsettings"); + // /login redirecting to itself would loop, so a 401 there stays the catch-all 500. assert.equal((await fetch(url + "/login", { redirect: "manual" })).status, 500); + // Only a 401 means "sign in": an Ory outage on /settings is still the honest 503. + failure = new KratosError("Kratos init settings flow failed (500)", 500, ""); + assert.equal((await fetch(url + "/settings", { redirect: "manual" })).status, 503); }); // return_to: a deep-link login lands back on the requested page. The gate redirects to @@ -838,6 +840,23 @@ test("login return_to: a first-party deep link is wrapped through /auth/complete assert.equal(lastReturnTo, "//evil.com"); }); +test("login return_to: a chosen language alone still routes through /auth/complete, so it survives the sign-in", async (t) => { + let lastReturnTo: string | undefined; + const kratos: KratosPublic = { + ...mockKratos(async (_t, id) => loginFlow(id)), + initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; }, + }; + const app = createApp({ i18n: createI18n(await loadI18n()), kratos }); + await new Promise((r) => app.listen(0, r)); + t.after(() => app.close()); + const url = `http://localhost:${(app.address() as AddressInfo).port}`; + + await fetch(url + "/login?locale=sv-SE", { redirect: "manual" }); + assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?locale=sv-SE$/); + await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" }); + assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Flocale%3Dsv-SE&locale=sv-SE$/); +}); + // "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the // generic "error on our end" 500 the catch-all renders. test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => { -- 2.52.0 From 943520566ed42fe6e89fb21101f313403de18980 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 09:10:57 +0100 Subject: [PATCH 4/7] =?UTF-8?q?Answer=20prose=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=205=20=F0=9F=9F=A1=20(0=20misread);=20shorten=20t?= =?UTF-8?q?he=20return-target=20comment,=20state=20why=20/settings=20is=20?= =?UTF-8?q?public,=20drop=20the=20test=20restatements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- e2e-tests/full-flow.spec.ts | 2 -- src/auth/routes.ts | 18 ++++++++---------- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5ad575f..0f1871b 100644 --- a/README.md +++ b/README.md @@ -1506,7 +1506,7 @@ no Node/browsers on the host. Five suites, each on its own stack: | `visual.spec.ts` + `language.spec.ts` | `e2e-tests/compose.visual.yml` | Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and [language switching](#languages-i18n) | | `auth-refresh.spec.ts` | `e2e-tests/compose.auth.yml` | The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared | | `oauth-login.spec.ts` | `e2e-tests/compose.oauth.yml` | Another app logging in through us — register a client, start an authorization flow, `/oauth2/login` accept, `/oauth2/consent` Allow → authorization code | -| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, an anonymous `/settings` visit signing in first, logout | +| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout | | `devstack-login.spec.ts` | `compose.override.yml` + `e2e-tests/compose.devstack.yml` | The plain `docker compose up` topology on the **host network**: sign-in works both from the banner's `localhost:3000` and from `127.0.0.1:3000` via the [canonical-host redirect](#canonical-host-one-public-url) | ```bash diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index d0ee3eb..8f90403 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -242,8 +242,6 @@ test("return_to: a deep link while logged out returns to that page after login", test("settings: an anonymous visit signs in first, then lands on the settings page", async ({ page }) => { test.setTimeout(90_000); - // /settings is public (recovery lands there with a Kratos session but no app JWT), so Kratos' 401 - // for an anonymous settings flow must send the visitor to sign in, not to the 500 page. await page.goto("/settings"); await expect(page).toHaveURL(/\/login(\?|$)/); await page.fill('input[name="identifier"]', ADMIN_EMAIL); diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 6003879..3354b81 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -38,13 +38,10 @@ const FORBIDDEN: RouteResult = { status: 403, view: "403" }; const selfOrigin = (ctx: RequestContext, secure: boolean): string => `${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`; -// Where Kratos lands the browser after a freshly initialised flow, or undefined for its default -// completion route. A first-party deep link (host-relative, from the gate's return_to) is wrapped -// through /auth/complete so the session JWT is minted before the user reaches the page; an absolute -// target (the OAuth2 login challenge) is passed as-is — Kratos allow-lists it. localPath rejects an -// off-origin "//evil.com". The target is the host's, not Kratos', so the language the visitor picked -// on the sign-in page survives the round-trip and lands on the page after it; without this the -// most-travelled path in the product (pick Swedish → sign in) drops back to Accept-Language. +// Where Kratos lands the browser after sign-in, or undefined for its default. A host-relative +// return_to (the gate's deep link) or a chosen language goes through /auth/complete, which mints the +// session JWT first and carries the language across Kratos; any other target (the OAuth2 login +// challenge) is passed as-is, since Kratos allow-lists it. function flowReturnTo(ctx: RequestContext, secureCookies: boolean): string | undefined { const raw = ctx.url.searchParams.get("return_to"); const local = localPath(raw); @@ -56,13 +53,14 @@ function flowReturnTo(ctx: RequestContext, secureCookies: boolean): string | und return complete.toString(); } -// What a failed Kratos call renders instead of the flow; rethrows what is genuinely unexpected. +// What a failed Kratos call returns instead of the flow; rethrows what is genuinely unexpected. function flowFailure(err: unknown, ctx: RequestContext, flowType: FlowType): RouteResult { const pathname = ctx.url.pathname; // Expired/unknown flow → restart by re-initialising (drop the stale ?flow=). if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname }; - // No Kratos session on a page that needs one (/settings is public, so the gate never sent them - // to sign in): sign in, then come back. Not for /login itself, which would redirect to itself. + // No Kratos session on a page that needs one. /settings is public, since recovery lands there with + // a Kratos session but no app JWT, so the gate never sends an anonymous visitor to sign in. + // Not for /login itself, which would redirect to itself. if (err instanceof KratosError && err.status === 401 && flowType !== "login") { return { redirect: `/login?return_to=${encodeURIComponent(pathname)}` }; } -- 2.52.0 From 5c84cc77fee2ee68f1d6b3a6f32aa792a57563b7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 09:10:57 +0100 Subject: [PATCH 5/7] =?UTF-8?q?Answer=20product-owner=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20one=20?= =?UTF-8?q?=F0=9F=9F=A2=20declined?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0 From dfd26ada3404a300c77ee8faf35558d95267488a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 09:11:39 +0100 Subject: [PATCH 6/7] File the absolute return_to dropped by a chosen locale as item 39 --- todo.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/todo.md b/todo.md index 6f70512..de02fc6 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 1` -`Next ID = 39` +`Next ID = 40` ## Items @@ -15,6 +15,7 @@ | 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 | | 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 | | 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 | +| 39 | 0.4.21 | | | **Pass an absolute `return_to` on to Kratos even when the URL also carries a chosen `locale`.** | 1 | 2 | 2 | 3 | 2 | 2 by 2 | 2.8 | | 22 | 0.4.21 | | | **Offer a `users:read` holder a View row action on the users list, not Edit.** | 2 | 2 | 1 | 3 | 3 | 2 by 2 | 2.1 | | 34 | 0.4.21 | | | **Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.** | 4 | 2 | 3 | 7 | 6 | 8 by 6, 2 by 2 | 1.8 | | 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 | @@ -49,6 +50,17 @@ Sketch: post the rendered set as a hidden baseline; when it no longer matches Ke half-applied set behind the generic error page. The `alert alert-pos` pattern of the recovery-code banner is available. +### 39. Pass an absolute `return_to` on to Kratos even when the URL also carries a chosen `locale`. + +`flowReturnTo` (`src/auth/routes.ts`) returns the absolute target as-is only when no locale is chosen. +With `/login?return_to=&locale=sv-SE` and no `?flow=`, it builds +`/auth/complete?locale=sv-SE` instead and drops the absolute target, so after sign-in the +visitor lands on the dashboard and the OAuth2 login challenge never continues. Normal paths don't hit +it, because Hydra's redirect carries no `locale` and the language picker acts on a page that already +holds a `?flow=`; a hand-built URL does. Fix by keeping the absolute target when `localPath` rejects +it, losing the language across that round-trip instead, and add the case to the `login return_to` +test in `src/http/app.test.ts`. + ### 22. Offer a `users:read` holder a View row action on the users list, not Edit. The pencil opens the read-only detail page, so it is harmless, but the label contradicts what the -- 2.52.0 From 76995008ebcb17d42d81a3cb2e7d0dfd64d4ee77 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 09:15:12 +0100 Subject: [PATCH 7/7] Remove the shipped item 20 from the plan --- todo.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/todo.md b/todo.md index de02fc6..726b815 100644 --- a/todo.md +++ b/todo.md @@ -12,7 +12,6 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 | | 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 | | 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 | | 39 | 0.4.21 | | | **Pass an absolute `return_to` on to Kratos even when the URL also carries a chosen `locale`.** | 1 | 2 | 2 | 3 | 2 | 2 by 2 | 2.8 | @@ -33,12 +32,6 @@ ## Details -### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500. - -`flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no -session. `/settings` stays `public`, since recovery lands there with a Kratos session but no app JWT; -a 401 should redirect to `/login` with the page as `return_to`. Add the missing E2E. - ### 4. Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost. Sketch: post the rendered set as a hidden baseline; when it no longer matches Keto, re-render with -- 2.52.0