mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 16:11:13 -05:00
Refactor: Implement full authentication overhaul
This commit is contained in:
@@ -10,19 +10,15 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react';
|
import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react';
|
||||||
import { MFARemovalDialog } from './MFARemovalDialog';
|
import { MFARemovalDialog } from './MFARemovalDialog';
|
||||||
|
import { setStepUpRequired, getAuthMethod } from '@/lib/sessionFlags';
|
||||||
interface TOTPFactor {
|
import { useNavigate } from 'react-router-dom';
|
||||||
id: string;
|
import type { MFAFactor } from '@/types/auth';
|
||||||
friendly_name?: string;
|
|
||||||
factor_type: string;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TOTPSetup() {
|
export function TOTPSetup() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [factors, setFactors] = useState<TOTPFactor[]>([]);
|
const navigate = useNavigate();
|
||||||
|
const [factors, setFactors] = useState<MFAFactor[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [enrolling, setEnrolling] = useState(false);
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
const [qrCode, setQrCode] = useState('');
|
const [qrCode, setQrCode] = useState('');
|
||||||
@@ -111,10 +107,14 @@ export function TOTPSetup() {
|
|||||||
|
|
||||||
if (verifyError) throw verifyError;
|
if (verifyError) throw verifyError;
|
||||||
|
|
||||||
// Check if user signed in via OAuth
|
// Check if user signed in via OAuth and trigger step-up flow
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const authMethod = getAuthMethod();
|
||||||
const provider = session?.user?.app_metadata?.provider;
|
if (authMethod === 'oauth') {
|
||||||
const isOAuthUser = provider === 'google' || provider === 'discord';
|
console.log('[TOTPSetup] OAuth user enrolled MFA, triggering step-up...');
|
||||||
|
setStepUpRequired(true, window.location.pathname);
|
||||||
|
navigate('/auth/mfa-step-up');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'TOTP Enabled',
|
title: 'TOTP Enabled',
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ import { supabase } from '@/integrations/supabase/client';
|
|||||||
import type { Profile } from '@/types/database';
|
import type { Profile } from '@/types/database';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { authLog, authWarn, authError } from '@/lib/authLogger';
|
import { authLog, authWarn, authError } from '@/lib/authLogger';
|
||||||
|
import type { AALLevel, CheckAalResult } from '@/types/auth';
|
||||||
export interface CheckAalResult {
|
import { getSessionAal, checkAalStepUp as checkAalStepUpService, signOutUser } from '@/lib/authService';
|
||||||
needsStepUp: boolean;
|
import { clearAllAuthFlags } from '@/lib/sessionFlags';
|
||||||
hasMfaEnrolled: boolean;
|
|
||||||
currentLevel: 'aal1' | 'aal2' | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
aal: 'aal1' | 'aal2' | null;
|
aal: AALLevel | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
pendingEmail: string | null;
|
pendingEmail: string | null;
|
||||||
sessionError: string | null;
|
sessionError: string | null;
|
||||||
@@ -29,7 +26,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [session, setSession] = useState<Session | 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 [loading, setLoading] = useState(true);
|
||||||
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
|
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
|
||||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||||
@@ -88,30 +85,13 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
|||||||
// Clear any error
|
// Clear any error
|
||||||
setSessionError(null);
|
setSessionError(null);
|
||||||
|
|
||||||
// Update session and user state based on event
|
// Synchronous state updates only
|
||||||
if (event === 'SIGNED_IN' && session) {
|
setSession(session);
|
||||||
authLog('[Auth] SIGNED_IN - user authenticated');
|
setUser(session?.user ?? null);
|
||||||
setSession(session);
|
|
||||||
setUser(session.user);
|
// Handle loading state
|
||||||
const userAal = (session.user as any).aal as 'aal1' | 'aal2' | undefined;
|
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
|
||||||
setAal(userAal || 'aal1');
|
|
||||||
setLoading(false);
|
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') {
|
} else if (event === 'SIGNED_OUT') {
|
||||||
authLog('[Auth] SIGNED_OUT - clearing state');
|
authLog('[Auth] SIGNED_OUT - clearing state');
|
||||||
setSession(null);
|
setSession(null);
|
||||||
@@ -119,13 +99,20 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
|||||||
setAal(null);
|
setAal(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
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
|
// Detect confirmed email change: email changed AND no longer pending
|
||||||
if (
|
if (
|
||||||
session?.user &&
|
session?.user &&
|
||||||
@@ -217,10 +204,11 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
const { error } = await supabase.auth.signOut();
|
authLog('[Auth] Signing out...');
|
||||||
if (error) {
|
const result = await signOutUser();
|
||||||
authError('Error signing out:', error);
|
if (!result.success) {
|
||||||
throw error;
|
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> => {
|
const checkAalStepUp = async (): Promise<CheckAalResult> => {
|
||||||
if (!session?.user) {
|
return checkAalStepUpService(session);
|
||||||
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 };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
|
|||||||
@@ -1,21 +1,45 @@
|
|||||||
import { useAuth } from './useAuth';
|
import { useAuth } from './useAuth';
|
||||||
import { useUserRole } from './useUserRole';
|
import { useUserRole } from './useUserRole';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getEnrolledFactors } from '@/lib/authService';
|
||||||
|
|
||||||
export function useRequireMFA() {
|
export function useRequireMFA() {
|
||||||
const { aal } = useAuth();
|
const { aal, session } = useAuth();
|
||||||
const { isModerator, isAdmin, loading } = useUserRole();
|
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
|
// MFA is required for moderators and admins
|
||||||
const requiresMFA = isModerator() || isAdmin();
|
const requiresMFA = isModerator() || isAdmin();
|
||||||
|
|
||||||
// User has MFA if they have AAL2
|
// User has MFA if they have AAL2 AND have enrolled factors
|
||||||
const hasMFA = aal === 'aal2';
|
const hasMFA = aal === 'aal2' && isEnrolled;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requiresMFA,
|
requiresMFA,
|
||||||
hasMFA,
|
hasMFA,
|
||||||
needsEnrollment: requiresMFA && !hasMFA,
|
isEnrolled,
|
||||||
|
needsEnrollment: requiresMFA && !isEnrolled,
|
||||||
aal,
|
aal,
|
||||||
loading,
|
loading: loading || roleLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/lib/sessionFlags.ts
Normal file
83
src/lib/sessionFlags.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { supabase } from '@/integrations/supabase/client';
|
|||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { handlePostAuthFlow } from '@/lib/authService';
|
||||||
|
import type { AuthMethod } from '@/types/auth';
|
||||||
|
|
||||||
export default function AuthCallback() {
|
export default function AuthCallback() {
|
||||||
const navigate = useNavigate();
|
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) {
|
if (isOAuthUser) {
|
||||||
console.log('[AuthCallback] Checking MFA requirements for OAuth user...');
|
authMethod = 'oauth';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
console.log('[AuthCallback] Auth method:', authMethod);
|
||||||
const { data: factors } = await supabase.auth.mfa.listFactors();
|
|
||||||
const hasMfaEnrolled = factors?.totp?.some(f => f.status === 'verified');
|
|
||||||
|
|
||||||
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:', {
|
if (result.success && result.data?.shouldRedirect) {
|
||||||
hasMfaEnrolled,
|
console.log('[AuthCallback] Redirecting to:', result.data.redirectTo);
|
||||||
currentLevel,
|
navigate(result.data.redirectTo);
|
||||||
});
|
return;
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useToast } from '@/hooks/use-toast';
|
|||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { MFAChallenge } from '@/components/auth/MFAChallenge';
|
import { MFAChallenge } from '@/components/auth/MFAChallenge';
|
||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
|
import { getStepUpRequired, getIntendedPath, clearStepUpFlags } from '@/lib/sessionFlags';
|
||||||
|
import { getEnrolledFactors } from '@/lib/authService';
|
||||||
|
|
||||||
export default function MFAStepUp() {
|
export default function MFAStepUp() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -14,30 +16,28 @@ export default function MFAStepUp() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkStepUpRequired = async () => {
|
const checkStepUpRequired = async () => {
|
||||||
// Check if this page was accessed via proper flow
|
// Check if this page was accessed via proper flow
|
||||||
const needsStepUp = sessionStorage.getItem('mfa_step_up_required');
|
if (!getStepUpRequired()) {
|
||||||
if (!needsStepUp) {
|
|
||||||
console.log('[MFAStepUp] No step-up flag found, redirecting to auth');
|
console.log('[MFAStepUp] No step-up flag found, redirecting to auth');
|
||||||
navigate('/auth');
|
navigate('/auth');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the verified TOTP factor
|
// Get enrolled MFA factors
|
||||||
const { data: factors } = await supabase.auth.mfa.listFactors();
|
const factors = await getEnrolledFactors();
|
||||||
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
|
|
||||||
|
|
||||||
if (!totpFactor) {
|
if (factors.length === 0) {
|
||||||
console.log('[MFAStepUp] No verified TOTP factor found');
|
console.log('[MFAStepUp] No verified TOTP factor found');
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'MFA not enrolled',
|
title: 'MFA not enrolled',
|
||||||
description: 'Please enroll in two-factor authentication first.',
|
description: 'Please enroll in two-factor authentication first.',
|
||||||
});
|
});
|
||||||
sessionStorage.removeItem('mfa_step_up_required');
|
clearStepUpFlags();
|
||||||
navigate('/settings?tab=security');
|
navigate('/settings?tab=security');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFactorId(totpFactor.id);
|
setFactorId(factors[0].id);
|
||||||
};
|
};
|
||||||
|
|
||||||
checkStepUpRequired();
|
checkStepUpRequired();
|
||||||
@@ -46,33 +46,26 @@ export default function MFAStepUp() {
|
|||||||
const handleSuccess = async () => {
|
const handleSuccess = async () => {
|
||||||
console.log('[MFAStepUp] MFA verification successful');
|
console.log('[MFAStepUp] MFA verification successful');
|
||||||
|
|
||||||
// Clear the step-up flag
|
|
||||||
sessionStorage.removeItem('mfa_step_up_required');
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Verification successful',
|
title: 'Verification successful',
|
||||||
description: 'You now have full access to all features.',
|
description: 'You now have full access to all features.',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to home or intended destination
|
// Redirect to home or intended destination
|
||||||
const intendedPath = sessionStorage.getItem('mfa_intended_path') || '/';
|
const intendedPath = getIntendedPath();
|
||||||
sessionStorage.removeItem('mfa_intended_path');
|
clearStepUpFlags();
|
||||||
navigate(intendedPath);
|
navigate(intendedPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
console.log('[MFAStepUp] MFA verification cancelled');
|
console.log('[MFAStepUp] MFA verification cancelled');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags and redirect to sign-in (less harsh than forcing sign-out)
|
||||||
sessionStorage.removeItem('mfa_step_up_required');
|
clearStepUpFlags();
|
||||||
sessionStorage.removeItem('mfa_intended_path');
|
|
||||||
|
|
||||||
// Sign out for security
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Verification cancelled',
|
title: 'Verification cancelled',
|
||||||
description: 'You have been signed out.',
|
description: 'Please sign in again to continue.',
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate('/auth');
|
navigate('/auth');
|
||||||
|
|||||||
74
src/types/auth.ts
Normal file
74
src/types/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user