feat: full MFA enrollment during sign-in — passkey + TOTP + backup codes
All checks were successful
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m10s

- Bind BackupCode after primary MFA factor (WebAuthn or TOTP) to satisfy
  Logto's requirement that backup codes accompany any MFA method.
- Add TOTP enrollment option alongside passkey on the enrollment screen:
  "Use passkey" / "Use authenticator app" / "Set up later".
- TOTP enrollment shows QR code + secret + 6-digit verification inline
  in the sign-in UI, using Experience API endpoints.
- Added createTotpSecret() and verifyTotpSetup() to experience-api.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 19:22:53 +02:00
parent c4fe16048c
commit d8f7452ab7
2 changed files with 114 additions and 7 deletions

View File

@@ -9,12 +9,13 @@ import {
verifyTotp, verifyBackupCode, submitMfa, verifyTotp, verifyBackupCode, submitMfa,
startWebAuthnAuth, verifyWebAuthnAuth, startWebAuthnAuth, verifyWebAuthnAuth,
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile, startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
createTotpSecret, verifyTotpSetup,
skipMfaEnrollment, submitInteraction, skipMfaEnrollment, submitInteraction,
MfaRequiredError, MfaEnrollmentError, MfaRequiredError, MfaEnrollmentError,
} from './experience-api'; } from './experience-api';
import styles from './SignInPage.module.css'; import styles from './SignInPage.module.css';
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll'; type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp';
const SIGN_IN_SUBTITLES = [ const SIGN_IN_SUBTITLES = [
"Prove you're not a mirage", "Prove you're not a mirage",
@@ -305,7 +306,10 @@ export function SignInPage() {
} }
}; };
// --- MFA enrollment: passkey registration --- // --- MFA enrollment ---
const [totpSetup, setTotpSetup] = useState<{ secret: string; secretQrCode: string; verificationId: string } | null>(null);
const [totpCode, setTotpCode] = useState('');
async function handleEnrollPasskey() { async function handleEnrollPasskey() {
setError(null); setError(null);
setLoading(true); setLoading(true);
@@ -314,6 +318,7 @@ export function SignInPage() {
const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any }); const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any });
const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>); const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>);
await bindMfaProfile('WebAuthn', verifiedId); await bindMfaProfile('WebAuthn', verifiedId);
await bindMfaProfile('BackupCode');
const result = await submitInteraction(); const result = await submitInteraction();
window.location.replace(result); window.location.replace(result);
} catch (err) { } catch (err) {
@@ -326,6 +331,37 @@ export function SignInPage() {
} }
} }
async function handleStartTotpEnroll() {
setError(null);
setLoading(true);
try {
const data = await createTotpSecret();
setTotpSetup(data);
setTotpCode('');
setMode('mfaEnrollTotp');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start TOTP setup');
} finally {
setLoading(false);
}
}
async function handleVerifyTotpEnroll(e: FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const verifiedId = await verifyTotpSetup(totpCode);
await bindMfaProfile('Totp', verifiedId);
await bindMfaProfile('BackupCode');
const result = await submitInteraction();
window.location.replace(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
}
async function handleSkipEnrollment() { async function handleSkipEnrollment() {
setLoading(true); setLoading(true);
try { try {
@@ -758,19 +794,22 @@ export function SignInPage() {
</div> </div>
)} )}
{/* --- MFA enrollment: offer passkey registration --- */} {/* --- MFA enrollment: choose method --- */}
{mode === 'mfaEnroll' && ( {mode === 'mfaEnroll' && (
<div className={styles.fields}> <div className={styles.fields}>
<div style={{ textAlign: 'center', marginBottom: 16 }}> <div style={{ textAlign: 'center', marginBottom: 16 }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2> <h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}> <p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
Add a passkey to sign in faster with your fingerprint, face, or security key. Add an extra layer of security to your account.
</p> </p>
</div> </div>
{error && <Alert variant="error">{error}</Alert>} {error && <Alert variant="error">{error}</Alert>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}> <Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
Register passkey Use passkey
</Button>
<Button variant="secondary" onClick={handleStartTotpEnroll} disabled={loading}>
Use authenticator app
</Button> </Button>
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}> <Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
Set up later Set up later
@@ -778,6 +817,53 @@ export function SignInPage() {
</div> </div>
</div> </div>
)} )}
{/* --- MFA enrollment: TOTP setup --- */}
{mode === 'mfaEnrollTotp' && totpSetup && (
<div className={styles.fields}>
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Set up authenticator</h2>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
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>
<div style={{
textAlign: 'center', padding: '6px 10px',
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.7rem',
wordBreak: 'break-all',
}}>
{totpSetup.secret}
</div>
<form onSubmit={handleVerifyTotpEnroll} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<FormField label="Verification code" htmlFor="enroll-totp-code">
<Input
id="enroll-totp-code"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
autoFocus
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={loading} disabled={totpCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setTotpSetup(null); setMode('mfaEnroll'); }} disabled={loading}>
Back
</Button>
</div>
</form>
</div>
)}
</div> </div>
</Card> </Card>
</div> </div>

View File

@@ -322,14 +322,35 @@ export async function verifyWebAuthnRegistration(verificationId: string, payload
return data.verificationId; return data.verificationId;
} }
export async function bindMfaProfile(type: string, verificationId: string): Promise<void> { export async function bindMfaProfile(type: string, verificationId?: string): Promise<void> {
const res = await request('POST', '/profile/mfa', { type, verificationId }); const body: Record<string, string> = { type };
if (verificationId) body.verificationId = verificationId;
const res = await request('POST', '/profile/mfa', body);
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to bind MFA (${res.status})`); throw new Error(err.message || `Failed to bind MFA (${res.status})`);
} }
} }
export async function createTotpSecret(): Promise<{ secret: string; secretQrCode: string; verificationId: string }> {
const res = await request('POST', '/verification/totp');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to create TOTP secret (${res.status})`);
}
return res.json();
}
export async function verifyTotpSetup(code: string): Promise<string> {
const res = await request('POST', '/verification/totp/verify', { code });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `TOTP verification failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function skipMfaEnrollment(): Promise<string> { export async function skipMfaEnrollment(): Promise<string> {
await skipMfaBinding(); await skipMfaBinding();
const result = await trySubmit(); const result = await trySubmit();