67 lines
2.4 KiB
TypeScript
67 lines
2.4 KiB
TypeScript
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<NextResponse> {
|
|
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 });
|
|
}
|