fix: sign-in MFA flow overhaul — passkey verify, backup codes, defaults
All checks were successful
CI / build (push) Successful in 2m19s
CI / docker (push) Successful in 1m4s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 20:49:32 +02:00
parent 040ae60be5
commit 0481cefaf4
2 changed files with 76 additions and 19 deletions

View File

@@ -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<string[] | null>(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<string, unknown>);
const { verificationId, authenticationOptions } = await startWebAuthnAuth();
const credential = await startAuthentication({ optionsJSON: authenticationOptions as any });
await verifyWebAuthnAuth(verificationId, credential as unknown as Record<string, unknown>);
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.
</p>
</div>
{error && <Alert variant="error">{error}</Alert>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
Use passkey
@@ -829,7 +847,6 @@ export function SignInPage() {
Scan this QR code with your authenticator app, then enter the 6-digit code.
</p>
</div>
{error && <Alert variant="error">{error}</Alert>}
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
<img src={totpSetup.secretQrCode} alt="TOTP QR Code" width={180} height={180} />
</div>
@@ -866,6 +883,47 @@ export function SignInPage() {
</form>
</div>
)}
{/* --- MFA enrollment: backup codes --- */}
{mode === 'mfaEnrollBackupCodes' && backupCodes && (
<div className={styles.fields}>
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Save your backup codes</h2>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
Store these codes safely. Each can be used once to sign in if you lose access to your authenticator or passkey.
</p>
</div>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: '6px 20px', padding: 12,
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.85rem',
}}>
{backupCodes.map((c) => <span key={c}>{c}</span>)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(backupCodes.join('\n'))}>
Copy all
</Button>
<Button variant="secondary" onClick={() => {
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'cameleer-backup-codes.txt'; a.click();
URL.revokeObjectURL(url);
}}>
Download
</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={backupCodesSaved} onChange={(e) => setBackupCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<Button variant="primary" disabled={!backupCodesSaved} onClick={handleBackupCodesDone} loading={loading}>
Continue
</Button>
</div>
)}
</div>
</Card>
</div>

View File

@@ -277,18 +277,17 @@ export async function submitMfa(_verificationId: string): Promise<string> {
// --- WebAuthn MFA Verification ---
export async function startWebAuthnAuth(): Promise<Record<string, unknown>> {
export async function startWebAuthnAuth(): Promise<{ verificationId: string; authenticationOptions: Record<string, unknown> }> {
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<string, unknown>): Promise<string> {
const res = await request('POST', '/verification/web-authn/authentication/verify', payload);
export async function verifyWebAuthnAuth(verificationId: string, payload: Record<string, unknown>): Promise<string> {
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) {