GeoGuessrMCP/src/geoguessr_mcp/middleware/auth.py
Claude 80ed791b01
Add multi-user support - each API key gets own GeoGuessr session
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
2025-11-29 22:30:55 +00:00

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