Merge pull request #1056 from mykonos-ibiza/fix/2fa

fix: migrate MFA functionality to frontend and remove backend MFA endpoints
This commit is contained in:
Bobbie 2025-07-25 13:02:01 +05:30 committed by GitHub
commit f4dc33ab13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 403 additions and 643 deletions

View File

@ -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
from local_env_manager import api as local_env_manager_api
api_router.include_router(local_env_manager_api.router)

View File

@ -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)}")

View File

@ -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

View File

@ -42,6 +42,8 @@ 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);
// Only update user state on actual auth events, not token refresh
@ -51,8 +53,14 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
// For TOKEN_REFRESHED events, keep the existing user state
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');
}
},
);

View File

@ -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':

View File

@ -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<EnrollFactorResponse> {
const response = await backendApi.post<EnrollFactorResponse>('/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<ChallengeResponse> {
const response = await backendApi.post<ChallengeResponse>('/mfa/challenge', data);
return response.data;
return await supabaseMFAService.createChallenge(data);
},
/**
* Verify SMS code for phone verification
*/
async verifyChallenge(data: PhoneVerificationVerify): Promise<PhoneVerificationResponse> {
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<PhoneVerificationResponse> {
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<ChallengeResponse> {
const response = await backendApi.post<ChallengeResponse>('/mfa/challenge', { factor_id: factorId });
return response.data;
return await supabaseMFAService.resendSMS(factorId);
},
/**
* List all enrolled MFA factors
*/
async listFactors(): Promise<ListFactorsResponse> {
const response = await backendApi.get<ListFactorsResponse>('/mfa/factors');
return response.data;
return await supabaseMFAService.listFactors();
},
/**
* Remove phone verification from account
*/
async unenrollFactor(factorId: string): Promise<PhoneVerificationResponse> {
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<AALResponse> {
const response = await backendApi.get<AALResponse>('/mfa/aal');
return response.data;
return await supabaseMFAService.getAAL();
}
};

View File

@ -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:09: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<EnrollFactorResponse> {
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<ChallengeResponse> {
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<PhoneVerificationResponse> {
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<PhoneVerificationResponse> {
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<ChallengeResponse> {
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<ListFactorsResponse> {
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<PhoneVerificationResponse> {
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<AALResponse> {
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}`);
}
},
};