Files
thrilltrack-explorer/src-old/lib/authService.ts

311 lines
7.5 KiB
TypeScript

/**
* Centralized Authentication Service
* Handles all authentication flows with consistent AAL checking and MFA verification
*/
import { supabase } from '@/lib/supabaseClient';
import type { Session } from '@supabase/supabase-js';
import type {
AALLevel,
MFAFactor,
CheckAalResult,
AuthServiceResponse,
MFAChallengeResult
} from '@/types/auth';
import { setStepUpRequired, setAuthMethod, clearAllAuthFlags } from './sessionFlags';
import { logger } from './logger';
import { getErrorMessage, handleNonCriticalError } from './errorHandler';
/**
* 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) {
logger.log('[AuthService] No session, returning aal1');
return 'aal1';
}
try {
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
logger.log('[AuthService] getSessionAal result', {
hasData: !!data,
currentLevel: data?.currentLevel,
nextLevel: data?.nextLevel,
error: error?.message
});
if (error) {
handleNonCriticalError(error, {
action: 'Get session AAL',
});
return 'aal1';
}
const level = (data.currentLevel as AALLevel) || 'aal1';
logger.log('[AuthService] Returning AAL', { level });
return level;
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Get session AAL exception',
});
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) {
handleNonCriticalError(error, {
action: 'List MFA factors',
});
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: unknown) {
handleNonCriticalError(error, {
action: 'List MFA factors exception',
});
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) {
handleNonCriticalError(error, {
action: 'Verify MFA required',
userId,
});
return false;
}
return (data?.length || 0) > 0;
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Verify MFA required exception',
userId,
});
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) {
logger.info('[AuthService] MFA step-up required', {
authMethod,
currentAal: aalCheck.currentLevel
});
// 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: unknown) {
handleNonCriticalError(error, {
action: 'Handle post-auth flow',
metadata: { authMethod },
});
return {
success: false,
error: getErrorMessage(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') {
handleNonCriticalError(new Error('MFA verification failed'), {
action: 'Verify MFA upgrade',
metadata: { expectedAal: 'aal2', actualAal: currentAal },
});
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) {
handleNonCriticalError(error, {
action: 'Log auth event',
metadata: { eventAction: action, userId },
});
}
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Log auth event exception',
metadata: { eventAction: action, userId },
});
}
}
/**
* 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: unknown) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}