Files
mcp-nextcloud/nextcloud_mcp_server/auth/client_registry.py
T
Chris CoutinhoandClaude Opus 4.6 91e7665f41 refactor: consolidate ALLOWED_MCP_CLIENTS and add redirect URI validation
Merge ALLOWED_MCP_CLOUD_CLIENTS into a single ALLOWED_MCP_CLIENTS env var
that supports both simple client IDs and pipe-separated client_id|redirect_uri
entries. Enforce HTTPS for non-localhost redirect URIs, warn on malformed
entries, and use wildcard scopes for all static clients (upstream IdP enforces
actual scopes). Add deprecation warning for the old env var.

Also fixes DCR proxy error messages to reference only ALLOWED_MCP_CLIENTS and
use "Upstream" instead of "Nextcloud" for IdP-agnostic language. Enables
Login Flow v2 + DCR on the mcp-keycloak docker-compose service.

Adds 17 unit tests for ClientRegistry parsing/validation and 7 keycloak
integration tests for DCR lifecycle, AS metadata, and client authorization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:33:29 +02:00

312 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
"""
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
"""
# Deprecation warning for old env var
if os.getenv("ALLOWED_MCP_CLOUD_CLIENTS"):
logger.warning(
"ALLOWED_MCP_CLOUD_CLIENTS is deprecated. "
"Merge entries into ALLOWED_MCP_CLIENTS using the format: "
"client_id|https://redirect-uri"
)
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)
is_loopback = parsed.hostname in ("localhost", "127.0.0.1")
if parsed.scheme == "https":
pass # HTTPS always allowed
elif parsed.scheme == "http" and is_loopback:
pass # HTTP localhost allowed
else:
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}")
# Add well-known clients if not explicitly configured
if not self._clients:
self._add_well_known_clients()
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",
"test-mcp-client": "Test MCP Client",
}
return known_names.get(client_id, client_id.replace("-", " ").title())
def _add_well_known_clients(self):
"""Add well-known MCP clients for testing and development."""
well_known = [
MCPClientInfo(
client_id="claude-desktop",
name="Claude Desktop",
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["*"],
is_public=True,
metadata={"vendor": "Anthropic"},
),
MCPClientInfo(
client_id="test-mcp-client",
name="Test MCP Client",
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["*"],
is_public=True,
metadata={"purpose": "testing"},
),
]
for client in well_known:
self._clients[client.client_id] = client
logger.info(f"Registered well-known client: {client.client_id}")
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)
# 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"]:
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