from fastapi import APIRouter, HTTPException, Depends, Request, Response, status, Cookie from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import msal import os import logging import sys import json from typing import Dict, Any, Optional from datetime import datetime, timedelta from modules.auth import getCurrentActiveUser, getUserContext from modules.configuration import APP_CONFIG from modules.lucydomInterface import getLucydomInterface # 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 } # Create a simple file-based token storage TOKEN_DIR = './token_storage' if not os.path.exists(TOKEN_DIR): os.makedirs(TOKEN_DIR) logger.info(f"Created token storage directory: {TOKEN_DIR}") def save_token_to_file(user_id: str, token_data: Dict[str, Any]): """Save token data to a file""" filename = os.path.join(TOKEN_DIR, f"{user_id}.json") with open(filename, 'w') as f: json.dump(token_data, f) logger.info(f"Token saved for user: {user_id}") def load_token_from_file(user_id: str) -> Optional[Dict[str, Any]]: """Load token data from a file""" filename = os.path.join(TOKEN_DIR, f"{user_id}.json") if os.path.exists(filename): with open(filename, 'r') as f: return json.load(f) 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 def refresh_token(user_id: str) -> bool: """Refresh the access token using the stored refresh token""" token_data = load_token_from_file(user_id) 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"] save_token_to_file(user_id, token_data) logger.info("Access token refreshed successfully") return True def silent_login(user_id: str) -> bool: """Try to silently log in a user using their refresh token""" token_data = load_token_from_file(user_id) if not token_data or not token_data.get("refresh_token"): logger.info(f"No refresh token found for user: {user_id}") return False # Try to refresh the token 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"] save_token_to_file(user_id, token_data) return True @router.get("/login") async def login(): # Modified implementation without requiring 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 auth_url = msal_app.get_authorization_request_url( SCOPES, state="anonymous-user", # Use a general state since we don't have user context redirect_uri=app_config["redirect_uri"] ) logger.info(f"Redirecting to Microsoft login: {auth_url[:60]}...") 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"Error initiating Microsoft login: {str(e)}" ) @router.get("/auth/callback") async def auth_callback(request: Request, code: str = None, state: str = None): """Handle callback from Microsoft login""" try: # Log callback for debugging logger.info("Received callback from Microsoft login") if not code: logger.error("No authorization code received in callback") return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"message": "No authorization code received"} ) # Extract user and mandate info from state if available user_id = None mandate_id = None if state and state != "anonymous-user": try: mandate_id, user_id = state.split(":") logger.info(f"State contains mandate_id: {mandate_id}, user_id: {user_id}") except ValueError: logger.warning(f"Invalid state format: {state}") # Generate a generic user ID if state is invalid user_id = f"user_{datetime.now().strftime('%Y%m%d%H%M%S')}" else: # For anonymous authentication, create a generic user ID logger.info("Anonymous authentication (no user context)") user_id = f"user_{datetime.now().strftime('%Y%m%d%H%M%S')}" # Create a confidential client application msal_app = msal.ConfidentialClientApplication( app_config["client_id"], authority=app_config["authority"], client_credential=app_config["client_credential"] ) # Get tokens using the authorization code result = msal_app.acquire_token_by_authorization_code( code, scopes=SCOPES, redirect_uri=app_config["redirect_uri"] ) if "error" in result: logger.error(f"Error acquiring token: {result.get('error')}") return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"message": f"Error acquiring token: {result.get('error_description', result.get('error'))}"} ) # Store user information user_info = {} if "id_token_claims" in result: user_info = { "name": result["id_token_claims"].get("name", ""), "email": result["id_token_claims"].get("preferred_username", ""), } # If we have user info from the token, use that for user_id token_user_id = result["id_token_claims"].get("oid") or result["id_token_claims"].get("sub") if token_user_id: user_id = token_user_id elif not user_id and user_info.get("email"): # Fall back to email-based ID if no other ID is available user_id = user_info.get("email", "user").replace("@", "_").replace(".", "_") # Save tokens to file token_data = { "access_token": result["access_token"], "refresh_token": result.get("refresh_token", ""), "user_info": user_info, "timestamp": datetime.now().isoformat() } # Ensure token directory exists if not os.path.exists(TOKEN_DIR): os.makedirs(TOKEN_DIR) # Save token to file token_file = os.path.join(TOKEN_DIR, f"{user_id}.json") with open(token_file, 'w') as f: json.dump(token_data, f) logger.info(f"User authenticated: {user_info.get('email', 'unknown')}") # Create a success page html_content = """ Authentication Successful

Authentication Successful

You have successfully authenticated with Microsoft.

You can now close this tab and return to the application.

Your email templates will now be able to create drafts in your mailbox.

Close Window
""" return HTMLResponse(content=html_content) else: logger.warning("No id_token_claims found in result") return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"message": "Failed to retrieve user information"} ) except Exception as e: logger.error(f"Error in auth callback: {str(e)}", exc_info=True) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"Error in auth callback: {str(e)}"} ) @router.get("/status") async def auth_status( msft_user_id: Optional[str] = Cookie(None), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Check Microsoft authentication status""" try: # Get user ID if not msft_user_id: mandateId, userId = await getUserContext(currentUser) user_id = str(userId) else: user_id = msft_user_id # Check if user has a token token_data = load_token_from_file(user_id) if not token_data: return JSONResponse( content={"authenticated": False, "message": "Not authenticated with Microsoft"} ) # Check if token is valid if not verify_token(token_data.get("access_token", "")): # Try to refresh token if refresh_token(user_id): token_data = load_token_from_file(user_id) user_info = token_data.get("user_info", {}) return JSONResponse( content={ "authenticated": True, "message": "Token refreshed successfully", "user": user_info } ) else: return JSONResponse( content={ "authenticated": False, "message": "Token expired and couldn't be refreshed" } ) # Token is valid, return user info user_info = token_data.get("user_info", {}) return JSONResponse( content={ "authenticated": True, "message": "Authenticated with Microsoft", "user": user_info } ) except Exception as e: logger.error(f"Error checking auth status: {str(e)}") return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"Error checking auth status: {str(e)}"} )