feat(auth): mandatory 2FA (TOTP + WebAuthn passkeys) with hard enrollment gate, AAL2 step-up, and single-use recovery codes

This commit is contained in:
Gerhard Scheikl
2026-05-31 21:38:01 +02:00
parent 129e21529c
commit e14e909700
19 changed files with 1310 additions and 142 deletions
+185
View File
@@ -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>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { redirect } from 'next/navigation';
import { createSupabaseServerClient } from '@/lib/supabase/server';
import { safeNextPath } from '@/lib/auth/mfa';
import { ChallengeClient } from './challenge-client';
export const dynamic = 'force-dynamic';
/**
* AAL2 step-up challenge. The middleware sends authenticated aal1 users here
* when they have verified factors. Users with no verified factors are sent to
* enrollment instead; users already at aal2 are forwarded to their target.
*/
export default async function ChallengePage({
searchParams,
}: {
searchParams: { next?: string };
}) {
const supabase = createSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const next = safeNextPath(searchParams.next);
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (aal?.currentLevel === 'aal2') redirect(next);
const { data: factors } = await supabase.auth.mfa.listFactors();
const verified = factors?.all.filter((f) => f.status === 'verified') ?? [];
if (verified.length === 0) redirect('/security/enroll');
const totp = verified.find((f) => f.factor_type === 'totp');
const passkey = verified.find((f) => f.factor_type === 'webauthn');
return (
<main className="container">
<h1>Verify it&apos;s you</h1>
<p className="muted">Complete two-factor authentication to continue.</p>
<ChallengeClient
totpFactorId={totp?.id ?? null}
passkeyFactorId={passkey?.id ?? null}
next={next}
/>
</main>
);
}
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createSupabaseBrowserClient } from '@/lib/supabase/browser';
import {
PasskeyEnrollPanel,
RecoveryCodesPanel,
TotpEnrollPanel,
} from '../mfa-components';
/**
* Drives the mandatory first-factor enrollment: pick TOTP and/or passkey, then
* (once a factor is verified and the session is aal2) generate and display the
* one-time recovery codes before releasing the user to the dashboard.
*/
export function EnrollClient() {
const router = useRouter();
const supabase = useMemo(() => createSupabaseBrowserClient(), []);
const [phase, setPhase] = useState<'choose' | 'recovery'>('choose');
const [codes, setCodes] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
async function handleVerified() {
setError(null);
try {
const res = await fetch('/api/security/recovery/generate', {
method: 'POST',
});
const json = (await res.json()) as { codes?: string[]; error?: string };
if (!res.ok || !json.codes) {
throw new Error(json.error || 'Could not generate recovery codes.');
}
setCodes(json.codes);
} catch (e) {
// The factor is already enabled; surface the error but still let the
// user proceed (they can regenerate codes later in Security settings).
setError(
`${(e as Error).message} You can generate recovery codes later in Security settings.`,
);
setCodes([]);
} finally {
setPhase('recovery');
}
}
function finish() {
router.replace('/dashboard');
router.refresh();
}
if (phase === 'recovery') {
return (
<div className="stack">
{error && <p className="error">{error}</p>}
{codes.length > 0 ? (
<RecoveryCodesPanel codes={codes} />
) : (
<div className="card">
<h2>Two-factor authentication enabled</h2>
<p className="muted">Your account is now protected.</p>
</div>
)}
<div className="row">
<button type="button" onClick={finish}>
Continue to dashboard
</button>
</div>
</div>
);
}
return (
<div className="stack">
<TotpEnrollPanel supabase={supabase} onVerified={handleVerified} />
<PasskeyEnrollPanel supabase={supabase} onVerified={handleVerified} />
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from 'next/navigation';
import { createSupabaseServerClient } from '@/lib/supabase/server';
import { EnrollClient } from './enroll-client';
export const dynamic = 'force-dynamic';
/**
* Mandatory MFA enrollment gate. Reachable only by authenticated users (the
* middleware sends users here when they have zero verified factors). If the
* user already has a verified factor, there is nothing to enroll, so we send
* them on to the dashboard.
*/
export default async function EnrollPage() {
const supabase = createSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const { data: factors } = await supabase.auth.mfa.listFactors();
const verified = factors?.all.filter((f) => f.status === 'verified') ?? [];
if (verified.length > 0) redirect('/dashboard');
return (
<main className="container">
<h1>Secure your account</h1>
<p className="muted">
Two-factor authentication is required. Add at least one method below to
continue.
</p>
<EnrollClient />
</main>
);
}
+229
View File
@@ -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&apos;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&apos;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>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { redirect } from 'next/navigation';
import { createSupabaseServerClient } from '@/lib/supabase/server';
import { SecurityClient } from './security-client';
export const dynamic = 'force-dynamic';
/**
* Security settings: manage MFA factors and regenerate recovery codes. The
* middleware guarantees the visitor is authenticated and at aal2 (mandatory
* MFA), so factor mutations here always run with the required assurance level.
*/
export default async function SecurityPage() {
const supabase = createSupabaseServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const { data: factors } = await supabase.auth.mfa.listFactors();
const verified =
factors?.all
.filter((f) => f.status === 'verified')
.map((f) => ({
id: f.id,
type: f.factor_type,
friendlyName: f.friendly_name ?? null,
createdAt: f.created_at,
})) ?? [];
return (
<main className="container">
<h1>Security</h1>
<p className="muted">Manage two-factor authentication for your account.</p>
<SecurityClient initialFactors={verified} />
</main>
);
}
+182
View File
@@ -0,0 +1,182 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createSupabaseBrowserClient } from '@/lib/supabase/browser';
import {
PasskeyEnrollPanel,
RecoveryCodesPanel,
TotpEnrollPanel,
} from './mfa-components';
type FactorView = {
id: string;
type: string;
friendlyName: string | null;
createdAt: string;
};
const TYPE_LABEL: Record<string, string> = {
totp: 'Authenticator app',
webauthn: 'Passkey',
phone: 'Phone',
};
/**
* Security settings client: list verified factors, add new ones, remove
* factors (never the last one — mandatory MFA), and regenerate recovery codes.
*/
export function SecurityClient({
initialFactors,
}: {
initialFactors: FactorView[];
}) {
const router = useRouter();
const supabase = useMemo(() => createSupabaseBrowserClient(), []);
const [factors] = useState<FactorView[]>(initialFactors);
const [adding, setAdding] = useState<null | 'totp' | 'webauthn'>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [codes, setCodes] = useState<string[] | null>(null);
const [regenBusy, setRegenBusy] = useState(false);
async function remove(id: string) {
if (factors.length <= 1) {
setError('You must keep at least one two-factor method enabled.');
return;
}
setError(null);
setNotice(null);
setBusyId(id);
try {
const { error } = await supabase.auth.mfa.unenroll({ factorId: id });
if (error) throw error;
router.refresh();
} catch (e) {
setError((e as Error).message);
} finally {
setBusyId(null);
}
}
function onAdded() {
setAdding(null);
setNotice('Two-factor method added.');
router.refresh();
}
async function regenerate() {
setError(null);
setNotice(null);
setCodes(null);
setRegenBusy(true);
try {
const res = await fetch('/api/security/recovery/generate', {
method: 'POST',
});
const json = (await res.json()) as { codes?: string[]; error?: string };
if (!res.ok || !json.codes) {
throw new Error(json.error || 'Could not generate recovery codes.');
}
setCodes(json.codes);
} catch (e) {
setError((e as Error).message);
} finally {
setRegenBusy(false);
}
}
return (
<div className="stack">
{error && <p className="error">{error}</p>}
{notice && <p className="success">{notice}</p>}
<div className="card">
<h2>Your two-factor methods</h2>
{factors.length === 0 ? (
<p className="muted">No methods enabled.</p>
) : (
<table className="admin-table">
<thead>
<tr>
<th>Method</th>
<th>Name</th>
<th>Added</th>
<th aria-label="actions" />
</tr>
</thead>
<tbody>
{factors.map((f) => (
<tr key={f.id}>
<td>{TYPE_LABEL[f.type] ?? f.type}</td>
<td>{f.friendlyName ?? '—'}</td>
<td>{new Date(f.createdAt).toLocaleDateString()}</td>
<td>
<button
type="button"
className="btn-sm btn-danger"
onClick={() => remove(f.id)}
disabled={busyId === f.id || factors.length <= 1}
title={
factors.length <= 1
? 'At least one method is required'
: 'Remove this method'
}
>
{busyId === f.id ? 'Removing…' : 'Remove'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{adding === 'totp' && (
<TotpEnrollPanel supabase={supabase} onVerified={onAdded} />
)}
{adding === 'webauthn' && (
<PasskeyEnrollPanel supabase={supabase} onVerified={onAdded} />
)}
{adding === null && (
<div className="card">
<h2>Add a method</h2>
<div className="row">
<button type="button" onClick={() => setAdding('totp')}>
Add authenticator app
</button>
<button
type="button"
className="secondary"
onClick={() => setAdding('webauthn')}
>
Add passkey
</button>
</div>
</div>
)}
<div className="card">
<h2>Recovery codes</h2>
<p className="muted">
Generate a new set of single-use recovery codes. This invalidates any
codes you were issued before.
</p>
{codes && <RecoveryCodesPanel codes={codes} />}
<div className="row" style={{ marginTop: '1rem' }}>
<button
type="button"
className="secondary"
onClick={regenerate}
disabled={regenBusy}
>
{regenBusy ? 'Generating…' : 'Regenerate recovery codes'}
</button>
</div>
</div>
</div>
);
}