feat: full MFA enrollment during sign-in — passkey + TOTP + backup codes
- 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:
@@ -9,12 +9,13 @@ import {
|
||||
verifyTotp, verifyBackupCode, submitMfa,
|
||||
startWebAuthnAuth, verifyWebAuthnAuth,
|
||||
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
|
||||
createTotpSecret, verifyTotpSetup,
|
||||
skipMfaEnrollment, submitInteraction,
|
||||
MfaRequiredError, MfaEnrollmentError,
|
||||
} from './experience-api';
|
||||
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 = [
|
||||
"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() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
@@ -314,6 +318,7 @@ export function SignInPage() {
|
||||
const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any });
|
||||
const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>);
|
||||
await bindMfaProfile('WebAuthn', verifiedId);
|
||||
await bindMfaProfile('BackupCode');
|
||||
const result = await submitInteraction();
|
||||
window.location.replace(result);
|
||||
} 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() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -758,19 +794,22 @@ export function SignInPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- MFA enrollment: offer passkey registration --- */}
|
||||
{/* --- MFA enrollment: choose method --- */}
|
||||
{mode === 'mfaEnroll' && (
|
||||
<div className={styles.fields}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2>
|
||||
<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>
|
||||
</div>
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
|
||||
Register passkey
|
||||
Use passkey
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleStartTotpEnroll} disabled={loading}>
|
||||
Use authenticator app
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
|
||||
Set up later
|
||||
@@ -778,6 +817,53 @@ export function SignInPage() {
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -322,14 +322,35 @@ export async function verifyWebAuthnRegistration(verificationId: string, payload
|
||||
return data.verificationId;
|
||||
}
|
||||
|
||||
export async function bindMfaProfile(type: string, verificationId: string): Promise<void> {
|
||||
const res = await request('POST', '/profile/mfa', { type, verificationId });
|
||||
export async function bindMfaProfile(type: string, verificationId?: string): Promise<void> {
|
||||
const body: Record<string, string> = { type };
|
||||
if (verificationId) body.verificationId = verificationId;
|
||||
const res = await request('POST', '/profile/mfa', body);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
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> {
|
||||
await skipMfaBinding();
|
||||
const result = await trySubmit();
|
||||
|
||||
Reference in New Issue
Block a user