From 0481cefaf4a8eb7e5a4e8d37f26f0b15d271cfd4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:49:32 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20sign-in=20MFA=20flow=20overhaul=20?= =?UTF-8?q?=E2=80=94=20passkey=20verify,=20backup=20codes,=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes for the MFA sign-in flow: 1. Fix passkey verify crash: extract authenticationOptions from Logto response (was passing full response as optionsJSON). Pass verificationId to the verify endpoint. 2. Default to passkey verification when no MFA method preference is stored (was showing method picker which offered TOTP to passkey-only users). 3. Show backup codes after MFA enrollment: new mfaEnrollBackupCodes mode with copy/download buttons and confirmation checkbox. Users must save codes before completing sign-in. 4. Remove duplicate error alerts in enrollment screens (top-level alert handles all modes). Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/sign-in/src/SignInPage.tsx | 86 ++++++++++++++++++++++++++------ ui/sign-in/src/experience-api.ts | 9 ++-- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx index 5429d07..f8703d9 100644 --- a/ui/sign-in/src/SignInPage.tsx +++ b/ui/sign-in/src/SignInPage.tsx @@ -15,7 +15,7 @@ import { } from './experience-api'; import styles from './SignInPage.module.css'; -type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp'; +type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp' | 'mfaEnrollBackupCodes'; const SIGN_IN_SUBTITLES = [ "Prove you're not a mirage", @@ -90,6 +90,8 @@ export function SignInPage() { const [confirmNewPassword, setConfirmNewPassword] = useState(''); const [webauthnError, setWebauthnError] = useState(''); const [webauthnLoading, setWebauthnLoading] = useState(false); + const [backupCodes, setBackupCodes] = useState(null); + const [backupCodesSaved, setBackupCodesSaved] = useState(false); // Fetch sign-in experience to check if registration is enabled useEffect(() => { @@ -130,12 +132,10 @@ export function SignInPage() { } catch (err) { if (err instanceof MfaRequiredError) { const pref = localStorage.getItem('mfa_method_preference'); - if (pref === 'webauthn') { - setMode('mfaWebauthn'); - } else if (pref === 'totp') { + if (pref === 'totp') { setMode('mfaVerify'); } else { - setMode('mfaMethodPicker'); + setMode('mfaWebauthn'); } setLoading(false); return; @@ -271,13 +271,17 @@ export function SignInPage() { setWebauthnError(''); setWebauthnLoading(true); try { - const options = await startWebAuthnAuth(); - const credential = await startAuthentication({ optionsJSON: options as any }); - const verificationId = await verifyWebAuthnAuth(credential as unknown as Record); + const { verificationId, authenticationOptions } = await startWebAuthnAuth(); + const credential = await startAuthentication({ optionsJSON: authenticationOptions as any }); + await verifyWebAuthnAuth(verificationId, credential as unknown as Record); localStorage.setItem('mfa_method_preference', 'webauthn'); const redirectTo = await submitMfa(verificationId); window.location.replace(redirectTo); } catch (err) { + if (err instanceof Error && err.name === 'NotAllowedError') { + setWebauthnLoading(false); + return; + } setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed'); setWebauthnLoading(false); } @@ -320,8 +324,10 @@ export function SignInPage() { await bindMfaProfile('WebAuthn', verifiedId); const bc = await generateBackupCodes(); await bindMfaProfile('BackupCode', bc.verificationId); - const result = await submitInteraction(); - window.location.replace(result); + setBackupCodes(bc.codes); + setBackupCodesSaved(false); + setMode('mfaEnrollBackupCodes'); + setLoading(false); } catch (err) { if (err instanceof Error && err.name === 'NotAllowedError') { setLoading(false); @@ -356,14 +362,27 @@ export function SignInPage() { await bindMfaProfile('Totp', verifiedId); const bc = await generateBackupCodes(); await bindMfaProfile('BackupCode', bc.verificationId); - const result = await submitInteraction(); - window.location.replace(result); + setBackupCodes(bc.codes); + setBackupCodesSaved(false); + setMode('mfaEnrollBackupCodes'); + setLoading(false); } catch (err) { setError(err instanceof Error ? err.message : 'Verification failed'); setLoading(false); } } + async function handleBackupCodesDone() { + setLoading(true); + try { + const redirectTo = await submitInteraction(); + window.location.replace(redirectTo); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to complete sign-in'); + setLoading(false); + } + } + async function handleSkipEnrollment() { setLoading(true); try { @@ -805,7 +824,6 @@ export function SignInPage() { Add an extra layer of security to your account.

- {error && {error}}
- {error && {error}}
TOTP QR Code
@@ -866,6 +883,47 @@ export function SignInPage() { )} + + {/* --- MFA enrollment: backup codes --- */} + {mode === 'mfaEnrollBackupCodes' && backupCodes && ( +
+
+

Save your backup codes

+

+ Store these codes safely. Each can be used once to sign in if you lose access to your authenticator or passkey. +

+
+
+ {backupCodes.map((c) => {c})} +
+
+ + +
+ + +
+ )} diff --git a/ui/sign-in/src/experience-api.ts b/ui/sign-in/src/experience-api.ts index 437721e..8e1c0d9 100644 --- a/ui/sign-in/src/experience-api.ts +++ b/ui/sign-in/src/experience-api.ts @@ -277,18 +277,17 @@ export async function submitMfa(_verificationId: string): Promise { // --- WebAuthn MFA Verification --- -export async function startWebAuthnAuth(): Promise> { +export async function startWebAuthnAuth(): Promise<{ verificationId: string; authenticationOptions: Record }> { const res = await request('POST', '/verification/web-authn/authentication'); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Failed to start passkey authentication (${res.status})`); } - const data = await res.json(); - return data; + return res.json(); } -export async function verifyWebAuthnAuth(payload: Record): Promise { - const res = await request('POST', '/verification/web-authn/authentication/verify', payload); +export async function verifyWebAuthnAuth(verificationId: string, payload: Record): Promise { + const res = await request('POST', '/verification/web-authn/authentication/verify', { verificationId, payload }); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) {