completely working
This commit is contained in:
338
auth/auth_info_middleware.py
Normal file
338
auth/auth_info_middleware.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"""
|
||||||
|
Authentication middleware to populate context state with user information
|
||||||
|
"""
|
||||||
|
import jwt
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict
|
||||||
|
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
||||||
|
from fastmcp.server.dependencies import get_http_headers
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthInfoMiddleware(Middleware):
|
||||||
|
"""
|
||||||
|
Middleware to extract authentication information from JWT tokens
|
||||||
|
and populate the FastMCP context state for use in tools and prompts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.auth_provider_type = "Unknown"
|
||||||
|
|
||||||
|
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
||||||
|
"""Extract auth info from token and set in context state"""
|
||||||
|
logger.info(f"on_call_tool called - context type: {type(context)}")
|
||||||
|
logger.info(f"fastmcp_context available: {context.fastmcp_context is not None}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not context.fastmcp_context:
|
||||||
|
logger.warning("No fastmcp_context available")
|
||||||
|
return await call_next(context)
|
||||||
|
|
||||||
|
# Check if access_token is already in state
|
||||||
|
access_token = context.fastmcp_context.get_state("access_token")
|
||||||
|
|
||||||
|
if access_token:
|
||||||
|
logger.info(f"Access token already in state: {type(access_token)}")
|
||||||
|
else:
|
||||||
|
# Try to get the HTTP request to extract Authorization header
|
||||||
|
try:
|
||||||
|
# Use the new FastMCP method to get HTTP headers
|
||||||
|
headers = get_http_headers()
|
||||||
|
if headers:
|
||||||
|
logger.info(f"Got HTTP headers: {type(headers)}")
|
||||||
|
|
||||||
|
# Get the Authorization header
|
||||||
|
auth_header = headers.get("authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token_str = auth_header[7:] # Remove "Bearer " prefix
|
||||||
|
logger.info(f"Found Bearer token in HTTP request")
|
||||||
|
|
||||||
|
# For Google OAuth tokens (ya29.*), we need to verify them differently
|
||||||
|
if token_str.startswith("ya29."):
|
||||||
|
logger.info("Detected Google OAuth access token")
|
||||||
|
|
||||||
|
# Verify the token to get user info
|
||||||
|
from core.server import get_auth_provider
|
||||||
|
auth_provider = get_auth_provider()
|
||||||
|
|
||||||
|
if auth_provider:
|
||||||
|
try:
|
||||||
|
# Verify the token
|
||||||
|
verified_auth = await auth_provider.verify_token(token_str)
|
||||||
|
if verified_auth:
|
||||||
|
# Extract user info from verified token
|
||||||
|
user_email = None
|
||||||
|
if hasattr(verified_auth, 'claims'):
|
||||||
|
user_email = verified_auth.claims.get("email")
|
||||||
|
|
||||||
|
# Create access token object with verified info
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Get expires_at, defaulting to 1 hour from now if not available
|
||||||
|
if hasattr(verified_auth, 'expires_at'):
|
||||||
|
expires_at = verified_auth.expires_at
|
||||||
|
else:
|
||||||
|
expires_at = int(time.time()) + 3600 # Default to 1 hour
|
||||||
|
|
||||||
|
# Get client_id from verified auth or use default
|
||||||
|
client_id = getattr(verified_auth, 'client_id', None) or "google"
|
||||||
|
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
token=token_str,
|
||||||
|
client_id=client_id,
|
||||||
|
scopes=verified_auth.scopes if hasattr(verified_auth, 'scopes') else [],
|
||||||
|
session_id=f"google_oauth_{token_str[:8]}",
|
||||||
|
expires_at=expires_at,
|
||||||
|
# Add other fields that might be needed
|
||||||
|
sub=verified_auth.sub if hasattr(verified_auth, 'sub') else user_email,
|
||||||
|
email=user_email
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in context state
|
||||||
|
context.fastmcp_context.set_state("access_token", access_token)
|
||||||
|
context.fastmcp_context.set_state("auth_provider_type", self.auth_provider_type)
|
||||||
|
context.fastmcp_context.set_state("token_type", "google_oauth")
|
||||||
|
context.fastmcp_context.set_state("user_email", user_email)
|
||||||
|
context.fastmcp_context.set_state("username", user_email)
|
||||||
|
|
||||||
|
logger.info(f"Stored verified Google OAuth token for user: {user_email}")
|
||||||
|
else:
|
||||||
|
logger.error("Failed to verify Google OAuth token")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying Google OAuth token: {e}")
|
||||||
|
# Still store the unverified token - service decorator will handle verification
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import time
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
token=token_str,
|
||||||
|
client_id=os.getenv("GOOGLE_OAUTH_CLIENT_ID", "google"),
|
||||||
|
scopes=[],
|
||||||
|
session_id=f"google_oauth_{token_str[:8]}",
|
||||||
|
expires_at=int(time.time()) + 3600, # Default to 1 hour
|
||||||
|
sub="unknown",
|
||||||
|
email=""
|
||||||
|
)
|
||||||
|
context.fastmcp_context.set_state("access_token", access_token)
|
||||||
|
context.fastmcp_context.set_state("auth_provider_type", self.auth_provider_type)
|
||||||
|
context.fastmcp_context.set_state("token_type", "google_oauth")
|
||||||
|
else:
|
||||||
|
logger.warning("No auth provider available to verify Google token")
|
||||||
|
# Store unverified token
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import time
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
token=token_str,
|
||||||
|
client_id=os.getenv("GOOGLE_OAUTH_CLIENT_ID", "google"),
|
||||||
|
scopes=[],
|
||||||
|
session_id=f"google_oauth_{token_str[:8]}",
|
||||||
|
expires_at=int(time.time()) + 3600, # Default to 1 hour
|
||||||
|
sub="unknown",
|
||||||
|
email=""
|
||||||
|
)
|
||||||
|
context.fastmcp_context.set_state("access_token", access_token)
|
||||||
|
context.fastmcp_context.set_state("auth_provider_type", self.auth_provider_type)
|
||||||
|
context.fastmcp_context.set_state("token_type", "google_oauth")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Decode JWT to get user info
|
||||||
|
try:
|
||||||
|
token_payload = jwt.decode(
|
||||||
|
token_str,
|
||||||
|
options={"verify_signature": False}
|
||||||
|
)
|
||||||
|
logger.debug(f"JWT payload decoded: {list(token_payload.keys())}")
|
||||||
|
|
||||||
|
# Create an AccessToken-like object
|
||||||
|
from types import SimpleNamespace
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
token=token_str,
|
||||||
|
client_id=token_payload.get("client_id", "unknown"),
|
||||||
|
scopes=token_payload.get("scope", "").split() if token_payload.get("scope") else [],
|
||||||
|
session_id=token_payload.get("sid", token_payload.get("jti", token_payload.get("session_id", "unknown"))),
|
||||||
|
expires_at=token_payload.get("exp", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in context state
|
||||||
|
context.fastmcp_context.set_state("access_token", access_token)
|
||||||
|
|
||||||
|
# Store additional user info
|
||||||
|
context.fastmcp_context.set_state("user_id", token_payload.get("sub"))
|
||||||
|
context.fastmcp_context.set_state("username", token_payload.get("username", token_payload.get("email")))
|
||||||
|
context.fastmcp_context.set_state("name", token_payload.get("name"))
|
||||||
|
context.fastmcp_context.set_state("auth_time", token_payload.get("auth_time"))
|
||||||
|
context.fastmcp_context.set_state("issuer", token_payload.get("iss"))
|
||||||
|
context.fastmcp_context.set_state("audience", token_payload.get("aud"))
|
||||||
|
context.fastmcp_context.set_state("jti", token_payload.get("jti"))
|
||||||
|
context.fastmcp_context.set_state("auth_provider_type", self.auth_provider_type)
|
||||||
|
|
||||||
|
logger.info("Successfully extracted and stored auth info from HTTP request")
|
||||||
|
|
||||||
|
except jwt.DecodeError as e:
|
||||||
|
logger.error(f"Failed to decode JWT: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing JWT: {e}")
|
||||||
|
else:
|
||||||
|
logger.debug("No Bearer token in Authorization header")
|
||||||
|
else:
|
||||||
|
logger.debug("No HTTP headers available (might be using stdio transport)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get HTTP request: {e}")
|
||||||
|
|
||||||
|
logger.info("Calling next middleware/handler")
|
||||||
|
result = await call_next(context)
|
||||||
|
logger.info("Successfully completed call_next()")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in on_call_tool middleware: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def on_get_prompt(self, context: MiddlewareContext, call_next):
|
||||||
|
"""Extract auth info for prompt requests too"""
|
||||||
|
logger.info(f"on_get_prompt called - context type: {type(context)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not context.fastmcp_context:
|
||||||
|
logger.warning("No fastmcp_context available for prompt")
|
||||||
|
return await call_next(context)
|
||||||
|
|
||||||
|
# Same logic as on_call_tool
|
||||||
|
access_token = context.fastmcp_context.get_state("access_token")
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
try:
|
||||||
|
# Use the new FastMCP method to get HTTP headers
|
||||||
|
headers = get_http_headers()
|
||||||
|
if headers:
|
||||||
|
auth_header = headers.get("authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token_str = auth_header[7:]
|
||||||
|
|
||||||
|
# For Google OAuth tokens (ya29.*), we need to verify them differently
|
||||||
|
if token_str.startswith("ya29."):
|
||||||
|
logger.info("Detected Google OAuth access token in prompt")
|
||||||
|
|
||||||
|
# Same verification logic as on_call_tool
|
||||||
|
from core.server import get_auth_provider
|
||||||
|
auth_provider = get_auth_provider()
|
||||||
|
|
||||||
|
if auth_provider:
|
||||||
|
try:
|
||||||
|
verified_auth = await auth_provider.verify_token(token_str)
|
||||||
|
if verified_auth:
|
||||||
|
user_email = None
|
||||||
|
if hasattr(verified_auth, 'claims'):
|
||||||
|
user_email = verified_auth.claims.get("email")
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Get expires_at, defaulting to 1 hour from now if not available
|
||||||
|
if hasattr(verified_auth, 'expires_at'):
|
||||||
|
expires_at = verified_auth.expires_at
|
||||||
|
else:
|
||||||
|
expires_at = int(time.time()) + 3600 # Default to 1 hour
|
||||||
|
|
||||||
|
# Get client_id from verified auth or use default
|
||||||
|
client_id = getattr(verified_auth, 'client_id', None) or "google"
|
||||||
|
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
token=token_str,
|
||||||
|
client_id=client_id,
|
||||||
|
scopes=verified_auth.scopes if hasattr(verified_auth, 'scopes') else [],
|
||||||
|
session_id=f"google_oauth_{token_str[:8]}",
|
||||||
|
expires_at=expires_at,
|
||||||
|
# Add other fields that might be needed
|
||||||
|
sub=verified_auth.sub if hasattr(verified_auth, 'sub') else user_email,
|
||||||
|
email=user_email
|
||||||
|
)
|
||||||
|
|
||||||
|
context.fastmcp_context.set_state("access_token", access_token)
|
||||||
|
context.fastmcp_context.set_state("auth_provider_type", self.auth_provider_type)
|
||||||
|
context.fastmcp_context.set_state("token_type", "google_oauth")
|
||||||
|
context.fastmcp_context.set_state("user_email", user_email)
|
||||||
|
context.fastmcp_context.set_state("username", user_email)
|
||||||
|
|
||||||
|
logger.info(f"Stored verified Google OAuth token for prompt user: {user_email}")
|
||||||
|
else:
|
||||||
|
logger.error("Failed to verify Google OAuth token for prompt")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying Google OAuth token for prompt: {e}")
|
||||||
|
# Store unverified token
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import time
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
token=token_str,
|
||||||
|
client_id=os.getenv("GOOGLE_OAUTH_CLIENT_ID", "google"),
|
||||||
|
scopes=[],
|
||||||
|
session_id=f"google_oauth_{token_str[:8]}",
|
||||||
|
expires_at=int(time.time()) + 3600, # Default to 1 hour
|
||||||
|
sub="unknown",
|
||||||
|
email=""
|
||||||
|
)
|
||||||
|
context.fastmcp_context.set_state("access_token", access_token)
|
||||||
|
context.fastmcp_context.set_state("auth_provider_type", self.auth_provider_type)
|
||||||
|
context.fastmcp_context.set_state("token_type", "google_oauth")
|
||||||
|
else:
|
||||||
|
# Store unverified token
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import time
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
token=token_str,
|
||||||
|
client_id=os.getenv("GOOGLE_OAUTH_CLIENT_ID", "google"),
|
||||||
|
scopes=[],
|
||||||
|
session_id=f"google_oauth_{token_str[:8]}",
|
||||||
|
expires_at=int(time.time()) + 3600, # Default to 1 hour
|
||||||
|
sub="unknown",
|
||||||
|
email=""
|
||||||
|
)
|
||||||
|
context.fastmcp_context.set_state("access_token", access_token)
|
||||||
|
context.fastmcp_context.set_state("auth_provider_type", self.auth_provider_type)
|
||||||
|
context.fastmcp_context.set_state("token_type", "google_oauth")
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
token_payload = jwt.decode(
|
||||||
|
token_str,
|
||||||
|
options={"verify_signature": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
token=token_str,
|
||||||
|
client_id=token_payload.get("client_id", "unknown"),
|
||||||
|
scopes=token_payload.get("scope", "").split() if token_payload.get("scope") else [],
|
||||||
|
session_id=token_payload.get("sid", token_payload.get("jti", token_payload.get("session_id", "unknown"))),
|
||||||
|
expires_at=token_payload.get("exp", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
context.fastmcp_context.set_state("access_token", access_token)
|
||||||
|
context.fastmcp_context.set_state("user_id", token_payload.get("sub"))
|
||||||
|
context.fastmcp_context.set_state("username", token_payload.get("username", token_payload.get("email")))
|
||||||
|
context.fastmcp_context.set_state("name", token_payload.get("name"))
|
||||||
|
context.fastmcp_context.set_state("auth_time", token_payload.get("auth_time"))
|
||||||
|
context.fastmcp_context.set_state("issuer", token_payload.get("iss"))
|
||||||
|
context.fastmcp_context.set_state("audience", token_payload.get("aud"))
|
||||||
|
context.fastmcp_context.set_state("jti", token_payload.get("jti"))
|
||||||
|
context.fastmcp_context.set_state("auth_provider_type", self.auth_provider_type)
|
||||||
|
|
||||||
|
logger.info("Successfully extracted auth info for prompt")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing JWT for prompt: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get HTTP request for prompt: {e}")
|
||||||
|
|
||||||
|
logger.info("Calling next middleware/handler for prompt")
|
||||||
|
result = await call_next(context)
|
||||||
|
logger.info("Successfully completed prompt call_next()")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in on_get_prompt middleware: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
@@ -561,7 +561,8 @@ def handle_auth_callback(
|
|||||||
client_secret=credentials.client_secret,
|
client_secret=credentials.client_secret,
|
||||||
scopes=credentials.scopes,
|
scopes=credentials.scopes,
|
||||||
expiry=credentials.expiry,
|
expiry=credentials.expiry,
|
||||||
mcp_session_id=session_id
|
mcp_session_id=session_id,
|
||||||
|
issuer="https://accounts.google.com" # Add issuer for Google tokens
|
||||||
)
|
)
|
||||||
|
|
||||||
# If session_id is provided, also save to session cache for compatibility
|
# If session_id is provided, also save to session cache for compatibility
|
||||||
@@ -762,7 +763,8 @@ def get_credentials(
|
|||||||
client_secret=credentials.client_secret,
|
client_secret=credentials.client_secret,
|
||||||
scopes=credentials.scopes,
|
scopes=credentials.scopes,
|
||||||
expiry=credentials.expiry,
|
expiry=credentials.expiry,
|
||||||
mcp_session_id=session_id
|
mcp_session_id=session_id,
|
||||||
|
issuer="https://accounts.google.com" # Add issuer for Google tokens
|
||||||
)
|
)
|
||||||
|
|
||||||
if session_id: # Update session cache if it was the source or is active
|
if session_id: # Update session cache if it was the source or is active
|
||||||
|
|||||||
605
auth/google_remote_auth_provider.py
Normal file
605
auth/google_remote_auth_provider.py
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
"""
|
||||||
|
Google Workspace RemoteAuthProvider for FastMCP v2.11.1+
|
||||||
|
|
||||||
|
This module implements OAuth 2.1 authentication for Google Workspace using FastMCP's
|
||||||
|
RemoteAuthProvider pattern. It provides:
|
||||||
|
|
||||||
|
- JWT token verification using Google's public keys
|
||||||
|
- OAuth proxy endpoints to work around CORS restrictions
|
||||||
|
- Dynamic client registration workaround
|
||||||
|
- Session bridging to Google credentials for API access
|
||||||
|
|
||||||
|
This provider is used only in streamable-http transport mode with FastMCP v2.11.1+.
|
||||||
|
For earlier versions or other transport modes, the legacy GoogleWorkspaceAuthProvider is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import aiohttp
|
||||||
|
import jwt
|
||||||
|
import time
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from jwt import PyJWKClient
|
||||||
|
from pydantic import AnyHttpUrl
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastmcp.server.auth import RemoteAuthProvider
|
||||||
|
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||||
|
REMOTEAUTHPROVIDER_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
REMOTEAUTHPROVIDER_AVAILABLE = False
|
||||||
|
RemoteAuthProvider = object # Fallback for type hints
|
||||||
|
JWTVerifier = object
|
||||||
|
|
||||||
|
from auth.oauth21_session_store import get_oauth21_session_store, store_token_session
|
||||||
|
from auth.google_auth import save_credentials_to_file
|
||||||
|
from auth.scopes import SCOPES
|
||||||
|
from core.config import (
|
||||||
|
WORKSPACE_MCP_PORT,
|
||||||
|
WORKSPACE_MCP_BASE_URI,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleRemoteAuthProvider(RemoteAuthProvider):
|
||||||
|
"""
|
||||||
|
RemoteAuthProvider implementation for Google Workspace using FastMCP v2.11.1+.
|
||||||
|
|
||||||
|
This provider extends RemoteAuthProvider to add:
|
||||||
|
- OAuth proxy endpoints for CORS workaround
|
||||||
|
- Dynamic client registration support
|
||||||
|
- Enhanced session management with issuer tracking
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the Google RemoteAuthProvider."""
|
||||||
|
if not REMOTEAUTHPROVIDER_AVAILABLE:
|
||||||
|
raise ImportError("FastMCP v2.11.1+ required for RemoteAuthProvider")
|
||||||
|
|
||||||
|
# Get configuration from environment
|
||||||
|
self.client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
||||||
|
self.client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||||
|
self.base_url = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost")
|
||||||
|
self.port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000)))
|
||||||
|
|
||||||
|
if not self.client_id:
|
||||||
|
logger.warning("GOOGLE_OAUTH_CLIENT_ID not set - OAuth 2.1 authentication will not work")
|
||||||
|
# Still initialize to avoid errors, but auth won't work
|
||||||
|
|
||||||
|
# Configure JWT verifier for Google tokens
|
||||||
|
token_verifier = JWTVerifier(
|
||||||
|
jwks_uri="https://www.googleapis.com/oauth2/v3/certs",
|
||||||
|
issuer="https://accounts.google.com",
|
||||||
|
audience=self.client_id or "placeholder", # Use placeholder if not configured
|
||||||
|
algorithm="RS256"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize RemoteAuthProvider with local server as the authorization server
|
||||||
|
# This ensures OAuth discovery points to our proxy endpoints instead of Google directly
|
||||||
|
super().__init__(
|
||||||
|
token_verifier=token_verifier,
|
||||||
|
authorization_servers=[AnyHttpUrl(f"{self.base_url}:{self.port}")],
|
||||||
|
resource_server_url=f"{self.base_url}:{self.port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("GoogleRemoteAuthProvider initialized with RemoteAuthProvider pattern")
|
||||||
|
|
||||||
|
def get_routes(self) -> List[Route]:
|
||||||
|
"""
|
||||||
|
Add custom OAuth proxy endpoints to the standard protected resource routes.
|
||||||
|
|
||||||
|
These endpoints work around Google's CORS restrictions and provide
|
||||||
|
dynamic client registration support.
|
||||||
|
"""
|
||||||
|
# Get the standard OAuth protected resource routes from RemoteAuthProvider
|
||||||
|
routes = super().get_routes()
|
||||||
|
|
||||||
|
# Log what routes we're getting from the parent
|
||||||
|
logger.info(f"GoogleRemoteAuthProvider: Parent provided {len(routes)} routes")
|
||||||
|
for route in routes:
|
||||||
|
logger.info(f" - {route.path} ({', '.join(route.methods)})")
|
||||||
|
|
||||||
|
# Add our custom proxy endpoints
|
||||||
|
|
||||||
|
# OAuth authorization proxy endpoint
|
||||||
|
async def oauth_authorize(request: Request):
|
||||||
|
"""Redirect to Google's authorization endpoint with CORS support."""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return JSONResponse(
|
||||||
|
content={},
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get query parameters
|
||||||
|
params = dict(request.query_params)
|
||||||
|
|
||||||
|
# Add our client ID if not provided
|
||||||
|
if "client_id" not in params and self.client_id:
|
||||||
|
params["client_id"] = self.client_id
|
||||||
|
|
||||||
|
# Ensure response_type is code
|
||||||
|
params["response_type"] = "code"
|
||||||
|
|
||||||
|
# Merge client scopes with our full SCOPES list
|
||||||
|
client_scopes = params.get("scope", "").split() if params.get("scope") else []
|
||||||
|
# Always include all Google Workspace scopes for full functionality
|
||||||
|
all_scopes = set(client_scopes) | set(SCOPES)
|
||||||
|
params["scope"] = " ".join(sorted(all_scopes))
|
||||||
|
logger.info(f"OAuth 2.1 authorization: Requesting scopes: {params['scope']}")
|
||||||
|
|
||||||
|
# Build Google authorization URL
|
||||||
|
google_auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params)
|
||||||
|
|
||||||
|
# Return redirect
|
||||||
|
return RedirectResponse(
|
||||||
|
url=google_auth_url,
|
||||||
|
status_code=302,
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
routes.append(Route("/oauth2/authorize", oauth_authorize, methods=["GET", "OPTIONS"]))
|
||||||
|
|
||||||
|
# Token exchange proxy endpoint
|
||||||
|
async def proxy_token_exchange(request: Request):
|
||||||
|
"""Proxy token exchange to Google to avoid CORS issues."""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return JSONResponse(
|
||||||
|
content={},
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get form data
|
||||||
|
body = await request.body()
|
||||||
|
content_type = request.headers.get("content-type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
# Parse form data to add missing client credentials
|
||||||
|
from urllib.parse import parse_qs, urlencode
|
||||||
|
|
||||||
|
if content_type and "application/x-www-form-urlencoded" in content_type:
|
||||||
|
form_data = parse_qs(body.decode('utf-8'))
|
||||||
|
|
||||||
|
# Check if client_id is missing (public client)
|
||||||
|
if 'client_id' not in form_data or not form_data['client_id'][0]:
|
||||||
|
if self.client_id:
|
||||||
|
form_data['client_id'] = [self.client_id]
|
||||||
|
logger.debug(f"Added missing client_id to token request")
|
||||||
|
|
||||||
|
# Check if client_secret is missing (public client using PKCE)
|
||||||
|
if 'client_secret' not in form_data:
|
||||||
|
if self.client_secret:
|
||||||
|
form_data['client_secret'] = [self.client_secret]
|
||||||
|
logger.debug(f"Added missing client_secret to token request")
|
||||||
|
|
||||||
|
# Reconstruct body with added credentials
|
||||||
|
body = urlencode(form_data, doseq=True).encode('utf-8')
|
||||||
|
|
||||||
|
# Forward request to Google
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
headers = {"Content-Type": content_type}
|
||||||
|
|
||||||
|
async with session.post("https://oauth2.googleapis.com/token", data=body, headers=headers) as response:
|
||||||
|
response_data = await response.json()
|
||||||
|
|
||||||
|
# Log for debugging
|
||||||
|
if response.status != 200:
|
||||||
|
logger.error(f"Token exchange failed: {response.status} - {response_data}")
|
||||||
|
else:
|
||||||
|
logger.info("Token exchange successful")
|
||||||
|
|
||||||
|
# Store the token session for credential bridging
|
||||||
|
if "access_token" in response_data:
|
||||||
|
try:
|
||||||
|
# Extract user email from ID token if present
|
||||||
|
if "id_token" in response_data:
|
||||||
|
# Verify ID token using Google's public keys for security
|
||||||
|
try:
|
||||||
|
# Get Google's public keys for verification
|
||||||
|
jwks_client = PyJWKClient("https://www.googleapis.com/oauth2/v3/certs")
|
||||||
|
|
||||||
|
# Get signing key from JWT header
|
||||||
|
signing_key = jwks_client.get_signing_key_from_jwt(response_data["id_token"])
|
||||||
|
|
||||||
|
# Verify and decode the ID token
|
||||||
|
id_token_claims = jwt.decode(
|
||||||
|
response_data["id_token"],
|
||||||
|
signing_key.key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=self.client_id,
|
||||||
|
issuer="https://accounts.google.com"
|
||||||
|
)
|
||||||
|
user_email = id_token_claims.get("email")
|
||||||
|
|
||||||
|
if user_email:
|
||||||
|
# Try to get FastMCP session ID from request context for binding
|
||||||
|
mcp_session_id = None
|
||||||
|
try:
|
||||||
|
# Check if this is a streamable HTTP request with session
|
||||||
|
if hasattr(request, 'state') and hasattr(request.state, 'session_id'):
|
||||||
|
mcp_session_id = request.state.session_id
|
||||||
|
logger.info(f"Found MCP session ID for binding: {mcp_session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get MCP session ID: {e}")
|
||||||
|
|
||||||
|
# Store the token session with MCP session binding and issuer
|
||||||
|
session_id = store_token_session(response_data, user_email, mcp_session_id)
|
||||||
|
logger.info(f"Stored OAuth session for {user_email} (session: {session_id}, mcp: {mcp_session_id})")
|
||||||
|
|
||||||
|
# Also create and store Google credentials
|
||||||
|
expiry = None
|
||||||
|
if "expires_in" in response_data:
|
||||||
|
# Google auth library expects timezone-naive datetime
|
||||||
|
expiry = datetime.utcnow() + timedelta(seconds=response_data["expires_in"])
|
||||||
|
|
||||||
|
credentials = Credentials(
|
||||||
|
token=response_data["access_token"],
|
||||||
|
refresh_token=response_data.get("refresh_token"),
|
||||||
|
token_uri="https://oauth2.googleapis.com/token",
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret,
|
||||||
|
scopes=response_data.get("scope", "").split() if response_data.get("scope") else None,
|
||||||
|
expiry=expiry
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save credentials to file for legacy auth
|
||||||
|
save_credentials_to_file(user_email, credentials)
|
||||||
|
logger.info(f"Saved Google credentials for {user_email}")
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
logger.error("ID token has expired - cannot extract user email")
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
logger.error(f"Invalid ID token - cannot extract user email: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to verify ID token - cannot extract user email: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store OAuth session: {e}")
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=response.status,
|
||||||
|
content=response_data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in token proxy: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "server_error", "error_description": str(e)},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
|
routes.append(Route("/oauth2/token", proxy_token_exchange, methods=["POST", "OPTIONS"]))
|
||||||
|
|
||||||
|
# Dynamic client registration endpoint
|
||||||
|
async def oauth_register(request: Request):
|
||||||
|
"""
|
||||||
|
Dynamic client registration workaround endpoint.
|
||||||
|
|
||||||
|
Google doesn't support OAuth 2.1 dynamic client registration, so this endpoint
|
||||||
|
accepts any registration request and returns our pre-configured Google OAuth
|
||||||
|
credentials, allowing standards-compliant clients to work seamlessly.
|
||||||
|
"""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return JSONResponse(
|
||||||
|
content={},
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"error": "invalid_request", "error_description": "OAuth not configured"},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse the registration request
|
||||||
|
body = await request.json()
|
||||||
|
logger.info(f"Dynamic client registration request received: {body}")
|
||||||
|
|
||||||
|
# Extract redirect URIs from the request or use defaults
|
||||||
|
redirect_uris = body.get("redirect_uris", [])
|
||||||
|
if not redirect_uris:
|
||||||
|
redirect_uris = [
|
||||||
|
f"{self.base_url}:{self.port}/oauth2callback",
|
||||||
|
"http://localhost:5173/auth/callback"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build the registration response with our pre-configured credentials
|
||||||
|
response_data = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"client_name": body.get("client_name", "Google Workspace MCP Server"),
|
||||||
|
"client_uri": body.get("client_uri", f"{self.base_url}:{self.port}"),
|
||||||
|
"redirect_uris": redirect_uris,
|
||||||
|
"grant_types": body.get("grant_types", ["authorization_code", "refresh_token"]),
|
||||||
|
"response_types": body.get("response_types", ["code"]),
|
||||||
|
"scope": body.get("scope", " ".join(SCOPES)),
|
||||||
|
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "client_secret_basic"),
|
||||||
|
"code_challenge_methods": ["S256"],
|
||||||
|
# Additional OAuth 2.1 fields
|
||||||
|
"client_id_issued_at": int(time.time()),
|
||||||
|
"registration_access_token": "not-required", # We don't implement client management
|
||||||
|
"registration_client_uri": f"{self.base_url}:{self.port}/oauth2/register/{self.client_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Dynamic client registration successful - returning pre-configured Google credentials")
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=201,
|
||||||
|
content=response_data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in dynamic client registration: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"error": "invalid_request", "error_description": str(e)},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
|
routes.append(Route("/oauth2/register", oauth_register, methods=["POST", "OPTIONS"]))
|
||||||
|
|
||||||
|
# Authorization server metadata proxy
|
||||||
|
async def oauth_authorization_server(request: Request):
|
||||||
|
"""OAuth 2.1 Authorization Server Metadata endpoint proxy."""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return JSONResponse(
|
||||||
|
content={},
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch metadata from Google
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
url = "https://accounts.google.com/.well-known/openid-configuration"
|
||||||
|
async with session.get(url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
metadata = await response.json()
|
||||||
|
|
||||||
|
# Add OAuth 2.1 required fields
|
||||||
|
metadata.setdefault("code_challenge_methods_supported", ["S256"])
|
||||||
|
metadata.setdefault("pkce_required", True)
|
||||||
|
|
||||||
|
# Override endpoints to use our proxies
|
||||||
|
metadata["token_endpoint"] = f"{self.base_url}:{self.port}/oauth2/token"
|
||||||
|
metadata["authorization_endpoint"] = f"{self.base_url}:{self.port}/oauth2/authorize"
|
||||||
|
metadata["enable_dynamic_registration"] = True
|
||||||
|
metadata["registration_endpoint"] = f"{self.base_url}:{self.port}/oauth2/register"
|
||||||
|
return JSONResponse(
|
||||||
|
content=metadata,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback metadata
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"issuer": "https://accounts.google.com",
|
||||||
|
"authorization_endpoint": f"{self.base_url}:{self.port}/oauth2/authorize",
|
||||||
|
"token_endpoint": f"{self.base_url}:{self.port}/oauth2/token",
|
||||||
|
"userinfo_endpoint": "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
|
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
|
||||||
|
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
"pkce_required": True,
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"scopes_supported": SCOPES,
|
||||||
|
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"]
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching auth server metadata: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Failed to fetch authorization server metadata"},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
|
routes.append(Route("/.well-known/oauth-authorization-server", oauth_authorization_server, methods=["GET", "OPTIONS"]))
|
||||||
|
|
||||||
|
# OAuth client configuration endpoint
|
||||||
|
async def oauth_client_config(request: Request):
|
||||||
|
"""Return OAuth client configuration."""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return JSONResponse(
|
||||||
|
content={},
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.client_id:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"error": "OAuth not configured"},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_name": "Google Workspace MCP Server",
|
||||||
|
"client_uri": f"{self.base_url}:{self.port}",
|
||||||
|
"redirect_uris": [
|
||||||
|
f"{self.base_url}:{self.port}/oauth2callback",
|
||||||
|
"http://localhost:5173/auth/callback"
|
||||||
|
],
|
||||||
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
|
"response_types": ["code"],
|
||||||
|
"scope": " ".join(SCOPES),
|
||||||
|
"token_endpoint_auth_method": "client_secret_basic",
|
||||||
|
"code_challenge_methods": ["S256"]
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
routes.append(Route("/.well-known/oauth-client", oauth_client_config, methods=["GET", "OPTIONS"]))
|
||||||
|
|
||||||
|
return routes
|
||||||
|
|
||||||
|
async def verify_token(self, token: str) -> Optional[object]:
|
||||||
|
"""
|
||||||
|
Override verify_token to handle Google OAuth access tokens.
|
||||||
|
|
||||||
|
Google OAuth access tokens (ya29.*) are opaque tokens that need to be
|
||||||
|
verified using the tokeninfo endpoint, not JWT verification.
|
||||||
|
"""
|
||||||
|
# Check if this is a Google OAuth access token (starts with ya29.)
|
||||||
|
if token.startswith("ya29."):
|
||||||
|
logger.debug("Detected Google OAuth access token, using tokeninfo verification")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify the access token using Google's tokeninfo endpoint
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
url = f"https://oauth2.googleapis.com/tokeninfo?access_token={token}"
|
||||||
|
async with session.get(url) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
logger.error(f"Token verification failed: {response.status}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_info = await response.json()
|
||||||
|
|
||||||
|
# Verify the token is for our client
|
||||||
|
if token_info.get("aud") != self.client_id:
|
||||||
|
logger.error(f"Token audience mismatch: expected {self.client_id}, got {token_info.get('aud')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if token is expired
|
||||||
|
expires_in = token_info.get("expires_in", 0)
|
||||||
|
if int(expires_in) <= 0:
|
||||||
|
logger.error("Token is expired")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create an access token object that matches the expected interface
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Calculate expires_at timestamp
|
||||||
|
expires_in = int(token_info.get("expires_in", 0))
|
||||||
|
expires_at = int(time.time()) + expires_in if expires_in > 0 else 0
|
||||||
|
|
||||||
|
access_token = SimpleNamespace(
|
||||||
|
claims={
|
||||||
|
"email": token_info.get("email"),
|
||||||
|
"sub": token_info.get("sub"),
|
||||||
|
"aud": token_info.get("aud"),
|
||||||
|
"scope": token_info.get("scope", ""),
|
||||||
|
},
|
||||||
|
scopes=token_info.get("scope", "").split(),
|
||||||
|
token=token,
|
||||||
|
expires_at=expires_at, # Add the expires_at attribute
|
||||||
|
client_id=self.client_id, # Add client_id at top level
|
||||||
|
# Add other required fields
|
||||||
|
sub=token_info.get("sub", ""),
|
||||||
|
email=token_info.get("email", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
user_email = token_info.get("email")
|
||||||
|
if user_email:
|
||||||
|
from auth.oauth21_session_store import get_oauth21_session_store
|
||||||
|
store = get_oauth21_session_store()
|
||||||
|
session_id = f"google_{token_info.get('sub', 'unknown')}"
|
||||||
|
|
||||||
|
# Try to get FastMCP session ID for binding
|
||||||
|
mcp_session_id = None
|
||||||
|
try:
|
||||||
|
from fastmcp.server.dependencies import get_context
|
||||||
|
ctx = get_context()
|
||||||
|
if ctx and hasattr(ctx, 'session_id'):
|
||||||
|
mcp_session_id = ctx.session_id
|
||||||
|
logger.debug(f"Binding MCP session {mcp_session_id} to user {user_email}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Store session with issuer information
|
||||||
|
store.store_session(
|
||||||
|
user_email=user_email,
|
||||||
|
access_token=token,
|
||||||
|
scopes=access_token.scopes,
|
||||||
|
session_id=session_id,
|
||||||
|
mcp_session_id=mcp_session_id,
|
||||||
|
issuer="https://accounts.google.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Successfully verified Google OAuth token for user: {user_email}")
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying Google OAuth token: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
else:
|
||||||
|
# For JWT tokens, use parent's JWT verification
|
||||||
|
logger.debug("Using JWT verification for non-OAuth token")
|
||||||
|
access_token = await super().verify_token(token)
|
||||||
|
|
||||||
|
if access_token and self.client_id:
|
||||||
|
# Extract user information from token claims
|
||||||
|
user_email = access_token.claims.get("email")
|
||||||
|
if user_email:
|
||||||
|
from auth.oauth21_session_store import get_oauth21_session_store
|
||||||
|
store = get_oauth21_session_store()
|
||||||
|
session_id = f"google_{access_token.claims.get('sub', 'unknown')}"
|
||||||
|
|
||||||
|
# Store session with issuer information
|
||||||
|
store.store_session(
|
||||||
|
user_email=user_email,
|
||||||
|
access_token=token,
|
||||||
|
scopes=access_token.scopes or [],
|
||||||
|
session_id=session_id,
|
||||||
|
issuer="https://accounts.google.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Successfully verified JWT token for user: {user_email}")
|
||||||
|
|
||||||
|
return access_token
|
||||||
@@ -37,6 +37,7 @@ class SessionContext:
|
|||||||
auth_context: Optional[Any] = None
|
auth_context: Optional[Any] = None
|
||||||
request: Optional[Any] = None
|
request: Optional[Any] = None
|
||||||
metadata: Dict[str, Any] = None
|
metadata: Dict[str, Any] = None
|
||||||
|
issuer: Optional[str] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if self.metadata is None:
|
if self.metadata is None:
|
||||||
@@ -172,6 +173,7 @@ class OAuth21SessionStore:
|
|||||||
expiry: Optional[Any] = None,
|
expiry: Optional[Any] = None,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
mcp_session_id: Optional[str] = None,
|
mcp_session_id: Optional[str] = None,
|
||||||
|
issuer: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Store OAuth 2.1 session information.
|
Store OAuth 2.1 session information.
|
||||||
@@ -187,6 +189,7 @@ class OAuth21SessionStore:
|
|||||||
expiry: Token expiry time
|
expiry: Token expiry time
|
||||||
session_id: OAuth 2.1 session ID
|
session_id: OAuth 2.1 session ID
|
||||||
mcp_session_id: FastMCP session ID to map to this user
|
mcp_session_id: FastMCP session ID to map to this user
|
||||||
|
issuer: Token issuer (e.g., "https://accounts.google.com")
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
session_info = {
|
session_info = {
|
||||||
@@ -199,6 +202,7 @@ class OAuth21SessionStore:
|
|||||||
"expiry": expiry,
|
"expiry": expiry,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"mcp_session_id": mcp_session_id,
|
"mcp_session_id": mcp_session_id,
|
||||||
|
"issuer": issuer,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._sessions[user_email] = session_info
|
self._sessions[user_email] = session_info
|
||||||
@@ -337,11 +341,25 @@ class OAuth21SessionStore:
|
|||||||
return self.get_credentials(requested_user_email)
|
return self.get_credentials(requested_user_email)
|
||||||
|
|
||||||
# Special case: Allow access if user has recently authenticated (for clients that don't send tokens)
|
# Special case: Allow access if user has recently authenticated (for clients that don't send tokens)
|
||||||
# This is a temporary workaround for MCP clients that complete OAuth but don't send bearer tokens
|
# CRITICAL SECURITY: This is ONLY allowed in stdio mode, NEVER in OAuth 2.1 mode
|
||||||
if allow_recent_auth and requested_user_email in self._sessions:
|
if allow_recent_auth and requested_user_email in self._sessions:
|
||||||
|
# Check transport mode to ensure this is only used in stdio
|
||||||
|
try:
|
||||||
|
from core.config import get_transport_mode
|
||||||
|
transport_mode = get_transport_mode()
|
||||||
|
if transport_mode != "stdio":
|
||||||
|
logger.error(
|
||||||
|
f"SECURITY: Attempted to use allow_recent_auth in {transport_mode} mode. "
|
||||||
|
f"This is only allowed in stdio mode!"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to check transport mode: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Allowing credential access for {requested_user_email} based on recent authentication "
|
f"Allowing credential access for {requested_user_email} based on recent authentication "
|
||||||
f"(client not sending bearer token)"
|
f"(stdio mode only - client not sending bearer token)"
|
||||||
)
|
)
|
||||||
return self.get_credentials(requested_user_email)
|
return self.get_credentials(requested_user_email)
|
||||||
|
|
||||||
@@ -364,6 +382,19 @@ class OAuth21SessionStore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._mcp_session_mapping.get(mcp_session_id)
|
return self._mcp_session_mapping.get(mcp_session_id)
|
||||||
|
|
||||||
|
def get_session_info(self, user_email: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get complete session information including issuer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_email: User's email address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session information dictionary or None
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return self._sessions.get(user_email)
|
||||||
|
|
||||||
def remove_session(self, user_email: str):
|
def remove_session(self, user_email: str):
|
||||||
"""Remove session for a user."""
|
"""Remove session for a user."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -530,6 +561,7 @@ def store_token_session(token_response: dict, user_email: str, mcp_session_id: O
|
|||||||
expiry=datetime.utcnow() + timedelta(seconds=token_response.get("expires_in", 3600)),
|
expiry=datetime.utcnow() + timedelta(seconds=token_response.get("expires_in", 3600)),
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
mcp_session_id=mcp_session_id,
|
mcp_session_id=mcp_session_id,
|
||||||
|
issuer="https://accounts.google.com", # Add issuer for Google tokens
|
||||||
)
|
)
|
||||||
|
|
||||||
if mcp_session_id:
|
if mcp_session_id:
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ async def _extract_and_verify_bearer_token() -> tuple[Optional[str], Optional[st
|
|||||||
logger.debug("No HTTP headers available for bearer token extraction")
|
logger.debug("No HTTP headers available for bearer token extraction")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Look for Authorization header
|
# Look for Authorization header (Google OAuth token)
|
||||||
auth_header = headers.get("authorization") or headers.get("Authorization")
|
auth_header = headers.get("authorization") or headers.get("Authorization")
|
||||||
|
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
logger.debug("No Authorization header found in request")
|
logger.debug("No Authorization header found in request")
|
||||||
return None, None
|
return None, None
|
||||||
@@ -55,26 +56,35 @@ async def _extract_and_verify_bearer_token() -> tuple[Optional[str], Optional[st
|
|||||||
logger.debug("Empty bearer token found")
|
logger.debug("Empty bearer token found")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
logger.debug(f"Found bearer token in Authorization header: {token[:20]}...")
|
logger.info(f"Found bearer token in Authorization header: {token[:20]}...")
|
||||||
|
|
||||||
# Verify token using GoogleWorkspaceAuthProvider
|
# Verify token using GoogleWorkspaceAuthProvider
|
||||||
try:
|
try:
|
||||||
from core.server import get_auth_provider
|
from core.server import get_auth_provider
|
||||||
auth_provider = get_auth_provider()
|
auth_provider = get_auth_provider()
|
||||||
if not auth_provider:
|
if not auth_provider:
|
||||||
logger.debug("No auth provider available for token verification")
|
logger.error("No auth provider available for token verification")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
logger.debug(f"Auth provider type: {type(auth_provider).__name__}")
|
||||||
|
|
||||||
# Verify the token
|
# Verify the token
|
||||||
access_token = await auth_provider.verify_token(token)
|
access_token = await auth_provider.verify_token(token)
|
||||||
if not access_token:
|
if not access_token:
|
||||||
logger.debug("Bearer token verification failed")
|
logger.error("Bearer token verification failed")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
logger.debug(f"Token verified, access_token type: {type(access_token).__name__}")
|
||||||
|
|
||||||
# Extract user email from verified token
|
# Extract user email from verified token
|
||||||
user_email = access_token.claims.get("email")
|
if hasattr(access_token, 'claims'):
|
||||||
|
user_email = access_token.claims.get("email")
|
||||||
|
else:
|
||||||
|
logger.error(f"Access token has no claims attribute: {dir(access_token)}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
if not user_email:
|
if not user_email:
|
||||||
logger.debug("No email claim found in verified token")
|
logger.error(f"No email claim found in verified token. Available claims: {list(access_token.claims.keys()) if hasattr(access_token, 'claims') else 'N/A'}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
logger.info(f"Successfully verified bearer token for user: {user_email}")
|
logger.info(f"Successfully verified bearer token for user: {user_email}")
|
||||||
@@ -418,14 +428,27 @@ def require_google_service(
|
|||||||
try:
|
try:
|
||||||
from fastmcp.server.dependencies import get_context
|
from fastmcp.server.dependencies import get_context
|
||||||
ctx = get_context()
|
ctx = get_context()
|
||||||
if ctx and hasattr(ctx, 'auth') and ctx.auth:
|
if ctx:
|
||||||
# We have authentication info from FastMCP
|
# Check if AuthInfoMiddleware has stored the access token
|
||||||
is_authenticated_request = True
|
access_token = ctx.get_state("access_token")
|
||||||
if hasattr(ctx.auth, 'claims'):
|
if access_token:
|
||||||
authenticated_user = ctx.auth.claims.get('email')
|
# We have authentication info from the middleware
|
||||||
logger.debug(f"[{tool_name}] Authenticated via FastMCP context: {authenticated_user}")
|
is_authenticated_request = True
|
||||||
except Exception:
|
authenticated_user = ctx.get_state("username") or ctx.get_state("user_email")
|
||||||
pass
|
bearer_token = access_token.token if hasattr(access_token, 'token') else str(access_token)
|
||||||
|
logger.info(f"[{tool_name}] Authenticated via FastMCP context state: {authenticated_user}")
|
||||||
|
|
||||||
|
# Store auth info for later use
|
||||||
|
auth_token_email = authenticated_user
|
||||||
|
else:
|
||||||
|
# Check legacy auth field
|
||||||
|
if hasattr(ctx, 'auth') and ctx.auth:
|
||||||
|
is_authenticated_request = True
|
||||||
|
if hasattr(ctx.auth, 'claims'):
|
||||||
|
authenticated_user = ctx.auth.claims.get('email')
|
||||||
|
logger.debug(f"[{tool_name}] Authenticated via legacy FastMCP auth: {authenticated_user}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[{tool_name}] Error checking FastMCP context: {e}")
|
||||||
|
|
||||||
# If FastMCP context didn't provide authentication, check HTTP headers directly
|
# If FastMCP context didn't provide authentication, check HTTP headers directly
|
||||||
if not is_authenticated_request:
|
if not is_authenticated_request:
|
||||||
@@ -538,8 +561,9 @@ def require_google_service(
|
|||||||
# Must use OAuth 2.1 authentication
|
# Must use OAuth 2.1 authentication
|
||||||
logger.info(f"[{tool_name}] Using OAuth 2.1 authentication (required for OAuth 2.1 mode)")
|
logger.info(f"[{tool_name}] Using OAuth 2.1 authentication (required for OAuth 2.1 mode)")
|
||||||
|
|
||||||
# Check if we're allowing recent auth (for clients that don't send bearer tokens)
|
# CRITICAL SECURITY: Never use allow_recent_auth in OAuth 2.1 mode
|
||||||
allow_recent = not authenticated_user and not auth_token_email and not mcp_session_id
|
# This should always be False in streamable-http mode
|
||||||
|
allow_recent = False # Explicitly disable for OAuth 2.1 mode
|
||||||
|
|
||||||
service, actual_user_email = await get_authenticated_google_service_oauth21(
|
service, actual_user_email = await get_authenticated_google_service_oauth21(
|
||||||
service_name=service_name,
|
service_name=service_name,
|
||||||
@@ -568,14 +592,48 @@ def require_google_service(
|
|||||||
if transport_mode == "stdio":
|
if transport_mode == "stdio":
|
||||||
session_id_for_legacy = mcp_session_id if mcp_session_id else (session_ctx.session_id if session_ctx else None)
|
session_id_for_legacy = mcp_session_id if mcp_session_id else (session_ctx.session_id if session_ctx else None)
|
||||||
logger.info(f"[{tool_name}] Using legacy authentication (stdio mode)")
|
logger.info(f"[{tool_name}] Using legacy authentication (stdio mode)")
|
||||||
service, actual_user_email = await get_authenticated_google_service(
|
|
||||||
service_name=service_name,
|
# In stdio mode, first try to get credentials from OAuth21 store with allow_recent_auth
|
||||||
version=service_version,
|
# This handles the case where user just completed OAuth flow
|
||||||
tool_name=tool_name,
|
# CRITICAL SECURITY: allow_recent_auth=True is ONLY safe in stdio mode because:
|
||||||
user_google_email=user_google_email,
|
# 1. Stdio mode is single-user by design
|
||||||
required_scopes=resolved_scopes,
|
# 2. No bearer tokens are available in stdio mode
|
||||||
session_id=session_id_for_legacy,
|
# 3. This allows access immediately after OAuth callback
|
||||||
)
|
# NEVER use allow_recent_auth=True in multi-user OAuth 2.1 mode!
|
||||||
|
if OAUTH21_INTEGRATION_AVAILABLE:
|
||||||
|
try:
|
||||||
|
service, actual_user_email = await get_authenticated_google_service_oauth21(
|
||||||
|
service_name=service_name,
|
||||||
|
version=service_version,
|
||||||
|
tool_name=tool_name,
|
||||||
|
user_google_email=user_google_email,
|
||||||
|
required_scopes=resolved_scopes,
|
||||||
|
session_id=session_id_for_legacy,
|
||||||
|
auth_token_email=None,
|
||||||
|
allow_recent_auth=True, # ONLY safe in stdio single-user mode!
|
||||||
|
)
|
||||||
|
logger.info(f"[{tool_name}] Successfully used OAuth21 store in stdio mode")
|
||||||
|
except Exception as oauth_error:
|
||||||
|
logger.debug(f"[{tool_name}] OAuth21 store failed in stdio mode, falling back to legacy: {oauth_error}")
|
||||||
|
# Fall back to traditional file-based auth
|
||||||
|
service, actual_user_email = await get_authenticated_google_service(
|
||||||
|
service_name=service_name,
|
||||||
|
version=service_version,
|
||||||
|
tool_name=tool_name,
|
||||||
|
user_google_email=user_google_email,
|
||||||
|
required_scopes=resolved_scopes,
|
||||||
|
session_id=session_id_for_legacy,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No OAuth21 integration, use legacy directly
|
||||||
|
service, actual_user_email = await get_authenticated_google_service(
|
||||||
|
service_name=service_name,
|
||||||
|
version=service_version,
|
||||||
|
tool_name=tool_name,
|
||||||
|
user_google_email=user_google_email,
|
||||||
|
required_scopes=resolved_scopes,
|
||||||
|
session_id=session_id_for_legacy,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"[{tool_name}] No authentication available in {transport_mode} mode")
|
logger.error(f"[{tool_name}] No authentication available in {transport_mode} mode")
|
||||||
raise Exception(f"Authentication not available in {transport_mode} mode")
|
raise Exception(f"Authentication not available in {transport_mode} mode")
|
||||||
|
|||||||
819
core/server.py
819
core/server.py
@@ -13,7 +13,7 @@ from importlib import metadata
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
@@ -24,6 +24,7 @@ from auth.oauth21_session_store import get_oauth21_session_store
|
|||||||
from auth.google_auth import handle_auth_callback, start_auth_flow, check_client_secrets, save_credentials_to_file
|
from auth.google_auth import handle_auth_callback, start_auth_flow, check_client_secrets, save_credentials_to_file
|
||||||
from auth.mcp_session_middleware import MCPSessionMiddleware
|
from auth.mcp_session_middleware import MCPSessionMiddleware
|
||||||
from auth.oauth_responses import create_error_response, create_success_response, create_server_error_response
|
from auth.oauth_responses import create_error_response, create_success_response, create_server_error_response
|
||||||
|
from auth.auth_info_middleware import AuthInfoMiddleware
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from jwt import PyJWKClient
|
from jwt import PyJWKClient
|
||||||
|
|
||||||
@@ -31,6 +32,14 @@ from jwt import PyJWKClient
|
|||||||
from auth.fastmcp_google_auth import GoogleWorkspaceAuthProvider
|
from auth.fastmcp_google_auth import GoogleWorkspaceAuthProvider
|
||||||
from auth.oauth21_session_store import set_auth_provider, store_token_session
|
from auth.oauth21_session_store import set_auth_provider, store_token_session
|
||||||
|
|
||||||
|
# Try to import GoogleRemoteAuthProvider for FastMCP 2.11.1+
|
||||||
|
try:
|
||||||
|
from auth.google_remote_auth_provider import GoogleRemoteAuthProvider
|
||||||
|
GOOGLE_REMOTE_AUTH_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
GOOGLE_REMOTE_AUTH_AVAILABLE = False
|
||||||
|
GoogleRemoteAuthProvider = None
|
||||||
|
|
||||||
# Import shared configuration
|
# Import shared configuration
|
||||||
from auth.scopes import SCOPES
|
from auth.scopes import SCOPES
|
||||||
from core.config import (
|
from core.config import (
|
||||||
@@ -47,7 +56,8 @@ logging.basicConfig(level=logging.INFO)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# FastMCP authentication provider instance
|
# FastMCP authentication provider instance
|
||||||
_auth_provider: Optional[GoogleWorkspaceAuthProvider] = None
|
from typing import Union
|
||||||
|
_auth_provider: Optional[Union[GoogleWorkspaceAuthProvider, GoogleRemoteAuthProvider]] = None
|
||||||
|
|
||||||
# Create middleware configuration
|
# Create middleware configuration
|
||||||
|
|
||||||
@@ -84,6 +94,12 @@ server = CORSEnabledFastMCP(
|
|||||||
auth=None # Will be set in set_transport_mode() for HTTP
|
auth=None # Will be set in set_transport_mode() for HTTP
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add the AuthInfo middleware to inject authentication into FastMCP context
|
||||||
|
auth_info_middleware = AuthInfoMiddleware()
|
||||||
|
# Set the auth provider type so tools can access it
|
||||||
|
auth_info_middleware.auth_provider_type = "GoogleRemoteAuthProvider"
|
||||||
|
server.add_middleware(auth_info_middleware)
|
||||||
|
|
||||||
# Add startup and shutdown event handlers to the underlying FastAPI app
|
# Add startup and shutdown event handlers to the underlying FastAPI app
|
||||||
def add_lifecycle_events():
|
def add_lifecycle_events():
|
||||||
"""Add lifecycle events after server creation."""
|
"""Add lifecycle events after server creation."""
|
||||||
@@ -124,16 +140,37 @@ def set_transport_mode(mode: str):
|
|||||||
# Initialize auth provider immediately for HTTP mode
|
# Initialize auth provider immediately for HTTP mode
|
||||||
if os.getenv("GOOGLE_OAUTH_CLIENT_ID") and not _auth_provider:
|
if os.getenv("GOOGLE_OAUTH_CLIENT_ID") and not _auth_provider:
|
||||||
try:
|
try:
|
||||||
_auth_provider = GoogleWorkspaceAuthProvider()
|
# Use GoogleRemoteAuthProvider if available (FastMCP 2.11.1+)
|
||||||
server.auth = _auth_provider
|
if GOOGLE_REMOTE_AUTH_AVAILABLE:
|
||||||
set_auth_provider(_auth_provider)
|
try:
|
||||||
logger.info("OAuth 2.1 authentication provider initialized")
|
_auth_provider = GoogleRemoteAuthProvider()
|
||||||
|
server.auth = _auth_provider
|
||||||
|
set_auth_provider(_auth_provider)
|
||||||
|
|
||||||
|
# Manually register the auth provider's routes
|
||||||
|
auth_routes = _auth_provider.get_routes()
|
||||||
|
logger.info(f"Registering {len(auth_routes)} routes from GoogleRemoteAuthProvider")
|
||||||
|
for route in auth_routes:
|
||||||
|
server.custom_route(route.path, methods=list(route.methods))(route.endpoint)
|
||||||
|
|
||||||
|
logger.info("OAuth 2.1 authentication provider initialized with GoogleRemoteAuthProvider")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to initialize GoogleRemoteAuthProvider, falling back to legacy: {e}")
|
||||||
|
_auth_provider = GoogleWorkspaceAuthProvider()
|
||||||
|
server.auth = _auth_provider
|
||||||
|
set_auth_provider(_auth_provider)
|
||||||
|
logger.info("OAuth 2.1 authentication provider initialized (legacy)")
|
||||||
|
else:
|
||||||
|
_auth_provider = GoogleWorkspaceAuthProvider()
|
||||||
|
server.auth = _auth_provider
|
||||||
|
set_auth_provider(_auth_provider)
|
||||||
|
logger.info("OAuth 2.1 authentication provider initialized (legacy)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize auth provider: {e}")
|
logger.error(f"Failed to initialize auth provider: {e}")
|
||||||
|
|
||||||
add_lifecycle_events()
|
add_lifecycle_events()
|
||||||
|
|
||||||
async def initialize_auth() -> Optional[GoogleWorkspaceAuthProvider]:
|
async def initialize_auth() -> Optional[Union[GoogleWorkspaceAuthProvider, GoogleRemoteAuthProvider]]:
|
||||||
"""Initialize FastMCP authentication if available and configured."""
|
"""Initialize FastMCP authentication if available and configured."""
|
||||||
global _auth_provider
|
global _auth_provider
|
||||||
|
|
||||||
@@ -153,12 +190,32 @@ async def initialize_auth() -> Optional[GoogleWorkspaceAuthProvider]:
|
|||||||
return _auth_provider
|
return _auth_provider
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create and configure auth provider
|
# Use GoogleRemoteAuthProvider if available (FastMCP 2.11.1+)
|
||||||
|
if GOOGLE_REMOTE_AUTH_AVAILABLE:
|
||||||
|
try:
|
||||||
|
_auth_provider = GoogleRemoteAuthProvider()
|
||||||
|
server.auth = _auth_provider
|
||||||
|
set_auth_provider(_auth_provider)
|
||||||
|
|
||||||
|
# Manually register the auth provider's routes
|
||||||
|
# This ensures the OAuth discovery endpoints are available
|
||||||
|
auth_routes = _auth_provider.get_routes()
|
||||||
|
logger.info(f"Registering {len(auth_routes)} routes from GoogleRemoteAuthProvider")
|
||||||
|
for route in auth_routes:
|
||||||
|
logger.info(f" - Registering route: {route.path} ({', '.join(route.methods)})")
|
||||||
|
server.custom_route(route.path, methods=list(route.methods))(route.endpoint)
|
||||||
|
|
||||||
|
logger.info("FastMCP authentication initialized with GoogleRemoteAuthProvider (v2.11.1+)")
|
||||||
|
return _auth_provider
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to initialize GoogleRemoteAuthProvider, falling back to legacy: {e}")
|
||||||
|
|
||||||
|
# Fallback to legacy GoogleWorkspaceAuthProvider
|
||||||
_auth_provider = GoogleWorkspaceAuthProvider()
|
_auth_provider = GoogleWorkspaceAuthProvider()
|
||||||
server.auth = _auth_provider
|
server.auth = _auth_provider
|
||||||
set_auth_provider(_auth_provider)
|
set_auth_provider(_auth_provider)
|
||||||
|
|
||||||
logger.info("FastMCP authentication initialized with Google Workspace provider")
|
logger.info("FastMCP authentication initialized with Google Workspace provider (legacy)")
|
||||||
return _auth_provider
|
return _auth_provider
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize authentication: {e}")
|
logger.error(f"Failed to initialize authentication: {e}")
|
||||||
@@ -177,7 +234,7 @@ async def shutdown_auth():
|
|||||||
_auth_provider = None
|
_auth_provider = None
|
||||||
server.auth = None
|
server.auth = None
|
||||||
|
|
||||||
def get_auth_provider() -> Optional[GoogleWorkspaceAuthProvider]:
|
def get_auth_provider() -> Optional[Union[GoogleWorkspaceAuthProvider, GoogleRemoteAuthProvider]]:
|
||||||
"""Get the global authentication provider instance."""
|
"""Get the global authentication provider instance."""
|
||||||
return _auth_provider
|
return _auth_provider
|
||||||
|
|
||||||
@@ -335,94 +392,151 @@ async def start_google_auth(
|
|||||||
return auth_result
|
return auth_result
|
||||||
|
|
||||||
|
|
||||||
# OAuth 2.1 Discovery Endpoints
|
# OAuth 2.1 Discovery Endpoints are now handled by GoogleRemoteAuthProvider when available
|
||||||
@server.custom_route("/.well-known/oauth-protected-resource", methods=["GET", "OPTIONS"])
|
# For legacy mode, we need to register them manually
|
||||||
async def oauth_protected_resource(request: Request):
|
if not GOOGLE_REMOTE_AUTH_AVAILABLE:
|
||||||
"""OAuth 2.1 Protected Resource Metadata endpoint."""
|
@server.custom_route("/.well-known/oauth-protected-resource", methods=["GET", "OPTIONS"])
|
||||||
if request.method == "OPTIONS":
|
async def oauth_protected_resource(request: Request):
|
||||||
return JSONResponse(
|
"""OAuth 2.1 Protected Resource Metadata endpoint."""
|
||||||
content={},
|
if request.method == "OPTIONS":
|
||||||
headers={
|
return JSONResponse(
|
||||||
"Access-Control-Allow-Origin": "*",
|
content={},
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
headers={
|
||||||
"Access-Control-Allow-Headers": "Content-Type"
|
"Access-Control-Allow-Origin": "*",
|
||||||
}
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
)
|
"Access-Control-Allow-Headers": "Content-Type"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"resource": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}",
|
"resource": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}",
|
||||||
"authorization_servers": [
|
"authorization_servers": [
|
||||||
f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}"
|
f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}"
|
||||||
],
|
],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
"scopes_supported": SCOPES,
|
"scopes_supported": SCOPES,
|
||||||
"resource_documentation": "https://developers.google.com/workspace",
|
"resource_documentation": "https://developers.google.com/workspace",
|
||||||
"client_registration_required": True,
|
"client_registration_required": True,
|
||||||
"client_configuration_endpoint": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/.well-known/oauth-client",
|
"client_configuration_endpoint": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/.well-known/oauth-client",
|
||||||
}
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
content=metadata,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Access-Control-Allow-Origin": "*"
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@server.custom_route("/.well-known/oauth-authorization-server", methods=["GET", "OPTIONS"])
|
|
||||||
async def oauth_authorization_server(request: Request):
|
|
||||||
"""OAuth 2.1 Authorization Server Metadata endpoint."""
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={},
|
content=metadata,
|
||||||
headers={
|
headers={
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Content-Type": "application/json",
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
"Access-Control-Allow-Origin": "*"
|
||||||
"Access-Control-Allow-Headers": "Content-Type"
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
# Fetch metadata from Google
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
url = "https://accounts.google.com/.well-known/openid-configuration"
|
|
||||||
async with session.get(url) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
metadata = await response.json()
|
|
||||||
|
|
||||||
# Add OAuth 2.1 required fields
|
# Authorization server metadata endpoint now handled by GoogleRemoteAuthProvider
|
||||||
metadata.setdefault("code_challenge_methods_supported", ["S256"])
|
if not GOOGLE_REMOTE_AUTH_AVAILABLE:
|
||||||
metadata.setdefault("pkce_required", True)
|
@server.custom_route("/.well-known/oauth-authorization-server", methods=["GET", "OPTIONS"])
|
||||||
|
async def oauth_authorization_server(request: Request):
|
||||||
|
"""OAuth 2.1 Authorization Server Metadata endpoint."""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return JSONResponse(
|
||||||
|
content={},
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Override endpoints to use our proxies
|
try:
|
||||||
metadata["token_endpoint"] = f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/token"
|
# Fetch metadata from Google
|
||||||
metadata["authorization_endpoint"] = f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/authorize"
|
async with aiohttp.ClientSession() as session:
|
||||||
metadata["enable_dynamic_registration"] = True
|
url = "https://accounts.google.com/.well-known/openid-configuration"
|
||||||
metadata["registration_endpoint"] = f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/register"
|
async with session.get(url) as response:
|
||||||
return JSONResponse(
|
if response.status == 200:
|
||||||
content=metadata,
|
metadata = await response.json()
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
# Add OAuth 2.1 required fields
|
||||||
"Access-Control-Allow-Origin": "*"
|
metadata.setdefault("code_challenge_methods_supported", ["S256"])
|
||||||
}
|
metadata.setdefault("pkce_required", True)
|
||||||
)
|
|
||||||
|
# Override endpoints to use our proxies
|
||||||
|
metadata["token_endpoint"] = f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/token"
|
||||||
|
metadata["authorization_endpoint"] = f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/authorize"
|
||||||
|
metadata["enable_dynamic_registration"] = True
|
||||||
|
metadata["registration_endpoint"] = f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/register"
|
||||||
|
return JSONResponse(
|
||||||
|
content=metadata,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback metadata
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"issuer": "https://accounts.google.com",
|
||||||
|
"authorization_endpoint": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/authorize",
|
||||||
|
"token_endpoint": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/token",
|
||||||
|
"userinfo_endpoint": "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
|
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
|
||||||
|
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
"pkce_required": True,
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"scopes_supported": SCOPES,
|
||||||
|
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"]
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching auth server metadata: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Failed to fetch authorization server metadata"},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# OAuth client configuration endpoint now handled by GoogleRemoteAuthProvider
|
||||||
|
if not GOOGLE_REMOTE_AUTH_AVAILABLE:
|
||||||
|
@server.custom_route("/.well-known/oauth-client", methods=["GET", "OPTIONS"])
|
||||||
|
async def oauth_client_config(request: Request):
|
||||||
|
"""Return OAuth client configuration."""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return JSONResponse(
|
||||||
|
content={},
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
||||||
|
if not client_id:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"error": "OAuth not configured"},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
# Fallback metadata
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"issuer": "https://accounts.google.com",
|
"client_id": client_id,
|
||||||
"authorization_endpoint": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/authorize",
|
"client_name": "Google Workspace MCP Server",
|
||||||
"token_endpoint": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/token",
|
"client_uri": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}",
|
||||||
"userinfo_endpoint": "https://www.googleapis.com/oauth2/v2/userinfo",
|
"redirect_uris": [
|
||||||
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
|
f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2callback",
|
||||||
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
|
"http://localhost:5173/auth/callback"
|
||||||
"response_types_supported": ["code"],
|
],
|
||||||
"code_challenge_methods_supported": ["S256"],
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
"pkce_required": True,
|
"response_types": ["code"],
|
||||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
"scope": " ".join(SCOPES),
|
||||||
"scopes_supported": SCOPES,
|
"token_endpoint_auth_method": "client_secret_basic",
|
||||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"]
|
"code_challenge_methods": ["S256"]
|
||||||
},
|
},
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -430,324 +544,275 @@ async def oauth_authorization_server(request: Request):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching auth server metadata: {e}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"error": "Failed to fetch authorization server metadata"},
|
|
||||||
headers={"Access-Control-Allow-Origin": "*"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# OAuth authorization proxy endpoint now handled by GoogleRemoteAuthProvider
|
||||||
|
if not GOOGLE_REMOTE_AUTH_AVAILABLE:
|
||||||
|
@server.custom_route("/oauth2/authorize", methods=["GET", "OPTIONS"])
|
||||||
|
async def oauth_authorize(request: Request):
|
||||||
|
"""Redirect to Google's authorization endpoint."""
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return JSONResponse(
|
||||||
|
content={},
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# OAuth client configuration endpoint
|
# Get query parameters
|
||||||
@server.custom_route("/.well-known/oauth-client", methods=["GET", "OPTIONS"])
|
params = dict(request.query_params)
|
||||||
async def oauth_client_config(request: Request):
|
|
||||||
"""Return OAuth client configuration."""
|
# Add our client ID if not provided
|
||||||
if request.method == "OPTIONS":
|
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
||||||
return JSONResponse(
|
if "client_id" not in params and client_id:
|
||||||
content={},
|
params["client_id"] = client_id
|
||||||
|
|
||||||
|
# Ensure response_type is code
|
||||||
|
params["response_type"] = "code"
|
||||||
|
|
||||||
|
# Merge client scopes with our full SCOPES list
|
||||||
|
client_scopes = params.get("scope", "").split() if params.get("scope") else []
|
||||||
|
# Always include all Google Workspace scopes for full functionality
|
||||||
|
all_scopes = set(client_scopes) | set(SCOPES)
|
||||||
|
params["scope"] = " ".join(sorted(all_scopes))
|
||||||
|
logger.info(f"OAuth 2.1 authorization: Requesting scopes: {params['scope']}")
|
||||||
|
|
||||||
|
# Build Google authorization URL
|
||||||
|
google_auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params)
|
||||||
|
|
||||||
|
# Return redirect
|
||||||
|
return RedirectResponse(
|
||||||
|
url=google_auth_url,
|
||||||
|
status_code=302,
|
||||||
headers={
|
headers={
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*"
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type"
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
|
||||||
if not client_id:
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={"error": "OAuth not configured"},
|
|
||||||
headers={"Access-Control-Allow-Origin": "*"}
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
# Token exchange proxy endpoint now handled by GoogleRemoteAuthProvider
|
||||||
content={
|
if not GOOGLE_REMOTE_AUTH_AVAILABLE:
|
||||||
"client_id": client_id,
|
@server.custom_route("/oauth2/token", methods=["POST", "OPTIONS"])
|
||||||
"client_name": "Google Workspace MCP Server",
|
async def proxy_token_exchange(request: Request):
|
||||||
"client_uri": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}",
|
"""Proxy token exchange to Google to avoid CORS issues."""
|
||||||
"redirect_uris": [
|
if request.method == "OPTIONS":
|
||||||
f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2callback",
|
return JSONResponse(
|
||||||
"http://localhost:5173/auth/callback"
|
content={},
|
||||||
],
|
headers={
|
||||||
"grant_types": ["authorization_code", "refresh_token"],
|
"Access-Control-Allow-Origin": "*",
|
||||||
"response_types": ["code"],
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
"scope": " ".join(SCOPES),
|
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
||||||
"token_endpoint_auth_method": "client_secret_basic",
|
}
|
||||||
"code_challenge_methods": ["S256"]
|
)
|
||||||
},
|
try:
|
||||||
headers={
|
# Get form data
|
||||||
"Content-Type": "application/json",
|
body = await request.body()
|
||||||
"Access-Control-Allow-Origin": "*"
|
content_type = request.headers.get("content-type", "application/x-www-form-urlencoded")
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# OAuth authorization endpoint (redirect to Google)
|
|
||||||
@server.custom_route("/oauth2/authorize", methods=["GET", "OPTIONS"])
|
|
||||||
async def oauth_authorize(request: Request):
|
|
||||||
"""Redirect to Google's authorization endpoint."""
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return JSONResponse(
|
|
||||||
content={},
|
|
||||||
headers={
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get query parameters
|
|
||||||
params = dict(request.query_params)
|
|
||||||
|
|
||||||
# Add our client ID if not provided
|
|
||||||
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
|
||||||
if "client_id" not in params and client_id:
|
|
||||||
params["client_id"] = client_id
|
|
||||||
|
|
||||||
# Ensure response_type is code
|
|
||||||
params["response_type"] = "code"
|
|
||||||
|
|
||||||
# Merge client scopes with our full SCOPES list
|
|
||||||
client_scopes = params.get("scope", "").split() if params.get("scope") else []
|
|
||||||
# Always include all Google Workspace scopes for full functionality
|
|
||||||
all_scopes = set(client_scopes) | set(SCOPES)
|
|
||||||
params["scope"] = " ".join(sorted(all_scopes))
|
|
||||||
logger.info(f"OAuth 2.1 authorization: Requesting scopes: {params['scope']}")
|
|
||||||
|
|
||||||
# Build Google authorization URL
|
|
||||||
google_auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params)
|
|
||||||
|
|
||||||
# Return redirect
|
|
||||||
return RedirectResponse(
|
|
||||||
url=google_auth_url,
|
|
||||||
status_code=302,
|
|
||||||
headers={
|
|
||||||
"Access-Control-Allow-Origin": "*"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Token exchange proxy endpoint
|
|
||||||
@server.custom_route("/oauth2/token", methods=["POST", "OPTIONS"])
|
|
||||||
async def proxy_token_exchange(request: Request):
|
|
||||||
"""Proxy token exchange to Google to avoid CORS issues."""
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return JSONResponse(
|
|
||||||
content={},
|
|
||||||
headers={
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# Get form data
|
|
||||||
body = await request.body()
|
|
||||||
content_type = request.headers.get("content-type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
# Parse form data to add missing client credentials
|
|
||||||
from urllib.parse import parse_qs, urlencode
|
|
||||||
|
|
||||||
if content_type and "application/x-www-form-urlencoded" in content_type:
|
|
||||||
form_data = parse_qs(body.decode('utf-8'))
|
|
||||||
|
|
||||||
# Check if client_id is missing (public client)
|
# Parse form data to add missing client credentials
|
||||||
if 'client_id' not in form_data or not form_data['client_id'][0]:
|
from urllib.parse import parse_qs, urlencode
|
||||||
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
|
||||||
if client_id:
|
|
||||||
form_data['client_id'] = [client_id]
|
|
||||||
logger.debug(f"Added missing client_id to token request")
|
|
||||||
|
|
||||||
# Check if client_secret is missing (public client using PKCE)
|
if content_type and "application/x-www-form-urlencoded" in content_type:
|
||||||
if 'client_secret' not in form_data:
|
form_data = parse_qs(body.decode('utf-8'))
|
||||||
client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
|
|
||||||
if client_secret:
|
# Check if client_id is missing (public client)
|
||||||
form_data['client_secret'] = [client_secret]
|
if 'client_id' not in form_data or not form_data['client_id'][0]:
|
||||||
logger.debug(f"Added missing client_secret to token request")
|
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
||||||
|
if client_id:
|
||||||
# Reconstruct body with added credentials
|
form_data['client_id'] = [client_id]
|
||||||
body = urlencode(form_data, doseq=True).encode('utf-8')
|
logger.debug(f"Added missing client_id to token request")
|
||||||
|
|
||||||
|
# Check if client_secret is missing (public client using PKCE)
|
||||||
|
if 'client_secret' not in form_data:
|
||||||
|
client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||||
|
if client_secret:
|
||||||
|
form_data['client_secret'] = [client_secret]
|
||||||
|
logger.debug(f"Added missing client_secret to token request")
|
||||||
|
|
||||||
|
# Reconstruct body with added credentials
|
||||||
|
body = urlencode(form_data, doseq=True).encode('utf-8')
|
||||||
|
|
||||||
# Forward request to Google
|
# Forward request to Google
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
headers = {"Content-Type": content_type}
|
headers = {"Content-Type": content_type}
|
||||||
|
|
||||||
async with session.post("https://oauth2.googleapis.com/token", data=body, headers=headers) as response:
|
async with session.post("https://oauth2.googleapis.com/token", data=body, headers=headers) as response:
|
||||||
response_data = await response.json()
|
response_data = await response.json()
|
||||||
|
|
||||||
# Log for debugging
|
# Log for debugging
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
logger.error(f"Token exchange failed: {response.status} - {response_data}")
|
logger.error(f"Token exchange failed: {response.status} - {response_data}")
|
||||||
else:
|
else:
|
||||||
logger.info("Token exchange successful")
|
logger.info("Token exchange successful")
|
||||||
|
|
||||||
# Store the token session for credential bridging
|
# Store the token session for credential bridging
|
||||||
if "access_token" in response_data:
|
if "access_token" in response_data:
|
||||||
try:
|
try:
|
||||||
# Extract user email from ID token if present
|
# Extract user email from ID token if present
|
||||||
if "id_token" in response_data:
|
if "id_token" in response_data:
|
||||||
# Verify ID token using Google's public keys for security
|
# Verify ID token using Google's public keys for security
|
||||||
try:
|
try:
|
||||||
# Get Google's public keys for verification
|
# Get Google's public keys for verification
|
||||||
jwks_client = PyJWKClient("https://www.googleapis.com/oauth2/v3/certs")
|
jwks_client = PyJWKClient("https://www.googleapis.com/oauth2/v3/certs")
|
||||||
|
|
||||||
# Get signing key from JWT header
|
# Get signing key from JWT header
|
||||||
signing_key = jwks_client.get_signing_key_from_jwt(response_data["id_token"])
|
signing_key = jwks_client.get_signing_key_from_jwt(response_data["id_token"])
|
||||||
|
|
||||||
# Verify and decode the ID token
|
# Verify and decode the ID token
|
||||||
id_token_claims = jwt.decode(
|
id_token_claims = jwt.decode(
|
||||||
response_data["id_token"],
|
response_data["id_token"],
|
||||||
signing_key.key,
|
signing_key.key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
audience=os.getenv("GOOGLE_OAUTH_CLIENT_ID"),
|
audience=os.getenv("GOOGLE_OAUTH_CLIENT_ID"),
|
||||||
issuer="https://accounts.google.com"
|
issuer="https://accounts.google.com"
|
||||||
)
|
|
||||||
user_email = id_token_claims.get("email")
|
|
||||||
|
|
||||||
if user_email:
|
|
||||||
# Try to get FastMCP session ID from request context for binding
|
|
||||||
mcp_session_id = None
|
|
||||||
try:
|
|
||||||
# Check if this is a streamable HTTP request with session
|
|
||||||
if hasattr(request, 'state') and hasattr(request.state, 'session_id'):
|
|
||||||
mcp_session_id = request.state.session_id
|
|
||||||
logger.info(f"Found MCP session ID for binding: {mcp_session_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not get MCP session ID: {e}")
|
|
||||||
|
|
||||||
# Store the token session with MCP session binding
|
|
||||||
session_id = store_token_session(response_data, user_email, mcp_session_id)
|
|
||||||
logger.info(f"Stored OAuth session for {user_email} (session: {session_id}, mcp: {mcp_session_id})")
|
|
||||||
|
|
||||||
# Also create and store Google credentials
|
|
||||||
expiry = None
|
|
||||||
if "expires_in" in response_data:
|
|
||||||
# Google auth library expects timezone-naive datetime
|
|
||||||
expiry = datetime.utcnow() + timedelta(seconds=response_data["expires_in"])
|
|
||||||
|
|
||||||
credentials = Credentials(
|
|
||||||
token=response_data["access_token"],
|
|
||||||
refresh_token=response_data.get("refresh_token"),
|
|
||||||
token_uri="https://oauth2.googleapis.com/token",
|
|
||||||
client_id=os.getenv("GOOGLE_OAUTH_CLIENT_ID"),
|
|
||||||
client_secret=os.getenv("GOOGLE_OAUTH_CLIENT_SECRET"),
|
|
||||||
scopes=response_data.get("scope", "").split() if response_data.get("scope") else None,
|
|
||||||
expiry=expiry
|
|
||||||
)
|
)
|
||||||
|
user_email = id_token_claims.get("email")
|
||||||
|
|
||||||
|
if user_email:
|
||||||
|
# Try to get FastMCP session ID from request context for binding
|
||||||
|
mcp_session_id = None
|
||||||
|
try:
|
||||||
|
# Check if this is a streamable HTTP request with session
|
||||||
|
if hasattr(request, 'state') and hasattr(request.state, 'session_id'):
|
||||||
|
mcp_session_id = request.state.session_id
|
||||||
|
logger.info(f"Found MCP session ID for binding: {mcp_session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get MCP session ID: {e}")
|
||||||
|
|
||||||
|
# Store the token session with MCP session binding
|
||||||
|
session_id = store_token_session(response_data, user_email, mcp_session_id)
|
||||||
|
logger.info(f"Stored OAuth session for {user_email} (session: {session_id}, mcp: {mcp_session_id})")
|
||||||
|
|
||||||
# Save credentials to file for legacy auth
|
# Also create and store Google credentials
|
||||||
save_credentials_to_file(user_email, credentials)
|
expiry = None
|
||||||
logger.info(f"Saved Google credentials for {user_email}")
|
if "expires_in" in response_data:
|
||||||
except jwt.ExpiredSignatureError:
|
# Google auth library expects timezone-naive datetime
|
||||||
logger.error("ID token has expired - cannot extract user email")
|
expiry = datetime.utcnow() + timedelta(seconds=response_data["expires_in"])
|
||||||
except jwt.InvalidTokenError as e:
|
|
||||||
logger.error(f"Invalid ID token - cannot extract user email: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to verify ID token - cannot extract user email: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
credentials = Credentials(
|
||||||
logger.error(f"Failed to store OAuth session: {e}")
|
token=response_data["access_token"],
|
||||||
|
refresh_token=response_data.get("refresh_token"),
|
||||||
|
token_uri="https://oauth2.googleapis.com/token",
|
||||||
|
client_id=os.getenv("GOOGLE_OAUTH_CLIENT_ID"),
|
||||||
|
client_secret=os.getenv("GOOGLE_OAUTH_CLIENT_SECRET"),
|
||||||
|
scopes=response_data.get("scope", "").split() if response_data.get("scope") else None,
|
||||||
|
expiry=expiry
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
# Save credentials to file for legacy auth
|
||||||
status_code=response.status,
|
save_credentials_to_file(user_email, credentials)
|
||||||
content=response_data,
|
logger.info(f"Saved Google credentials for {user_email}")
|
||||||
headers={
|
except jwt.ExpiredSignatureError:
|
||||||
"Content-Type": "application/json",
|
logger.error("ID token has expired - cannot extract user email")
|
||||||
"Access-Control-Allow-Origin": "*",
|
except jwt.InvalidTokenError as e:
|
||||||
"Cache-Control": "no-store"
|
logger.error(f"Invalid ID token - cannot extract user email: {e}")
|
||||||
}
|
except Exception as e:
|
||||||
)
|
logger.error(f"Failed to verify ID token - cannot extract user email: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in token proxy: {e}")
|
logger.error(f"Failed to store OAuth session: {e}")
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
return JSONResponse(
|
||||||
content={"error": "server_error", "error_description": str(e)},
|
status_code=response.status,
|
||||||
headers={"Access-Control-Allow-Origin": "*"}
|
content=response_data,
|
||||||
)
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in token proxy: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "server_error", "error_description": str(e)},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# OAuth 2.1 Dynamic Client Registration endpoint
|
# Dynamic client registration endpoint now handled by GoogleRemoteAuthProvider
|
||||||
@server.custom_route("/oauth2/register", methods=["POST", "OPTIONS"])
|
if not GOOGLE_REMOTE_AUTH_AVAILABLE:
|
||||||
async def oauth_register(request: Request):
|
@server.custom_route("/oauth2/register", methods=["POST", "OPTIONS"])
|
||||||
"""
|
async def oauth_register(request: Request):
|
||||||
Dynamic client registration workaround endpoint.
|
"""
|
||||||
|
Dynamic client registration workaround endpoint.
|
||||||
|
|
||||||
Google doesn't support OAuth 2.1 dynamic client registration, so this endpoint
|
Google doesn't support OAuth 2.1 dynamic client registration, so this endpoint
|
||||||
accepts any registration request and returns our pre-configured Google OAuth
|
accepts any registration request and returns our pre-configured Google OAuth
|
||||||
credentials, allowing standards-compliant clients to work seamlessly.
|
credentials, allowing standards-compliant clients to work seamlessly.
|
||||||
"""
|
"""
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={},
|
content={},
|
||||||
headers={
|
headers={
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||||
|
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"error": "invalid_request", "error_description": "OAuth not configured"},
|
||||||
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse the registration request
|
||||||
|
body = await request.json()
|
||||||
|
logger.info(f"Dynamic client registration request received: {body}")
|
||||||
|
|
||||||
|
# Extract redirect URIs from the request or use defaults
|
||||||
|
redirect_uris = body.get("redirect_uris", [])
|
||||||
|
if not redirect_uris:
|
||||||
|
redirect_uris = [
|
||||||
|
f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2callback",
|
||||||
|
"http://localhost:5173/auth/callback"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build the registration response with our pre-configured credentials
|
||||||
|
response_data = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"client_name": body.get("client_name", "Google Workspace MCP Server"),
|
||||||
|
"client_uri": body.get("client_uri", f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}"),
|
||||||
|
"redirect_uris": redirect_uris,
|
||||||
|
"grant_types": body.get("grant_types", ["authorization_code", "refresh_token"]),
|
||||||
|
"response_types": body.get("response_types", ["code"]),
|
||||||
|
"scope": body.get("scope", " ".join(SCOPES)),
|
||||||
|
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "client_secret_basic"),
|
||||||
|
"code_challenge_methods": ["S256"],
|
||||||
|
# Additional OAuth 2.1 fields
|
||||||
|
"client_id_issued_at": int(time.time()),
|
||||||
|
"registration_access_token": "not-required", # We don't implement client management
|
||||||
|
"registration_client_uri": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/register/{client_id}"
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
logger.info("Dynamic client registration successful - returning pre-configured Google credentials")
|
||||||
client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
|
|
||||||
|
|
||||||
if not client_id or not client_secret:
|
return JSONResponse(
|
||||||
return JSONResponse(
|
status_code=201,
|
||||||
status_code=400,
|
content=response_data,
|
||||||
content={"error": "invalid_request", "error_description": "OAuth not configured"},
|
headers={
|
||||||
headers={"Access-Control-Allow-Origin": "*"}
|
"Content-Type": "application/json",
|
||||||
)
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
except Exception as e:
|
||||||
# Parse the registration request
|
logger.error(f"Error in dynamic client registration: {e}")
|
||||||
body = await request.json()
|
return JSONResponse(
|
||||||
logger.info(f"Dynamic client registration request received: {body}")
|
status_code=400,
|
||||||
|
content={"error": "invalid_request", "error_description": str(e)},
|
||||||
# Extract redirect URIs from the request or use defaults
|
headers={"Access-Control-Allow-Origin": "*"}
|
||||||
redirect_uris = body.get("redirect_uris", [])
|
)
|
||||||
if not redirect_uris:
|
|
||||||
redirect_uris = [
|
|
||||||
f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2callback",
|
|
||||||
"http://localhost:5173/auth/callback"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Build the registration response with our pre-configured credentials
|
|
||||||
response_data = {
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"client_name": body.get("client_name", "Google Workspace MCP Server"),
|
|
||||||
"client_uri": body.get("client_uri", f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}"),
|
|
||||||
"redirect_uris": redirect_uris,
|
|
||||||
"grant_types": body.get("grant_types", ["authorization_code", "refresh_token"]),
|
|
||||||
"response_types": body.get("response_types", ["code"]),
|
|
||||||
"scope": body.get("scope", " ".join(SCOPES)),
|
|
||||||
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "client_secret_basic"),
|
|
||||||
"code_challenge_methods": ["S256"],
|
|
||||||
# Additional OAuth 2.1 fields
|
|
||||||
"client_id_issued_at": int(time.time()),
|
|
||||||
"registration_access_token": "not-required", # We don't implement client management
|
|
||||||
"registration_client_uri": f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/oauth2/register/{client_id}"
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Dynamic client registration successful - returning pre-configured Google credentials")
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=201,
|
|
||||||
content=response_data,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Cache-Control": "no-store"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in dynamic client registration: {e}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=400,
|
|
||||||
content={"error": "invalid_request", "error_description": str(e)},
|
|
||||||
headers={"Access-Control-Allow-Origin": "*"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ keywords = [ "mcp", "google", "workspace", "llm", "ai", "claude", "model", "cont
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115.12",
|
"fastapi>=0.115.12",
|
||||||
"fastmcp>=2.11.0",
|
"fastmcp==2.11.1",
|
||||||
"google-api-python-client>=2.168.0",
|
"google-api-python-client>=2.168.0",
|
||||||
"google-auth-httplib2>=0.2.0",
|
"google-auth-httplib2>=0.2.0",
|
||||||
"google-auth-oauthlib>=1.2.2",
|
"google-auth-oauthlib>=1.2.2",
|
||||||
|
|||||||
8
uv.lock
generated
8
uv.lock
generated
@@ -456,7 +456,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp"
|
name = "fastmcp"
|
||||||
version = "2.11.0"
|
version = "2.11.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
@@ -471,9 +471,9 @@ dependencies = [
|
|||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/02/0701624e938fe4d1f13464de9bdc27be9aba2e4c4d41edab3ea496d31751/fastmcp-2.11.0.tar.gz", hash = "sha256:af0c52988607d8e9197df300e91880169e8fe24fd6ca177dca6a9eb6b245ce3c", size = 2663877, upload_time = "2025-08-01T21:30:11.629Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/92/89/d100073d15cdfa5fa029107b44ef55916b04ed6010ff2b0f7bed92a35ed9/fastmcp-2.11.1.tar.gz", hash = "sha256:2b5af21b093d4926fef17a9a162d5729a2fcb46f3b195699762fa01f61ac3c60", size = 2672724, upload_time = "2025-08-04T15:39:29.623Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/9a/51108b68e77650a7289b5f1ceff8dc0929ab48a26d1d2015f22121a9d183/fastmcp-2.11.0-py3-none-any.whl", hash = "sha256:8709a04522e66fda407b469fbe4d3290651aa7b06097b91c097e9a973c9b9bb3", size = 256193, upload_time = "2025-08-01T21:30:09.905Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/9f/f3703867a8be93f2a139f6664fa7ff46c5c844e28998ce288f7b919ed197/fastmcp-2.11.1-py3-none-any.whl", hash = "sha256:9f0b6a3f61dcf6f688a0a24b8b507be24bfae051a00b7d590c01395d63da8c00", size = 256573, upload_time = "2025-08-04T15:39:27.594Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1859,7 +1859,7 @@ requires-dist = [
|
|||||||
{ name = "cachetools", specifier = ">=5.3.0" },
|
{ name = "cachetools", specifier = ">=5.3.0" },
|
||||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.12" },
|
{ name = "fastapi", specifier = ">=0.115.12" },
|
||||||
{ name = "fastmcp", specifier = ">=2.11.0" },
|
{ name = "fastmcp", specifier = "==2.11.1" },
|
||||||
{ name = "google-api-python-client", specifier = ">=2.168.0" },
|
{ name = "google-api-python-client", specifier = ">=2.168.0" },
|
||||||
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
|
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
|
||||||
{ name = "google-auth-oauthlib", specifier = ">=1.2.2" },
|
{ name = "google-auth-oauthlib", specifier = ">=1.2.2" },
|
||||||
|
|||||||
Reference in New Issue
Block a user