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 }