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>
This commit is contained in:
Chris Coutinho
2026-04-05 14:33:29 +02:00
co-authored by Claude Opus 4.6
parent 2a34015443
commit 91e7665f41
9 changed files with 439 additions and 178 deletions
+56 -25
View File
@@ -50,35 +50,57 @@ class ClientRegistry:
self._load_static_clients()
def _load_static_clients(self):
"""Load statically configured clients from environment."""
# Load from ALLOWED_MCP_CLIENTS environment variable
"""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:
# Parse comma-separated list
for client_id in allowed_clients.split(","):
client_id = client_id.strip()
if client_id:
# Create basic client info
# In production, would load full metadata from database
self._clients[client_id] = MCPClientInfo(
client_id=client_id,
name=self._get_client_name(client_id),
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
is_public=True,
)
logger.info(f"Registered static client: {client_id}")
# Load cloud clients (web-based, HTTPS redirect URIs)
# Format: "client_id|redirect_uri,client_id2|redirect_uri2"
cloud_clients = os.getenv("ALLOWED_MCP_CLOUD_CLIENTS", "").strip()
if cloud_clients:
for entry in cloud_clients.split(","):
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),
@@ -86,7 +108,16 @@ class ClientRegistry:
allowed_scopes=["*"],
is_public=True,
)
logger.info(f"Registered cloud client: {cid}")
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:
@@ -111,7 +142,7 @@ class ClientRegistry:
client_id="claude-desktop",
name="Claude Desktop",
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
allowed_scopes=["*"],
is_public=True,
metadata={"vendor": "Anthropic"},
),
@@ -119,7 +150,7 @@ class ClientRegistry:
client_id="test-mcp-client",
name="Test MCP Client",
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
allowed_scopes=["*"],
is_public=True,
metadata={"purpose": "testing"},
),
+2 -2
View File
@@ -1267,7 +1267,7 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
"error": "registration_not_supported",
"error_description": "The upstream identity provider does not support "
"dynamic client registration. Configure the client statically using "
"ALLOWED_MCP_CLIENTS or ALLOWED_MCP_CLOUD_CLIENTS.",
"ALLOWED_MCP_CLIENTS.",
},
status_code=400,
)
@@ -1283,7 +1283,7 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
if response.status_code not in (200, 201):
logger.error(
f"DCR proxy: Nextcloud registration failed: {response.status_code} {response.text}"
f"DCR proxy: Upstream registration failed: {response.status_code} {response.text}"
)
return JSONResponse(
response.json()