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:
co-authored by
Claude Opus 4.6
parent
2a34015443
commit
91e7665f41
@@ -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"},
|
||||
),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user