# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Routes for Google authentication. """ from fastapi import APIRouter, HTTPException, Request, Response, status, Depends, Body, Query from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import logging import json from typing import Dict, Any, Optional from requests_oauthlib import OAuth2Session import httpx from modules.shared.configuration import APP_CONFIG from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie from modules.auth.tokenManager import TokenManager from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp # Configure logger logger = logging.getLogger(__name__) async def verify_google_token(access_token: str) -> Dict[str, Any]: """ Verify Google access token validity and get token info. Returns token information including scopes and expiration. """ try: headers = { 'Authorization': f"Bearer {access_token}", 'Content-Type': 'application/json' } async with httpx.AsyncClient() as client: # Use Google's tokeninfo endpoint to verify token response = await client.get( "https://www.googleapis.com/oauth2/v1/tokeninfo", headers=headers, params={"access_token": access_token} ) if response.status_code == 200: token_info = response.json() logger.debug(f"Token verification successful: {token_info.get('email', 'unknown')}") return { "valid": True, "token_info": token_info, "scopes": token_info.get("scope", "").split(" ") if token_info.get("scope") else [], "expires_in": int(token_info.get("expires_in", 0)), "user_id": token_info.get("user_id"), "email": token_info.get("email") } else: logger.warning(f"Token verification failed: {response.status_code} - {response.text}") return { "valid": False, "error": f"HTTP {response.status_code}", "details": response.text } except Exception as e: logger.error(f"Error verifying Google token: {str(e)}") return { "valid": False, "error": str(e) } # Create router router = APIRouter( prefix="/api/google", tags=["Security Google"], responses={ 404: {"description": "Not found"}, 400: {"description": "Bad request"}, 401: {"description": "Unauthorized"}, 403: {"description": "Forbidden"}, 500: {"description": "Internal server error"} } ) # Google OAuth configuration CLIENT_ID = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID") CLIENT_SECRET = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET") REDIRECT_URI = APP_CONFIG.get("Service_GOOGLE_REDIRECT_URI") SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email", "openid", ] @router.get("/config") def get_config(): """Debug endpoint to check Google OAuth configuration""" return { "client_id": CLIENT_ID, "client_secret": "***" if CLIENT_SECRET else None, "redirect_uri": REDIRECT_URI, "scopes": SCOPES, "config_loaded": bool(CLIENT_ID and CLIENT_SECRET and REDIRECT_URI), "config_source": { "client_id_from": "config.ini" if CLIENT_ID and "354925410565" in CLIENT_ID else "env file", "redirect_uri_from": "config.ini" if REDIRECT_URI and "gateway-int.poweron-center.net" in REDIRECT_URI else "env file" } } @router.get("/login") @limiter.limit("5/minute") def login( request: Request, state: str = Query("login", description="State parameter to distinguish between login and connection flows"), connectionId: Optional[str] = Query(None, description="Connection ID for connection flow") ) -> RedirectResponse: """Initiate Google login""" try: # Debug: Log configuration values logger.info(f"Google OAuth Configuration - CLIENT_ID: {CLIENT_ID}, REDIRECT_URI: {REDIRECT_URI}") # Validate required configuration if not CLIENT_ID: logger.error("Google OAuth CLIENT_ID is not configured") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Google OAuth CLIENT_ID is not configured" ) if not CLIENT_SECRET: logger.error("Google OAuth CLIENT_SECRET is not configured") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Google OAuth CLIENT_SECRET is not configured" ) if not REDIRECT_URI: logger.error("Google OAuth REDIRECT_URI is not configured") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Google OAuth REDIRECT_URI is not configured" ) # Generate auth URL with state - use state as is if it's already JSON, otherwise create new state try: # Try to parse state as JSON to check if it's already encoded json.loads(state) state_param = state # Use state as is if it's valid JSON except json.JSONDecodeError: # If not JSON, create new state object state_param = json.dumps({ "type": state, "connectionId": connectionId }) logger.info(f"Using state parameter: {state_param}") # Use OAuth2Session directly - it works reliably oauth = OAuth2Session( client_id=CLIENT_ID, redirect_uri=REDIRECT_URI, scope=SCOPES ) extra_params = { "access_type": "offline", "include_granted_scopes": "true", "state": state_param } # If targeting specific connection, add login_hint and hd to preselect account try: if connectionId: rootInterface = getRootInterface() connection = rootInterface.getUserConnectionById(connectionId) if connection: login_hint = connection.externalEmail or connection.externalUsername if login_hint: extra_params["login_hint"] = login_hint if "@" in login_hint: extra_params["hd"] = login_hint.split("@", 1)[1] # Avoid account picker when targeting a known account extra_params["prompt"] = "consent" else: extra_params["prompt"] = "consent select_account" else: extra_params["prompt"] = "consent select_account" except Exception: extra_params["prompt"] = "consent select_account" auth_url, state = oauth.authorization_url( "https://accounts.google.com/o/oauth2/auth", **extra_params ) logger.info(f"Generated Google OAuth URL using OAuth2Session: {auth_url}") return RedirectResponse(auth_url) except Exception as e: logger.error(f"Error initiating Google login: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to initiate Google login: {str(e)}" ) @router.get("/auth/callback") async def auth_callback(code: str, state: str, request: Request, response: Response) -> HTMLResponse: """Handle Google OAuth callback""" try: # Import Token at function level to avoid scoping issues from modules.datamodels.datamodelSecurity import Token # Parse state state_data = json.loads(state) state_type = state_data.get("type", "login") connection_id = state_data.get("connectionId") user_id = state_data.get("userId") # Get user ID from state logger.info(f"Processing Google auth callback: state_type={state_type}, connection_id={connection_id}, user_id={user_id}") # Use OAuth2Session directly for token exchange oauth = OAuth2Session( client_id=CLIENT_ID, redirect_uri=REDIRECT_URI ) # Get token using OAuth2Session token_data = oauth.fetch_token( "https://oauth2.googleapis.com/token", client_secret=CLIENT_SECRET, code=code, include_client_id=True ) # Verify which scopes were actually granted (as per Google OAuth 2.0 spec) granted_scopes = token_data.get("scope", "") logger.info(f"Granted scopes: {granted_scopes}") # Check if all requested scopes were granted missing_scopes = [] for requested_scope in SCOPES: if requested_scope not in granted_scopes: missing_scopes.append(requested_scope) if missing_scopes: logger.warning(f"Some requested scopes were not granted: {missing_scopes}") # Continue with available scopes, but log the limitation token_response = { "access_token": token_data.get("access_token"), "refresh_token": token_data.get("refresh_token", ""), "token_type": token_data.get("token_type", "bearer"), "expires_in": token_data.get("expires_in", 0) } # If Google did not return a refresh_token, try to reuse an existing one for this user/connection if not token_response.get("refresh_token"): try: rootInterface = getRootInterface() # Prefer connection flow reuse; fallback to user access token if connection_id: existing_tokens = rootInterface.getTokensByConnectionIdAndAuthority( connection_id, AuthAuthority.GOOGLE ) if existing_tokens: # Use most recent by createdAt existing_tokens.sort(key=lambda x: parseTimestamp(x.createdAt, default=0), reverse=True) token_response["refresh_token"] = existing_tokens[0].tokenRefresh or "" if not token_response.get("refresh_token") and user_id: existing_access_tokens = rootInterface.getTokensByUserIdNoConnection( user_id, AuthAuthority.GOOGLE ) if existing_access_tokens: existing_access_tokens.sort(key=lambda x: parseTimestamp(x.createdAt, default=0), reverse=True) token_response["refresh_token"] = existing_access_tokens[0].tokenRefresh or "" except Exception: # Non-fatal; continue without refresh token pass if not token_response.get("access_token"): logger.error("Token acquisition failed: No access token received") return HTMLResponse( content="
Could not acquire token.
", status_code=400 ) # Verify the token before proceeding (as per Google OAuth 2.0 spec) token_verification = await verify_google_token(token_response['access_token']) if not token_verification.get("valid"): logger.error(f"Token verification failed: {token_verification.get('error')}") return HTMLResponse( content=f"Token verification failed: {token_verification.get('error')}
", status_code=400 ) # Get user info using the verified access token headers = { 'Authorization': f"Bearer {token_response['access_token']}", 'Content-Type': 'application/json' } async with httpx.AsyncClient() as client: user_info_response = await client.get( "https://www.googleapis.com/oauth2/v2/userinfo", headers=headers ) if user_info_response.status_code != 200: logger.error(f"Failed to get user info: {user_info_response.text}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get user info from Google" ) user_info = user_info_response.json() logger.info(f"Got user info from Google: {user_info.get('email')}") # Log verified scopes for debugging verified_scopes = token_verification.get("scopes", []) logger.info(f"Verified token scopes: {verified_scopes}") if state_type == "login": # Handle login flow rootInterface = getRootInterface() user = rootInterface.getUserByUsername(user_info.get("email")) if not user: # Create new user if doesn't exist user = rootInterface.createUser( username=user_info.get("email"), email=user_info.get("email"), fullName=user_info.get("name"), authenticationAuthority=AuthAuthority.GOOGLE, externalId=user_info.get("id"), externalUsername=user_info.get("email"), externalEmail=user_info.get("email") ) # Create JWT token data (like Microsoft does) # MULTI-TENANT: Token does NOT contain mandateId anymore jwt_token_data = { "sub": user.username, "userId": str(user.id), "authenticationAuthority": AuthAuthority.GOOGLE.value # NO mandateId in token - stateless multi-tenant design } # Create JWT access token jwt_token, jwt_expires_at = createAccessToken(jwt_token_data) # Create refresh token refresh_token, _refresh_expires = createRefreshToken(jwt_token_data) # Decode token to get jti for database record from jose import jwt payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM]) jti = payload.get("jti") # Create JWT token with matching id # MULTI-TENANT: Token model no longer has mandateId field token = Token( id=jti, userId=user.id, # Use local user's ID authority=AuthAuthority.GOOGLE, tokenAccess=jwt_token, # Use JWT token instead of Google access token tokenRefresh=token_response.get("refresh_token", ""), tokenType="bearer", expiresAt=jwt_expires_at.timestamp(), createdAt=getUtcTimestamp() # NO mandateId - Token is not mandate-bound ) # Save access token (no connectionId) appInterface = getInterface(user) appInterface.saveAccessToken(token) # Convert token to dict and ensure proper timestamp handling token_dict = token.model_dump() # Create HTML response html_response = HTMLResponse( content=f"""