/** * Identity Management Service * Handles OAuth provider connections, disconnections, and password fallback */ import { supabase } from '@/lib/supabaseClient'; import type { UserIdentity as SupabaseUserIdentity } from '@supabase/supabase-js'; import type { UserIdentity, OAuthProvider, IdentitySafetyCheck, IdentityOperationResult } from '@/types/identity'; import { handleNonCriticalError, handleError, getErrorMessage } from './errorHandler'; /** * Get all identities for the current user */ export async function getUserIdentities(): Promise { try { const { data, error } = await supabase.auth.getUserIdentities(); if (error) throw error; return (data?.identities || []) as UserIdentity[]; } catch (error) { handleNonCriticalError(error, { action: 'Get User Identities', metadata: { returnedEmptyArray: true } }); return []; } } /** * Check if user has password authentication (email provider) */ export async function hasPasswordAuth(): Promise { const identities = await getUserIdentities(); return identities.some(identity => identity.provider === 'email'); } /** * Check if it's safe to disconnect a provider * Returns safety information and reason if unsafe */ export async function checkDisconnectSafety( provider: OAuthProvider ): Promise { const identities = await getUserIdentities(); const hasPassword = identities.some(i => i.provider === 'email'); const oauthIdentities = identities.filter(i => i.provider !== 'email' && i.provider !== 'phone' ); const totalIdentities = identities.length; // Can't disconnect if it's the only identity if (totalIdentities === 1) { return { canDisconnect: false, reason: 'last_identity', hasPasswordAuth: hasPassword, totalIdentities, oauthIdentities: oauthIdentities.length }; } // Can't disconnect last OAuth provider if no password backup if (oauthIdentities.length === 1 && !hasPassword) { return { canDisconnect: false, reason: 'no_password_backup', hasPasswordAuth: hasPassword, totalIdentities, oauthIdentities: oauthIdentities.length }; } return { canDisconnect: true, reason: 'safe', hasPasswordAuth: hasPassword, totalIdentities, oauthIdentities: oauthIdentities.length }; } /** * Disconnect an OAuth identity from the user's account * Requires AAL2 session for security */ export async function disconnectIdentity( provider: OAuthProvider ): Promise { try { // AAL2 check for security-critical operation (MUST fail closed) const { data: { session } } = await supabase.auth.getSession(); // Get AAL level - fail closed on error const { data: aalData, error: aalError } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); if (aalError) { handleNonCriticalError(aalError, { action: 'Get AAL Level (Identity Disconnect)', metadata: { failClosed: true } }); return { success: false, error: 'Unable to verify security level. Please try again.', requiresAAL2: true }; } const currentAal = aalData?.currentLevel || 'aal1'; // If not at AAL2, check if MFA is enrolled - fail closed on error if (currentAal !== 'aal2') { const { data: factors, error: factorsError } = await supabase.auth.mfa.listFactors(); if (factorsError) { handleNonCriticalError(factorsError, { action: 'List MFA Factors (Identity Disconnect)', metadata: { failClosed: true } }); return { success: false, error: 'Unable to verify MFA status. Please try again.', requiresAAL2: true }; } const hasEnrolledMFA = factors?.totp?.some(f => f.status === 'verified') || false; if (hasEnrolledMFA) { return { success: false, error: 'Please verify your identity with MFA before disconnecting accounts', requiresAAL2: true }; } } // Safety check const safetyCheck = await checkDisconnectSafety(provider); if (!safetyCheck.canDisconnect) { return { success: false, error: safetyCheck.reason === 'last_identity' ? 'Cannot disconnect your only login method' : 'Please set a password before disconnecting your last social login' }; } // Get all identities to find the one to unlink const identities = await getUserIdentities(); const identity = identities.find(i => i.provider === provider); if (!identity) { return { success: false, error: `No ${provider} identity found` }; } // Unlink the identity - cast to Supabase's expected type const { error } = await supabase.auth.unlinkIdentity(identity as SupabaseUserIdentity); if (error) throw error; // Log audit event const { data: { user } } = await supabase.auth.getUser(); if (user) { await logIdentityChange(user.id, 'identity_disconnected', { provider }); } return { success: true }; } catch (error) { handleError(error, { action: 'Disconnect Identity', metadata: { provider } }); return { success: false, error: getErrorMessage(error) }; } } /** * Connect an OAuth identity to the user's account */ export async function connectIdentity( provider: OAuthProvider, redirectTo?: string ): Promise { try { const { error } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: redirectTo || `${window.location.origin}/settings?tab=security`, skipBrowserRedirect: false } }); if (error) throw error; return { success: true }; } catch (error) { handleError(error, { action: 'Connect Identity', metadata: { provider } }); return { success: false, error: getErrorMessage(error) }; } } /** * Add password authentication to an OAuth-only account * Triggers Supabase password reset flow - user sets password via email link */ export async function addPasswordToAccount(): Promise { try { const { data: { user } } = await supabase.auth.getUser(); const userEmail = user?.email; if (!userEmail) { return { success: false, error: 'No email address found on your account' }; } // Trigger Supabase password reset email // User clicks link and sets password, which automatically creates email identity const { error: resetError } = await supabase.auth.resetPasswordForEmail( userEmail, { redirectTo: `${window.location.origin}/auth/callback?type=recovery` } ); if (resetError) { handleError(resetError, { action: 'Send Password Reset Email', userId: user?.id, metadata: { email: userEmail } }); throw resetError; } // Log the action await logIdentityChange(user!.id, 'password_setup_initiated', { method: 'reset_password_flow', timestamp: new Date().toISOString() }); // Log to admin audit trail for security tracking try { const { logAdminAction } = await import('@/lib/adminActionAuditHelpers'); await logAdminAction( 'password_setup_initiated', { method: 'reset_password_email', email: userEmail, has_oauth: true, // If they're adding password, they must have OAuth } ); } catch (auditError) { // Non-critical - don't fail operation if audit logging fails } return { success: true, needsEmailConfirmation: true, email: userEmail }; } catch (error) { handleError(error, { action: 'Initiate Password Setup' }); return { success: false, error: getErrorMessage(error) }; } } /** * Log identity changes to audit log */ async function logIdentityChange( userId: string, action: string, details: Record ): Promise { try { await supabase.rpc('log_admin_action', { _admin_user_id: userId, _target_user_id: userId, _action: action, _details: details }); } catch (error) { handleNonCriticalError(error, { action: 'Log Identity Change to Audit', userId, metadata: { auditAction: action } }); // Don't fail the operation if audit logging fails } }