Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion e2e/tests/01-login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
28 changes: 26 additions & 2 deletions ui/src/lib/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> {
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
}
Expand All @@ -38,7 +62,7 @@ async function cookieHeader() {
*/
export async function loadFlow<T>(kind: FlowKind, params: SearchParams): Promise<T> {
const id = first(params.flow)
if (!id) startFlow(kind, params)
if (!id) return restartFlow(kind, params)
const cookie = await cookieHeader()
const api = frontend()
try {
Expand Down Expand Up @@ -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
}
Expand Down