import { NextResponse, type NextRequest } from 'next/server'; import { createSupabaseServerClient } from '@/lib/supabase/server'; import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { jsonNoStore } from '@/lib/admin/response'; import { hashRecoveryCode } from '@/lib/auth/recovery'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; /** * Redeem a single-use recovery code for account recovery. * * The user is authenticated (aal1) but locked out of step-up because they lost * their authenticator/passkey. On a valid code we mark it used and DELETE all * of the user's MFA factors via the admin API. GoTrue logs the user out of all * sessions when a verified factor is deleted, so the client signs out and * returns to /login; after signing back in (aal1, zero factors) the middleware * forces fresh enrollment. We never fabricate an aal2 JWT. */ 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 }); let body: { code?: string }; try { body = (await request.json()) as { code?: string }; } catch { return jsonNoStore({ error: 'bad_request' }, { status: 400 }); } const code = (body.code ?? '').trim(); if (!code) return jsonNoStore({ error: 'invalid_code' }, { status: 400 }); const admin = getSupabaseAdmin(); const hash = hashRecoveryCode(code); const { data: match, error } = await admin .from('mfa_recovery_codes') .select('id') .eq('user_id', user.id) .eq('code_hash', hash) .is('used_at', null) .limit(1) .maybeSingle<{ id: number }>(); if (error) return jsonNoStore({ error: 'failed' }, { status: 500 }); if (!match) return jsonNoStore({ error: 'invalid_code' }, { status: 400 }); const upd = await admin .from('mfa_recovery_codes') .update({ used_at: new Date().toISOString() }) .eq('id', match.id) .is('used_at', null); if (upd.error) return jsonNoStore({ error: 'failed' }, { status: 500 }); // Account recovery: remove every MFA factor so the user can re-enroll. const { data: list } = await admin.auth.admin.mfa.listFactors({ userId: user.id, }); for (const factor of list?.factors ?? []) { await admin.auth.admin.mfa.deleteFactor({ id: factor.id, userId: user.id }); } return jsonNoStore({ ok: true }); }