mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 12:31: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 { 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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
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 { 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');
|
||||
|
||||
@@ -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
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