Both auth surfaces now fail-closed by default: - ALLOWED_MCP_CLIENTS: removed the silent `claude-desktop` and `test-mcp-client` fallbacks. Empty/unset env var leaves the registry empty so /oauth/authorize rejects every client_id. - ALLOWED_MGMT_CLIENT (new): comma-separated list of OIDC client_ids whose tokens are accepted by /api/management/*. Enforced in verify_token_for_management_api on both the cache-hit and cache-miss paths against the token's client_id claim. Unset/empty rejects all. Compose: set ALLOWED_MGMT_CLIENT=nextcloudMcpServerUIPublicClient on mcp-multi-user-basic so the existing Astrolabe integration test (test_astrolabe_chunk_context.py) still passes. env.sample documents both vars and notes they may be consolidated later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""
|
|
MCP Client Registry for ADR-004 Progressive Consent Architecture.
|
|
|
|
This module manages the registry of allowed MCP clients that can authenticate
|
|
via Flow 1. In production, this would integrate with Dynamic Client Registration
|
|
(DCR) or a database of pre-registered clients.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Dict, List, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class MCPClientInfo:
|
|
"""Information about a registered MCP client."""
|
|
|
|
client_id: str
|
|
name: str
|
|
redirect_uris: List[str]
|
|
allowed_scopes: List[str]
|
|
is_public: bool = True # Native clients are public (no client_secret)
|
|
metadata: Optional[Dict] = None
|
|
|
|
|
|
class ClientRegistry:
|
|
"""
|
|
Registry for MCP clients allowed to authenticate via Flow 1.
|
|
|
|
In production, this would:
|
|
1. Support Dynamic Client Registration (DCR) per RFC 7591
|
|
2. Integrate with IdP client registry
|
|
3. Store client metadata in database
|
|
4. Support client updates and revocation
|
|
|
|
Scope Policy:
|
|
All clients are registered with allowed_scopes=["*"] (wildcard).
|
|
The MCP server acts as an OAuth AS proxy — it validates client
|
|
identity and redirect URIs locally, but delegates scope enforcement
|
|
to the upstream IdP (Nextcloud or Keycloak).
|
|
"""
|
|
|
|
def __init__(self, allow_dynamic_registration: bool = False):
|
|
"""
|
|
Initialize the client registry.
|
|
|
|
Args:
|
|
allow_dynamic_registration: Whether to allow DCR for new clients
|
|
"""
|
|
self.allow_dynamic_registration = allow_dynamic_registration
|
|
self._clients: Dict[str, MCPClientInfo] = {}
|
|
self._load_static_clients()
|
|
|
|
def _load_static_clients(self):
|
|
"""Load statically configured clients from environment.
|
|
|
|
Format: comma-separated entries, each either:
|
|
- Simple client ID: gets localhost redirect URIs
|
|
- client_id|redirect_uri: gets the specified redirect URI
|
|
|
|
Redirect URI rules:
|
|
- http://localhost:* and http://127.0.0.1:* are allowed (native clients)
|
|
- https:// redirect URIs are allowed (cloud clients)
|
|
- http:// non-localhost redirect URIs are rejected with a warning
|
|
|
|
If the env var is unset or empty, the registry remains empty and
|
|
``validate_client`` rejects every client_id (fail-closed). There are no
|
|
built-in defaults; operators must opt-in clients explicitly.
|
|
"""
|
|
# NOTE: ALLOWED_MCP_CLIENTS and ALLOWED_MGMT_CLIENT are currently separate
|
|
# env vars to keep the MCP-route and management-API auth surfaces
|
|
# independent. These may be consolidated into a single env var later
|
|
# once the deployment story stabilises.
|
|
allowed_clients = os.getenv("ALLOWED_MCP_CLIENTS", "").strip()
|
|
|
|
if allowed_clients:
|
|
for entry in allowed_clients.split(","):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
|
|
if "|" in entry:
|
|
cid, redirect = entry.split("|", 1)
|
|
cid, redirect = cid.strip(), redirect.strip()
|
|
|
|
if not cid or not redirect:
|
|
logger.warning(
|
|
f"Skipping malformed ALLOWED_MCP_CLIENTS entry: {entry!r}"
|
|
)
|
|
continue
|
|
|
|
parsed = urlparse(redirect)
|
|
hostname = parsed.hostname
|
|
if hostname is None:
|
|
logger.warning(
|
|
f"Skipping client {cid!r}: cannot parse hostname "
|
|
f"from {redirect!r}"
|
|
)
|
|
continue
|
|
is_loopback = hostname in ("localhost", "127.0.0.1", "::1")
|
|
|
|
if not (
|
|
parsed.scheme == "https"
|
|
or (parsed.scheme == "http" and is_loopback)
|
|
):
|
|
logger.warning(
|
|
f"Rejecting client {cid!r}: HTTP redirect URIs are only "
|
|
f"allowed for localhost, got {redirect!r}"
|
|
)
|
|
continue
|
|
|
|
self._clients[cid] = MCPClientInfo(
|
|
client_id=cid,
|
|
name=self._get_client_name(cid),
|
|
redirect_uris=[redirect],
|
|
allowed_scopes=["*"],
|
|
is_public=True,
|
|
)
|
|
logger.info(f"Registered static client: {cid}")
|
|
else:
|
|
self._clients[entry] = MCPClientInfo(
|
|
client_id=entry,
|
|
name=self._get_client_name(entry),
|
|
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
|
allowed_scopes=["*"],
|
|
is_public=True,
|
|
)
|
|
logger.info(f"Registered static client: {entry}")
|
|
|
|
if not self._clients:
|
|
logger.warning(
|
|
"Client registry is empty: ALLOWED_MCP_CLIENTS is unset or empty. "
|
|
"All MCP-flow OAuth requests will be rejected until configured."
|
|
)
|
|
|
|
def _get_client_name(self, client_id: str) -> str:
|
|
"""Get human-readable name for client_id."""
|
|
known_names = {
|
|
"claude-desktop": "Claude Desktop",
|
|
"claude-ai": "Claude AI",
|
|
"continue-dev": "Continue IDE Extension",
|
|
"zed-editor": "Zed Editor",
|
|
"vscode-mcp": "VS Code MCP Extension",
|
|
}
|
|
return known_names.get(client_id, client_id.replace("-", " ").title())
|
|
|
|
def validate_client(
|
|
self,
|
|
client_id: str,
|
|
redirect_uri: Optional[str] = None,
|
|
scopes: Optional[List[str]] = None,
|
|
) -> tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate a client_id and optionally its redirect_uri and scopes.
|
|
|
|
Args:
|
|
client_id: The client identifier to validate
|
|
redirect_uri: Optional redirect URI to validate
|
|
scopes: Optional list of scopes to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
# Check if client exists
|
|
client = self._clients.get(client_id)
|
|
if not client:
|
|
if self.allow_dynamic_registration:
|
|
# In production, would attempt DCR here
|
|
logger.info(f"Unknown client {client_id}, would attempt DCR")
|
|
return True, None
|
|
else:
|
|
return False, f"Unknown client_id: {client_id}"
|
|
|
|
# Validate redirect_uri if provided
|
|
if redirect_uri:
|
|
if not self._validate_redirect_uri(client, redirect_uri):
|
|
return False, f"Invalid redirect_uri for client {client_id}"
|
|
|
|
# Validate scopes if provided (wildcard "*" allows all scopes)
|
|
if scopes and "*" not in client.allowed_scopes:
|
|
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
|
if invalid_scopes:
|
|
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
|
|
|
|
return True, None
|
|
|
|
def _validate_redirect_uri(self, client: MCPClientInfo, redirect_uri: str) -> bool:
|
|
"""
|
|
Validate redirect_uri against client's registered URIs.
|
|
|
|
Args:
|
|
client: The client info
|
|
redirect_uri: The URI to validate
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
"""
|
|
# Parse the redirect URI
|
|
parsed = urlparse(redirect_uri)
|
|
if not parsed.hostname:
|
|
return False
|
|
|
|
# Check against registered patterns
|
|
for pattern in client.redirect_uris:
|
|
if "*" in pattern:
|
|
# Handle wildcard port (localhost:*)
|
|
pattern_base = pattern.replace(":*", "")
|
|
if redirect_uri.startswith(pattern_base + ":"):
|
|
# Validate it's localhost with a port
|
|
if parsed.hostname in ("localhost", "127.0.0.1", "::1"):
|
|
return True
|
|
elif redirect_uri == pattern:
|
|
return True
|
|
|
|
return False
|
|
|
|
def register_client(self, client_info: MCPClientInfo) -> bool:
|
|
"""
|
|
Register a new MCP client (DCR support).
|
|
|
|
Args:
|
|
client_info: Client information to register
|
|
|
|
Returns:
|
|
True if registered successfully
|
|
"""
|
|
if not self.allow_dynamic_registration:
|
|
logger.warning(f"DCR disabled, cannot register {client_info.client_id}")
|
|
return False
|
|
|
|
if client_info.client_id in self._clients:
|
|
logger.warning(f"Client {client_info.client_id} already registered")
|
|
return False
|
|
|
|
self._clients[client_info.client_id] = client_info
|
|
logger.info(f"Dynamically registered client: {client_info.client_id}")
|
|
|
|
# In production, would persist to database
|
|
return True
|
|
|
|
def register_proxy_client(
|
|
self, client_id: str, redirect_uris: list[str], name: str = ""
|
|
) -> None:
|
|
"""Register a client discovered via DCR proxy.
|
|
|
|
When the MCP server acts as an OAuth AS proxy, clients register via
|
|
the proxy's /oauth/register endpoint. This method stores the client
|
|
locally so /oauth/authorize can validate it.
|
|
|
|
Args:
|
|
client_id: Client identifier from Nextcloud DCR response
|
|
redirect_uris: Allowed redirect URIs
|
|
name: Optional human-readable name
|
|
"""
|
|
self._clients[client_id] = MCPClientInfo(
|
|
client_id=client_id,
|
|
name=name or f"DCR-{client_id[:8]}",
|
|
redirect_uris=redirect_uris or ["http://localhost:*", "http://127.0.0.1:*"],
|
|
allowed_scopes=["*"], # Nextcloud enforces actual scopes
|
|
is_public=True,
|
|
)
|
|
logger.info(f"Registered proxy client: {client_id}")
|
|
|
|
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
|
"""
|
|
Get client information.
|
|
|
|
Args:
|
|
client_id: The client identifier
|
|
|
|
Returns:
|
|
Client info if found, None otherwise
|
|
"""
|
|
return self._clients.get(client_id)
|
|
|
|
def list_clients(self) -> List[MCPClientInfo]:
|
|
"""
|
|
List all registered clients.
|
|
|
|
Returns:
|
|
List of client information
|
|
"""
|
|
return list(self._clients.values())
|
|
|
|
|
|
# Global registry instance
|
|
_registry: Optional[ClientRegistry] = None
|
|
|
|
|
|
def get_client_registry() -> ClientRegistry:
|
|
"""Get the global client registry instance."""
|
|
global _registry
|
|
if _registry is None:
|
|
# Check if DCR is enabled
|
|
allow_dcr = os.getenv("ENABLE_DCR", "false").lower() == "true"
|
|
_registry = ClientRegistry(allow_dynamic_registration=allow_dcr)
|
|
return _registry
|