import re from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.security import HTTPBearer from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase from ...core.config import settings from ...core.database import get_database from ...core.security import ( create_access_token, create_refresh_token, decode_token, verify_password, ) from ...models.user import User, AuthProvider, UserRole from ...schemas.auth import ( LoginRequest, LoginResponse, LogoutResponse, RefreshResponse, MicrosoftLoginRequest, MicrosoftLoginResponse, ) from ...services.microsoft_auth import ( get_microsoft_auth_service, MicrosoftTokenValidationError, MicrosoftAuthError, ) from ...services.audit_logger import log_auth_success, log_auth_failure, audit_logger from ...models.audit_log import AuditAction, AuditLogSeverity router = APIRouter(prefix="/auth", tags=["auth"]) security = HTTPBearer() @router.post("/login", response_model=LoginResponse) async def login( login_data: LoginRequest, request: Request, response: Response, ): print(f"LOGIN: Starting login for {login_data.email}") # Create database connection directly (bypass dependency injection issues) client = AsyncIOMotorClient(settings.mongodb_uri) db = client[settings.mongodb_db] try: print("LOGIN: Database connection created") # Find user by email print("LOGIN: Looking up user in database") user_doc = await db.users.find_one({"email": login_data.email}) print(f"LOGIN: User lookup complete, found: {user_doc is not None}") if not user_doc: await log_auth_failure(login_data.email, request, "User not found") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", ) user = User(**user_doc) # Check if user uses Microsoft authentication if user.auth_provider == AuthProvider.MICROSOFT: await log_auth_failure(login_data.email, request, "Account uses Microsoft SSO") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="This account uses Microsoft authentication. Please sign in with Microsoft.", ) # Verify password if not user.hashed_password or not verify_password(login_data.password, user.hashed_password): await log_auth_failure(login_data.email, request, "Invalid password") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", ) if not user.is_active: await log_auth_failure(login_data.email, request, "Account disabled") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User account is disabled", ) # Create tokens access_token = create_access_token(subject=str(user.id)) refresh_token = create_refresh_token(subject=str(user.id)) # Set refresh token as HttpOnly cookie response.set_cookie( key="refresh_token", value=refresh_token, httponly=True, secure=settings.cookie_secure, samesite=settings.cookie_samesite, domain=settings.cookie_domain if settings.app_env == "prod" else None, max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60, ) await log_auth_success(user, request) return LoginResponse( access_token=access_token, user_id=str(user.id), role=user.role, ) finally: # Close database connection client.close() @router.post("/microsoft", response_model=MicrosoftLoginResponse) async def microsoft_login( login_data: MicrosoftLoginRequest, request: Request, response: Response, ): """Authenticate user with Microsoft ID token. This endpoint validates the Microsoft ID token, finds or creates the user, and returns JWT tokens for API access. """ print(f"MICROSOFT LOGIN: Starting Microsoft authentication") # Create database connection client = AsyncIOMotorClient(settings.mongodb_uri) db = client[settings.mongodb_db] try: # Validate Microsoft token microsoft_auth = get_microsoft_auth_service() try: user_info = microsoft_auth.validate_token(login_data.id_token) print(f"MICROSOFT LOGIN: Token validated for {user_info.email}") except MicrosoftTokenValidationError as e: print(f"MICROSOFT LOGIN ERROR: Token validation failed: {e}") await log_auth_failure(login_data.id_token[:20] + "…", request, f"MS token invalid: {e}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Microsoft authentication failed: {str(e)}", ) except MicrosoftAuthError as e: print(f"MICROSOFT LOGIN ERROR: Authentication error: {e}") await log_auth_failure("microsoft-sso", request, f"MS auth service error: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Microsoft authentication service error", ) # Find or create user # Look up by Microsoft-derived ID first — handles email casing changes across logins # (Microsoft can return vadymsamoilenko@... vs VadymSamoilenko@... for the same user) ms_user_id = f"ms-{user_info.sub[:20]}" user_doc = await db.users.find_one({"_id": ms_user_id}) if not user_doc: # Fall back to case-insensitive email lookup (handles local-to-Microsoft migration) user_doc = await db.users.find_one( {"email": {"$regex": f"^{re.escape(user_info.email)}$", "$options": "i"}} ) if user_doc: # User exists user = User(**user_doc) print(f"MICROSOFT LOGIN: Existing user found: {user.id}") # Update auth_provider if user is switching from local to Microsoft if user.auth_provider == AuthProvider.LOCAL: print(f"MICROSOFT LOGIN: Updating user to Microsoft auth provider") await db.users.update_one( {"_id": user_doc["_id"]}, { "$set": { "auth_provider": AuthProvider.MICROSOFT.value, "updated_at": datetime.utcnow() } } ) user.auth_provider = AuthProvider.MICROSOFT else: # Create new user with zero org memberships (SaaS model). # They will see a "no access" landing until an admin invites them. print(f"MICROSOFT LOGIN: Creating new user for {user_info.email}") new_user = { "_id": ms_user_id, "email": user_info.email, "full_name": user_info.name, "hashed_password": None, "role": UserRole.CLIENT.value, "auth_provider": AuthProvider.MICROSOFT.value, "is_active": True, "pm_client_ids": [], "created_at": datetime.utcnow(), "updated_at": datetime.utcnow(), } await db.users.insert_one(new_user) user = User(**new_user) print(f"MICROSOFT LOGIN: New user created (zero memberships): {user.id}") # Check if user is active if not user.is_active: await log_auth_failure(user.email, request, "Account disabled") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User account is disabled", ) # Create JWT tokens access_token = create_access_token(subject=str(user.id)) refresh_token = create_refresh_token(subject=str(user.id)) # Set refresh token as HttpOnly cookie response.set_cookie( key="refresh_token", value=refresh_token, httponly=True, secure=settings.cookie_secure, samesite=settings.cookie_samesite, domain=settings.cookie_domain if settings.app_env == "prod" else None, max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60, ) print(f"MICROSOFT LOGIN: Authentication successful for {user.email}") await log_auth_success(user, request) return MicrosoftLoginResponse( access_token=access_token, user_id=str(user.id), role=user.role if isinstance(user.role, str) else user.role.value, email=user.email, full_name=user.full_name, auth_provider=user.auth_provider, ) finally: # Close database connection client.close() @router.post("/refresh", response_model=RefreshResponse) async def refresh_token( request: Request, response: Response, db: AsyncIOMotorDatabase = Depends(get_database), ): refresh_token = request.cookies.get("refresh_token") print(f"🔍 REFRESH DEBUG: Cookie exists: {bool(refresh_token)}") if not refresh_token: print("🚨 REFRESH ERROR: No refresh token in cookies") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token not found", ) try: print(f"🔍 REFRESH DEBUG: Attempting to decode token...") payload = decode_token(refresh_token) print(f"🔍 REFRESH DEBUG: Token decoded successfully, type={payload.get('type')}") if payload.get("type") != "refresh": print(f"🚨 REFRESH ERROR: Wrong token type: {payload.get('type')}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type", ) user_id = payload.get("sub") print(f"🔍 REFRESH DEBUG: User ID from token: {user_id}") if not user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token", ) # Verify user still exists and is active user_doc = await db.users.find_one({"_id": user_id}) if not user_doc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", ) user = User(**user_doc) if not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User account is disabled", ) # Create new tokens new_access_token = create_access_token(subject=user_id) new_refresh_token = create_refresh_token(subject=user_id) # Update refresh token cookie response.set_cookie( key="refresh_token", value=new_refresh_token, httponly=True, secure=settings.cookie_secure, samesite=settings.cookie_samesite, domain=settings.cookie_domain if settings.app_env == "prod" else None, max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60, ) print(f"🔍 REFRESH DEBUG: Refresh successful for user {user_id}") return RefreshResponse( access_token=new_access_token, user_id=user_id, role=user.role if isinstance(user.role, str) else user.role.value, email=user.email, full_name=user.full_name ) except Exception as e: print(f"🚨 REFRESH ERROR: Exception during refresh: {type(e).__name__}: {e}") import traceback print(f"Traceback:\n{traceback.format_exc()}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid refresh token: {str(e)}", ) @router.post("/logout", response_model=LogoutResponse) async def logout(request: Request, response: Response): # Clear refresh token cookie response.delete_cookie( key="refresh_token", httponly=True, secure=settings.cookie_secure, samesite=settings.cookie_samesite, domain=settings.cookie_domain if settings.app_env == "prod" else None, ) await audit_logger.log_action( action=AuditAction.LOGOUT, description="User logged out", request=request, severity=AuditLogSeverity.INFO, ) return LogoutResponse()