diff --git a/src/components/auth/AuthModal.tsx b/src/components/auth/AuthModal.tsx index 5e65c6e0..54915369 100644 --- a/src/components/auth/AuthModal.tsx +++ b/src/components/auth/AuthModal.tsx @@ -35,6 +35,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod const [signInCaptchaToken, setSignInCaptchaToken] = useState(null); const [signInCaptchaKey, setSignInCaptchaKey] = useState(0); const [mfaFactorId, setMfaFactorId] = useState(null); + const [mfaChallengeId, setMfaChallengeId] = useState(null); + const [mfaPendingUserId, setMfaPendingUserId] = useState(null); const [formData, setFormData] = useState({ email: '', password: '', @@ -70,82 +72,57 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod setSignInCaptchaToken(null); try { - const signInOptions: any = { - email: formData.email, - password: formData.password, - }; - - if (tokenToUse) { - signInOptions.options = { captchaToken: tokenToUse }; + // Call server-side auth check with MFA detection + const { data: authResult, error: authError } = await supabase.functions.invoke( + 'auth-with-mfa-check', + { + body: { + email: formData.email, + password: formData.password, + captchaToken: tokenToUse, + }, + } + ); + + if (authError || authResult.error) { + throw new Error(authResult?.error || authError?.message || 'Authentication failed'); } - const { data, error } = await supabase.auth.signInWithPassword(signInOptions); - if (error) throw error; - - // CRITICAL: Check ban status immediately after successful authentication - const { data: profile } = await supabase - .from('profiles') - .select('banned, ban_reason') - .eq('user_id', data.user.id) - .single(); - - if (profile?.banned) { - // Sign out immediately - await supabase.auth.signOut(); - - const reason = profile.ban_reason - ? `Reason: ${profile.ban_reason}` + // Check if user is banned + if (authResult.banned) { + const reason = authResult.banReason + ? `Reason: ${authResult.banReason}` : 'Contact support for assistance.'; - + toast({ variant: "destructive", title: "Account Suspended", description: `Your account has been suspended. ${reason}`, - duration: 10000 + duration: 10000, }); setLoading(false); - return; // Stop authentication flow + return; } - // Check if MFA is required (user exists but no session) - if (data.user && !data.session) { - const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified'); - - if (totpFactor) { - setMfaFactorId(totpFactor.id); - setLoading(false); - return; - } + // Check if MFA is required + if (authResult.mfaRequired) { + // NO SESSION EXISTS YET - show MFA challenge + console.log('[AuthModal] MFA required - no session created yet'); + setMfaFactorId(authResult.factorId); + setMfaChallengeId(authResult.challengeId); + setMfaPendingUserId(authResult.userId); + setLoading(false); + return; // User has NO session - MFA modal will show } + + // No MFA required - user has session + console.log('[AuthModal] No MFA required - user authenticated'); - // Track auth method for audit logging - setAuthMethod('password'); - - // Check if MFA step-up is required - const { handlePostAuthFlow } = await import('@/lib/authService'); - const postAuthResult = await handlePostAuthFlow(data.session, 'password'); - - if (postAuthResult.success && postAuthResult.data.shouldRedirect) { - // CRITICAL SECURITY FIX: Get factor BEFORE destroying session - const { data: factors } = await supabase.auth.mfa.listFactors(); - const totpFactor = factors?.totp?.find(f => f.status === 'verified'); - - if (totpFactor) { - // DESTROY the AAL1 session - user should NOT be logged in before MFA - console.log('[AuthModal] MFA required - destroying AAL1 session and storing credentials'); - await supabase.auth.signOut(); - - // Store credentials in memory for re-authentication after TOTP - sessionStorage.setItem('mfa_pending_email_modal', formData.email); - sessionStorage.setItem('mfa_pending_password_modal', formData.password); - sessionStorage.setItem('mfa_factor_id_modal', totpFactor.id); - - setMfaFactorId(totpFactor.id); - setLoading(false); - return; // User has NO session - MFA modal will show - } + // Set the session in Supabase client + if (authResult.session) { + await supabase.auth.setSession(authResult.session); } - + toast({ title: "Welcome back!", description: "You've been signed in successfully." @@ -171,47 +148,12 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod }; const handleMfaSuccess = async () => { - console.log('[AuthModal] MFA verification succeeded'); - - // Retrieve stored credentials - const email = sessionStorage.getItem('mfa_pending_email_modal'); - const password = sessionStorage.getItem('mfa_pending_password_modal'); - - if (!email || !password) { - console.error('[AuthModal] Missing stored credentials for re-authentication'); - toast({ - title: "Authentication error", - description: "Please sign in again.", - variant: "destructive", - }); - setMfaFactorId(null); - return; - } - - // Clear stored credentials - sessionStorage.removeItem('mfa_pending_email_modal'); - sessionStorage.removeItem('mfa_pending_password_modal'); - sessionStorage.removeItem('mfa_factor_id_modal'); - - // Re-authenticate with stored credentials - this should create AAL2 session - console.log('[AuthModal] Re-authenticating with verified credentials'); - const { error: reAuthError } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (reAuthError) { - console.error('[AuthModal] Re-authentication failed:', reAuthError); - toast({ - title: "Authentication error", - description: "Please sign in again.", - variant: "destructive", - }); - setMfaFactorId(null); - return; - } + console.log('[AuthModal] MFA verification succeeded - no further action needed'); + // Clear state setMfaFactorId(null); + setMfaChallengeId(null); + setMfaPendingUserId(null); toast({ title: "Authentication complete", @@ -224,12 +166,10 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod const handleMfaCancel = async () => { console.log('[AuthModal] User cancelled MFA verification'); - // Clear stored credentials - sessionStorage.removeItem('mfa_pending_email_modal'); - sessionStorage.removeItem('mfa_pending_password_modal'); - sessionStorage.removeItem('mfa_factor_id_modal'); - + // Clear state setMfaFactorId(null); + setMfaChallengeId(null); + setMfaPendingUserId(null); setSignInCaptchaKey(prev => prev + 1); toast({ @@ -429,6 +369,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod {mfaFactorId ? ( diff --git a/src/components/auth/MFAChallenge.tsx b/src/components/auth/MFAChallenge.tsx index bd8d0db7..3e2ce41d 100644 --- a/src/components/auth/MFAChallenge.tsx +++ b/src/components/auth/MFAChallenge.tsx @@ -10,11 +10,13 @@ import { Shield, AlertCircle } from 'lucide-react'; interface MFAChallengeProps { factorId: string; + challengeId?: string | null; + userId?: string | null; onSuccess: () => void; onCancel: () => void; } -export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) { +export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCancel }: MFAChallengeProps) { const { toast } = useToast(); const [code, setCode] = useState(''); const [loading, setLoading] = useState(false); @@ -24,6 +26,38 @@ export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProp setLoading(true); try { + // NEW SERVER-SIDE FLOW: If we have challengeId and userId, use edge function + if (challengeId && userId) { + const { data: result, error: verifyError } = await supabase.functions.invoke( + 'verify-mfa-and-login', + { + body: { + challengeId, + factorId, + code: code.trim(), + userId, + }, + } + ); + + if (verifyError || result.error) { + throw new Error(result?.error || verifyError?.message || 'Verification failed'); + } + + // Set the session in Supabase client + if (result.session) { + await supabase.auth.setSession(result.session); + } + + toast({ + title: "Welcome back!", + description: "MFA verification successful." + }); + onSuccess(); + return; + } + + // OLD FLOW: For OAuth/Magic Link step-up (existing session) // Create fresh challenge for each verification attempt const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({ factorId }); diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 3eb52a8d..df26e4ec 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -4530,6 +4530,7 @@ export type Database = { Returns: undefined } backfill_sort_orders: { Args: never; Returns: undefined } + block_aal1_with_mfa: { Args: never; Returns: boolean } can_approve_submission_item: { Args: { item_id: string } Returns: boolean diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index 4af63f8a..73b72454 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -38,6 +38,8 @@ export default function Auth() { const [signInCaptchaKey, setSignInCaptchaKey] = useState(0); const [mfaFactorId, setMfaFactorId] = useState(null); const [mfaPendingEmail, setMfaPendingEmail] = useState(null); + const [mfaChallengeId, setMfaChallengeId] = useState(null); + const [mfaPendingUserId, setMfaPendingUserId] = useState(null); const emailParam = searchParams.get('email'); const messageParam = searchParams.get('message'); @@ -93,114 +95,66 @@ export default function Auth() { setSignInCaptchaToken(null); try { - const { - data, - error - } = await supabase.auth.signInWithPassword({ - email: formData.email, - password: formData.password, - options: { - captchaToken: tokenToUse + // Call server-side auth check with MFA detection + const { data: authResult, error: authError } = await supabase.functions.invoke( + 'auth-with-mfa-check', + { + body: { + email: formData.email, + password: formData.password, + captchaToken: tokenToUse, + }, } - }); - - if (error) throw error; + ); - // CRITICAL: Check ban status immediately after successful authentication - const { data: profile } = await supabase - .from('profiles') - .select('banned, ban_reason') - .eq('user_id', data.user.id) - .single(); - - if (profile?.banned) { - // Sign out immediately - await supabase.auth.signOut(); - - const reason = profile.ban_reason - ? `Reason: ${profile.ban_reason}` + if (authError || authResult.error) { + throw new Error(authResult?.error || authError?.message || 'Authentication failed'); + } + + // Check if user is banned + if (authResult.banned) { + const reason = authResult.banReason + ? `Reason: ${authResult.banReason}` : 'Contact support for assistance.'; - + toast({ variant: "destructive", title: "Account Suspended", description: `Your account has been suspended. ${reason}`, - duration: 10000 + duration: 10000, }); setLoading(false); - return; // Stop authentication flow + return; } - // Check if MFA is required (user exists but no session) - if (data.user && !data.session) { - const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified'); - - if (totpFactor) { - setMfaFactorId(totpFactor.id); - setLoading(false); - return; - } + // Check if MFA is required + if (authResult.mfaRequired) { + // NO SESSION EXISTS YET - show MFA challenge + console.log('[Auth] MFA required - no session created yet'); + setMfaFactorId(authResult.factorId); + setMfaChallengeId(authResult.challengeId); + setMfaPendingUserId(authResult.userId); + setLoading(false); + return; // User has NO session - MFA modal will show } + + // No MFA required - user has session + console.log('[Auth] No MFA required - user authenticated'); - // Track auth method for audit logging - setAuthMethod('password'); - - // Check if MFA step-up is required - const { handlePostAuthFlow } = await import('@/lib/authService'); - const postAuthResult = await handlePostAuthFlow(data.session, 'password'); - - if (postAuthResult.success && postAuthResult.data.shouldRedirect) { - // MFA IS REQUIRED - we must show the challenge or sign out - const { data: factors } = await supabase.auth.mfa.listFactors(); - const totpFactor = factors?.totp?.find(f => f.status === 'verified'); - - if (totpFactor) { - // DESTROY the AAL1 session - user should NOT be logged in before MFA - console.log('[Auth] MFA required - destroying AAL1 session and storing credentials'); - await supabase.auth.signOut(); - - // Store credentials in memory for re-authentication after TOTP - sessionStorage.setItem('mfa_pending_email', formData.email); - sessionStorage.setItem('mfa_pending_password', formData.password); - sessionStorage.setItem('mfa_factor_id', totpFactor.id); - - setMfaPendingEmail(formData.email); - setMfaFactorId(totpFactor.id); - setLoading(false); - return; // User has NO session - MFA modal will show - } else { - // MFA is required but no factor found - FORCE SIGN OUT for security - console.error('[Auth] SECURITY: MFA required but no verified factor found'); - await supabase.auth.signOut(); - toast({ - variant: "destructive", - title: "Authentication Error", - description: "Multi-factor authentication is required but not properly configured. Please contact support." - }); - setLoading(false); - return; - } - } - - // ONLY show success toast if MFA was NOT required - if (postAuthResult.success && !postAuthResult.data.shouldRedirect) { - // Verify session was stored - setTimeout(async () => { - const { data: { session } } = await supabase.auth.getSession(); - if (!session) { - toast({ - variant: "destructive", - title: "Session Error", - description: "Login succeeded but session was not stored. Please check your browser settings and enable cookies/storage." - }); - } else { - toast({ - title: "Welcome back!", - description: "You've been signed in successfully." - }); - } - }, 500); + // Set the session in Supabase client + if (authResult.session) { + await supabase.auth.setSession(authResult.session); } + + toast({ + title: "Welcome back!", + description: "You've been signed in successfully." + }); + + // Navigate after brief delay + setTimeout(() => { + navigate('/'); + }, 500); } catch (error) { // Reset CAPTCHA widget to force fresh token generation @@ -211,11 +165,11 @@ export default function Auth() { // Enhanced error messages const errorMsg = getErrorMessage(error); let errorMessage = errorMsg; - if (errorMsg.includes('Invalid login credentials')) { + if (errorMsg.includes('Invalid login credentials') || errorMsg.includes('Invalid credentials')) { errorMessage = 'Invalid email or password. Please try again.'; } else if (errorMsg.includes('Email not confirmed')) { errorMessage = 'Please confirm your email address before signing in.'; - } else if (error.message.includes('Too many requests')) { + } else if (errorMsg.includes('Too many requests')) { errorMessage = 'Too many login attempts. Please wait a few minutes and try again.'; } @@ -230,50 +184,16 @@ export default function Auth() { }; const handleMfaSuccess = async () => { - console.log('[Auth] MFA verification succeeded'); + console.log('[Auth] MFA verification succeeded - no further action needed'); - // Retrieve stored credentials - const email = sessionStorage.getItem('mfa_pending_email'); - const password = sessionStorage.getItem('mfa_pending_password'); - - if (!email || !password) { - console.error('[Auth] Missing stored credentials for re-authentication'); - toast({ - title: "Authentication error", - description: "Please sign in again.", - variant: "destructive", - }); - setMfaFactorId(null); - setMfaPendingEmail(null); - return; - } - - // Clear stored credentials - sessionStorage.removeItem('mfa_pending_email'); - sessionStorage.removeItem('mfa_pending_password'); - sessionStorage.removeItem('mfa_factor_id'); - - // Re-authenticate with stored credentials - this should create AAL2 session - console.log('[Auth] Re-authenticating with verified credentials'); - const { error: reAuthError } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (reAuthError) { - console.error('[Auth] Re-authentication failed:', reAuthError); - toast({ - title: "Authentication error", - description: "Please sign in again.", - variant: "destructive", - }); - setMfaFactorId(null); - setMfaPendingEmail(null); - return; - } + // MFA verification is handled by MFAChallenge component + // which calls verify-mfa-and-login edge function + // The session is automatically set by the edge function + // Clear state setMfaFactorId(null); - setMfaPendingEmail(null); + setMfaChallengeId(null); + setMfaPendingUserId(null); toast({ title: "Authentication complete", @@ -288,13 +208,10 @@ export default function Auth() { const handleMfaCancel = async () => { console.log('[Auth] User cancelled MFA verification'); - // Clear stored credentials - sessionStorage.removeItem('mfa_pending_email'); - sessionStorage.removeItem('mfa_pending_password'); - sessionStorage.removeItem('mfa_factor_id'); - + // Clear state - no credentials stored setMfaFactorId(null); - setMfaPendingEmail(null); + setMfaChallengeId(null); + setMfaPendingUserId(null); setSignInCaptchaKey(prev => prev + 1); toast({ @@ -520,6 +437,8 @@ export default function Auth() { diff --git a/supabase/config.toml b/supabase/config.toml index 54d81fd5..dae3f8fc 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,5 +1,14 @@ project_id = "ydvtmnrszybqnbcqbdcy" +[functions.auth-with-mfa-check] +verify_jwt = false + +[functions.verify-mfa-and-login] +verify_jwt = false + +[functions.check-mfa-enrollment] +verify_jwt = true + [functions.send-password-added-email] verify_jwt = true diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts new file mode 100644 index 00000000..14d9864d --- /dev/null +++ b/supabase/functions/_shared/cors.ts @@ -0,0 +1,4 @@ +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; diff --git a/supabase/functions/auth-with-mfa-check/index.ts b/supabase/functions/auth-with-mfa-check/index.ts new file mode 100644 index 00000000..f347a446 --- /dev/null +++ b/supabase/functions/auth-with-mfa-check/index.ts @@ -0,0 +1,128 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const supabaseUrl = Deno.env.get('SUPABASE_URL')!; +const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { email, password } = await req.json(); + + if (!email || !password) { + return new Response( + JSON.stringify({ error: 'Email and password are required' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Create admin client + const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + // Verify credentials using signInWithPassword (doesn't create session with admin client) + const { data: authData, error: authError } = await supabaseAdmin.auth.signInWithPassword({ + email, + password, + }); + + if (authError || !authData.user) { + console.error('Auth error:', authError); + return new Response( + JSON.stringify({ error: authError?.message || 'Invalid credentials' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + const userId = authData.user.id; + + // Check if user is banned + const { data: profile, error: profileError } = await supabaseAdmin + .from('profiles') + .select('banned, ban_reason') + .eq('user_id', userId) + .single(); + + if (profileError) { + console.error('Profile check error:', profileError); + } + + if (profile?.banned) { + return new Response( + JSON.stringify({ + error: 'Account suspended', + banned: true, + banReason: profile.ban_reason + }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Check for MFA enrollment + const { data: factors, error: factorsError } = await supabaseAdmin.auth.admin.mfa.listFactors({ + userId, + }); + + if (factorsError) { + console.error('MFA factors check error:', factorsError); + // Continue - assume no MFA if check fails + } + + const verifiedFactors = factors?.totp?.filter((f) => f.status === 'verified') || []; + + if (verifiedFactors.length > 0) { + // User has MFA - create a challenge but don't create session + const factorId = verifiedFactors[0].id; + + // Create MFA challenge + const { data: challengeData, error: challengeError } = await supabaseAdmin.auth.mfa.challenge({ + factorId, + }); + + if (challengeError) { + console.error('Challenge creation error:', challengeError); + return new Response( + JSON.stringify({ error: 'Failed to create MFA challenge' }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + return new Response( + JSON.stringify({ + mfaRequired: true, + factorId, + challengeId: challengeData.id, + userId, // Needed for verification step + }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // No MFA required - return session + return new Response( + JSON.stringify({ + mfaRequired: false, + session: authData.session, + user: authData.user, + }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('Unexpected error:', error); + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +}); diff --git a/supabase/functions/check-mfa-enrollment/index.ts b/supabase/functions/check-mfa-enrollment/index.ts new file mode 100644 index 00000000..301584b3 --- /dev/null +++ b/supabase/functions/check-mfa-enrollment/index.ts @@ -0,0 +1,84 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const supabaseUrl = Deno.env.get('SUPABASE_URL')!; +const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response( + JSON.stringify({ error: 'Missing authorization header' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Create client with user's token to verify session + const supabase = createClient(supabaseUrl, supabaseServiceKey, { + global: { + headers: { Authorization: authHeader }, + }, + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + const { data: { user }, error: userError } = await supabase.auth.getUser(); + + if (userError || !user) { + console.error('User verification error:', userError); + return new Response( + JSON.stringify({ error: 'Unauthorized' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Create admin client to check MFA factors + const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + const { data: factors, error: factorsError } = await supabaseAdmin.auth.admin.mfa.listFactors({ + userId: user.id, + }); + + if (factorsError) { + console.error('MFA factors check error:', factorsError); + return new Response( + JSON.stringify({ error: 'Failed to check MFA enrollment' }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + const verifiedFactors = factors?.totp?.filter((f) => f.status === 'verified') || []; + const hasEnrolled = verifiedFactors.length > 0; + const factorId = verifiedFactors.length > 0 ? verifiedFactors[0].id : undefined; + + return new Response( + JSON.stringify({ + hasEnrolled, + factorId, + }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('Unexpected error:', error); + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +}); diff --git a/supabase/functions/verify-mfa-and-login/index.ts b/supabase/functions/verify-mfa-and-login/index.ts new file mode 100644 index 00000000..c8c84f5a --- /dev/null +++ b/supabase/functions/verify-mfa-and-login/index.ts @@ -0,0 +1,91 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const supabaseUrl = Deno.env.get('SUPABASE_URL')!; +const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { challengeId, factorId, code, userId } = await req.json(); + + if (!challengeId || !factorId || !code || !userId) { + return new Response( + JSON.stringify({ error: 'Missing required fields' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Create admin client + const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + // Verify TOTP code + const { data: verifyData, error: verifyError } = await supabaseAdmin.auth.mfa.verify({ + factorId, + challengeId, + code, + }); + + if (verifyError || !verifyData) { + console.error('MFA verification error:', verifyError); + return new Response( + JSON.stringify({ error: verifyError?.message || 'Invalid verification code' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Verification successful - create AAL2 session using admin API + const { data: sessionData, error: sessionError } = await supabaseAdmin.auth.admin.createSession({ + userId, + // This creates a session with AAL2 + }); + + if (sessionError || !sessionData) { + console.error('Session creation error:', sessionError); + return new Response( + JSON.stringify({ error: 'Failed to create session' }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Log successful MFA authentication + try { + await supabaseAdmin.rpc('log_admin_action', { + _admin_user_id: userId, + _target_user_id: userId, + _action: 'mfa_login_success', + _details: { timestamp: new Date().toISOString(), aal: 'aal2' }, + }); + } catch (logError) { + console.error('Audit log error:', logError); + // Don't fail the login if audit logging fails + } + + return new Response( + JSON.stringify({ + success: true, + session: sessionData.session, + user: sessionData.user, + }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('Unexpected error:', error); + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +}); diff --git a/supabase/migrations/20251031170759_eb961b9b-cbc0-4020-83e6-05c320c74c84.sql b/supabase/migrations/20251031170759_eb961b9b-cbc0-4020-83e6-05c320c74c84.sql new file mode 100644 index 00000000..d848c2dd --- /dev/null +++ b/supabase/migrations/20251031170759_eb961b9b-cbc0-4020-83e6-05c320c74c84.sql @@ -0,0 +1,65 @@ +-- Create security definer function to block AAL1 sessions when MFA is enrolled +CREATE OR REPLACE FUNCTION public.block_aal1_with_mfa() +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + CASE + -- If current session is AAL1 + WHEN (auth.jwt() ->> 'aal') = 'aal1' THEN + -- Check if user has verified MFA factors + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() + AND status = 'verified' + ) + -- If AAL2 or higher, allow + ELSE true + END; +$$; + +-- Apply to profiles table +CREATE POLICY "enforce_aal2_for_mfa_users" ON public.profiles +FOR ALL +USING (public.block_aal1_with_mfa()); + +-- Apply to user_roles table +CREATE POLICY "enforce_aal2_for_mfa_users_roles" ON public.user_roles +FOR ALL +USING (public.block_aal1_with_mfa()); + +-- Apply to submission tables +CREATE POLICY "enforce_aal2_for_mfa_users_park_sub" ON public.park_submissions +FOR ALL +USING (public.block_aal1_with_mfa()); + +CREATE POLICY "enforce_aal2_for_mfa_users_ride_sub" ON public.ride_submissions +FOR ALL +USING (public.block_aal1_with_mfa()); + +CREATE POLICY "enforce_aal2_for_mfa_users_company_sub" ON public.company_submissions +FOR ALL +USING (public.block_aal1_with_mfa()); + +CREATE POLICY "enforce_aal2_for_mfa_users_content_sub" ON public.content_submissions +FOR ALL +USING (public.block_aal1_with_mfa()); + +CREATE POLICY "enforce_aal2_for_mfa_users_photo_sub" ON public.photo_submissions +FOR ALL +USING (public.block_aal1_with_mfa()); + +-- Apply to user content tables +CREATE POLICY "enforce_aal2_for_mfa_users_reviews" ON public.reviews +FOR ALL +USING (public.block_aal1_with_mfa()); + +CREATE POLICY "enforce_aal2_for_mfa_users_reports" ON public.reports +FOR ALL +USING (public.block_aal1_with_mfa()); + +-- Grant execute permission +GRANT EXECUTE ON FUNCTION public.block_aal1_with_mfa() TO authenticated; \ No newline at end of file