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
View File
@@ -0,0 +1,200 @@
"""
Integration tests for DCR and static OIDC clients against the mcp-keycloak service.
The mcp-keycloak service uses Keycloak as an external IdP. Keycloak supports
Dynamic Client Registration (RFC 7591/7592), so the DCR proxy forwards
registrations to Keycloak and registers the resulting client locally.
Static clients configured via ALLOWED_MCP_CLIENTS should work for the
authorization flow without DCR.
Requires: docker compose --profile keycloak up --build -d
"""
import base64
import hashlib
import logging
import secrets
import httpx
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.keycloak]
MCP_KEYCLOAK_BASE_URL = "http://localhost:8002"
KEYCLOAK_BASE_URL = "http://localhost:8888"
KEYCLOAK_REALM = "nextcloud-mcp"
@pytest.fixture(scope="module")
async def keycloak_mcp_available():
"""Check that the mcp-keycloak service is reachable and return AS metadata."""
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
f"{MCP_KEYCLOAK_BASE_URL}/.well-known/oauth-authorization-server"
)
resp.raise_for_status()
return resp.json()
except (httpx.ConnectError, httpx.HTTPStatusError) as e:
pytest.skip(f"mcp-keycloak service not available: {e}")
@pytest.fixture()
async def dcr_client(keycloak_mcp_available):
"""Register a client via DCR proxy and clean up after test."""
async with httpx.AsyncClient(timeout=30.0) as http:
# Register via MCP proxy
resp = await http.post(
f"{MCP_KEYCLOAK_BASE_URL}/oauth/register",
json={
"client_name": "test-dcr-keycloak",
"redirect_uris": ["http://localhost:9999/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic",
"scope": "openid profile email",
},
)
resp.raise_for_status()
data = resp.json()
yield data
# Cleanup: delete via Keycloak directly (proxy URI uses internal hostname)
client_id = data.get("client_id")
rat = data.get("registration_access_token")
if client_id and rat:
async with httpx.AsyncClient(timeout=10.0) as http:
await http.delete(
f"{KEYCLOAK_BASE_URL}/realms/{KEYCLOAK_REALM}"
f"/clients-registrations/openid-connect/{client_id}",
headers={"Authorization": f"Bearer {rat}"},
)
# --- DCR tests ---
async def test_dcr_proxy_registers_client(dcr_client):
"""DCR proxy should forward registration to Keycloak and return
RFC 7591 response with client credentials."""
assert "client_id" in dcr_client
assert "client_secret" in dcr_client
assert dcr_client["client_name"] == "test-dcr-keycloak"
async def test_dcr_proxy_returns_rfc7592_fields(dcr_client):
"""DCR response should include RFC 7592 management fields for
client lifecycle management."""
assert "registration_access_token" in dcr_client
assert dcr_client["registration_access_token"]
assert "registration_client_uri" in dcr_client
assert dcr_client["registration_client_uri"]
async def test_dcr_client_accepted_by_authorize(keycloak_mcp_available, dcr_client):
"""A DCR-registered client should be accepted by the authorization endpoint
(redirects to IdP login rather than returning an error)."""
# Generate PKCE challenge (required by the server)
verifier = secrets.token_urlsafe(64)
challenge = hashlib.sha256(verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as http:
resp = await http.get(
f"{MCP_KEYCLOAK_BASE_URL}/oauth/authorize",
params={
"response_type": "code",
"client_id": dcr_client["client_id"],
"redirect_uri": "http://localhost:9999/callback",
"state": "test-state",
"scope": "openid",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
)
# Should redirect to IdP (302) not error (400)
assert resp.status_code == 302, (
f"Expected redirect to IdP, got {resp.status_code}: {resp.text}"
)
async def test_dcr_client_deletion_via_keycloak(keycloak_mcp_available):
"""Full DCR lifecycle: register via proxy, verify, delete via RFC 7592."""
async with httpx.AsyncClient(timeout=30.0) as http:
# Register
resp = await http.post(
f"{MCP_KEYCLOAK_BASE_URL}/oauth/register",
json={
"client_name": "test-dcr-lifecycle",
"redirect_uris": ["http://localhost:9999/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
},
)
assert resp.status_code in (200, 201)
data = resp.json()
client_id = data["client_id"]
rat = data["registration_access_token"]
# Delete via Keycloak (external URL)
del_resp = await http.delete(
f"{KEYCLOAK_BASE_URL}/realms/{KEYCLOAK_REALM}"
f"/clients-registrations/openid-connect/{client_id}",
headers={"Authorization": f"Bearer {rat}"},
)
assert del_resp.status_code == 204
# --- AS metadata tests ---
async def test_as_metadata_advertises_registration_endpoint(keycloak_mcp_available):
"""AS metadata should advertise /oauth/register for DCR discovery."""
metadata = keycloak_mcp_available
assert "registration_endpoint" in metadata
assert metadata["registration_endpoint"].endswith("/oauth/register")
async def test_as_metadata_has_required_fields(keycloak_mcp_available):
"""Verify the AS metadata contains all RFC 8414 required fields."""
metadata = keycloak_mcp_available
required_fields = [
"issuer",
"authorization_endpoint",
"token_endpoint",
"response_types_supported",
"grant_types_supported",
"code_challenge_methods_supported",
]
for field in required_fields:
assert field in metadata, f"Missing required field: {field}"
# --- Static client / unknown client tests ---
async def test_authorize_rejects_unknown_client(keycloak_mcp_available):
"""Authorization endpoint should reject client_ids that are neither
statically configured nor dynamically registered."""
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as http:
resp = await http.get(
f"{MCP_KEYCLOAK_BASE_URL}/oauth/authorize",
params={
"response_type": "code",
"client_id": "nonexistent-client-id",
"redirect_uri": "http://localhost:9999/callback",
"state": "test-state",
"scope": "openid",
},
)
# Should return an error (400 or redirect with error)
assert resp.status_code in (400, 302)
if resp.status_code == 400:
data = resp.json()
assert "error" in data
+177
View File
@@ -0,0 +1,177 @@
"""Unit tests for ClientRegistry ALLOWED_MCP_CLIENTS parsing and validation."""
import logging
import pytest
import nextcloud_mcp_server.auth.client_registry as registry_mod
pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def _reset_registry():
"""Reset the singleton registry before each test."""
registry_mod._registry = None
yield
registry_mod._registry = None
def _get_registry(monkeypatch, value: str | None = None):
"""Helper to create a registry with the given ALLOWED_MCP_CLIENTS value."""
if value is not None:
monkeypatch.setenv("ALLOWED_MCP_CLIENTS", value)
else:
monkeypatch.delenv("ALLOWED_MCP_CLIENTS", raising=False)
monkeypatch.delenv("ALLOWED_MCP_CLOUD_CLIENTS", raising=False)
return registry_mod.get_client_registry()
def test_simple_client_ids(monkeypatch):
registry = _get_registry(monkeypatch, "claude-desktop, zed-editor")
clients = registry.list_clients()
assert len(clients) == 2
claude = registry.get_client("claude-desktop")
assert claude is not None
assert claude.redirect_uris == ["http://localhost:*", "http://127.0.0.1:*"]
assert claude.allowed_scopes == ["*"]
zed = registry.get_client("zed-editor")
assert zed is not None
assert zed.redirect_uris == ["http://localhost:*", "http://127.0.0.1:*"]
def test_pipe_separated_https(monkeypatch):
registry = _get_registry(monkeypatch, "myapp|https://app.example.com/callback")
client = registry.get_client("myapp")
assert client is not None
assert client.redirect_uris == ["https://app.example.com/callback"]
assert client.allowed_scopes == ["*"]
def test_pipe_separated_localhost(monkeypatch):
registry = _get_registry(monkeypatch, "dev-tool|http://localhost:3000/cb")
client = registry.get_client("dev-tool")
assert client is not None
assert client.redirect_uris == ["http://localhost:3000/cb"]
def test_pipe_separated_loopback_ip(monkeypatch):
registry = _get_registry(monkeypatch, "dev|http://127.0.0.1:9090/cb")
client = registry.get_client("dev")
assert client is not None
assert client.redirect_uris == ["http://127.0.0.1:9090/cb"]
def test_mixed_entries(monkeypatch):
registry = _get_registry(
monkeypatch, "claude-desktop, cloud-app|https://cloud.example.com/cb"
)
clients = registry.list_clients()
assert len(clients) == 2
claude = registry.get_client("claude-desktop")
assert claude is not None
assert claude.redirect_uris == ["http://localhost:*", "http://127.0.0.1:*"]
cloud = registry.get_client("cloud-app")
assert cloud is not None
assert cloud.redirect_uris == ["https://cloud.example.com/cb"]
def test_http_non_localhost_rejected(monkeypatch, caplog):
with caplog.at_level(logging.WARNING):
registry = _get_registry(monkeypatch, "bad-client|http://evil.com/cb")
assert registry.get_client("bad-client") is None
assert "Rejecting client" in caplog.text
assert "evil.com" in caplog.text
def test_empty_string_uses_well_known(monkeypatch):
registry = _get_registry(monkeypatch, "")
clients = registry.list_clients()
client_ids = {c.client_id for c in clients}
assert "claude-desktop" in client_ids
assert "test-mcp-client" in client_ids
def test_unset_env_uses_well_known(monkeypatch):
registry = _get_registry(monkeypatch, None)
clients = registry.list_clients()
client_ids = {c.client_id for c in clients}
assert "claude-desktop" in client_ids
assert "test-mcp-client" in client_ids
def test_malformed_entries_skipped_with_warning(monkeypatch, caplog):
with caplog.at_level(logging.WARNING):
registry = _get_registry(monkeypatch, "good, |, , bad|")
# Only "good" should be registered
assert registry.get_client("good") is not None
assert len(registry.list_clients()) == 1
assert "malformed" in caplog.text.lower()
def test_all_scopes_wildcard(monkeypatch):
registry = _get_registry(monkeypatch, "test-client")
client = registry.get_client("test-client")
assert client is not None
assert client.allowed_scopes == ["*"]
def test_validate_client_wildcard_scopes(monkeypatch):
registry = _get_registry(monkeypatch, "test-client")
valid, err = registry.validate_client(
"test-client", scopes=["anything", "goes", "here"]
)
assert valid is True
assert err is None
def test_validate_redirect_uri_https_match(monkeypatch):
registry = _get_registry(monkeypatch, "cloud|https://x.com/cb")
valid, err = registry.validate_client("cloud", redirect_uri="https://x.com/cb")
assert valid is True
assert err is None
def test_validate_redirect_uri_https_mismatch(monkeypatch):
registry = _get_registry(monkeypatch, "cloud|https://x.com/cb")
valid, err = registry.validate_client("cloud", redirect_uri="https://other.com/cb")
assert valid is False
assert "redirect_uri" in err.lower()
def test_validate_redirect_uri_localhost_wildcard(monkeypatch):
registry = _get_registry(monkeypatch, "native-client")
valid, err = registry.validate_client(
"native-client", redirect_uri="http://localhost:12345/callback"
)
assert valid is True
assert err is None
def test_well_known_clients_wildcard_scopes(monkeypatch):
registry = _get_registry(monkeypatch, None)
for client in registry.list_clients():
assert client.allowed_scopes == ["*"], (
f"Well-known client {client.client_id} should have wildcard scopes"
)
def test_deprecated_cloud_clients_warning(monkeypatch, caplog):
monkeypatch.setenv("ALLOWED_MCP_CLOUD_CLIENTS", "old|https://old.com/cb")
monkeypatch.setenv("ALLOWED_MCP_CLIENTS", "new-client")
with caplog.at_level(logging.WARNING):
registry_mod.get_client_registry()
assert "ALLOWED_MCP_CLOUD_CLIENTS is deprecated" in caplog.text
def test_client_name_resolution(monkeypatch):
registry = _get_registry(monkeypatch, "claude-desktop, custom-tool")
assert registry.get_client("claude-desktop").name == "Claude Desktop"
assert registry.get_client("custom-tool").name == "Custom Tool"