fix(mfa): hard-navigate after TOTP verify, same race as #1984 on enroll (#2275)

handleVerify in /mfa/verify ended its default path with router.push(...)
followed by router.refresh(). Verifying the challenge raises the session
to aal2, which lib/supabase/middleware.ts only re-evaluates on a fresh
document request; the push and the refresh raced, the refresh won, and
the user stayed on the code screen with a session that was already aal2.
Re-entering the same TOTP code is then rejected as reuse, which bumps the
lockout counter and makes MFA look broken. PR #1984 closed the identical
shape in leave() on /mfa/enroll (#1948) and listed this file as the
follow-up.

Always window.location.assign(...) instead, exactly as #1984 did, and
fold the now-redundant /api/ returnTo branch into the same call. The
WL-14 cockpit landing is unchanged: resolvePostLoginDestination() is
still awaited before navigating, and it only ever returns '/clients' or
'/'. returnTo is already validated by safeReturnTo, so the unconditional
hard navigation stays same-origin. The invite path keeps its own
window.location.href = '/' since it deliberately ignores returnTo.

Accepted trade-off, same as #1984 and DECISIONS.md 2026-07-26: a toast
fired before the hard navigation does not survive the full page load. On
this page that is the invite-problem warning; the invite cookie survives
non-definitive outcomes so /onboarding and /select-company retry
acceptance server-side.

Fixes #2056


Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-04 17:00:39 +02:00
committed by GitHub
parent 417abfbe1f
commit 6a68ecb4d4
+12 -9
View File
@@ -145,20 +145,23 @@ function MfaVerifyContent() {
return
}
if (returnTo.startsWith('/api/')) {
// Route-handler destinations (e.g. the MCP OAuth consent page)
// return raw HTML the client router cannot render: hard-navigate.
window.location.assign(returnTo)
return
}
// Hosted byrå staff hit MFA before any dashboard, so the cockpit
// landing (WL-14) resolves here too: only when no explicit step-up
// destination was requested. Everyone else keeps returnTo/'/' exactly
// as before (the helper degrades to '/' on any failure). The session
// is AAL2 at this point, so the /api MFA gate passes.
router.push(returnTo === '/' ? await resolvePostLoginDestination() : returnTo)
router.refresh()
//
// Always a hard navigation, for two reasons that point the same way.
// Route-handler destinations (e.g. the MCP OAuth consent page) return
// raw HTML the client router cannot render. And verifying raises the
// session to aal2, which lib/supabase/middleware.ts only re-evaluates
// on a fresh document request: `router.push` followed by
// `router.refresh` raced, the refresh won, and the user stayed on the
// code screen; re-entering the same code is then rejected as reuse and
// bumps the lockout counter (#2056, the shape #1984 fixed on enroll).
// returnTo went through safeReturnTo and the helper only ever returns
// '/clients' or '/', so the navigation stays same-origin.
window.location.assign(returnTo === '/' ? await resolvePostLoginDestination() : returnTo)
} catch {
toast({
title: t('verify_failed_title'),