mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 15:11:12 -05:00
Refactor: Implement full authentication overhaul
This commit is contained in:
267
src/lib/authService.ts
Normal file
267
src/lib/authService.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Centralized Authentication Service
|
||||
* Handles all authentication flows with consistent AAL checking and MFA verification
|
||||
*/
|
||||
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import type {
|
||||
AALLevel,
|
||||
MFAFactor,
|
||||
CheckAalResult,
|
||||
AuthServiceResponse,
|
||||
MFAChallengeResult
|
||||
} from '@/types/auth';
|
||||
import { setStepUpRequired, setAuthMethod, clearAllAuthFlags } from './sessionFlags';
|
||||
|
||||
/**
|
||||
* Extract AAL level from session using Supabase API
|
||||
* Always returns ground truth from server, not cached session data
|
||||
*/
|
||||
export async function getSessionAal(session: Session | null): Promise<AALLevel> {
|
||||
if (!session) return 'aal1';
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
|
||||
if (error) {
|
||||
console.error('[AuthService] Error getting AAL:', error);
|
||||
return 'aal1';
|
||||
}
|
||||
|
||||
return (data.currentLevel as AALLevel) || 'aal1';
|
||||
} catch (error) {
|
||||
console.error('[AuthService] Exception getting AAL:', error);
|
||||
return 'aal1';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enrolled MFA factors for the current user
|
||||
*/
|
||||
export async function getEnrolledFactors(): Promise<MFAFactor[]> {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.listFactors();
|
||||
|
||||
if (error) {
|
||||
console.error('[AuthService] Error listing factors:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data?.totp || [])
|
||||
.filter(f => f.status === 'verified')
|
||||
.map(f => ({
|
||||
id: f.id,
|
||||
factor_type: 'totp' as const,
|
||||
status: 'verified' as const,
|
||||
friendly_name: f.friendly_name,
|
||||
created_at: f.created_at,
|
||||
updated_at: f.updated_at,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[AuthService] Exception listing factors:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user needs AAL step-up
|
||||
* Returns detailed information about enrollment and current AAL level
|
||||
*/
|
||||
export async function checkAalStepUp(session: Session | null): Promise<CheckAalResult> {
|
||||
if (!session?.user) {
|
||||
return {
|
||||
needsStepUp: false,
|
||||
hasMfaEnrolled: false,
|
||||
currentLevel: 'aal1',
|
||||
hasEnrolledFactors: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [currentLevel, factors] = await Promise.all([
|
||||
getSessionAal(session),
|
||||
getEnrolledFactors(),
|
||||
]);
|
||||
|
||||
const hasEnrolledFactors = factors.length > 0;
|
||||
const needsStepUp = hasEnrolledFactors && currentLevel === 'aal1';
|
||||
|
||||
return {
|
||||
needsStepUp,
|
||||
hasMfaEnrolled: hasEnrolledFactors,
|
||||
currentLevel,
|
||||
hasEnrolledFactors,
|
||||
factorId: factors[0]?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA is required for a user based on their role
|
||||
*/
|
||||
export async function verifyMfaRequired(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', userId)
|
||||
.in('role', ['admin', 'moderator']);
|
||||
|
||||
if (error) {
|
||||
console.error('[AuthService] Error checking roles:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return (data?.length || 0) > 0;
|
||||
} catch (error) {
|
||||
console.error('[AuthService] Exception checking roles:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle post-authentication flow for all auth methods
|
||||
* Detects if MFA step-up is needed and redirects accordingly
|
||||
*/
|
||||
export async function handlePostAuthFlow(
|
||||
session: Session,
|
||||
authMethod: 'password' | 'oauth' | 'magiclink'
|
||||
): Promise<AuthServiceResponse<{ shouldRedirect: boolean; redirectTo?: string }>> {
|
||||
try {
|
||||
// Store auth method for audit logging
|
||||
setAuthMethod(authMethod);
|
||||
|
||||
// Check if step-up is needed
|
||||
const aalCheck = await checkAalStepUp(session);
|
||||
|
||||
if (aalCheck.needsStepUp) {
|
||||
console.log(`[AuthService] ${authMethod} sign-in requires MFA step-up`);
|
||||
|
||||
// Set flag and redirect to step-up page
|
||||
setStepUpRequired(true, window.location.pathname);
|
||||
|
||||
// Log audit event
|
||||
await logAuthEvent(session.user.id, 'mfa_step_up_required', {
|
||||
auth_method: authMethod,
|
||||
current_aal: aalCheck.currentLevel,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
shouldRedirect: true,
|
||||
redirectTo: '/auth/mfa-step-up',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Log successful authentication
|
||||
await logAuthEvent(session.user.id, 'authentication_success', {
|
||||
auth_method: authMethod,
|
||||
aal: aalCheck.currentLevel,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
shouldRedirect: false,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthService] Error in post-auth flow:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA challenge was successful and session upgraded to AAL2
|
||||
*/
|
||||
export async function verifyMfaUpgrade(session: Session | null): Promise<MFAChallengeResult> {
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No session found',
|
||||
};
|
||||
}
|
||||
|
||||
const currentAal = await getSessionAal(session);
|
||||
|
||||
if (currentAal !== 'aal2') {
|
||||
console.error('[AuthService] MFA verification failed - still at AAL1');
|
||||
await logAuthEvent(session.user.id, 'mfa_verification_failed', {
|
||||
expected_aal: 'aal2',
|
||||
actual_aal: currentAal,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to upgrade session to AAL2',
|
||||
newAal: currentAal,
|
||||
};
|
||||
}
|
||||
|
||||
// Log successful upgrade
|
||||
await logAuthEvent(session.user.id, 'mfa_verification_success', {
|
||||
new_aal: currentAal,
|
||||
});
|
||||
|
||||
// Clear auth flags
|
||||
clearAllAuthFlags();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newAal: currentAal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log authentication event to audit log
|
||||
*/
|
||||
async function logAuthEvent(
|
||||
userId: string,
|
||||
action: string,
|
||||
details: Record<string, any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { error } = await supabase.rpc('log_admin_action', {
|
||||
_admin_user_id: userId,
|
||||
_action: action,
|
||||
_target_user_id: userId,
|
||||
_details: details,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('[AuthService] Error logging auth event:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthService] Exception logging auth event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sign out with proper cleanup
|
||||
*/
|
||||
export async function signOutUser(): Promise<AuthServiceResponse> {
|
||||
try {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear all session flags
|
||||
clearAllAuthFlags();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user