feat(auth): mandatory 2FA (TOTP + WebAuthn passkeys) with hard enrollment gate, AAL2 step-up, and single-use recovery codes
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createSupabaseBrowserClient } from '@/lib/supabase/browser';
|
||||
import {
|
||||
passkeyErrorMessage,
|
||||
stepUpWithPasskey,
|
||||
} from '@/lib/auth/webauthn-client';
|
||||
|
||||
/**
|
||||
* AAL2 step-up UI: verify with TOTP or passkey, or fall back to a single-use
|
||||
* recovery code (which resets MFA and bounces the user back to /login to set up
|
||||
* a fresh factor).
|
||||
*/
|
||||
export function ChallengeClient({
|
||||
totpFactorId,
|
||||
passkeyFactorId,
|
||||
next,
|
||||
}: {
|
||||
totpFactorId: string | null;
|
||||
passkeyFactorId: string | null;
|
||||
next: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const supabase = useMemo(() => createSupabaseBrowserClient(), []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [showRecovery, setShowRecovery] = useState(false);
|
||||
const [recovery, setRecovery] = useState('');
|
||||
|
||||
function done() {
|
||||
router.replace(next);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function verifyTotp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!totpFactorId) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data: ch, error: ce } = await supabase.auth.mfa.challenge({
|
||||
factorId: totpFactorId,
|
||||
});
|
||||
if (ce) throw ce;
|
||||
const { error: ve } = await supabase.auth.mfa.verify({
|
||||
factorId: totpFactorId,
|
||||
challengeId: ch.id,
|
||||
code: code.trim(),
|
||||
});
|
||||
if (ve) throw ve;
|
||||
done();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPasskey() {
|
||||
if (!passkeyFactorId) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await stepUpWithPasskey(supabase, passkeyFactorId);
|
||||
done();
|
||||
} catch (e) {
|
||||
setError(passkeyErrorMessage(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemRecovery(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/security/recovery/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: recovery.trim() }),
|
||||
});
|
||||
const json = (await res.json()) as { ok?: boolean; error?: string };
|
||||
if (!res.ok || !json.ok) {
|
||||
throw new Error(
|
||||
json.error === 'invalid_code'
|
||||
? 'That recovery code is invalid or already used.'
|
||||
: 'Recovery failed. Please try again.',
|
||||
);
|
||||
}
|
||||
// Deleting verified factors logs the user out everywhere; clear local
|
||||
// cookies and return to login to re-enroll.
|
||||
await supabase.auth.signOut().catch(() => {});
|
||||
router.replace('/login?mfa_reset=1');
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{passkeyFactorId && (
|
||||
<div className="card">
|
||||
<h2>Passkey</h2>
|
||||
<p className="muted">
|
||||
Verify with your device biometrics or security key.
|
||||
</p>
|
||||
<button type="button" onClick={verifyPasskey} disabled={busy}>
|
||||
{busy ? 'Waiting…' : 'Use a passkey'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpFactorId && (
|
||||
<div className="card">
|
||||
<h2>Authenticator app</h2>
|
||||
<form onSubmit={verifyTotp}>
|
||||
<label htmlFor="challenge-code">6-digit code</label>
|
||||
<input
|
||||
id="challenge-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
required
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button type="submit" disabled={busy || code.trim().length < 6}>
|
||||
{busy ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2>Lost access?</h2>
|
||||
{showRecovery ? (
|
||||
<form onSubmit={redeemRecovery}>
|
||||
<p className="muted">
|
||||
Enter a recovery code. This removes your current two-factor
|
||||
methods so you can set up new ones after signing in again.
|
||||
</p>
|
||||
<label htmlFor="recovery-code">Recovery code</label>
|
||||
<input
|
||||
id="recovery-code"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="xxxx-xxxx-xxxx"
|
||||
required
|
||||
value={recovery}
|
||||
onChange={(e) => setRecovery(e.target.value)}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-danger"
|
||||
disabled={busy || recovery.trim().length === 0}
|
||||
>
|
||||
{busy ? 'Working…' : 'Use recovery code'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setShowRecovery(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
Use a recovery code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user