GeoGuessrMCP/src/geoguessr_mcp/auth/multi_user_session.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

192 lines
5.7 KiB
Python

"""
Multi-user session management.
This module provides session management for multiple users,
where each API key can have its own GeoGuessr session.
"""
import asyncio
import logging
from typing import Optional
from ..config import settings
from .session import SessionManager, UserSession
from .user_context import UserContext
logger = logging.getLogger(__name__)
class MultiUserSessionManager:
"""
Manages GeoGuessr sessions for multiple users.
Each API key can have its own GeoGuessr session, allowing
multiple users to access their own accounts through the same
MCP server instance.
"""
def __init__(self):
"""Initialize the multi-user session manager."""
# Map API keys to their session managers
self._user_managers: dict[str, SessionManager] = {}
self._lock = asyncio.Lock()
# Create default session manager if default cookie is configured
if settings.DEFAULT_NCFA_COOKIE:
logger.info("Default GeoGuessr cookie configured - will be used as fallback")
async def get_user_context(self, api_key: str) -> UserContext:
"""
Get or create a user context for an API key.
Args:
api_key: The API key identifying the user
Returns:
UserContext: The context for this user
"""
async with self._lock:
# Get or create session manager for this user
if api_key not in self._user_managers:
# Create new session manager with default cookie as fallback
self._user_managers[api_key] = SessionManager(
default_cookie=settings.DEFAULT_NCFA_COOKIE
)
logger.info(f"Created new session manager for API key {api_key[:8]}...")
manager = self._user_managers[api_key]
# Get the session (may return default session if no user login)
session = await manager.get_session()
# Create user context
context = UserContext(api_key=api_key, session=session)
return context
async def login_user(
self, api_key: str, email: str, password: str
) -> tuple[str, UserSession]:
"""
Login a specific user (API key) to their GeoGuessr account.
Args:
api_key: The API key identifying the user
email: GeoGuessr email
password: GeoGuessr password
Returns:
tuple[str, UserSession]: (session_token, UserSession)
Raises:
ValueError: If login fails
"""
async with self._lock:
if api_key not in self._user_managers:
self._user_managers[api_key] = SessionManager()
manager = self._user_managers[api_key]
# Perform login
session_token, session = await manager.login(email, password)
logger.info(
f"User {session.username} logged in successfully for API key {api_key[:8]}..."
)
return session_token, session
async def logout_user(self, api_key: str, session_token: str) -> bool:
"""
Logout a specific user's session.
Args:
api_key: The API key identifying the user
session_token: The session token to logout
Returns:
bool: True if logout successful, False otherwise
"""
async with self._lock:
if api_key not in self._user_managers:
return False
manager = self._user_managers[api_key]
success = await manager.logout(session_token)
if success:
logger.info(f"User logged out for API key {api_key[:8]}...")
return success
async def set_user_cookie(self, api_key: str, cookie: str) -> bool:
"""
Set a GeoGuessr cookie for a specific user.
Args:
api_key: The API key identifying the user
cookie: The NCFA cookie value
Returns:
bool: True if cookie is valid, False otherwise
"""
# Validate cookie first
profile = await SessionManager.validate_cookie(cookie)
if not profile:
return False
async with self._lock:
if api_key not in self._user_managers:
self._user_managers[api_key] = SessionManager()
manager = self._user_managers[api_key]
await manager.set_default_cookie(cookie)
logger.info(
f"Cookie set for user {profile.get('nick', 'unknown')} (API key {api_key[:8]}...)"
)
return True
async def get_session_for_api_key(self, api_key: str) -> Optional[UserSession]:
"""
Get the active session for a specific API key.
Args:
api_key: The API key identifying the user
Returns:
UserSession if available, None otherwise
"""
async with self._lock:
if api_key not in self._user_managers:
return None
manager = self._user_managers[api_key]
return await manager.get_session()
async def get_auth_status(self, api_key: str) -> dict:
"""
Get authentication status for a specific user.
Args:
api_key: The API key identifying the user
Returns:
dict: Authentication status information
"""
context = await self.get_user_context(api_key)
return {
"authenticated": context.is_authenticated,
"user_id": context.user_id if context.is_authenticated else None,
"username": context.username if context.is_authenticated else None,
"api_key": f"{api_key[:8]}..." if len(api_key) > 8 else "***",
}
# Global multi-user session manager instance
multi_user_session_manager = MultiUserSessionManager()