From 24d836f3e23c3fb64d5362fc9531cb830075616e Mon Sep 17 00:00:00 2001 From: bulbashenko Date: Sun, 11 Oct 2026 18:32:00 +0200 Subject: [PATCH] Ask for the second factor instead of looping between / and /login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the password accepted but the second factor still pending, the session is aal1. Opening / (or any return_to) then looped until the browser gave up with ERR_TOO_MANY_REDIRECTS: / toSession is 403 (highest_available wants aal2) → /login /login no flow → /self-service/login/browser, without aal=aal2 Kratos a session exists → "already logged in" → return_to, by default / It happened whenever someone left the 2FA screen, reopened the portal or an app, or the 2FA flow expired (Kratos sends the browser to /login?flow=, so a restarted flow had no aal=aal2 either). A fresh login flow now asks Kratos for aal2 when whoami answers session_aal2_required. The e2e test reproduces the loop on the old code. Co-Authored-By: Claude Opus 5.5 --- e2e/tests/01-login.spec.ts | 9 ++++++++- ui/src/lib/flows.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/e2e/tests/01-login.spec.ts b/e2e/tests/01-login.spec.ts index fb8cecc..6a7d378 100644 --- a/e2e/tests/01-login.spec.ts +++ b/e2e/tests/01-login.spec.ts @@ -35,7 +35,14 @@ test("password, TOTP enrolment, then every sign-in needs the second factor", asy await page.getByRole("link", { name: /Выйти/ }).first().click() await page.goto("/login") await passwordStep(page, person) - await expect(page.getByRole("heading", { name: new RegExp(`Привет, ${person.username}|Ещё шаг`) })).toBeVisible() + const secondFactor = page.getByRole("heading", { name: new RegExp(`Привет, ${person.username}|Ещё шаг`) }) + await expect(secondFactor).toBeVisible() + + // Leaving the second-factor screen with only the password done leads back to it, not into a redirect loop. + for (const path of ["/", "/login"]) { + await page.goto(path) + await expect(secondFactor).toBeVisible() + } await totpStep(page, "000000") await expect(page.locator(".msg.error")).toContainText(/Код не подошёл|invalid/) diff --git a/ui/src/lib/flows.ts b/ui/src/lib/flows.ts index 48ea10f..1327dcd 100644 --- a/ui/src/lib/flows.ts +++ b/ui/src/lib/flows.ts @@ -28,6 +28,30 @@ export function startFlow(kind: FlowKind, params: SearchParams): never { redirect(`${env.publicUrl}/self-service/${kind}/browser${query.size ? `?${query}` : ""}`) } +/** + * Starts a fresh flow. A login started while the session has only its first factor asks for the second (aal=aal2). + * Without it Kratos answers "already signed in" and sends the browser to return_to (by default /), which needs two + * factors and sends it back to /login: a redirect loop. + */ +async function restartFlow(kind: FlowKind, params: SearchParams): Promise { + if (kind === "login" && !first(params.aal) && !first(params.refresh) && (await needsSecondFactor())) { + startFlow(kind, { ...params, aal: "aal2" }) + } + startFlow(kind, params) +} + +/** True when the browser's session lacks the second factor Kratos requires (required_aal: highest_available). */ +async function needsSecondFactor() { + try { + await frontend().toSession({ cookie: await cookieHeader() }) + return false + } catch (error) { + if (!(error instanceof ResponseError) || error.response.status !== 403) return false + const body = (await error.response.json().catch(() => ({}))) as { error?: { id?: string } } + return body.error?.id === "session_aal2_required" + } +} + async function cookieHeader() { return (await headers()).get("cookie") ?? undefined } @@ -38,7 +62,7 @@ async function cookieHeader() { */ export async function loadFlow(kind: FlowKind, params: SearchParams): Promise { const id = first(params.flow) - if (!id) startFlow(kind, params) + if (!id) return restartFlow(kind, params) const cookie = await cookieHeader() const api = frontend() try { @@ -73,7 +97,7 @@ async function handleFlowError(error: unknown, kind: FlowKind, params: SearchPar // 403 (CSRF / another browser), 404 (unknown), 410 (expired): a fresh flow. if ([403, 404, 410].includes(status)) { const { flow: _drop, ...rest } = params - startFlow(kind, rest) + await restartFlow(kind, rest) } throw error }