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
192 lines
5.7 KiB
Python
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()
|