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,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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user