From fc47e89da8a1563dfcdeb002b9132b310a9f710b Mon Sep 17 00:00:00 2001 From: mykonos-ibiza <222371740+mykonos-ibiza@users.noreply.github.com> Date: Thu, 24 Jul 2025 14:16:04 +0530 Subject: [PATCH 1/2] migrate MFA functionality to frontend and remove backend MFA endpoints --- backend/api.py | 3 +- .../auth/phone_verification_supabase_mfa.py | 591 ------------------ backend/services/billing.py | 1 + frontend/src/components/AuthProvider.tsx | 8 + .../auth/background-aal-checker.tsx | 6 +- frontend/src/lib/api/phone-verification.ts | 55 +- frontend/src/lib/supabase/mfa.ts | 382 +++++++++++ 7 files changed, 403 insertions(+), 643 deletions(-) delete mode 100644 backend/auth/phone_verification_supabase_mfa.py create mode 100644 frontend/src/lib/supabase/mfa.ts diff --git a/backend/api.py b/backend/api.py index ffb3f602..e07c4a83 100644 --- a/backend/api.py +++ b/backend/api.py @@ -187,8 +187,7 @@ api_router.include_router(workflows_router, prefix="/workflows") from pipedream import api as pipedream_api api_router.include_router(pipedream_api.router) -from auth import phone_verification_supabase_mfa -api_router.include_router(phone_verification_supabase_mfa.router) +# MFA functionality moved to frontend @api_router.get("/health") async def health_check(): diff --git a/backend/auth/phone_verification_supabase_mfa.py b/backend/auth/phone_verification_supabase_mfa.py deleted file mode 100644 index a478292f..00000000 --- a/backend/auth/phone_verification_supabase_mfa.py +++ /dev/null @@ -1,591 +0,0 @@ -""" -Auth MFA endpoints for Supabase Phone-based Multi-Factor Authentication (MFA). - -Currently, only SMS is supported as a second factor. Users can enroll their phone number for SMS-based 2FA. -No recovery codes are supported, but users can update their phone number for backup. - -This API provides endpoints to: -- Enroll a phone number for SMS 2FA -- Create a challenge for a phone factor (sends SMS) -- Verify a challenge with SMS code -- Create and verify a challenge in a single step -- List enrolled factors -- Unenroll a factor -- Get Authenticator Assurance Level (AAL) -""" - -import json -import os -from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel, Field -from typing import List, Optional -import jwt -from datetime import datetime, timezone -from supabase import create_client, Client -from utils.auth_utils import get_current_user_id_from_jwt -from utils.config import config -from utils.logger import logger, structlog - -router = APIRouter(prefix="/mfa", tags=["MFA"]) - -# Initialize Supabase client with anon key for user operations -supabase_url = config.SUPABASE_URL -supabase_anon_key = config.SUPABASE_ANON_KEY - -# Cutoff date for new user phone verification requirement -# Users created after this date will be required to have phone verification -# Users created before this date are grandfathered in and not required to verify -PHONE_VERIFICATION_CUTOFF_DATE = datetime(2025, 7, 21, 0, 0, 0, tzinfo=timezone.utc) - -def is_phone_verification_mandatory() -> bool: - """Check if phone verification is mandatory based on environment variable.""" - env_val = os.getenv("PHONE_NUMBER_MANDATORY") - if env_val is None: - return False - return env_val.lower() in ('true', 't', 'yes', 'y', '1') - - -def get_authenticated_client(request: Request) -> Client: - """ - Create a Supabase client authenticated with the user's JWT token. - This approach uses the JWT token directly for server-side authentication. - """ - # Extract the JWT token from the Authorization header - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - raise HTTPException( - status_code=401, detail="Missing or invalid Authorization header" - ) - - token = auth_header.split(" ")[1] - - # Extract the refresh token from the custom header - refresh_token = request.headers.get("X-Refresh-Token") - - # Create a new Supabase client with the anon key - client = create_client(supabase_url, supabase_anon_key) - - # Set the session with the JWT token - # For server-side operations, we can use the token directly - try: - # Verify the token is valid by getting the user - user_response = client.auth.get_user(token) - if not user_response.user: - raise HTTPException(status_code=401, detail="Invalid token") - - # Set the session with both access and refresh tokens - client.auth.set_session(token, refresh_token) - return client - except Exception as e: - raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}") - - -# Request/Response Models -class EnrollFactorRequest(BaseModel): - friendly_name: str = Field(..., description="User-friendly name for the factor") - phone_number: str = Field( - ..., description="Phone number in E.164 format (e.g., +1234567890)" - ) - - -class EnrollFactorResponse(BaseModel): - id: str - friendly_name: str - phone_number: str - # Note: Supabase response may not include status, created_at, updated_at - qr_code: Optional[str] = None - secret: Optional[str] = None - - -class ChallengeRequest(BaseModel): - factor_id: str = Field(..., description="ID of the factor to challenge") - - -class ChallengeResponse(BaseModel): - id: str - expires_at: Optional[str] = None - # Note: Supabase response may not include factor_type, created_at - - -class VerifyRequest(BaseModel): - factor_id: str = Field(..., description="ID of the factor to verify") - challenge_id: str = Field(..., description="ID of the challenge to verify") - code: str = Field(..., description="SMS code received on phone") - - -class ChallengeAndVerifyRequest(BaseModel): - factor_id: str = Field(..., description="ID of the factor to challenge and verify") - code: str = Field(..., description="SMS code received on phone") - - -class FactorInfo(BaseModel): - id: str - friendly_name: Optional[str] = None - factor_type: Optional[str] = None - status: Optional[str] = None - phone: Optional[str] = None - created_at: Optional[str] = None - updated_at: Optional[str] = None - - -class ListFactorsResponse(BaseModel): - factors: List[FactorInfo] - - -class UnenrollRequest(BaseModel): - factor_id: str = Field(..., description="ID of the factor to unenroll") - - -class AALResponse(BaseModel): - current_level: Optional[str] = None - next_level: Optional[str] = None - current_authentication_methods: Optional[List[str]] = None - # Add action guidance based on AAL status - action_required: Optional[str] = None - message: Optional[str] = None - # Phone verification requirement fields - phone_verification_required: Optional[bool] = None - user_created_at: Optional[str] = None - cutoff_date: Optional[str] = None - # Computed verification status fields - verification_required: Optional[bool] = None - is_verified: Optional[bool] = None - factors: Optional[List[dict]] = None - - -@router.post("/enroll", response_model=EnrollFactorResponse) -async def enroll_factor( - request_data: EnrollFactorRequest, - client: Client = Depends(get_authenticated_client), - user_id: str = Depends(get_current_user_id_from_jwt), -): - """ - Enroll a new phone number for SMS-based 2FA. - - Currently only supports 'phone' factor type. - Phone number must be in E.164 format (e.g., +1234567890). - """ - structlog.contextvars.bind_contextvars( - user_id=user_id, - action="mfa_enroll", - phone_number=request_data.phone_number, - friendly_name=request_data.friendly_name - ) - - try: - response = client.auth.mfa.enroll( - { - "factor_type": "phone", - "friendly_name": request_data.friendly_name, - "phone": request_data.phone_number, - } - ) - - # Build response with defensive field access - enroll_response = EnrollFactorResponse( - id=response.id, - friendly_name=request_data.friendly_name, # Use request data as fallback - phone_number=request_data.phone_number, - ) - - # Add optional fields if they exist - if hasattr(response, "qr_code"): - enroll_response.qr_code = response.qr_code - if hasattr(response, "secret"): - enroll_response.secret = response.secret - - return enroll_response - except Exception as e: - raise HTTPException( - status_code=400, detail=f"Failed to enroll phone factor: {str(e)}" - ) - - -@router.post("/challenge", response_model=ChallengeResponse) -async def create_challenge( - request_data: ChallengeRequest, - client: Client = Depends(get_authenticated_client), - user_id: str = Depends(get_current_user_id_from_jwt), -): - """ - Create a challenge for an enrolled phone factor. - - This will send an SMS code to the registered phone number. - The challenge must be verified within the time limit. - """ - structlog.contextvars.bind_contextvars( - user_id=user_id, - action="mfa_challenge", - factor_id=request_data.factor_id - ) - - try: - response = client.auth.mfa.challenge( - { - "factor_id": request_data.factor_id, - } - ) - - # Build response with defensive field access - challenge_response = ChallengeResponse(id=response.id) - - # Add optional fields if they exist - if hasattr(response, "expires_at"): - challenge_response.expires_at = response.expires_at - - return challenge_response - except Exception as e: - raise HTTPException( - status_code=400, detail=f"Failed to create SMS challenge: {str(e)}" - ) - - -@router.post("/verify") -async def verify_challenge( - request_data: VerifyRequest, - client: Client = Depends(get_authenticated_client), - user_id: str = Depends(get_current_user_id_from_jwt), -): - """ - Verify a challenge with an SMS code. - - The challenge must be active and the SMS code must be valid. - """ - structlog.contextvars.bind_contextvars( - user_id=user_id, - action="mfa_verify", - factor_id=request_data.factor_id, - challenge_id=request_data.challenge_id - ) - - try: - logger.info(f"🔵 Starting MFA verification for user {user_id}: " - f"factor_id={request_data.factor_id}, " - f"challenge_id={request_data.challenge_id}") - - # Check AAL BEFORE verification - try: - aal_before = client.auth.mfa.get_authenticator_assurance_level() - logger.info(f"📊 AAL BEFORE verification: " - f"current={aal_before.current_level}, " - f"next={aal_before.next_level}") - except Exception as e: - logger.warning(f"Failed to get AAL before verification: {e}") - - # Verify the challenge - response = client.auth.mfa.verify( - { - "factor_id": request_data.factor_id, - "challenge_id": request_data.challenge_id, - "code": request_data.code, - } - ) - - logger.info(f"✅ MFA verification successful for user {user_id}") - logger.info(f"Verification response type: {type(response)}") - logger.info(f"Verification response attributes: {dir(response)}") - - # Check if response has session info - if hasattr(response, 'session') and response.session: - logger.info(f"New session info: access_token present: {bool(getattr(response.session, 'access_token', None))}") - logger.info(f"New session user: {getattr(response.session, 'user', None)}") - - # Check AAL AFTER verification - try: - aal_after = client.auth.mfa.get_authenticator_assurance_level() - logger.info(f"📊 AAL AFTER verification: " - f"current={aal_after.current_level}, " - f"next={aal_after.next_level}") - except Exception as e: - logger.warning(f"Failed to get AAL after verification: {e}") - - # Check factor status AFTER verification - try: - user_response = client.auth.get_user() - if user_response.user and hasattr(user_response.user, "factors"): - for factor in user_response.user.factors: - if factor.id == request_data.factor_id: - logger.info(f"Factor {request_data.factor_id} status after verification: {getattr(factor, 'status', 'unknown')}") - break - except Exception as e: - logger.warning(f"Failed to check factor status after verification: {e}") - - return { - "success": True, - "message": "SMS code verified successfully", - "session": response, - } - except Exception as e: - logger.error(f"❌ MFA verification failed for user {user_id}: {str(e)}") - raise HTTPException( - status_code=400, detail=f"Failed to verify SMS code: {str(e)}" - ) - - -@router.post("/challenge-and-verify") -async def challenge_and_verify( - request_data: ChallengeAndVerifyRequest, - client: Client = Depends(get_authenticated_client), - user_id: str = Depends(get_current_user_id_from_jwt), -): - """ - Create a challenge and verify it in a single step. - - This will send an SMS code and verify it immediately when provided. - This is a convenience method that combines challenge creation and verification. - """ - structlog.contextvars.bind_contextvars( - user_id=user_id, - action="mfa_challenge_and_verify", - factor_id=request_data.factor_id - ) - - try: - response = client.auth.mfa.challenge_and_verify( - {"factor_id": request_data.factor_id, "code": request_data.code} - ) - - return { - "success": True, - "message": "SMS challenge created and verified successfully", - "session": response, - } - except Exception as e: - raise HTTPException( - status_code=400, detail=f"Failed to challenge and verify SMS: {str(e)}" - ) - - -@router.get("/factors", response_model=ListFactorsResponse) -async def list_factors( - client: Client = Depends(get_authenticated_client), - user_id: str = Depends(get_current_user_id_from_jwt), -): - """ - List all enrolled factors for the authenticated user. - """ - structlog.contextvars.bind_contextvars( - user_id=user_id, - action="mfa_list_factors" - ) - - try: - # Get user info which includes factors - user_response = client.auth.get_user() - if not user_response.user: - raise HTTPException(status_code=401, detail="User not found") - - # Extract factors from user data with defensive access - factors = [] - if hasattr(user_response.user, "factors") and user_response.user.factors: - for factor in user_response.user.factors: - # Convert datetime objects to strings for Pydantic validation - created_at = getattr(factor, "created_at", None) - if created_at and hasattr(created_at, "isoformat"): - created_at = created_at.isoformat() - - updated_at = getattr(factor, "updated_at", None) - if updated_at and hasattr(updated_at, "isoformat"): - updated_at = updated_at.isoformat() - - factor_info = FactorInfo( - id=factor.id if hasattr(factor, "id") else str(factor), - friendly_name=getattr(factor, "friendly_name", None), - factor_type=getattr(factor, "factor_type", None), - status=getattr(factor, "status", None), - phone=getattr(factor, "phone", None), - created_at=created_at, - updated_at=updated_at, - ) - factors.append(factor_info) - - return ListFactorsResponse(factors=factors) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to list factors: {str(e)}") - - -@router.post("/unenroll") -async def unenroll_factor( - request_data: UnenrollRequest, - client: Client = Depends(get_authenticated_client), - user_id: str = Depends(get_current_user_id_from_jwt), -): - """ - Unenroll a phone factor for the authenticated user. - - This will remove the phone number and invalidate any active sessions if the factor was verified. - """ - structlog.contextvars.bind_contextvars( - user_id=user_id, - action="mfa_unenroll", - factor_id=request_data.factor_id - ) - - try: - response = client.auth.mfa.unenroll({"factor_id": request_data.factor_id}) - - return {"success": True, "message": "Phone factor unenrolled successfully"} - except Exception as e: - raise HTTPException( - status_code=400, detail=f"Failed to unenroll phone factor: {str(e)}" - ) - - -@router.get("/aal", response_model=AALResponse) -async def get_authenticator_assurance_level( - client: Client = Depends(get_authenticated_client), - user_id: str = Depends(get_current_user_id_from_jwt), -): - """ - Get the Authenticator Assurance Level (AAL) for the current session. - - This endpoint combines AAL status with phone verification requirements: - - aal1 -> aal1: User does not have MFA enrolled - - aal1 -> aal2: User has MFA enrolled but not verified (requires verification) - - aal2 -> aal2: User has verified their MFA factor - - aal2 -> aal1: User has disabled MFA (stale JWT, requires reauthentication) - - Also includes phone verification requirement based on account creation date. - """ - structlog.contextvars.bind_contextvars( - user_id=user_id, - action="mfa_get_aal" - ) - - try: - # Get the current AAL from Supabase - response = client.auth.mfa.get_authenticator_assurance_level() - - # Extract AAL levels from response first - current = response.current_level - next_level = response.next_level - - # Get user creation date and factors for phone verification requirement - user_response = client.auth.get_user() - if not user_response.user: - raise HTTPException(status_code=401, detail="User not found") - - user_created_at = None - if hasattr(user_response.user, 'created_at') and user_response.user.created_at: - try: - # Handle different possible formats for created_at - created_at_value = user_response.user.created_at - if isinstance(created_at_value, str): - # Parse ISO format string - user_created_at = datetime.fromisoformat(created_at_value.replace('Z', '+00:00')) - elif hasattr(created_at_value, 'isoformat'): - # Already a datetime object - user_created_at = created_at_value - if user_created_at.tzinfo is None: - user_created_at = user_created_at.replace(tzinfo=timezone.utc) - else: - logger.warning(f"Unexpected created_at type: {type(created_at_value)}") - except Exception as e: - logger.error(f"Failed to parse user created_at: {e}") - # Fall back to treating as new user for safety - user_created_at = datetime.now(timezone.utc) - - # Determine if this is a new user who needs phone verification - is_new_user = ( - user_created_at is not None and - user_created_at >= PHONE_VERIFICATION_CUTOFF_DATE - ) - - # Get factors and compute phone verification status - factors = [] - phone_factors = [] - has_verified_phone = False - - if hasattr(user_response.user, "factors") and user_response.user.factors: - for factor in user_response.user.factors: - # Convert datetime objects to strings for JSON serialization - created_at = getattr(factor, "created_at", None) - if created_at and hasattr(created_at, "isoformat"): - created_at = created_at.isoformat() - - updated_at = getattr(factor, "updated_at", None) - if updated_at and hasattr(updated_at, "isoformat"): - updated_at = updated_at.isoformat() - - factor_dict = { - "id": factor.id if hasattr(factor, "id") else str(factor), - "friendly_name": getattr(factor, "friendly_name", None), - "factor_type": getattr(factor, "factor_type", None), - "status": getattr(factor, "status", None), - "phone": getattr(factor, "phone", None), - "created_at": created_at, - "updated_at": updated_at, - } - factors.append(factor_dict) - - # Track phone factors - if factor_dict.get("factor_type") == "phone": - phone_factors.append(factor_dict) - if factor_dict.get("status") == "verified": - has_verified_phone = True - - # Determine action required based on AAL combination - action_required = None - message = None - - if current == "aal1" and next_level == "aal1": - # User does not have MFA enrolled - action_required = "none" - message = "MFA is not enrolled for this account" - elif current == "aal1" and next_level == "aal2": - # User has MFA enrolled but needs to verify it - action_required = "verify_mfa" - message = "MFA verification required to access full features" - elif current == "aal2" and next_level == "aal2": - # User has verified their MFA factor - action_required = "none" - message = "MFA is verified and active" - elif current == "aal2" and next_level == "aal1": - # User has disabled MFA or has stale JWT - action_required = "reauthenticate" - message = "Session needs refresh due to MFA changes" - else: - # Unknown combination - action_required = "unknown" - message = f"Unknown AAL combination: {current} -> {next_level}" - - # Determine verification_required based on AAL status AND grandfathering logic - verification_required = False - if is_new_user: - # New users (created after cutoff date) must have phone verification - if current == 'aal1' and next_level == 'aal1': - # No MFA enrolled - new users must enroll - verification_required = True - elif action_required == 'verify_mfa': - # MFA enrolled but needs verification - verification_required = True - else: - # Existing users (grandfathered) - only require verification if AAL demands it - verification_required = action_required == 'verify_mfa' - - phone_verification_required = False and is_new_user and is_phone_verification_mandatory() - verification_required = False and is_new_user and verification_required and is_phone_verification_mandatory() - - logger.info(f"AAL check for user {user_id}: " - f"current_level={current}, " - f"next_level={next_level}, " - f"action_required={action_required}, " - f"phone_verification_required={phone_verification_required}, " - f"verification_required={verification_required}, " - f"is_verified={has_verified_phone}") - - return AALResponse( - current_level=current, - next_level=next_level, - current_authentication_methods=[x.method for x in response.current_authentication_methods], - action_required=action_required, - message=message, - phone_verification_required=phone_verification_required, - user_created_at=user_created_at.isoformat() if user_created_at else None, - cutoff_date=PHONE_VERIFICATION_CUTOFF_DATE.isoformat(), - verification_required=verification_required, - is_verified=has_verified_phone, - factors=factors, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to get AAL: {str(e)}") diff --git a/backend/services/billing.py b/backend/services/billing.py index 1b37a553..117c79a2 100644 --- a/backend/services/billing.py +++ b/backend/services/billing.py @@ -497,6 +497,7 @@ async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optiona # Calculate current month's usage current_usage = await calculate_monthly_usage(client, user_id) + # TODO: also do user's AAL check # Check if within limits if current_usage >= tier_info['cost']: return False, f"Monthly limit of {tier_info['cost']} dollars reached. Please upgrade your plan or wait until next month.", subscription diff --git a/frontend/src/components/AuthProvider.tsx b/frontend/src/components/AuthProvider.tsx index c5028444..8171002d 100644 --- a/frontend/src/components/AuthProvider.tsx +++ b/frontend/src/components/AuthProvider.tsx @@ -42,11 +42,19 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const { data: authListener } = supabase.auth.onAuthStateChange( async (event, newSession) => { + console.log('🔵 Auth state change:', { event, session: !!newSession, user: !!newSession?.user }); + setSession(newSession); setUser(newSession?.user ?? null); if (isLoading) setIsLoading(false); + if (event === 'SIGNED_IN' && newSession?.user) { await checkAndInstallSunaAgent(newSession.user.id, newSession.user.created_at); + } else if (event === 'MFA_CHALLENGE_VERIFIED') { + console.log('✅ MFA challenge verified, session updated'); + // Session is automatically updated by Supabase, just log for debugging + } else if (event === 'TOKEN_REFRESHED') { + console.log('🔄 Token refreshed, session updated'); } }, ); diff --git a/frontend/src/components/auth/background-aal-checker.tsx b/frontend/src/components/auth/background-aal-checker.tsx index d5f7dbbe..6536fb42 100644 --- a/frontend/src/components/auth/background-aal-checker.tsx +++ b/frontend/src/components/auth/background-aal-checker.tsx @@ -64,8 +64,7 @@ export function BackgroundAALChecker({ if (current_level === "aal1" && next_level === "aal1") { // New user has no MFA enrolled - redirect to enrollment console.log('Background: New user without MFA enrolled, redirecting to phone verification'); - // Temporarily disabled - // router.push(redirectTo); + router.push(redirectTo); return; } // If new user has MFA enrolled, follow standard AAL flow below @@ -76,8 +75,7 @@ export function BackgroundAALChecker({ case 'verify_mfa': // User has MFA enrolled but needs to verify it console.log('Background: Redirecting to MFA verification'); - // Temporarily disabled - // router.push(redirectTo); + router.push(redirectTo); break; case 'reauthenticate': diff --git a/frontend/src/lib/api/phone-verification.ts b/frontend/src/lib/api/phone-verification.ts index c10f954e..e9333d76 100644 --- a/frontend/src/lib/api/phone-verification.ts +++ b/frontend/src/lib/api/phone-verification.ts @@ -1,5 +1,4 @@ -import { backendApi } from '@/lib/api-client'; -import { createClient } from '@/lib/supabase/client'; +import { supabaseMFAService } from '@/lib/supabase/mfa'; @@ -78,95 +77,59 @@ export interface AALResponse { export const phoneVerificationService = { - - - /** * Enroll phone number for SMS-based 2FA */ async enrollPhoneNumber(data: PhoneVerificationEnroll): Promise { - const response = await backendApi.post('/mfa/enroll', data); - return response.data; + return await supabaseMFAService.enrollPhoneNumber(data); }, /** * Create a challenge for an enrolled phone factor (sends SMS) */ async createChallenge(data: PhoneVerificationChallenge): Promise { - const response = await backendApi.post('/mfa/challenge', data); - return response.data; + return await supabaseMFAService.createChallenge(data); }, /** * Verify SMS code for phone verification */ async verifyChallenge(data: PhoneVerificationVerify): Promise { - try { - const response = await backendApi.post('/mfa/verify', data); - - // After successful verification, refresh the Supabase session - // This ensures the frontend client gets the updated session with AAL2 tokens - try { - const supabase = createClient(); - await supabase.auth.refreshSession(); - console.log("🔄 Frontend Supabase session refreshed after verification"); - } catch (refreshError) { - console.warn("⚠️ Failed to refresh Supabase session:", refreshError); - } - - return { - success: response.data.success || true, - message: response.data.message || 'SMS code verified successfully' - }; - } catch (error) { - console.error("❌ Verify challenge failed:", error); - throw error; - } + return await supabaseMFAService.verifyChallenge(data); }, /** * Create challenge and verify in one step */ async challengeAndVerify(data: PhoneVerificationChallengeAndVerify): Promise { - const response = await backendApi.post('/mfa/challenge-and-verify', data); - return { - success: response.data.success || true, - message: response.data.message || 'SMS challenge created and verified successfully' - }; + return await supabaseMFAService.challengeAndVerify(data); }, /** * Resend SMS code (create new challenge for existing factor) */ async resendSMS(factorId: string): Promise { - const response = await backendApi.post('/mfa/challenge', { factor_id: factorId }); - return response.data; + return await supabaseMFAService.resendSMS(factorId); }, /** * List all enrolled MFA factors */ async listFactors(): Promise { - const response = await backendApi.get('/mfa/factors'); - return response.data; + return await supabaseMFAService.listFactors(); }, /** * Remove phone verification from account */ async unenrollFactor(factorId: string): Promise { - const response = await backendApi.post('/mfa/unenroll', { factor_id: factorId }); - return { - success: response.data.success || true, - message: response.data.message || 'Phone factor unenrolled successfully' - }; + return await supabaseMFAService.unenrollFactor(factorId); }, /** * Get Authenticator Assurance Level */ async getAAL(): Promise { - const response = await backendApi.get('/mfa/aal'); - return response.data; + return await supabaseMFAService.getAAL(); } }; \ No newline at end of file diff --git a/frontend/src/lib/supabase/mfa.ts b/frontend/src/lib/supabase/mfa.ts new file mode 100644 index 00000000..55e6d1a6 --- /dev/null +++ b/frontend/src/lib/supabase/mfa.ts @@ -0,0 +1,382 @@ +import { createClient } from './client'; +import type { + FactorInfo, + PhoneVerificationEnroll, + PhoneVerificationChallenge, + PhoneVerificationVerify, + PhoneVerificationChallengeAndVerify, + PhoneVerificationResponse, + EnrollFactorResponse, + ChallengeResponse, + ListFactorsResponse, + AALResponse, +} from '@/lib/api/phone-verification'; + +// Cutoff date for new user phone verification requirement +// Users created after this date will be required to have phone verification +// Users created before this date are grandfathered in and not required to verify +const PHONE_VERIFICATION_CUTOFF_DATE = new Date('2025-07-25T00:05:30.000Z'); + +function isPhoneVerificationMandatory(): boolean { + const envVal = process.env.NEXT_PUBLIC_PHONE_NUMBER_MANDATORY; + if (!envVal) return false; + return envVal.toLowerCase() === 'true'; +} + +export const supabaseMFAService = { + /** + * Enroll phone number for SMS-based 2FA + */ + async enrollPhoneNumber(data: PhoneVerificationEnroll): Promise { + const supabase = createClient(); + + try { + const response = await supabase.auth.mfa.enroll({ + factorType: 'phone', + friendlyName: data.friendly_name, + phone: data.phone_number, + }); + + if (response.error) { + throw new Error(response.error.message); + } + + if (!response.data) { + throw new Error('No data returned from enrollment'); + } + + return { + id: response.data.id, + friendly_name: data.friendly_name, + phone_number: data.phone_number, + qr_code: undefined, // Phone factors don't have QR codes + secret: undefined, // Phone factors don't have secrets + }; + } catch (error: any) { + console.error('❌ Enroll phone factor failed:', error); + throw new Error(`Failed to enroll phone factor: ${error.message}`); + } + }, + + /** + * Create a challenge for an enrolled phone factor (sends SMS) + */ + async createChallenge(data: PhoneVerificationChallenge): Promise { + const supabase = createClient(); + + try { + const response = await supabase.auth.mfa.challenge({ + factorId: data.factor_id, + }); + + if (response.error) { + throw new Error(response.error.message); + } + + if (!response.data) { + throw new Error('No data returned from challenge'); + } + + return { + id: response.data.id, + expires_at: response.data.expires_at ? new Date(response.data.expires_at * 1000).toISOString() : undefined, + }; + } catch (error: any) { + console.error('❌ Create SMS challenge failed:', error); + throw new Error(`Failed to create SMS challenge: ${error.message}`); + } + }, + + /** + * Verify SMS code for phone verification + */ + async verifyChallenge(data: PhoneVerificationVerify): Promise { + const supabase = createClient(); + + try { + console.log('🔵 Starting MFA verification with Supabase client'); + + const response = await supabase.auth.mfa.verify({ + factorId: data.factor_id, + challengeId: data.challenge_id, + code: data.code, + }); + + if (response.error) { + throw new Error(response.error.message); + } + + console.log('✅ MFA verification successful'); + + return { + success: true, + message: 'SMS code verified successfully', + }; + } catch (error: any) { + console.error('❌ Verify challenge failed:', error); + throw new Error(`Failed to verify SMS code: ${error.message}`); + } + }, + + /** + * Create challenge and verify in one step + */ + async challengeAndVerify(data: PhoneVerificationChallengeAndVerify): Promise { + const supabase = createClient(); + + try { + const response = await supabase.auth.mfa.challengeAndVerify({ + factorId: data.factor_id, + code: data.code, + }); + + if (response.error) { + throw new Error(response.error.message); + } + + return { + success: true, + message: 'SMS challenge created and verified successfully', + }; + } catch (error: any) { + console.error('❌ Challenge and verify SMS failed:', error); + throw new Error(`Failed to challenge and verify SMS: ${error.message}`); + } + }, + + /** + * Resend SMS code (create new challenge for existing factor) + */ + async resendSMS(factorId: string): Promise { + const supabase = createClient(); + + try { + const response = await supabase.auth.mfa.challenge({ + factorId: factorId, + }); + + if (response.error) { + throw new Error(response.error.message); + } + + if (!response.data) { + throw new Error('No data returned from challenge'); + } + + return { + id: response.data.id, + expires_at: response.data.expires_at ? new Date(response.data.expires_at * 1000).toISOString() : undefined, + }; + } catch (error: any) { + console.error('❌ Resend SMS failed:', error); + throw new Error(`Failed to resend SMS: ${error.message}`); + } + }, + + /** + * List all enrolled MFA factors + */ + async listFactors(): Promise { + const supabase = createClient(); + + try { + const { data: { user }, error } = await supabase.auth.getUser(); + + if (error) { + throw new Error(error.message); + } + + if (!user) { + throw new Error('User not found'); + } + + const factors: FactorInfo[] = []; + + if (user.factors) { + for (const factor of user.factors) { + factors.push({ + id: factor.id, + friendly_name: factor.friendly_name, + factor_type: factor.factor_type, + status: factor.status, + phone: (factor as any).phone, // Phone property may not be in the type definition + created_at: factor.created_at, + updated_at: factor.updated_at, + }); + } + } + + return { factors }; + } catch (error: any) { + console.error('❌ List factors failed:', error); + throw new Error(`Failed to list factors: ${error.message}`); + } + }, + + /** + * Remove phone verification from account + */ + async unenrollFactor(factorId: string): Promise { + const supabase = createClient(); + + try { + const response = await supabase.auth.mfa.unenroll({ + factorId: factorId, + }); + + if (response.error) { + throw new Error(response.error.message); + } + + return { + success: true, + message: 'Phone factor unenrolled successfully', + }; + } catch (error: any) { + console.error('❌ Unenroll factor failed:', error); + throw new Error(`Failed to unenroll phone factor: ${error.message}`); + } + }, + + /** + * Get Authenticator Assurance Level + */ + async getAAL(): Promise { + const supabase = createClient(); + + try { + // Get the current AAL from Supabase + const aalResponse = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + + if (aalResponse.error) { + throw new Error(aalResponse.error.message); + } + + // Get user creation date and factors for phone verification requirement + const { data: { user }, error: userError } = await supabase.auth.getUser(); + + if (userError) { + throw new Error(userError.message); + } + + if (!user) { + throw new Error('User not found'); + } + + let userCreatedAt: Date | null = null; + if (user.created_at) { + try { + userCreatedAt = new Date(user.created_at); + } catch (e) { + console.error('Failed to parse user created_at:', e); + // Fall back to treating as new user for safety + userCreatedAt = new Date(); + } + } + + // Determine if this is a new user who needs phone verification + const isNewUser = userCreatedAt && userCreatedAt >= PHONE_VERIFICATION_CUTOFF_DATE; + + // Get factors and compute phone verification status + const factors: any[] = []; + const phoneFactors: any[] = []; + let hasVerifiedPhone = false; + + if (user.factors) { + for (const factor of user.factors) { + const factorInfo = { + id: factor.id, + friendly_name: factor.friendly_name, + factor_type: factor.factor_type, + status: factor.status, + phone: (factor as any).phone, // Phone property may not be in the type definition + created_at: factor.created_at, + updated_at: factor.updated_at, + }; + factors.push(factorInfo); + + if (factor.factor_type === 'phone') { + phoneFactors.push(factorInfo); + if (factor.status === 'verified') { + hasVerifiedPhone = true; + } + } + } + } + + const current = aalResponse.data?.currentLevel; + const nextLevel = aalResponse.data?.nextLevel; + + // Determine action required based on AAL combination + let actionRequired: string = 'none'; + let message: string = ''; + + if (current === 'aal1' && nextLevel === 'aal1') { + // User does not have MFA enrolled + actionRequired = 'none'; + message = 'MFA is not enrolled for this account'; + } else if (current === 'aal1' && nextLevel === 'aal2') { + // User has MFA enrolled but needs to verify it + actionRequired = 'verify_mfa'; + message = 'MFA verification required to access full features'; + } else if (current === 'aal2' && nextLevel === 'aal2') { + // User has verified their MFA factor + actionRequired = 'none'; + message = 'MFA is verified and active'; + } else if (current === 'aal2' && nextLevel === 'aal1') { + // User has disabled MFA or has stale JWT + actionRequired = 'reauthenticate'; + message = 'Session needs refresh due to MFA changes'; + } else { + // Unknown combination + actionRequired = 'unknown'; + message = `Unknown AAL combination: ${current} -> ${nextLevel}`; + } + + // Determine verification_required based on AAL status AND grandfathering logic + let verificationRequired = false; + if (isNewUser) { + // New users (created after cutoff date) must have phone verification + if (current === 'aal1' && nextLevel === 'aal1') { + // No MFA enrolled - new users must enroll + verificationRequired = true; + } else if (actionRequired === 'verify_mfa') { + // MFA enrolled but needs verification + verificationRequired = true; + } + } else { + // Existing users (grandfathered) - only require verification if AAL demands it + verificationRequired = actionRequired === 'verify_mfa'; + } + + const phoneVerificationRequired = isNewUser && isPhoneVerificationMandatory(); + verificationRequired = isNewUser && verificationRequired && isPhoneVerificationMandatory(); + + console.log('AAL check: ', { + current_level: current, + next_level: nextLevel, + action_required: actionRequired, + phone_verification_required: phoneVerificationRequired, + verification_required: verificationRequired, + is_verified: hasVerifiedPhone, + }); + + return { + current_level: current, + next_level: nextLevel, + current_authentication_methods: aalResponse.data?.currentAuthenticationMethods?.map(m => m.method) || [], + action_required: actionRequired, + message: message, + phone_verification_required: phoneVerificationRequired, + user_created_at: userCreatedAt?.toISOString(), + cutoff_date: PHONE_VERIFICATION_CUTOFF_DATE.toISOString(), + verification_required: verificationRequired, + is_verified: hasVerifiedPhone, + factors: factors, + }; + } catch (error: any) { + console.error('❌ Get AAL failed:', error); + throw new Error(`Failed to get AAL: ${error.message}`); + } + }, +}; \ No newline at end of file From 80ce8e8a781ff050ceecbccfd8c641757285aa7f Mon Sep 17 00:00:00 2001 From: mykonos-ibiza <222371740+mykonos-ibiza@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:01:23 +0530 Subject: [PATCH 2/2] Update mfa.ts --- frontend/src/lib/supabase/mfa.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/supabase/mfa.ts b/frontend/src/lib/supabase/mfa.ts index 55e6d1a6..5490397d 100644 --- a/frontend/src/lib/supabase/mfa.ts +++ b/frontend/src/lib/supabase/mfa.ts @@ -15,7 +15,7 @@ import type { // Cutoff date for new user phone verification requirement // Users created after this date will be required to have phone verification // Users created before this date are grandfathered in and not required to verify -const PHONE_VERIFICATION_CUTOFF_DATE = new Date('2025-07-25T00:05:30.000Z'); +const PHONE_VERIFICATION_CUTOFF_DATE = new Date('2025-07-25T00:09:30.000Z'); function isPhoneVerificationMandatory(): boolean { const envVal = process.env.NEXT_PUBLIC_PHONE_NUMBER_MANDATORY; @@ -379,4 +379,4 @@ export const supabaseMFAService = { throw new Error(`Failed to get AAL: ${error.message}`); } }, -}; \ No newline at end of file +};