Refactor: Implement full authentication overhaul

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 14:01:17 +00:00
parent ccfa83faee
commit 23f7cbb9de
8 changed files with 525 additions and 122 deletions

View File

@@ -10,19 +10,15 @@ import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react';
import { MFARemovalDialog } from './MFARemovalDialog';
interface TOTPFactor {
id: string;
friendly_name?: string;
factor_type: string;
status: string;
created_at: string;
}
import { setStepUpRequired, getAuthMethod } from '@/lib/sessionFlags';
import { useNavigate } from 'react-router-dom';
import type { MFAFactor } from '@/types/auth';
export function TOTPSetup() {
const { user } = useAuth();
const { toast } = useToast();
const [factors, setFactors] = useState<TOTPFactor[]>([]);
const navigate = useNavigate();
const [factors, setFactors] = useState<MFAFactor[]>([]);
const [loading, setLoading] = useState(false);
const [enrolling, setEnrolling] = useState(false);
const [qrCode, setQrCode] = useState('');
@@ -111,10 +107,14 @@ export function TOTPSetup() {
if (verifyError) throw verifyError;
// Check if user signed in via OAuth
const { data: { session } } = await supabase.auth.getSession();
const provider = session?.user?.app_metadata?.provider;
const isOAuthUser = provider === 'google' || provider === 'discord';
// Check if user signed in via OAuth and trigger step-up flow
const authMethod = getAuthMethod();
if (authMethod === 'oauth') {
console.log('[TOTPSetup] OAuth user enrolled MFA, triggering step-up...');
setStepUpRequired(true, window.location.pathname);
navigate('/auth/mfa-step-up');
return;
}
toast({
title: 'TOTP Enabled',

View File

@@ -4,17 +4,14 @@ import { supabase } from '@/integrations/supabase/client';
import type { Profile } from '@/types/database';
import { toast } from '@/hooks/use-toast';
import { authLog, authWarn, authError } from '@/lib/authLogger';
export interface CheckAalResult {
needsStepUp: boolean;
hasMfaEnrolled: boolean;
currentLevel: 'aal1' | 'aal2' | null;
}
import type { AALLevel, CheckAalResult } from '@/types/auth';
import { getSessionAal, checkAalStepUp as checkAalStepUpService, signOutUser } from '@/lib/authService';
import { clearAllAuthFlags } from '@/lib/sessionFlags';
interface AuthContextType {
user: User | null;
session: Session | null;
aal: 'aal1' | 'aal2' | null;
aal: AALLevel | null;
loading: boolean;
pendingEmail: string | null;
sessionError: string | null;
@@ -29,7 +26,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
function AuthProviderComponent({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [aal, setAal] = useState<'aal1' | 'aal2' | null>(null);
const [aal, setAal] = useState<AALLevel | null>(null);
const [loading, setLoading] = useState(true);
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
@@ -88,30 +85,13 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
// Clear any error
setSessionError(null);
// Update session and user state based on event
if (event === 'SIGNED_IN' && session) {
authLog('[Auth] SIGNED_IN - user authenticated');
setSession(session);
setUser(session.user);
const userAal = (session.user as any).aal as 'aal1' | 'aal2' | undefined;
setAal(userAal || 'aal1');
// Synchronous state updates only
setSession(session);
setUser(session?.user ?? null);
// Handle loading state
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
setLoading(false);
} else if (event === 'INITIAL_SESSION') {
if (session?.user) {
authLog('[Auth] INITIAL_SESSION - user exists');
setSession(session);
setUser(session.user);
const userAal = (session.user as any).aal as 'aal1' | 'aal2' | undefined;
setAal(userAal || 'aal1');
setLoading(false);
} else {
authLog('[Auth] INITIAL_SESSION - no user');
setSession(null);
setUser(null);
setAal(null);
setLoading(false);
return;
}
} else if (event === 'SIGNED_OUT') {
authLog('[Auth] SIGNED_OUT - clearing state');
setSession(null);
@@ -119,13 +99,20 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
setAal(null);
setLoading(false);
return;
} else {
setSession(session);
setUser(session?.user ?? null);
const userAal = session?.user ? ((session.user as any).aal as 'aal1' | 'aal2' | undefined) : null;
setAal(userAal || null);
}
// Defer async operations to avoid blocking the auth state change callback
setTimeout(async () => {
// Get AAL level from Supabase API (ground truth, not cached session data)
if (session) {
const currentAal = await getSessionAal(session);
setAal(currentAal);
authLog('[Auth] Current AAL:', currentAal);
} else {
setAal(null);
}
}, 0);
// Detect confirmed email change: email changed AND no longer pending
if (
session?.user &&
@@ -217,10 +204,11 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
}, []);
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
authError('Error signing out:', error);
throw error;
authLog('[Auth] Signing out...');
const result = await signOutUser();
if (!result.success) {
authError('Error signing out:', result.error);
throw new Error(result.error);
}
};
@@ -229,26 +217,7 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
};
const checkAalStepUp = async (): Promise<CheckAalResult> => {
if (!session?.user) {
return { needsStepUp: false, hasMfaEnrolled: false, currentLevel: null };
}
try {
const { data: { currentLevel } } =
await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
const { data: factors } = await supabase.auth.mfa.listFactors();
const hasMfaEnrolled = factors?.totp?.some(f => f.status === 'verified') || false;
return {
needsStepUp: hasMfaEnrolled && currentLevel === 'aal1',
hasMfaEnrolled,
currentLevel: currentLevel as 'aal1' | 'aal2' | null,
};
} catch (error) {
authError('[Auth] Failed to check AAL status:', error);
return { needsStepUp: false, hasMfaEnrolled: false, currentLevel: null };
}
return checkAalStepUpService(session);
};
const value = {

View File

@@ -1,21 +1,45 @@
import { useAuth } from './useAuth';
import { useUserRole } from './useUserRole';
import { useEffect, useState } from 'react';
import { getEnrolledFactors } from '@/lib/authService';
export function useRequireMFA() {
const { aal } = useAuth();
const { isModerator, isAdmin, loading } = useUserRole();
const { aal, session } = useAuth();
const { isModerator, isAdmin, loading: roleLoading } = useUserRole();
const [isEnrolled, setIsEnrolled] = useState(false);
const [loading, setLoading] = useState(true);
// Check actual enrollment status
useEffect(() => {
const checkEnrollment = async () => {
if (!session) {
setIsEnrolled(false);
setLoading(false);
return;
}
const factors = await getEnrolledFactors();
setIsEnrolled(factors.length > 0);
setLoading(false);
};
if (!roleLoading) {
checkEnrollment();
}
}, [session, roleLoading]);
// MFA is required for moderators and admins
const requiresMFA = isModerator() || isAdmin();
// User has MFA if they have AAL2
const hasMFA = aal === 'aal2';
// User has MFA if they have AAL2 AND have enrolled factors
const hasMFA = aal === 'aal2' && isEnrolled;
return {
requiresMFA,
hasMFA,
needsEnrollment: requiresMFA && !hasMFA,
isEnrolled,
needsEnrollment: requiresMFA && !isEnrolled,
aal,
loading,
loading: loading || roleLoading,
};
}

267
src/lib/authService.ts Normal file
View 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',
};
}
}

83
src/lib/sessionFlags.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* Type-safe session storage management for authentication flows
*/
export const SessionFlags = {
MFA_STEP_UP_REQUIRED: 'mfa_step_up_required',
MFA_INTENDED_PATH: 'mfa_intended_path',
MFA_CHALLENGE_ID: 'mfa_challenge_id',
AUTH_METHOD: 'auth_method',
} as const;
export type SessionFlagKey = typeof SessionFlags[keyof typeof SessionFlags];
/**
* Set the MFA step-up required flag
*/
export function setStepUpRequired(required: boolean, intendedPath?: string): void {
if (required) {
sessionStorage.setItem(SessionFlags.MFA_STEP_UP_REQUIRED, 'true');
if (intendedPath) {
sessionStorage.setItem(SessionFlags.MFA_INTENDED_PATH, intendedPath);
}
} else {
clearStepUpFlags();
}
}
/**
* Check if MFA step-up is required
*/
export function getStepUpRequired(): boolean {
return sessionStorage.getItem(SessionFlags.MFA_STEP_UP_REQUIRED) === 'true';
}
/**
* Get the intended path after MFA verification
*/
export function getIntendedPath(): string {
return sessionStorage.getItem(SessionFlags.MFA_INTENDED_PATH) || '/';
}
/**
* Clear all MFA step-up flags
*/
export function clearStepUpFlags(): void {
sessionStorage.removeItem(SessionFlags.MFA_STEP_UP_REQUIRED);
sessionStorage.removeItem(SessionFlags.MFA_INTENDED_PATH);
sessionStorage.removeItem(SessionFlags.MFA_CHALLENGE_ID);
}
/**
* Store the authentication method used
*/
export function setAuthMethod(method: 'password' | 'oauth' | 'magiclink'): void {
sessionStorage.setItem(SessionFlags.AUTH_METHOD, method);
}
/**
* Get the authentication method used
*/
export function getAuthMethod(): 'password' | 'oauth' | 'magiclink' | null {
const method = sessionStorage.getItem(SessionFlags.AUTH_METHOD);
if (method === 'password' || method === 'oauth' || method === 'magiclink') {
return method;
}
return null;
}
/**
* Clear the authentication method
*/
export function clearAuthMethod(): void {
sessionStorage.removeItem(SessionFlags.AUTH_METHOD);
}
/**
* Clear all authentication-related session flags
*/
export function clearAllAuthFlags(): void {
Object.values(SessionFlags).forEach(flag => {
sessionStorage.removeItem(flag);
});
}

View File

@@ -4,6 +4,8 @@ import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { Loader2 } from 'lucide-react';
import { Header } from '@/components/layout/Header';
import { handlePostAuthFlow } from '@/lib/authService';
import type { AuthMethod } from '@/types/auth';
export default function AuthCallback() {
const navigate = useNavigate();
@@ -71,31 +73,22 @@ export default function AuthCallback() {
}
}
// Check if MFA step-up is required for OAuth users
// Determine authentication method
let authMethod: AuthMethod = 'magiclink';
if (isOAuthUser) {
console.log('[AuthCallback] Checking MFA requirements for OAuth user...');
authMethod = 'oauth';
}
try {
const { data: factors } = await supabase.auth.mfa.listFactors();
const hasMfaEnrolled = factors?.totp?.some(f => f.status === 'verified');
console.log('[AuthCallback] Auth method:', authMethod);
const { data: { currentLevel } } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
// Unified post-authentication flow for ALL methods (OAuth, magic link, etc.)
console.log('[AuthCallback] Running post-auth flow...');
const result = await handlePostAuthFlow(session, authMethod);
console.log('[AuthCallback] MFA status:', {
hasMfaEnrolled,
currentLevel,
});
if (hasMfaEnrolled && currentLevel === 'aal1') {
console.log('[AuthCallback] MFA step-up required, redirecting...');
sessionStorage.setItem('mfa_step_up_required', 'true');
navigate('/auth/mfa-step-up');
return;
}
} catch (error) {
console.error('[AuthCallback] Failed to check MFA status:', error);
// Continue anyway - don't block sign-in
}
if (result.success && result.data?.shouldRedirect) {
console.log('[AuthCallback] Redirecting to:', result.data.redirectTo);
navigate(result.data.redirectTo);
return;
}
setStatus('success');

View File

@@ -5,6 +5,8 @@ import { useToast } from '@/hooks/use-toast';
import { Header } from '@/components/layout/Header';
import { MFAChallenge } from '@/components/auth/MFAChallenge';
import { Shield } from 'lucide-react';
import { getStepUpRequired, getIntendedPath, clearStepUpFlags } from '@/lib/sessionFlags';
import { getEnrolledFactors } from '@/lib/authService';
export default function MFAStepUp() {
const navigate = useNavigate();
@@ -14,30 +16,28 @@ export default function MFAStepUp() {
useEffect(() => {
const checkStepUpRequired = async () => {
// Check if this page was accessed via proper flow
const needsStepUp = sessionStorage.getItem('mfa_step_up_required');
if (!needsStepUp) {
if (!getStepUpRequired()) {
console.log('[MFAStepUp] No step-up flag found, redirecting to auth');
navigate('/auth');
return;
}
// Get the verified TOTP factor
const { data: factors } = await supabase.auth.mfa.listFactors();
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
// Get enrolled MFA factors
const factors = await getEnrolledFactors();
if (!totpFactor) {
if (factors.length === 0) {
console.log('[MFAStepUp] No verified TOTP factor found');
toast({
variant: 'destructive',
title: 'MFA not enrolled',
description: 'Please enroll in two-factor authentication first.',
});
sessionStorage.removeItem('mfa_step_up_required');
clearStepUpFlags();
navigate('/settings?tab=security');
return;
}
setFactorId(totpFactor.id);
setFactorId(factors[0].id);
};
checkStepUpRequired();
@@ -46,33 +46,26 @@ export default function MFAStepUp() {
const handleSuccess = async () => {
console.log('[MFAStepUp] MFA verification successful');
// Clear the step-up flag
sessionStorage.removeItem('mfa_step_up_required');
toast({
title: 'Verification successful',
description: 'You now have full access to all features.',
});
// Redirect to home or intended destination
const intendedPath = sessionStorage.getItem('mfa_intended_path') || '/';
sessionStorage.removeItem('mfa_intended_path');
const intendedPath = getIntendedPath();
clearStepUpFlags();
navigate(intendedPath);
};
const handleCancel = async () => {
console.log('[MFAStepUp] MFA verification cancelled');
// Clear flags
sessionStorage.removeItem('mfa_step_up_required');
sessionStorage.removeItem('mfa_intended_path');
// Sign out for security
await supabase.auth.signOut();
// Clear flags and redirect to sign-in (less harsh than forcing sign-out)
clearStepUpFlags();
toast({
title: 'Verification cancelled',
description: 'You have been signed out.',
description: 'Please sign in again to continue.',
});
navigate('/auth');

74
src/types/auth.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { Session, User } from '@supabase/supabase-js';
/**
* Authenticator Assurance Levels (AAL)
* - aal1: Basic authentication (password/OAuth/magic link)
* - aal2: Multi-factor authentication completed
*/
export type AALLevel = 'aal1' | 'aal2';
/**
* MFA Factor types supported by Supabase
*/
export type MFAFactorType = 'totp';
/**
* MFA Factor status
*/
export type MFAFactorStatus = 'verified' | 'unverified';
/**
* MFA Factor structure from Supabase
*/
export interface MFAFactor {
id: string;
factor_type: MFAFactorType;
status: MFAFactorStatus;
friendly_name?: string;
created_at: string;
updated_at: string;
}
/**
* Result of AAL step-up check
*/
export interface CheckAalResult {
needsStepUp: boolean;
hasMfaEnrolled: boolean;
currentLevel: AALLevel | null;
hasEnrolledFactors?: boolean;
factorId?: string;
}
/**
* Authentication method types
*/
export type AuthMethod = 'password' | 'oauth' | 'magiclink';
/**
* Authentication session with AAL information
*/
export interface AuthSessionInfo {
session: Session | null;
user: User | null;
aal: AALLevel;
authMethod?: AuthMethod;
}
/**
* MFA Challenge result
*/
export interface MFAChallengeResult {
success: boolean;
error?: string;
newAal?: AALLevel;
}
/**
* Auth service response
*/
export interface AuthServiceResponse<T = void> {
success: boolean;
data?: T;
error?: string;
}