mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:11:16 -05:00
311 lines
7.5 KiB
TypeScript
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',
|
|
};
|
|
}
|
|
}
|