Implements comprehensive multi-user support allowing multiple users to access the same MCP server instance with their own independent GeoGuessr accounts. Each API key now has its own session storage and context. ## Multi-User Architecture ### New Components **User Context System** (src/geoguessr_mcp/auth/user_context.py): - UserContext dataclass tracks API key and associated GeoGuessr session - Properties for user_id, username, ncfa_cookie, is_authenticated - Automatically attached to each request **Multi-User Session Manager** (src/geoguessr_mcp/auth/multi_user_session.py): - MultiUserSessionManager manages separate SessionManager per API key - Maps API keys to their own GeoGuessr sessions - Methods: get_user_context, login_user, logout_user, set_user_cookie - Global instance: multi_user_session_manager **Request Context** (src/geoguessr_mcp/auth/request_context.py): - ContextVar for accessing current user context in tools - Functions: get_current_user_context, require_user_context, set_current_user_context - Enables tools to access user-specific sessions automatically ### Updated Components **Authentication Middleware** (src/geoguessr_mcp/middleware/auth.py): - Now creates user context for each authenticated request - Attaches context to both request.state and ContextVar - Supports both authenticated and unauthenticated modes - Default user context created when auth is disabled **Authentication Tools** (src/geoguessr_mcp/tools/auth_tools.py): - Completely rewritten for multi-user support - login(): Creates session tied to caller's API key - logout(): Logs out only the calling user's session - set_ncfa_cookie(): Sets cookie for calling user only - get_auth_status(): Returns calling user's auth status - All tools use get_current_user_context() automatically **GeoGuessr Client** (src/geoguessr_mcp/api/geoguessr_client.py): - _get_authenticated_client() checks user context first - Falls back to session_manager for backward compatibility - Automatically uses caller's session when available - No changes needed in services (profile, game, analysis) ## How It Works 1. User connects with API key in Authorization header 2. Middleware validates API key and creates/retrieves UserContext 3. UserContext attached to request.state and ContextVar 4. Tools call get_current_user_context() to access caller's session 5. Client automatically uses correct session for API calls 6. Each user's session is completely isolated ## Usage Example ```bash # Configure multiple API keys MCP_AUTH_ENABLED=true MCP_API_KEYS=alice_key,bob_key,charlie_key # Alice connects with: Authorization: Bearer alice_key # Bob connects with: Authorization: Bearer bob_key # Each can login to their own GeoGuessr account # Sessions are completely independent ``` ## Key Features - **Zero Interference**: Users don't affect each other's sessions - **Automatic Routing**: Requests automatically use correct user's session - **Hot Reload**: Add new API keys and restart in ~2-3 seconds - **Backward Compatible**: Still works with single-user mode - **Fallback Support**: GEOGUESSR_NCFA_COOKIE still works as default ## Documentation Updates - README.md: Added Multi-User Mode section with examples - README.md: Updated authentication section with multi-user details - README.md: Added "Adding New Users" workflow - Key Features section now highlights multi-user support ## Technical Details - Uses Python ContextVar for request-scoped user context - Each API key gets its own SessionManager instance - Session storage is in-memory (persists across requests, not restarts) - Default cookie (GEOGUESSR_NCFA_COOKIE) used as fallback for all users - Fully async/await compatible throughout
112 lines
4.1 KiB
Python
112 lines
4.1 KiB
Python
"""
|
|
Authentication middleware for MCP server.
|
|
|
|
Provides Bearer token authentication for HTTP-based MCP transports
|
|
and attaches user context for multi-user support.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse, Response
|
|
|
|
from ..auth import multi_user_session_manager, set_current_user_context
|
|
from ..config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|
"""
|
|
Middleware to validate API keys via Bearer token authentication.
|
|
|
|
When MCP_AUTH_ENABLED is true, this middleware checks for a valid
|
|
Authorization header with a Bearer token matching one of the configured API keys.
|
|
"""
|
|
|
|
def __init__(self, app, valid_api_keys: Optional[set[str]] = None):
|
|
super().__init__(app)
|
|
self.valid_api_keys = valid_api_keys or settings.get_api_keys()
|
|
self.enabled = settings.MCP_AUTH_ENABLED
|
|
|
|
if self.enabled:
|
|
if not self.valid_api_keys:
|
|
logger.warning("Authentication is enabled but no API keys are configured!")
|
|
else:
|
|
logger.info(f"Authentication enabled with {len(self.valid_api_keys)} API key(s)")
|
|
else:
|
|
logger.info("Authentication is disabled")
|
|
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
"""Process the request and validate authentication if enabled."""
|
|
|
|
# If authentication is disabled, create a default user context
|
|
if not self.enabled:
|
|
# Use a default API key for all unauthenticated requests
|
|
user_context = await multi_user_session_manager.get_user_context("default")
|
|
request.state.user_context = user_context
|
|
set_current_user_context(user_context)
|
|
return await call_next(request)
|
|
|
|
# Skip authentication for health check endpoint
|
|
if request.url.path == "/health":
|
|
return await call_next(request)
|
|
|
|
# Check for Authorization header
|
|
auth_header = request.headers.get("Authorization")
|
|
|
|
if not auth_header:
|
|
logger.warning(f"Missing Authorization header from {request.client.host}")
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "Unauthorized",
|
|
"message": "Missing Authorization header. Use 'Authorization: Bearer YOUR_API_KEY'"
|
|
},
|
|
headers={"WWW-Authenticate": "Bearer"}
|
|
)
|
|
|
|
# Parse Bearer token
|
|
parts = auth_header.split()
|
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
logger.warning(f"Invalid Authorization header format from {request.client.host}")
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={
|
|
"error": "Unauthorized",
|
|
"message": "Invalid Authorization header format. Use 'Authorization: Bearer YOUR_API_KEY'"
|
|
},
|
|
headers={"WWW-Authenticate": "Bearer"}
|
|
)
|
|
|
|
token = parts[1]
|
|
|
|
# Validate token against configured API keys
|
|
if token not in self.valid_api_keys:
|
|
logger.warning(f"Invalid API key attempt from {request.client.host}")
|
|
return JSONResponse(
|
|
status_code=403,
|
|
content={
|
|
"error": "Forbidden",
|
|
"message": "Invalid API key"
|
|
},
|
|
headers={"WWW-Authenticate": "Bearer"}
|
|
)
|
|
|
|
# Authentication successful
|
|
logger.debug(f"Authenticated request from {request.client.host} with API key {token[:8]}...")
|
|
|
|
# Get or create user context for this API key
|
|
user_context = await multi_user_session_manager.get_user_context(token)
|
|
|
|
# Attach user context to request state and context variable
|
|
request.state.user_context = user_context
|
|
set_current_user_context(user_context)
|
|
|
|
logger.debug(f"Request context: {user_context}")
|
|
|
|
# Proceed with the request
|
|
response = await call_next(request)
|
|
return response
|