'use client'; import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; import type { SupabaseClient } from '@supabase/supabase-js'; import { MFA_RP_ID, getAppOrigin } from './mfa'; /** * WebAuthn (passkey) MFA ceremonies. * * supabase-js 2.106 ships a native WebAuthn helper, but its verify step expects * the raw browser `Credential` object, whereas we run the ceremony with * `@simplewebauthn/browser` (which yields the W3C JSON form). So we drive the * GoTrue REST endpoints directly with the user's bearer token and feed the JSON * options to simplewebauthn, then persist the returned aal2 session via * `setSession`. The RP id/origins are client-supplied (GoTrue v2.186 reads them * from the challenge/verify body). */ const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; type GoTrueJson = Record & { msg?: string; error?: string; error_description?: string; }; async function getAccessToken(supabase: SupabaseClient): Promise { const { data } = await supabase.auth.getSession(); const token = data.session?.access_token; if (!token) throw new Error('No active session.'); return token; } async function gotrue( token: string, path: string, body: unknown, ): Promise { const res = await fetch(`${SUPABASE_URL}/auth/v1/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', apikey: ANON_KEY, Authorization: `Bearer ${token}`, }, body: JSON.stringify(body), }); const json = (await res.json().catch(() => ({}))) as GoTrueJson; if (!res.ok) { throw new Error( json.msg || json.error_description || json.error || `Request failed (${res.status})`, ); } return json; } async function applyTokens( supabase: SupabaseClient, json: GoTrueJson, ): Promise { const access_token = json.access_token as string | undefined; const refresh_token = json.refresh_token as string | undefined; if (access_token && refresh_token) { await supabase.auth.setSession({ access_token, refresh_token }); } } function webauthnBody(extra: Record = {}) { return { rpId: MFA_RP_ID, rpOrigins: [getAppOrigin()], ...extra }; } /** * Enroll + verify a brand new passkey. Requires the browser to be online and a * platform/cross-platform authenticator available. On success the session is * upgraded to aal2. */ export async function enrollPasskey( supabase: SupabaseClient, friendlyName: string, ): Promise { const token = await getAccessToken(supabase); const factor = await gotrue(token, 'factors', { factor_type: 'webauthn', friendly_name: friendlyName, }); const factorId = factor.id as string; const challenge = await gotrue(token, `factors/${factorId}/challenge`, { webauthn: webauthnBody(), }); const webauthn = challenge.webauthn as { credential_options: { publicKey: unknown }; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const attResp = await startRegistration({ optionsJSON: webauthn.credential_options.publicKey as any, }); const verify = await gotrue(token, `factors/${factorId}/verify`, { challenge_id: challenge.id, webauthn: webauthnBody({ type: 'create', credential_response: attResp }), }); await applyTokens(supabase, verify); } /** * Run a passkey assertion against an already-verified factor to step the * session up to aal2. */ export async function stepUpWithPasskey( supabase: SupabaseClient, factorId: string, ): Promise { const token = await getAccessToken(supabase); const challenge = await gotrue(token, `factors/${factorId}/challenge`, { webauthn: webauthnBody(), }); const webauthn = challenge.webauthn as { credential_options: { publicKey: unknown }; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const asseResp = await startAuthentication({ optionsJSON: webauthn.credential_options.publicKey as any, }); const verify = await gotrue(token, `factors/${factorId}/verify`, { challenge_id: challenge.id, webauthn: webauthnBody({ type: 'request', credential_response: asseResp }), }); await applyTokens(supabase, verify); } /** Human-friendly message for the common WebAuthn ceremony failures. */ export function passkeyErrorMessage(e: unknown): string { const err = e as { name?: string; message?: string }; if (err?.name === 'NotAllowedError' || err?.name === 'AbortError') { return 'Passkey prompt was dismissed or timed out. Please try again.'; } return err?.message || 'Passkey operation failed.'; }