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
+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>
);
}