From 6a68ecb4d4b6faafee46a64366ceffbaacda2d02 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 4 Sep 2026 17:00:39 +0200 Subject: [PATCH] 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 --- app/(auth)/mfa/verify/page.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx index c98f3b15..df3aa266 100644 --- a/app/(auth)/mfa/verify/page.tsx +++ b/app/(auth)/mfa/verify/page.tsx @@ -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'),