Merge branch 'main' of github.com:escapade-mckv/suna into extend-agent-builder

This commit is contained in:
Saumya 2025-07-25 13:25:11 +05:30
commit 40a145552f
20 changed files with 886 additions and 912 deletions

View File

@ -15,7 +15,7 @@ COPY . .
# Calculate optimal worker count based on 16 vCPUs
# Using (2*CPU)+1 formula for CPU-bound applications
ENV WORKERS=33
ENV WORKERS=7
ENV THREADS=2
ENV WORKER_CONNECTIONS=2000

View File

@ -146,6 +146,9 @@ class ResponseProcessor:
prompt_messages: List[Dict[str, Any]],
llm_model: str,
config: ProcessorConfig = ProcessorConfig(),
can_auto_continue: bool = False,
auto_continue_count: int = 0,
continuous_state: Optional[Dict[str, Any]] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""Process a streaming LLM response, handling tool calls and execution.
@ -155,19 +158,25 @@ class ResponseProcessor:
prompt_messages: List of messages sent to the LLM (the prompt)
llm_model: The name of the LLM model used
config: Configuration for parsing and execution
can_auto_continue: Whether auto-continue is enabled
auto_continue_count: Number of auto-continue cycles
continuous_state: Previous state of the conversation
Yields:
Complete message objects matching the DB schema, except for content chunks.
"""
accumulated_content = ""
# Initialize from continuous state if provided (for auto-continue)
continuous_state = continuous_state or {}
accumulated_content = continuous_state.get('accumulated_content', "")
tool_calls_buffer = {}
current_xml_content = ""
current_xml_content = accumulated_content # equal to accumulated_content if auto-continuing, else blank
xml_chunks_buffer = []
pending_tool_executions = []
yielded_tool_indices = set() # Stores indices of tools whose *status* has been yielded
tool_index = 0
xml_tool_call_count = 0
finish_reason = None
should_auto_continue = False
last_assistant_message_object = None # Store the final saved assistant message object
tool_result_message_objects = {} # tool_index -> full saved message object
has_printed_thinking_prefix = False # Flag for printing thinking prefix only once
@ -191,10 +200,13 @@ class ResponseProcessor:
logger.info(f"Streaming Config: XML={config.xml_tool_calling}, Native={config.native_tool_calling}, "
f"Execute on stream={config.execute_on_stream}, Strategy={config.tool_execution_strategy}")
thread_run_id = str(uuid.uuid4())
# Reuse thread_run_id for auto-continue or create new one
thread_run_id = continuous_state.get('thread_run_id') or str(uuid.uuid4())
continuous_state['thread_run_id'] = thread_run_id
try:
# --- Save and Yield Start Events ---
# --- Save and Yield Start Events (only if not auto-continuing) ---
if auto_continue_count == 0:
start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id}
start_msg_obj = await self.add_message(
thread_id=thread_id, type="status", content=start_content,
@ -210,7 +222,7 @@ class ResponseProcessor:
if assist_start_msg_obj: yield format_for_yield(assist_start_msg_obj)
# --- End Start Events ---
__sequence = 0
__sequence = continuous_state.get('sequence', 0) # get the sequence from the previous auto-continue cycle
async for chunk in llm_response:
# Extract streaming metadata from chunks
@ -492,8 +504,12 @@ class ResponseProcessor:
logger.info(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls")
self.trace.event(name="stream_finished_with_reason_xml_tool_limit_reached_after_xml_tool_calls", level="DEFAULT", status_message=(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls"))
# Calculate if auto-continue is needed if the finish reason is length
should_auto_continue = (can_auto_continue and finish_reason == 'length')
# --- SAVE and YIELD Final Assistant Message ---
if accumulated_content:
# Only save assistant message if NOT auto-continuing due to length to avoid duplicate messages
if accumulated_content and not should_auto_continue:
# ... (Truncate accumulated_content logic) ...
if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls and xml_chunks_buffer:
last_xml_chunk = xml_chunks_buffer[-1]
@ -746,6 +762,8 @@ class ResponseProcessor:
return
# --- Save and Yield assistant_response_end ---
# Only save assistant_response_end if not auto-continuing (response is actually complete)
if not should_auto_continue:
if last_assistant_message_object: # Only save if assistant message was saved
try:
# Calculate response time if we have timing data
@ -815,7 +833,14 @@ class ResponseProcessor:
raise # Use bare 'raise' to preserve the original exception with its traceback
finally:
# Save and Yield the final thread_run_end status
# Update continuous state for potential auto-continue
if should_auto_continue:
continuous_state['accumulated_content'] = accumulated_content
continuous_state['sequence'] = __sequence
logger.info(f"Updated continuous state for auto-continue with {len(accumulated_content)} chars")
else:
# Save and Yield the final thread_run_end status (only if not auto-continuing and finish_reason is not 'length')
try:
end_content = {"status_type": "thread_run_end"}
end_msg_obj = await self.add_message(

View File

@ -298,6 +298,12 @@ Here are the XML tools available with examples:
auto_continue = True
auto_continue_count = 0
# Shared state for continuous streaming across auto-continues
continuous_state = {
'accumulated_content': '',
'thread_run_id': None
}
# Define inner function to handle a single run
async def _run_once(temp_msg=None):
try:
@ -342,6 +348,18 @@ Here are the XML tools available with examples:
prepared_messages.append(temp_msg)
logger.debug("Added temporary message to the end of prepared messages")
# Add partial assistant content for auto-continue context (without saving to DB)
if auto_continue_count > 0 and continuous_state.get('accumulated_content'):
partial_content = continuous_state.get('accumulated_content', '')
# Create temporary assistant message with just the text content
temporary_assistant_message = {
"role": "assistant",
"content": partial_content
}
prepared_messages.append(temporary_assistant_message)
logger.info(f"Added temporary assistant message with {len(partial_content)} chars for auto-continue context")
# 4. Prepare tools for LLM call
openapi_tool_schemas = None
if config.native_tool_calling:
@ -395,6 +413,9 @@ Here are the XML tools available with examples:
config=config,
prompt_messages=prepared_messages,
llm_model=llm_model,
can_auto_continue=(native_max_auto_continues > 0),
auto_continue_count=auto_continue_count,
continuous_state=continuous_state
)
else:
# Fallback to non-streaming if response is not iterable
@ -467,6 +488,14 @@ Here are the XML tools available with examples:
auto_continue = False
# Still yield the chunk to inform the client
elif chunk.get('type') == 'status':
# if the finish reason is length, auto-continue
content = json.loads(chunk.get('content'))
if content.get('finish_reason') == 'length':
logger.info(f"Detected finish_reason='length', auto-continuing ({auto_continue_count + 1}/{native_max_auto_continues})")
auto_continue = True
auto_continue_count += 1
continue
# Otherwise just yield the chunk normally
yield chunk
else:
@ -480,7 +509,9 @@ Here are the XML tools available with examples:
if ("AnthropicException - Overloaded" in str(e)):
logger.error(f"AnthropicException - Overloaded detected - Falling back to OpenRouter: {str(e)}", exc_info=True)
nonlocal llm_model
llm_model = f"openrouter/{llm_model}"
# Remove "-20250514" from the model name if present
model_name_cleaned = llm_model.replace("-20250514", "")
llm_model = f"openrouter/{model_name_cleaned}"
auto_continue = True
continue # Continue the loop
else:

View File

@ -187,8 +187,10 @@ 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)
@api_router.get("/health")
async def health_check():

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

@ -0,0 +1,44 @@
from fastapi import APIRouter
from utils.config import config, EnvMode
from fastapi import HTTPException
from typing import Dict
from dotenv import load_dotenv, set_key, find_dotenv, dotenv_values
from utils.logger import logger
router = APIRouter(tags=["local-env-manager"])
@router.get("/env-vars")
def get_env_vars() -> Dict[str, str]:
if config.ENV_MODE != EnvMode.LOCAL:
raise HTTPException(status_code=403, detail="Env vars management only available in local mode")
try:
env_path = find_dotenv()
if not env_path:
logger.error("Could not find .env file")
return {}
return dotenv_values(env_path)
except Exception as e:
logger.error(f"Failed to get env vars: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get env variables: {e}")
@router.post("/env-vars")
def save_env_vars(request: Dict[str, str]) -> Dict[str, str]:
if config.ENV_MODE != EnvMode.LOCAL:
raise HTTPException(status_code=403, detail="Env vars management only available in local mode")
try:
env_path = find_dotenv()
if not env_path:
raise HTTPException(status_code=500, detail="Could not find .env file")
for key, value in request.items():
set_key(env_path, key, value)
load_dotenv(override=True)
logger.info(f"Env variables saved successfully: {request}")
return {"message": "Env variables saved successfully"}
except Exception as e:
logger.error(f"Failed to save env variables: {e}")
raise HTTPException(status_code=500, detail=f"Failed to save env variables: {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

@ -37,7 +37,7 @@ services:
ports:
- "8000:8000"
volumes:
- ./backend/.env:/app/.env:ro
- ./backend/.env:/app/.env
env_file:
- ./backend/.env
environment:

View File

@ -0,0 +1,9 @@
import { isLocalMode } from "@/lib/config";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Shield } from "lucide-react";
import { LocalEnvManager } from "@/components/env-manager/local-env-manager";
export default function LocalEnvManagerPage() {
return <LocalEnvManager />
}

View File

@ -3,6 +3,7 @@
import { Separator } from '@/components/ui/separator';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { isLocalMode } from '@/lib/config';
export default function PersonalAccountSettingsPage({
children,
@ -15,6 +16,7 @@ export default function PersonalAccountSettingsPage({
// { name: "Teams", href: "/settings/teams" },
{ name: 'Billing', href: '/settings/billing' },
{ name: 'Usage Logs', href: '/settings/usage-logs' },
...(isLocalMode() ? [{ name: 'Local .Env Manager', href: '/settings/env-manager' }] : []),
];
return (
<>

View File

@ -42,11 +42,25 @@ 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
if (event === 'SIGNED_IN' || event === 'SIGNED_OUT') {
setUser(newSession?.user ?? null);
}
// 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

@ -1,197 +1,48 @@
'use client';
import { useEffect, useState } from 'react';
import Script from 'next/script';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuthMethodTracking } from '@/lib/stores/auth-tracking';
import { toast } from 'sonner';
import { FcGoogle } from "react-icons/fc";
import { Loader2 } from 'lucide-react';
declare global {
interface Window {
google?: {
accounts: {
id: {
initialize: (config: any) => void;
renderButton: (element: HTMLElement, config: any) => void;
prompt: (notification?: (notification: any) => void) => void;
};
};
};
}
}
interface GoogleSignInProps {
returnUrl?: string;
}
export default function GoogleSignIn({ returnUrl }: GoogleSignInProps) {
const [isLoading, setIsLoading] = useState(false);
const [isGoogleLoaded, setIsGoogleLoaded] = useState(false);
const { wasLastMethod, markAsUsed } = useAuthMethodTracking('google');
const supabase = createClient();
const handleGoogleResponse = async (response: any) => {
const handleGoogleSignIn = async () => {
try {
setIsLoading(true);
markAsUsed();
console.log('returnUrl', returnUrl);
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: response.credential,
});
if (error) {
const redirectTo = `${window.location.origin}/auth/callback${returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''}`;
console.log('OAuth redirect URI:', redirectTo);
const { error: oauthError } = await supabase.auth.signInWithOAuth({
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo,
redirectTo: `${window.location.origin}/auth/callback${
returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''
}`,
},
});
if (oauthError) {
throw oauthError;
}
} else {
window.location.href = returnUrl || '/dashboard';
if (error) {
throw error;
}
} catch (error: any) {
console.error('Google sign-in error:', error);
if (error.message?.includes('redirect_uri_mismatch')) {
const redirectUri = `${window.location.origin}/auth/callback`;
toast.error(
`Google OAuth configuration error. Add this exact URL to your Google Cloud Console: ${redirectUri}`,
{ duration: 10000 }
);
} else {
toast.error(error.message || 'Failed to sign in with Google');
}
setIsLoading(false);
}
};
useEffect(() => {
const initializeGoogleSignIn = () => {
if (!window.google || !process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) return;
window.google.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
callback: handleGoogleResponse,
auto_select: false,
cancel_on_tap_outside: false,
});
setIsGoogleLoaded(true);
};
if (window.google) {
initializeGoogleSignIn();
}
}, [returnUrl, markAsUsed, supabase]);
const handleScriptLoad = () => {
if (window.google && process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) {
window.google.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
callback: handleGoogleResponse,
auto_select: false,
cancel_on_tap_outside: false,
});
setIsGoogleLoaded(true);
}
};
const handleGoogleSignIn = () => {
if (!window.google || !isGoogleLoaded) {
toast.error('Google Sign-In is still loading. Please try again.');
return;
}
try {
window.google.accounts.id.prompt((notification: any) => {
if (notification.isNotDisplayed() || notification.isSkippedMoment()) {
console.log('One Tap not displayed, using OAuth flow');
setIsLoading(true);
const redirectTo = `${window.location.origin}/auth/callback${returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''}`;
console.log('OAuth redirect URI:', redirectTo);
supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo,
},
}).then(({ error }) => {
if (error) {
console.error('OAuth error:', error);
if (error.message?.includes('redirect_uri_mismatch')) {
const redirectUri = `${window.location.origin}/auth/callback`;
toast.error(
`Google OAuth configuration error. Add this exact URL to your Google Cloud Console: ${redirectUri}`,
{ duration: 10000 }
);
} else {
toast.error(error.message || 'Failed to sign in with Google');
}
setIsLoading(false);
}
});
}
});
} catch (error) {
console.error('Error triggering Google sign-in:', error);
setIsLoading(true);
const redirectTo = `${window.location.origin}/auth/callback${returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''}`;
supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo,
},
}).then(({ error }) => {
if (error) {
console.error('OAuth error:', error);
if (error.message?.includes('redirect_uri_mismatch')) {
const redirectUri = `${window.location.origin}/auth/callback`;
toast.error(
`Google OAuth configuration error. Add this exact URL to your Google Cloud Console: ${redirectUri}`,
{ duration: 10000 }
);
} else {
toast.error(error.message || 'Failed to sign in with Google');
}
setIsLoading(false);
}
});
}
};
if (!process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) {
return (
<div className="w-full text-center text-sm text-gray-500 py-3">
Google Sign-In not configured
</div>
);
}
return (
<div className="relative">
<button
onClick={handleGoogleSignIn}
disabled={isLoading || !isGoogleLoaded}
disabled={isLoading}
className="w-full h-12 flex items-center justify-center text-sm font-medium tracking-wide rounded-full bg-background text-foreground border border-border hover:bg-accent/30 transition-all duration-200 disabled:opacity-60 disabled:cursor-not-allowed font-sans"
aria-label={isLoading ? 'Signing in with Google...' : 'Sign in with Google'}
type="button"
>
{isLoading ? (
@ -203,18 +54,5 @@ export default function GoogleSignIn({ returnUrl }: GoogleSignInProps) {
{isLoading ? 'Signing in...' : 'Continue with Google'}
</span>
</button>
{wasLastMethod && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-background shadow-sm">
<div className="w-full h-full bg-green-500 rounded-full animate-pulse" />
</div>
)}
<Script
src="https://accounts.google.com/gsi/client"
strategy="afterInteractive"
onLoad={handleScriptLoad}
/>
</div>
);
}

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

@ -0,0 +1,224 @@
"use client";
import { Eye, EyeOff, Plus, Trash } from "lucide-react";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { isLocalMode } from "@/lib/config";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { backendApi } from "@/lib/api-client";
import { toast } from "sonner";
import { useForm } from "react-hook-form";
interface APIKeyForm {
[key: string]: string;
}
export function LocalEnvManager() {
const queryClient = useQueryClient();
const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({});
const [newApiKeys, setNewApiKeys] = useState<{key: string, value: string, id: string}[]>([]);
const {data: apiKeys, isLoading} = useQuery({
queryKey: ['api-keys'],
queryFn: async() => {
const response = await backendApi.get('/env-vars');
return response.data;
},
enabled: isLocalMode()
});
const { register, handleSubmit, formState: { errors, isDirty }, reset } = useForm<APIKeyForm>({
defaultValues: apiKeys || {}
});
const handleSave = async (data: APIKeyForm) => {
const duplicate_key = newApiKeys.find(entry => data[entry.key.trim()]);
if (duplicate_key) {
toast.error(`Key ${duplicate_key.key} already exists`);
return;
}
const submitData = {
...data,
...Object.fromEntries(newApiKeys.map(entry => [entry.key.trim(), entry.value.trim()]))
}
updateApiKeys.mutate(submitData);
}
const handleAddNewKey = () => {
setNewApiKeys([...newApiKeys, {key: "", value: "", id: crypto.randomUUID()}]);
}
const checkKeyIsDuplicate = (key: string) => {
const trimmedKey = key.trim();
const keyIsDuplicate =
trimmedKey &&
(
(apiKeys && Object.keys(apiKeys).includes(trimmedKey)) ||
newApiKeys.filter(e => e.key.trim() === trimmedKey).length > 1
);
return keyIsDuplicate;
}
const handleNewKeyChange = (id: string, field: string, value: string) => {
setNewApiKeys(prev =>
prev.map(entry => entry.id === id ? {...entry, [field]: value} : entry)
);
}
const handleDeleteKey = (id: string) => {
setNewApiKeys(prev => prev.filter(entry => entry.id !== id));
}
const hasEmptyKeyValues = newApiKeys.some(entry => entry.key.trim() === "" || entry.value.trim() === "");
const hasDuplicateKeys = (): boolean => {
const allKeys = [...Object.keys(apiKeys || {}), ...newApiKeys.map(entry => entry.key.trim())];
const uniqueKeys = new Set(allKeys);
return uniqueKeys.size !== allKeys.length;
}
const updateApiKeys = useMutation({
mutationFn: async (data: APIKeyForm) => {
const response = await backendApi.post('/env-vars', data);
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
return response.data;
},
onSuccess: (data) => {
toast.success(data.message);
setNewApiKeys([]);
},
onError: () => {
toast.error('Failed to update API keys');
}
});
const keysArray = apiKeys ? Object.entries(apiKeys).map(([key, value]) => ({
id: key,
name: key,
value: value
})) : [];
useEffect(() => {
if (apiKeys) {
reset(apiKeys);
}
}, [apiKeys, reset]);
const toggleKeyVisibility = (keyId: string) => {
setVisibleKeys(prev => ({
...prev,
[keyId]: !prev[keyId]
}));
}
if (isLoading) {
return <Card>
<CardHeader>
<CardTitle>Local .Env Manager</CardTitle>
<CardDescription>Loading...</CardDescription>
</CardHeader>
</Card>;
}
return <Card>
<CardHeader>
<CardTitle>Local .Env Manager</CardTitle>
<CardDescription>
{isLocalMode() ? (
<>
Manage your local environment variables
</>
) : (
<>
Local .Env Manager is only available in local mode.
</>
)}
</CardDescription>
</CardHeader>
{isLocalMode() && (
<CardContent>
<form onSubmit={handleSubmit(handleSave)} className="space-y-4">
{keysArray && keysArray?.map(key => (
<div key={key.id} className="space-y-2">
<Label htmlFor={key.id}>{key.name}</Label>
<div className="relative">
<Input
id={key.id}
type={visibleKeys[key.id] ? 'text' : 'password'}
placeholder={key.name}
{...register(key.id)}
/>
<Button
type="button"
variant="ghost"
className="absolute right-0 top-0 h-full px-3"
onClick={() => toggleKeyVisibility(key.id)}>
{visibleKeys[key.id] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors[key.id] && <p className="text-red-500">{errors[key.id]?.message}</p>}
</div>
))}
<div className="space-y-4">
{newApiKeys.map(entry => {
const keyIsDuplicate = checkKeyIsDuplicate(entry.key);
return (
<div key={entry.id} className="space-y-2">
<Label htmlFor={entry.id}>{entry.key || "New API Key"}</Label>
<div className="space-x-2 flex">
<Input
id={`${entry.id}-key`}
type="text"
placeholder="KEY"
value={entry.key}
onChange={(e) => handleNewKeyChange(entry.id, 'key', e.target.value)}
/>
<Input
id={`${entry.id}-value`}
type="text"
placeholder="VALUE"
value={entry.value}
onChange={(e) => handleNewKeyChange(entry.id, 'value', e.target.value)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleDeleteKey(entry.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
{keyIsDuplicate && <p className="text-red-400 font-light">Key already exists</p>}
</div>
)})}
</div>
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={handleAddNewKey}
>
<Plus className="h-4 w-4" />
Add New Key
</Button>
<Button
type="submit"
variant="default"
disabled={(!isDirty && newApiKeys.length === 0) || hasEmptyKeyValues || hasDuplicateKeys()}
>Save</Button>
</div>
</form>
</CardContent>
)}
</Card>
}

View File

@ -17,6 +17,7 @@ import {
AudioWaveform,
Sun,
Moon,
KeyRound,
} from 'lucide-react';
import { useAccounts } from '@/hooks/use-accounts';
import NewTeamForm from '@/components/basejump/new-team-form';
@ -48,6 +49,7 @@ import {
} from '@/components/ui/dialog';
import { createClient } from '@/lib/supabase/client';
import { useTheme } from 'next-themes';
import { isLocalMode } from '@/lib/config';
export function NavUserWithTeams({
user,
@ -286,6 +288,12 @@ export function NavUserWithTeams({
Billing
</Link>
</DropdownMenuItem>
{isLocalMode() && <DropdownMenuItem asChild>
<Link href="/settings/env-manager">
<KeyRound className="h-4 w-4" />
Local .Env Manager
</Link>
</DropdownMenuItem>}
{/* <DropdownMenuItem asChild>
<Link href="/settings">
<Settings className="mr-2 h-4 w-4" />

View File

@ -14,7 +14,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Check, ChevronDown, Search, AlertTriangle, Crown, ArrowUpRight, Brain, Plus, Edit, Trash, Cpu } from 'lucide-react';
import { Check, ChevronDown, Search, AlertTriangle, Crown, ArrowUpRight, Brain, Plus, Edit, Trash, Cpu, Key, KeyRound } from 'lucide-react';
import {
ModelOption,
SubscriptionStatus,
@ -32,6 +32,7 @@ import { cn } from '@/lib/utils';
import { useRouter } from 'next/navigation';
import { isLocalMode } from '@/lib/config';
import { CustomModelDialog, CustomModelFormData } from './custom-model-dialog';
import Link from 'next/link';
interface CustomModel {
id: string;
@ -674,6 +675,22 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<div className="px-3 py-3 flex justify-between items-center">
<span className="text-xs font-medium text-muted-foreground">All Models</span>
{isLocalMode() && (
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/settings/env-manager"
className="h-6 w-6 p-0 flex items-center justify-center"
>
<KeyRound className="h-3.5 w-3.5" />
</Link>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Local .Env Manager
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@ -694,6 +711,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
{uniqueModels

View File

@ -18,7 +18,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!block"
>
{children}
</ScrollAreaPrimitive.Viewport>

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

@ -14,5 +14,11 @@ export const createClient = () => {
// console.log('Supabase URL:', supabaseUrl);
// console.log('Supabase Anon Key:', supabaseAnonKey);
return createBrowserClient(supabaseUrl, supabaseAnonKey);
return createBrowserClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
});
};

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}`);
}
},
};