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
This commit is contained in:
parent
07b1cb84b2
commit
80ed791b01
8 changed files with 551 additions and 93 deletions
192
src/geoguessr_mcp/auth/multi_user_session.py
Normal file
192
src/geoguessr_mcp/auth/multi_user_session.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue