From d1f01d922852fad91c766c2b386eaa3466c9706e Mon Sep 17 00:00:00 2001 From: pac7 <47831526-pac7@users.noreply.replit.com> Date: Mon, 27 Oct 2025 23:53:33 +0000 Subject: [PATCH] Improve security by requiring higher authentication levels for sensitive actions Update authentication flows to enforce AAL2 requirements for MFA operations and identity disconnections, and adjust TOTP verification logic. Replit-Commit-Author: Agent Replit-Commit-Session-Id: da324197-4d44-4e4b-b342-fe8ae33cf0cf Replit-Commit-Checkpoint-Type: intermediate_checkpoint --- .replit | 4 -- .../settings/PasswordUpdateDialog.tsx | 14 ++++-- src/lib/identityService.ts | 49 ++++++++++++++++++- src/types/identity.ts | 1 + supabase/functions/mfa-unenroll/index.ts | 3 +- 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.replit b/.replit index 27f5cf7c..5586ae74 100644 --- a/.replit +++ b/.replit @@ -38,10 +38,6 @@ externalPort = 80 localPort = 5001 externalPort = 3000 -[[ports]] -localPort = 34475 -externalPort = 3003 - [[ports]] localPort = 37143 externalPort = 3001 diff --git a/src/components/settings/PasswordUpdateDialog.tsx b/src/components/settings/PasswordUpdateDialog.tsx index 513f9152..e0ff3d28 100644 --- a/src/components/settings/PasswordUpdateDialog.tsx +++ b/src/components/settings/PasswordUpdateDialog.tsx @@ -225,9 +225,16 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password setLoading(true); try { - // Verify TOTP code + // Get the factor ID first + const factorId = (await supabase.auth.mfa.listFactors()).data?.totp?.[0]?.id || ''; + + if (!factorId) { + throw new Error('No MFA factor found'); + } + + // Create challenge const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({ - factorId: (await supabase.auth.mfa.listFactors()).data?.totp?.[0]?.id || '' + factorId }); if (challengeError) { @@ -240,8 +247,9 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password throw challengeError; } + // Verify TOTP code with correct factorId const { error: verifyError } = await supabase.auth.mfa.verify({ - factorId: challengeData.id, + factorId, challengeId: challengeData.id, code: totpCode }); diff --git a/src/lib/identityService.ts b/src/lib/identityService.ts index dcab3b2b..3d185bdb 100644 --- a/src/lib/identityService.ts +++ b/src/lib/identityService.ts @@ -90,12 +90,59 @@ export async function checkDisconnectSafety( /** * Disconnect an OAuth identity from the user's account + * Requires AAL2 session for security */ export async function disconnectIdentity( provider: OAuthProvider ): Promise { try { - // Safety check first + // AAL2 check for security-critical operation (MUST fail closed) + const { data: { session } } = await supabase.auth.getSession(); + + // Get AAL level - fail closed on error + const { data: aalData, error: aalError } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (aalError) { + logger.error('Failed to get AAL level for identity disconnect', { + action: 'disconnect_identity_aal_check', + error: aalError.message + }); + return { + success: false, + error: 'Unable to verify security level. Please try again.', + requiresAAL2: true + }; + } + + const currentAal = aalData?.currentLevel || 'aal1'; + + // If not at AAL2, check if MFA is enrolled - fail closed on error + if (currentAal !== 'aal2') { + const { data: factors, error: factorsError } = await supabase.auth.mfa.listFactors(); + + if (factorsError) { + logger.error('Failed to list MFA factors for identity disconnect', { + action: 'disconnect_identity_mfa_check', + error: factorsError.message + }); + return { + success: false, + error: 'Unable to verify MFA status. Please try again.', + requiresAAL2: true + }; + } + + const hasEnrolledMFA = factors?.totp?.some(f => f.status === 'verified') || false; + + if (hasEnrolledMFA) { + return { + success: false, + error: 'Please verify your identity with MFA before disconnecting accounts', + requiresAAL2: true + }; + } + } + + // Safety check const safetyCheck = await checkDisconnectSafety(provider); if (!safetyCheck.canDisconnect) { return { diff --git a/src/types/identity.ts b/src/types/identity.ts index 4745d36c..4934d75d 100644 --- a/src/types/identity.ts +++ b/src/types/identity.ts @@ -33,4 +33,5 @@ export interface IdentityOperationResult { needsRelogin?: boolean; needsEmailConfirmation?: boolean; email?: string; + requiresAAL2?: boolean; } diff --git a/supabase/functions/mfa-unenroll/index.ts b/supabase/functions/mfa-unenroll/index.ts index 519053ce..73e75480 100644 --- a/supabase/functions/mfa-unenroll/index.ts +++ b/supabase/functions/mfa-unenroll/index.ts @@ -60,7 +60,8 @@ Deno.serve(async (req) => { // Phase 1: Check AAL level const { data: { session } } = await supabaseClient.auth.getSession(); - const aal = session?.aal || 'aal1'; + const { data: aalData } = await supabaseClient.auth.mfa.getAuthenticatorAssuranceLevel(); + const aal = aalData?.currentLevel || 'aal1'; if (aal !== 'aal2') { edgeLogger.warn('AAL2 required for MFA removal', { action: 'mfa_unenroll_aal', userId: user.id, aal });