mirror of https://github.com/kortix-ai/suna.git
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:
commit
f4dc33ab13
|
@ -187,8 +187,7 @@ api_router.include_router(workflows_router, prefix="/workflows")
|
||||||
from pipedream import api as pipedream_api
|
from pipedream import api as pipedream_api
|
||||||
api_router.include_router(pipedream_api.router)
|
api_router.include_router(pipedream_api.router)
|
||||||
|
|
||||||
from auth import phone_verification_supabase_mfa
|
# MFA functionality moved to frontend
|
||||||
api_router.include_router(phone_verification_supabase_mfa.router)
|
|
||||||
|
|
||||||
from local_env_manager import api as local_env_manager_api
|
from local_env_manager import api as local_env_manager_api
|
||||||
api_router.include_router(local_env_manager_api.router)
|
api_router.include_router(local_env_manager_api.router)
|
||||||
|
|
|
@ -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)}")
|
|
|
@ -497,6 +497,7 @@ async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optiona
|
||||||
# Calculate current month's usage
|
# Calculate current month's usage
|
||||||
current_usage = await calculate_monthly_usage(client, user_id)
|
current_usage = await calculate_monthly_usage(client, user_id)
|
||||||
|
|
||||||
|
# TODO: also do user's AAL check
|
||||||
# Check if within limits
|
# Check if within limits
|
||||||
if current_usage >= tier_info['cost']:
|
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
|
return False, f"Monthly limit of {tier_info['cost']} dollars reached. Please upgrade your plan or wait until next month.", subscription
|
||||||
|
|
|
@ -42,6 +42,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
|
||||||
const { data: authListener } = supabase.auth.onAuthStateChange(
|
const { data: authListener } = supabase.auth.onAuthStateChange(
|
||||||
async (event, newSession) => {
|
async (event, newSession) => {
|
||||||
|
console.log('🔵 Auth state change:', { event, session: !!newSession, user: !!newSession?.user });
|
||||||
|
|
||||||
setSession(newSession);
|
setSession(newSession);
|
||||||
|
|
||||||
// Only update user state on actual auth events, not token refresh
|
// 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
|
// For TOKEN_REFRESHED events, keep the existing user state
|
||||||
|
|
||||||
if (isLoading) setIsLoading(false);
|
if (isLoading) setIsLoading(false);
|
||||||
|
|
||||||
if (event === 'SIGNED_IN' && newSession?.user) {
|
if (event === 'SIGNED_IN' && newSession?.user) {
|
||||||
await checkAndInstallSunaAgent(newSession.user.id, newSession.user.created_at);
|
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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -64,8 +64,7 @@ export function BackgroundAALChecker({
|
||||||
if (current_level === "aal1" && next_level === "aal1") {
|
if (current_level === "aal1" && next_level === "aal1") {
|
||||||
// New user has no MFA enrolled - redirect to enrollment
|
// New user has no MFA enrolled - redirect to enrollment
|
||||||
console.log('Background: New user without MFA enrolled, redirecting to phone verification');
|
console.log('Background: New user without MFA enrolled, redirecting to phone verification');
|
||||||
// Temporarily disabled
|
router.push(redirectTo);
|
||||||
// router.push(redirectTo);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If new user has MFA enrolled, follow standard AAL flow below
|
// If new user has MFA enrolled, follow standard AAL flow below
|
||||||
|
@ -76,8 +75,7 @@ export function BackgroundAALChecker({
|
||||||
case 'verify_mfa':
|
case 'verify_mfa':
|
||||||
// User has MFA enrolled but needs to verify it
|
// User has MFA enrolled but needs to verify it
|
||||||
console.log('Background: Redirecting to MFA verification');
|
console.log('Background: Redirecting to MFA verification');
|
||||||
// Temporarily disabled
|
router.push(redirectTo);
|
||||||
// router.push(redirectTo);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'reauthenticate':
|
case 'reauthenticate':
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { backendApi } from '@/lib/api-client';
|
import { supabaseMFAService } from '@/lib/supabase/mfa';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -78,95 +77,59 @@ export interface AALResponse {
|
||||||
|
|
||||||
|
|
||||||
export const phoneVerificationService = {
|
export const phoneVerificationService = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enroll phone number for SMS-based 2FA
|
* Enroll phone number for SMS-based 2FA
|
||||||
*/
|
*/
|
||||||
async enrollPhoneNumber(data: PhoneVerificationEnroll): Promise<EnrollFactorResponse> {
|
async enrollPhoneNumber(data: PhoneVerificationEnroll): Promise<EnrollFactorResponse> {
|
||||||
const response = await backendApi.post<EnrollFactorResponse>('/mfa/enroll', data);
|
return await supabaseMFAService.enrollPhoneNumber(data);
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a challenge for an enrolled phone factor (sends SMS)
|
* Create a challenge for an enrolled phone factor (sends SMS)
|
||||||
*/
|
*/
|
||||||
async createChallenge(data: PhoneVerificationChallenge): Promise<ChallengeResponse> {
|
async createChallenge(data: PhoneVerificationChallenge): Promise<ChallengeResponse> {
|
||||||
const response = await backendApi.post<ChallengeResponse>('/mfa/challenge', data);
|
return await supabaseMFAService.createChallenge(data);
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify SMS code for phone verification
|
* Verify SMS code for phone verification
|
||||||
*/
|
*/
|
||||||
async verifyChallenge(data: PhoneVerificationVerify): Promise<PhoneVerificationResponse> {
|
async verifyChallenge(data: PhoneVerificationVerify): Promise<PhoneVerificationResponse> {
|
||||||
try {
|
return await supabaseMFAService.verifyChallenge(data);
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create challenge and verify in one step
|
* Create challenge and verify in one step
|
||||||
*/
|
*/
|
||||||
async challengeAndVerify(data: PhoneVerificationChallengeAndVerify): Promise<PhoneVerificationResponse> {
|
async challengeAndVerify(data: PhoneVerificationChallengeAndVerify): Promise<PhoneVerificationResponse> {
|
||||||
const response = await backendApi.post('/mfa/challenge-and-verify', data);
|
return await supabaseMFAService.challengeAndVerify(data);
|
||||||
return {
|
|
||||||
success: response.data.success || true,
|
|
||||||
message: response.data.message || 'SMS challenge created and verified successfully'
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resend SMS code (create new challenge for existing factor)
|
* Resend SMS code (create new challenge for existing factor)
|
||||||
*/
|
*/
|
||||||
async resendSMS(factorId: string): Promise<ChallengeResponse> {
|
async resendSMS(factorId: string): Promise<ChallengeResponse> {
|
||||||
const response = await backendApi.post<ChallengeResponse>('/mfa/challenge', { factor_id: factorId });
|
return await supabaseMFAService.resendSMS(factorId);
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all enrolled MFA factors
|
* List all enrolled MFA factors
|
||||||
*/
|
*/
|
||||||
async listFactors(): Promise<ListFactorsResponse> {
|
async listFactors(): Promise<ListFactorsResponse> {
|
||||||
const response = await backendApi.get<ListFactorsResponse>('/mfa/factors');
|
return await supabaseMFAService.listFactors();
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove phone verification from account
|
* Remove phone verification from account
|
||||||
*/
|
*/
|
||||||
async unenrollFactor(factorId: string): Promise<PhoneVerificationResponse> {
|
async unenrollFactor(factorId: string): Promise<PhoneVerificationResponse> {
|
||||||
const response = await backendApi.post('/mfa/unenroll', { factor_id: factorId });
|
return await supabaseMFAService.unenrollFactor(factorId);
|
||||||
return {
|
|
||||||
success: response.data.success || true,
|
|
||||||
message: response.data.message || 'Phone factor unenrolled successfully'
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Authenticator Assurance Level
|
* Get Authenticator Assurance Level
|
||||||
*/
|
*/
|
||||||
async getAAL(): Promise<AALResponse> {
|
async getAAL(): Promise<AALResponse> {
|
||||||
const response = await backendApi.get<AALResponse>('/mfa/aal');
|
return await supabaseMFAService.getAAL();
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
Loading…
Reference in New Issue