from fastapi import APIRouter, HTTPException, Depends, Request, Response, status, Cookie from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import msal import logging import json from typing import Dict, Any, Optional, List from datetime import datetime, timedelta import secrets from modules.auth import getCurrentActiveUser, getUserContext, createAccessToken, ACCESS_TOKEN_EXPIRE_MINUTES from modules.configuration import APP_CONFIG from modules.lucydomInterface import getLucydomInterface from modules.gatewayInterface import getGatewayInterface # Configure logger logger = logging.getLogger(__name__) # Create router for Microsoft Auth endpoints router = APIRouter( prefix="/api/msft", tags=["Microsoft"], responses={ 404: {"description": "Not found"}, 400: {"description": "Bad request"}, 401: {"description": "Unauthorized"}, 403: {"description": "Forbidden"}, 500: {"description": "Internal server error"} } ) # Azure AD configuration - load from config CLIENT_ID = APP_CONFIG.get("Agent_Mail_MSFT_CLIENT_ID") CLIENT_SECRET = APP_CONFIG.get("Agent_Mail_MSFT_CLIENT_SECRET") TENANT_ID = APP_CONFIG.get("Agent_Mail_MSFT_TENANT_ID", "common") # Use 'common' for multi-tenant AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" SCOPES = ["Mail.ReadWrite", "User.Read"] REDIRECT_URI = APP_CONFIG.get("Agent_Mail_MSFT_REDIRECT_URI") # Initialize MSAL application app_config = { "client_id": CLIENT_ID, "client_credential": CLIENT_SECRET, "authority": AUTHORITY, "redirect_uri": REDIRECT_URI } async def save_token_to_file(token_data, currentUser: Dict[str, Any]): """Save token data to database using LucyDOMInterface""" try: # Get current user context mandateId, userId = await getUserContext(currentUser) if not mandateId or not userId: logger.error("No user context available for token storage") return False # Get LucyDOM interface for current user mydom = getLucydomInterface( mandateId=mandateId, userId=userId ) if not mydom: logger.error("No LucyDOM interface available for token storage") return False # Save token to database success = mydom.saveMsftToken(token_data) if success: logger.info("Token saved successfully to database") return True else: logger.error("Failed to save token to database") return False except Exception as e: logger.error(f"Error saving token: {str(e)}") return False async def load_token_from_file(currentUser: Dict[str, Any]): """Load token data from database using LucyDOMInterface""" try: # Get current user context mandateId, userId = await getUserContext(currentUser) if not mandateId or not userId: logger.error("No user context available for token retrieval") return None # Get LucyDOM interface for current user mydom = getLucydomInterface( mandateId=mandateId, userId=userId ) if not mydom: logger.error("No LucyDOM interface available for token retrieval") return None # Get token from database token_data = mydom.getMsftToken() if token_data: logger.info("Token loaded successfully from database") return token_data else: logger.info("No token found in database") return None except Exception as e: logger.error(f"Error loading token: {str(e)}") return None def get_user_info_from_token(access_token: str) -> Optional[Dict[str, Any]]: """Get user information using the access token""" import requests headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } try: response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) if response.status_code == 200: user_data = response.json() return { "name": user_data.get("displayName", ""), "email": user_data.get("userPrincipalName", ""), "id": user_data.get("id", "") } else: logger.error(f"Error getting user info: {response.status_code} - {response.text}") return None except Exception as e: logger.error(f"Exception getting user info: {str(e)}") return None def verify_token(token: str) -> bool: """Verify the access token is valid""" import requests headers = { 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json' } try: logger.info("Verifying token validity...") response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) if response.status_code == 200: logger.info("Token verification successful") return True else: logger.error(f"Token verification failed: {response.status_code} - {response.text}") return False except Exception as e: logger.error(f"Exception verifying token: {str(e)}") return False async def refresh_token(user_id: str, currentUser: Dict[str, Any]) -> bool: """Refresh the access token using the stored refresh token""" token_data = await load_token_from_file(currentUser) if not token_data or not token_data.get("refresh_token"): logger.warning("No refresh token available") return False msal_app = msal.ConfidentialClientApplication( app_config["client_id"], authority=app_config["authority"], client_credential=app_config["client_credential"] ) result = msal_app.acquire_token_by_refresh_token( token_data["refresh_token"], scopes=SCOPES ) if "error" in result: logger.error(f"Error refreshing token: {result.get('error')}") return False # Update tokens in storage token_data["access_token"] = result["access_token"] if "refresh_token" in result: token_data["refresh_token"] = result["refresh_token"] await save_token_to_file(token_data, currentUser) logger.info("Access token refreshed successfully") return True @router.get("/login") async def login(): """Initiate Microsoft login for the current user""" try: # Create a confidential client application msal_app = msal.ConfidentialClientApplication( app_config["client_id"], authority=app_config["authority"], client_credential=app_config["client_credential"] ) # Build the auth URL with a random state state = secrets.token_urlsafe(32) auth_url = msal_app.get_authorization_request_url( SCOPES, state=state, # Use random state redirect_uri=app_config["redirect_uri"] ) logger.info(f"Redirecting to Microsoft login") return RedirectResponse(auth_url) except Exception as e: logger.error(f"Error initiating Microsoft login: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to initiate Microsoft login: {str(e)}" ) @router.get("/auth/callback") async def auth_callback(code: str, state: str, request: Request): """Handle Microsoft OAuth callback""" try: # Create MSAL app instance app = msal.ConfidentialClientApplication( client_id=CLIENT_ID, client_credential=CLIENT_SECRET, authority=AUTHORITY ) # Exchange code for token token_response = app.acquire_token_by_authorization_code( code=code, scopes=SCOPES, redirect_uri=REDIRECT_URI ) if "error" in token_response: logger.error(f"Token acquisition failed: {token_response['error']}") return HTMLResponse( content=""" Authentication Failed

