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,229 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import {
|
||||
enrollPasskey,
|
||||
passkeyErrorMessage,
|
||||
} from '@/lib/auth/webauthn-client';
|
||||
|
||||
/**
|
||||
* Remove any leftover *unverified* factors of a given type before starting a
|
||||
* fresh enrollment. GoTrue rejects a second enroll with a duplicate friendly
|
||||
* name, and abandoned/unverified factors would otherwise block re-enrollment
|
||||
* (e.g. after a refresh mid-flow). Unenrolling unverified factors does not
|
||||
* require aal2.
|
||||
*/
|
||||
async function cleanupUnverified(
|
||||
supabase: SupabaseClient,
|
||||
factorType: 'totp' | 'webauthn',
|
||||
): Promise<void> {
|
||||
const { data } = await supabase.auth.mfa.listFactors();
|
||||
const stale =
|
||||
data?.all.filter(
|
||||
(f) => f.factor_type === factorType && f.status === 'unverified',
|
||||
) ?? [];
|
||||
for (const f of stale) {
|
||||
await supabase.auth.mfa.unenroll({ factorId: f.id });
|
||||
}
|
||||
}
|
||||
|
||||
/** TOTP authenticator-app enrollment: QR + manual secret, then code verify. */
|
||||
export function TotpEnrollPanel({
|
||||
supabase,
|
||||
onVerified,
|
||||
}: {
|
||||
supabase: SupabaseClient;
|
||||
onVerified: () => void;
|
||||
}) {
|
||||
const [stage, setStage] = useState<'idle' | 'pending'>('idle');
|
||||
const [qr, setQr] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [factorId, setFactorId] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function start() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await cleanupUnverified(supabase, 'totp');
|
||||
const { data, error } = await supabase.auth.mfa.enroll({
|
||||
factorType: 'totp',
|
||||
friendlyName: 'Authenticator app',
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || !('totp' in data)) {
|
||||
throw new Error('Unexpected enrollment response.');
|
||||
}
|
||||
setFactorId(data.id);
|
||||
setQr(data.totp.qr_code);
|
||||
setSecret(data.totp.secret);
|
||||
setStage('pending');
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data: ch, error: ce } = await supabase.auth.mfa.challenge({
|
||||
factorId,
|
||||
});
|
||||
if (ce) throw ce;
|
||||
const { error: ve } = await supabase.auth.mfa.verify({
|
||||
factorId,
|
||||
challengeId: ch.id,
|
||||
code: code.trim(),
|
||||
});
|
||||
if (ve) throw ve;
|
||||
onVerified();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (stage === 'idle') {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Authenticator app</h2>
|
||||
<p className="muted">
|
||||
Use an app like Google Authenticator, 1Password, or Authy to generate
|
||||
6-digit codes.
|
||||
</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="button" onClick={start} disabled={busy}>
|
||||
{busy ? 'Preparing…' : 'Set up authenticator app'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Scan the QR code</h2>
|
||||
<p className="muted">
|
||||
Scan this with your authenticator app, then enter the 6-digit code to
|
||||
confirm.
|
||||
</p>
|
||||
{qr && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={qr}
|
||||
alt="TOTP QR code"
|
||||
width={200}
|
||||
height={200}
|
||||
style={{ background: '#fff', padding: 8, borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
<p className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
Can't scan? Enter this secret manually:
|
||||
</p>
|
||||
<div className="token">{secret}</div>
|
||||
<form onSubmit={verify}>
|
||||
<label htmlFor="totp-code">6-digit code</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
required
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button type="submit" disabled={busy || code.trim().length < 6}>
|
||||
{busy ? 'Verifying…' : 'Verify and enable'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Passkey (WebAuthn) enrollment via the simplewebauthn ceremony. */
|
||||
export function PasskeyEnrollPanel({
|
||||
supabase,
|
||||
onVerified,
|
||||
}: {
|
||||
supabase: SupabaseClient;
|
||||
onVerified: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function start() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await cleanupUnverified(supabase, 'webauthn');
|
||||
await enrollPasskey(supabase, 'Passkey');
|
||||
onVerified();
|
||||
} catch (e) {
|
||||
setError(passkeyErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Passkey</h2>
|
||||
<p className="muted">
|
||||
Use your device's biometrics, screen lock, or a hardware security
|
||||
key.
|
||||
</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="button" onClick={start} disabled={busy}>
|
||||
{busy ? 'Waiting for passkey…' : 'Set up a passkey'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One-time display of freshly generated recovery codes. */
|
||||
export function RecoveryCodesPanel({ codes }: { codes: string[] }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard
|
||||
.writeText(codes.join('\n'))
|
||||
.then(() => setCopied(true))
|
||||
.catch(() => setCopied(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Save your recovery codes</h2>
|
||||
<p className="muted">
|
||||
Store these somewhere safe. Each code can be used once to recover access
|
||||
if you lose your authenticator and passkeys. They will not be shown
|
||||
again.
|
||||
</p>
|
||||
<div
|
||||
className="token"
|
||||
style={{ display: 'grid', gap: '0.25rem', lineHeight: 1.8 }}
|
||||
>
|
||||
{codes.map((c) => (
|
||||
<span key={c}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button type="button" className="secondary" onClick={copy}>
|
||||
{copied ? 'Copied' : 'Copy codes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user