Files
linumiq_net-web_app/app/api/security/unenroll/route.ts
T

67 lines
2.5 KiB
TypeScript

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<NextResponse> {
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 });
}