Authentication Failed

Please try again.

""", status_code=400 ) # Get user info from token user_info = get_user_info_from_token(token_response["access_token"]) if not user_info: logger.error("Failed to get user info from token") return HTMLResponse( content=""" Authentication Failed

Authentication Failed

Could not retrieve user information.

""", status_code=400 ) # Add user info to token data token_response["user_info"] = user_info # Store tokens in session storage for the frontend to pick up response = HTMLResponse( content=f""" Authentication Successful

Authentication Successful

Welcome, {user_info.get('name', 'User')}!

This window will close automatically.

""" ) return response except Exception as e: logger.error(f"Authentication failed: {str(e)}") return HTMLResponse( content=""" Authentication Failed

Authentication Failed

An error occurred during authentication.

""", status_code=500 ) @router.get("/status") async def auth_status(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): """Check Microsoft authentication status""" try: # Get current user context mandateId, userId = await getUserContext(currentUser) if not mandateId or not userId: logger.info("No user context found") return JSONResponse({ "authenticated": False, "message": "Not authenticated with Microsoft" }) # Check if we have a token for the current user token_data = await load_token_from_file(currentUser) if not token_data: logger.info(f"No token data found for user {userId}") return JSONResponse({ "authenticated": False, "message": "Not authenticated with Microsoft" }) # Verify token is still valid if not verify_token(token_data["access_token"]): logger.info("Token invalid, attempting refresh") # Try to refresh the token if not await refresh_token(userId, currentUser): logger.info("Token refresh failed") return JSONResponse({ "authenticated": False, "message": "Token expired and refresh failed" }) # Reload token data after refresh token_data = await load_token_from_file(currentUser) # Get user info from token data user_info = token_data.get("user_info") if not user_info: logger.info("No user info found in token data") return JSONResponse({ "authenticated": False, "message": "No user information available" }) logger.info(f"User {user_info.get('name')} is authenticated") return JSONResponse({ "authenticated": True, "user": user_info }) except Exception as e: logger.error(f"Error checking authentication status: {str(e)}") return JSONResponse({ "authenticated": False, "message": f"Error checking authentication status: {str(e)}" }) @router.post("/logout") async def logout(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): """Logout from Microsoft""" try: # Get current user context mandateId, userId = await getUserContext(currentUser) if not mandateId or not userId: return JSONResponse({ "message": "Not authenticated with Microsoft" }) # Get LucyDOM interface for current user mydom = getLucydomInterface( mandateId=mandateId, userId=userId ) if not mydom: return JSONResponse({ "message": "Not authenticated with Microsoft" }) # Remove token from database tokens = mydom.db.getRecordset("msftTokens", recordFilter={ "mandateId": mandateId, "userId": userId }) if tokens and len(tokens) > 0: mydom.db.recordDelete("msftTokens", tokens[0]["id"]) logger.info(f"Removed Microsoft token for user {userId}") return JSONResponse({ "message": "Successfully logged out from Microsoft" }) except Exception as e: logger.error(f"Error during logout: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Logout failed: {str(e)}" ) @router.get("/token") async def get_access_token(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): """Get the current user's access token for Microsoft Graph API""" try: # Check if we have a token for the current user token_data = await load_token_from_file(currentUser) if not token_data: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated with Microsoft" ) # Verify token is still valid if not verify_token(token_data["access_token"]): # Try to refresh the token if not await refresh_token(currentUser["id"], currentUser): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired and refresh failed" ) # Reload token data after refresh token_data = await load_token_from_file(currentUser) return JSONResponse({ "access_token": token_data["access_token"] }) except Exception as e: logger.error(f"Error getting access token: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error getting access token: {str(e)}" ) @router.post("/token") async def get_backend_token(request: Request): """Convert MSAL token to backend token""" try: # Get the authorization header auth_header = request.headers.get('Authorization') if not auth_header or not auth_header.startswith('Bearer '): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid authorization header" ) # Extract the MSAL token msal_token = auth_header.split(' ')[1] # Verify the MSAL token and get user info user_info = get_user_info_from_token(msal_token) if not user_info: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MSAL token" ) # Get the user from the database using the email gateway = getGatewayInterface() user = gateway.getUserByUsername(user_info["email"]) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not registered in the system" ) # Create backend token access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = createAccessToken( data={ "sub": user["username"], "mandateId": user["mandateId"] }, expiresDelta=access_token_expires ) return { "accessToken": access_token, "tokenType": "bearer", "user": { "username": user["username"], "email": user["email"], "fullName": user.get("fullName", ""), "mandateId": user["mandateId"] } } except HTTPException: raise except Exception as e: logger.error(f"Error in MSAL token conversion: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error processing MSAL token: {str(e)}" ) @router.post("/save-token") async def save_token(token_data: Dict[str, Any], currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): """Save Microsoft token data from frontend""" try: # Save token to database success = await save_token_to_file(token_data, currentUser) if success: return JSONResponse({ "success": True, "message": "Token saved successfully" }) else: return JSONResponse({ "success": False, "message": "Failed to save token" }) except Exception as e: logger.error(f"Error saving token: {str(e)}") return JSONResponse({ "success": False, "message": f"Error saving token: {str(e)}" })