From 2256f8359b795261b4492f0630c2ee9c0a7908f3 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sun, 31 May 2026 23:02:40 +0200 Subject: [PATCH] feat(security): server-side guard preventing removal of last MFA factor --- app/api/security/unenroll/route.ts | 66 ++++++++++++++++++++++++++++++ app/security/security-client.tsx | 16 +++++++- 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 app/api/security/unenroll/route.ts diff --git a/app/api/security/unenroll/route.ts b/app/api/security/unenroll/route.ts new file mode 100644 index 0000000..8be610e --- /dev/null +++ b/app/api/security/unenroll/route.ts @@ -0,0 +1,66 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { jsonNoStore } from '@/lib/admin/response'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Remove one of the current user's MFA factors. Requires an aal2 session. + * + * Mandatory MFA means every user must keep at least one VERIFIED factor. The + * client disables the "Remove" button on the last factor, but that is only a + * first line of defense — this route is the real server-side guard: if deleting + * the requested factor would leave the user with zero verified factors we + * refuse with 409 and never call GoTrue. The factor must belong to the caller. + */ +export async function POST(request: NextRequest): Promise { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); + + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (aal?.currentLevel !== 'aal2') { + return jsonNoStore({ error: 'aal2_required' }, { status: 403 }); + } + + let body: { factorId?: string }; + try { + body = (await request.json()) as { factorId?: string }; + } catch { + return jsonNoStore({ error: 'bad_request' }, { status: 400 }); + } + const factorId = (body.factorId ?? '').trim(); + if (!factorId) { + return jsonNoStore({ error: 'invalid_factor' }, { status: 400 }); + } + + const { data: list, error: listErr } = await supabase.auth.mfa.listFactors(); + if (listErr) return jsonNoStore({ error: 'failed' }, { status: 500 }); + + const all = list?.all ?? []; + const target = all.find((f) => f.id === factorId); + if (!target) { + return jsonNoStore({ error: 'invalid_factor' }, { status: 404 }); + } + + // Only VERIFIED factors count as protection. If the user would be left with + // no verified factors after this removal, refuse. + const verified = all.filter((f) => f.status === 'verified'); + const remainingVerified = verified.filter((f) => f.id !== factorId).length; + if (target.status === 'verified' && remainingVerified === 0) { + return jsonNoStore( + { error: 'cannot_remove_last_factor' }, + { status: 409 }, + ); + } + + const { error: unenrollErr } = await supabase.auth.mfa.unenroll({ factorId }); + if (unenrollErr) { + return jsonNoStore({ error: unenrollErr.message }, { status: 400 }); + } + + return jsonNoStore({ ok: true }); +} diff --git a/app/security/security-client.tsx b/app/security/security-client.tsx index 5d115d7..15bf71f 100644 --- a/app/security/security-client.tsx +++ b/app/security/security-client.tsx @@ -50,8 +50,20 @@ export function SecurityClient({ setNotice(null); setBusyId(id); try { - const { error } = await supabase.auth.mfa.unenroll({ factorId: id }); - if (error) throw error; + const res = await fetch('/api/security/unenroll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ factorId: id }), + }); + if (!res.ok) { + const json = (await res.json().catch(() => null)) as { + error?: string; + } | null; + if (json?.error === 'cannot_remove_last_factor') { + throw new Error('You must keep at least one two-factor method enabled.'); + } + throw new Error(json?.error || 'Could not remove this method.'); + } router.refresh(); } catch (e) { setError((e as Error).message);