diff --git a/src/App.tsx b/src/App.tsx index 642e91c8..5085e78a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; import { AuthProvider } from "@/hooks/useAuth"; import { AuthModalProvider } from "@/contexts/AuthModalContext"; +import { MFAStepUpProvider } from "@/contexts/MFAStepUpContext"; import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider"; import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper"; import { Footer } from "@/components/layout/Footer"; @@ -385,9 +386,11 @@ const App = (): React.JSX.Element => ( - - - + + + + + {import.meta.env.DEV && } diff --git a/src/components/admin/TestDataGenerator.tsx b/src/components/admin/TestDataGenerator.tsx index 9d57f368..e29424ee 100644 --- a/src/components/admin/TestDataGenerator.tsx +++ b/src/components/admin/TestDataGenerator.tsx @@ -16,6 +16,8 @@ import { Beaker, CheckCircle, ChevronDown, Trash2, AlertTriangle } from 'lucide- import { clearTestData, getTestDataStats } from '@/lib/testDataGenerator'; import { TestDataTracker } from '@/lib/integrationTests/TestDataTracker'; import { logger } from '@/lib/logger'; +import { useMFAStepUp } from '@/contexts/MFAStepUpContext'; +import { isMFACancelledError } from '@/lib/aalErrorDetection'; const PRESETS = { small: { label: 'Small', description: '~30 submissions - Quick test', counts: '5 parks, 10 rides, 3 companies, 2 models, 5 photo sets' }, @@ -44,6 +46,7 @@ interface TestDataResults { export function TestDataGenerator(): React.JSX.Element { const { toast } = useToast(); + const { requireAAL2 } = useMFAStepUp(); const [preset, setPreset] = useState<'small' | 'medium' | 'large' | 'stress'>('small'); const [fieldDensity, setFieldDensity] = useState<'mixed' | 'minimal' | 'standard' | 'maximum'>('mixed'); const [entityTypes, setEntityTypes] = useState({ @@ -168,7 +171,12 @@ export function TestDataGenerator(): React.JSX.Element { setLoading(true); try { - const { deleted } = await clearTestData(); + // Wrap operation with AAL2 requirement + const { deleted } = await requireAAL2( + () => clearTestData(), + 'Clearing test data requires additional verification' + ); + await loadStats(); toast({ @@ -177,11 +185,14 @@ export function TestDataGenerator(): React.JSX.Element { }); setResults(null); } catch (error: unknown) { - toast({ - title: 'Clear Failed', - description: getErrorMessage(error), - variant: 'destructive' - }); + // Only show error if it's NOT an MFA cancellation + if (!isMFACancelledError(error)) { + toast({ + title: 'Clear Failed', + description: getErrorMessage(error), + variant: 'destructive' + }); + } } finally { setLoading(false); } @@ -191,7 +202,12 @@ export function TestDataGenerator(): React.JSX.Element { setLoading(true); try { - const { deleted, errors } = await TestDataTracker.bulkCleanupAllTestData(); + // Wrap operation with AAL2 requirement + const { deleted, errors } = await requireAAL2( + () => TestDataTracker.bulkCleanupAllTestData(), + 'Emergency cleanup requires additional verification' + ); + await loadStats(); toast({ @@ -200,11 +216,14 @@ export function TestDataGenerator(): React.JSX.Element { }); setResults(null); } catch (error: unknown) { - toast({ - title: 'Emergency Cleanup Failed', - description: getErrorMessage(error), - variant: 'destructive' - }); + // Only show error if it's NOT an MFA cancellation + if (!isMFACancelledError(error)) { + toast({ + title: 'Emergency Cleanup Failed', + description: getErrorMessage(error), + variant: 'destructive' + }); + } } finally { setLoading(false); } diff --git a/src/contexts/MFAStepUpContext.tsx b/src/contexts/MFAStepUpContext.tsx new file mode 100644 index 00000000..6e7032bc --- /dev/null +++ b/src/contexts/MFAStepUpContext.tsx @@ -0,0 +1,156 @@ +/** + * MFA Step-Up Context + * + * Provides global MFA step-up functionality that automatically + * prompts users for MFA verification when operations require AAL2 + */ + +import { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { useAuth } from '@/hooks/useAuth'; +import { MFAStepUpModal } from '@/components/auth/MFAStepUpModal'; +import { getEnrolledFactors } from '@/lib/authService'; +import { isAAL2PolicyError, getAAL2ErrorMessage, MFACancelledError } from '@/lib/aalErrorDetection'; +import { logger } from '@/lib/logger'; + +interface MFAStepUpContextType { + /** + * Wrap an operation that may require AAL2 verification + * If operation fails due to AAL2, prompts user for MFA and retries + */ + requireAAL2: (operation: () => Promise, errorMessage?: string) => Promise; + + /** + * Check if error is AAL2-related + */ + isAAL2Error: (error: unknown) => boolean; +} + +const MFAStepUpContext = createContext(undefined); + +interface MFAStepUpProviderProps { + children: ReactNode; +} + +export function MFAStepUpProvider({ children }: MFAStepUpProviderProps) { + const { session } = useAuth(); + const [modalOpen, setModalOpen] = useState(false); + const [factorId, setFactorId] = useState(''); + const [pendingOperation, setPendingOperation] = useState<(() => Promise) | null>(null); + const [pendingResolve, setPendingResolve] = useState<((value: any) => void) | null>(null); + const [pendingReject, setPendingReject] = useState<((error: any) => void) | null>(null); + + /** + * Handle successful MFA verification + * Retries the pending operation with upgraded AAL2 session + */ + const handleMFASuccess = useCallback(async () => { + setModalOpen(false); + + if (!pendingOperation || !pendingResolve || !pendingReject) { + logger.error('No pending operation to retry after MFA success'); + return; + } + + try { + logger.log('[MFAStepUp] Retrying operation after AAL2 upgrade'); + const result = await pendingOperation(); + pendingResolve(result); + } catch (error) { + logger.error('[MFAStepUp] Operation failed after AAL2 upgrade', { error }); + pendingReject(error); + } finally { + // Clean up + setPendingOperation(null); + setPendingResolve(null); + setPendingReject(null); + } + }, [pendingOperation, pendingResolve, pendingReject]); + + /** + * Handle MFA cancellation + */ + const handleMFACancel = useCallback(() => { + setModalOpen(false); + + if (pendingReject) { + pendingReject(new MFACancelledError()); + } + + // Clean up + setPendingOperation(null); + setPendingResolve(null); + setPendingReject(null); + }, [pendingReject]); + + /** + * Wrap an operation with AAL2 requirement checking + */ + const requireAAL2 = useCallback( + async (operation: () => Promise, customErrorMessage?: string): Promise => { + try { + // Try the operation first + return await operation(); + } catch (error) { + // Check if error is AAL2-related + if (!isAAL2PolicyError(error)) { + // Not an AAL2 error, re-throw + throw error; + } + + logger.log('[MFAStepUp] AAL2 error detected, prompting for MFA', { + error: error instanceof Error ? error.message : String(error), + }); + + // Get enrolled factors + const factors = await getEnrolledFactors(); + + if (factors.length === 0) { + logger.error('[MFAStepUp] No enrolled MFA factors found'); + throw new Error('MFA is not set up for your account'); + } + + const factor = factors[0]; + setFactorId(factor.id); + + // Return a promise that resolves when MFA is verified + return new Promise((resolve, reject) => { + setPendingOperation(() => operation); + setPendingResolve(() => resolve); + setPendingReject(() => reject); + setModalOpen(true); + }); + } + }, + [] + ); + + const value: MFAStepUpContextType = { + requireAAL2, + isAAL2Error: isAAL2PolicyError, + }; + + return ( + + {children} + + + ); +} + +/** + * Hook to access MFA step-up functionality + */ +export function useMFAStepUp(): MFAStepUpContextType { + const context = useContext(MFAStepUpContext); + + if (!context) { + throw new Error('useMFAStepUp must be used within MFAStepUpProvider'); + } + + return context; +} diff --git a/src/lib/aalErrorDetection.ts b/src/lib/aalErrorDetection.ts new file mode 100644 index 00000000..07fde5d2 --- /dev/null +++ b/src/lib/aalErrorDetection.ts @@ -0,0 +1,125 @@ +/** + * AAL2 Error Detection Utilities + * + * Detects when operations fail due to AAL2/MFA requirements + * and provides user-friendly error messages + */ + +import { PostgrestError } from '@supabase/supabase-js'; + +/** + * Check if an error is due to AAL2/RLS policy failure + */ +export function isAAL2PolicyError(error: unknown): boolean { + if (!error) return false; + + // Handle Supabase PostgrestError + if (isPostgrestError(error)) { + const code = error.code; + const message = error.message?.toLowerCase() || ''; + const details = error.details?.toLowerCase() || ''; + + // Check for RLS policy violations + if (code === 'PGRST301' || code === '42501') { + return true; + } + + // Check for permission denied messages + if ( + message.includes('permission denied') || + message.includes('row-level security') || + message.includes('policy') || + details.includes('policy') + ) { + return true; + } + } + + // Handle generic errors with 403 status + if (hasStatusCode(error) && error.status === 403) { + return true; + } + + // Handle error messages + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes('row-level security') || + message.includes('permission denied') || + message.includes('policy') || + message.includes('403') + ); + } + + return false; +} + +/** + * Get user-friendly error message for AAL2 errors + */ +export function getAAL2ErrorMessage(error: unknown): string { + // Default message + const defaultMessage = 'This action requires additional security verification'; + + if (!error) return defaultMessage; + + // Check if error mentions specific operations + if (error instanceof Error) { + const message = error.message.toLowerCase(); + + if (message.includes('delete') || message.includes('remove')) { + return 'Deleting this data requires additional security verification'; + } + + if (message.includes('update') || message.includes('modify')) { + return 'Modifying this data requires additional security verification'; + } + + if (message.includes('insert') || message.includes('create')) { + return 'Creating this data requires additional security verification'; + } + } + + return defaultMessage; +} + +/** + * Type guard for PostgrestError + */ +function isPostgrestError(error: unknown): error is PostgrestError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + 'message' in error + ); +} + +/** + * Type guard for errors with status code + */ +function hasStatusCode(error: unknown): error is { status: number } { + return ( + typeof error === 'object' && + error !== null && + 'status' in error && + typeof (error as any).status === 'number' + ); +} + +/** + * Create a user-cancellation error + */ +export class MFACancelledError extends Error { + constructor() { + super('MFA verification was cancelled by user'); + this.name = 'MFACancelledError'; + } +} + +/** + * Check if error is a user cancellation + */ +export function isMFACancelledError(error: unknown): boolean { + return error instanceof MFACancelledError; +}