Files
mcphub/core/oauth/client_registry.py
airano fdb050454e feat: OAuth user fixes + dashboard UI improvements (v3.2.0)
- Bug A: Fix OAuth consent redirect loop (return_url path-relative)
- Bug B: Accept OAuth JWT tokens on /u/{user_id}/{alias}/mcp
- Bug C: Add user OAuth client CRUD (list/create/delete) + new page
- D-1: Sidebar border via Tailwind class, version footer in sidebar
- D-2: Remove broken setInterval targeting #stats-container
- D-3: 404.html purple primary colors + system dark mode support
- D-4: Fix invisible buttons (bg-gray-200 + text-white in light mode)
- D-5: Pin Alpine.js to 3.14.8, remove duplicate CSRF meta tag
- D-6: Fix invisible lang toggle button in settings (dark mode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:27:59 +03:30

164 lines
4.9 KiB
Python

"""
OAuth 2.1 Client Registry
Manages OAuth client registration and validation
"""
import hashlib
import json
import logging
import secrets
from pathlib import Path
from .schemas import OAuthClient
logger = logging.getLogger(__name__)
# Default data directory: /app/data in Docker, ./data elsewhere
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
class ClientRegistry:
"""
OAuth Client Registry with JSON storage.
Storage: data/oauth_clients.json
"""
def __init__(self, data_dir: str | None = None):
data_dir = data_dir or _DEFAULT_DATA_DIR
self.data_dir = Path(data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
self.clients_file = self.data_dir / "oauth_clients.json"
# Initialize if not exists
if not self.clients_file.exists():
self._write_clients({})
# Load clients into memory (small dataset)
self.clients: dict[str, OAuthClient] = self._load_clients()
def _read_clients(self) -> dict:
"""Read clients JSON file"""
try:
with open(self.clients_file) as f:
return json.load(f)
except Exception as e:
logger.error(f"Error reading clients file: {e}")
return {}
def _write_clients(self, data: dict) -> bool:
"""Write clients JSON file"""
try:
with open(self.clients_file, "w") as f:
json.dump(data, f, indent=2, default=str)
return True
except Exception as e:
logger.error(f"Error writing clients file: {e}")
return False
def _load_clients(self) -> dict[str, OAuthClient]:
"""Load clients from file"""
data = self._read_clients()
return {client_id: OAuthClient(**client_data) for client_id, client_data in data.items()}
def _save_clients(self) -> bool:
"""Save clients to file"""
data = {
client_id: client.model_dump(mode="json") for client_id, client in self.clients.items()
}
return self._write_clients(data)
def create_client(
self,
client_name: str,
redirect_uris: list[str],
grant_types: list[str] | None = None,
allowed_scopes: list[str] | None = None,
metadata: dict | None = None,
owner_user_id: str | None = None,
) -> tuple[str, str]:
"""
Create new OAuth client.
Returns:
(client_id, client_secret) tuple
"""
# Generate client_id and client_secret
client_id = f"cmp_client_{secrets.token_urlsafe(16)}"
client_secret = secrets.token_urlsafe(32)
# Hash client secret
client_secret_hash = hashlib.sha256(client_secret.encode()).hexdigest()
# Create client
client = OAuthClient(
client_id=client_id,
client_secret_hash=client_secret_hash,
client_name=client_name,
redirect_uris=redirect_uris,
grant_types=grant_types or ["authorization_code", "refresh_token"],
allowed_scopes=allowed_scopes or ["read", "write"],
metadata=metadata or {},
owner_user_id=owner_user_id,
)
# Save
self.clients[client_id] = client
self._save_clients()
logger.info(f"Created OAuth client: {client_id} ({client_name})")
# Return client_secret in plain text (only time it's visible)
return client_id, client_secret
def get_client(self, client_id: str) -> OAuthClient | None:
"""Get client by ID"""
return self.clients.get(client_id)
def list_clients(self) -> list[OAuthClient]:
"""List all clients"""
return list(self.clients.values())
def validate_client_secret(self, client_id: str, client_secret: str) -> bool:
"""
Validate client secret.
Args:
client_id: Client ID
client_secret: Plain text client secret
Returns:
True if valid, False otherwise
"""
client = self.get_client(client_id)
if not client:
return False
# Hash provided secret
secret_hash = hashlib.sha256(client_secret.encode()).hexdigest()
# Constant-time comparison
return secrets.compare_digest(secret_hash, client.client_secret_hash)
def delete_client(self, client_id: str) -> bool:
"""Delete OAuth client"""
if client_id in self.clients:
del self.clients[client_id]
self._save_clients()
logger.info(f"Deleted OAuth client: {client_id}")
return True
return False
# Singleton instance
_client_registry: ClientRegistry | None = None
def get_client_registry() -> ClientRegistry:
"""Get singleton ClientRegistry instance"""
global _client_registry
if _client_registry is None:
_client_registry = ClientRegistry()
return _client_registry