Merge remote-tracking branch 'origin/master' into docs/adr-024-dynaconf-config-management

This commit is contained in:
Chris Coutinho
2026-04-05 19:42:26 +02:00
19 changed files with 648 additions and 188 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@58dbe8ed6879f0d3b02ac295b20d5fdfe7733e0c # v1.0.85
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # v1.0.89
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: "renovate-bot-cbcoutinho"
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@58dbe8ed6879f0d3b02ac295b20d5fdfe7733e0c # v1.0.85
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # v1.0.89
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+36 -1
View File
@@ -43,6 +43,7 @@ jobs:
- "single-user"
- "multi-user-basic"
- "login-flow"
- "keycloak"
include:
# Version-specific image pins — Renovate updates these via customManagers in renovate.json
# Each entry is pinned to its major version (e.g., NC 31 only gets 31.x updates)
@@ -81,6 +82,14 @@ jobs:
needs-playwright: true
extra-args: ""
- mode: keycloak
profile: keycloak
markers: "keycloak"
wait-port: 8002
mcp-internal-url: "http://mcp-keycloak:8002"
needs-playwright: true
extra-args: ""
name: integration (${{ matrix.mode }} / nc${{ matrix.nextcloud_version }})
steps:
@@ -174,14 +183,40 @@ jobs:
done
echo "MCP service is ready on port ${{ matrix.wait-port }}."
- name: Wait for Keycloak
if: matrix.mode == 'keycloak'
run: |
echo "Waiting for Keycloak realm at http://localhost:8888..."
max_attempts=30
attempt=0
until curl -sf http://localhost:8888/realms/nextcloud-mcp > /dev/null 2>&1; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo "Keycloak did not become ready in time."
docker compose --profile keycloak logs keycloak
exit 1
fi
echo "Attempt $attempt/$max_attempts: Not ready, sleeping 5s..."
sleep 5
done
echo "Keycloak is ready."
- name: Verify OIDC configuration
if: matrix.mode == 'login-flow'
if: matrix.mode == 'login-flow' || matrix.mode == 'keycloak'
run: |
echo "=== OIDC Discovery ==="
curl -s http://localhost:8080/.well-known/openid-configuration | jq .
echo "=== OIDC App Status ==="
docker compose exec -T app php occ app:list --output=json 2>/dev/null | jq '.enabled.oidc // "NOT INSTALLED"'
- name: Verify Keycloak realm
if: matrix.mode == 'keycloak'
run: |
echo "=== Keycloak Realm Discovery ==="
curl -s http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration | jq .
echo "=== Keycloak Provider in Nextcloud ==="
docker compose exec -T app php occ user_oidc:provider keycloak 2>/dev/null || echo "Provider not yet configured"
- name: Run tests (${{ matrix.mode }})
env:
NEXTCLOUD_HOST: "http://localhost:8080"
+12
View File
@@ -5,6 +5,18 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
## v0.68.3 (2026-04-05)
### Fix
- address PR review feedback for client registry and DCR proxy
- support cloud OAuth clients and graceful DCR fallback
### Refactor
- remove ALLOWED_MCP_CLOUD_CLIENTS and add keycloak CI profile
- consolidate ALLOWED_MCP_CLIENTS and add redirect URI validation
## v0.68.2 (2026-04-04)
### Fix
@@ -1,69 +0,0 @@
From deab2dac3d73d25f20a95c18103f327ab48f837a Mon Sep 17 00:00:00 2001
From: Chris Coutinho <chris@coutinho.io>
Date: Sun, 12 Oct 2025 21:09:29 +0200
Subject: [PATCH 1/1] Fix Bearer token authentication causing session logout
When using Bearer token authentication with OIDC, API requests to
endpoints with @CORS annotations (like Notes API) were failing with
401 Unauthorized errors. This occurred because:
1. Bearer token validation successfully authenticated the user
2. A session was created for the authenticated user
3. Nextcloud's CORSMiddleware detected the logged-in session but no
CSRF token, causing it to call session->logout()
4. The logout invalidated the session, breaking the API request
This fix sets the 'app_api' session flag during Bearer token
authentication, which instructs CORSMiddleware to skip the CSRF check
and logout logic. This is the same mechanism used by Nextcloud's
AppAPI framework for external application authentication.
The flag is set at all successful Bearer token authentication points:
- Line 243: After OIDC Identity Provider validation
- Line 310: After auto-provisioning with bearer provisioning
- Line 315: After existing user authentication
- Line 337: After LDAP user sync
Fixes: Bearer token authentication for all Nextcloud APIs
Tested-with: nextcloud-mcp-server integration tests
Signed-off-by: Chris Coutinho <chris@coutinho.io>
---
lib/User/Backend.php | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/lib/User/Backend.php b/lib/User/Backend.php
index 23cfb18..65665cc 100644
--- a/lib/User/Backend.php
+++ b/lib/User/Backend.php
@@ -240,6 +240,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
$this->eventDispatcher->dispatchTyped($validationEvent);
$oidcProviderUserId = $validationEvent->getUserId();
if ($oidcProviderUserId !== null) {
+ $this->session->set('app_api', true);
return $oidcProviderUserId;
} else {
$this->logger->debug('[NextcloudOidcProviderValidator] The bearer token validation has failed');
@@ -306,10 +307,12 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
}
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $userId;
} elseif ($this->userExists($tokenUserId)) {
$this->checkFirstLogin($tokenUserId);
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $tokenUserId;
} else {
// check if the user exists locally
@@ -331,6 +334,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
}
$this->checkFirstLogin($tokenUserId);
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $tokenUserId;
}
}
--
2.51.0
-18
View File
@@ -1,18 +0,0 @@
diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
index 4453f5a7d4b..f1ca9b48d21 100644
--- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
+++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
@@ -73,6 +73,13 @@ class CORSMiddleware extends Middleware {
$user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null;
$pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null;
+ // Allow Bearer token authentication for CORS requests
+ // Bearer tokens are stateless and don't require CSRF protection
+ $authorizationHeader = $this->request->getHeader('Authorization');
+ if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
+ return;
+ }
+
// Allow to use the current session if a CSRF token is provided
if ($this->request->passesCSRFCheck()) {
return;
@@ -1,64 +0,0 @@
#!/bin/bash
#
# Apply upstream CORSMiddleware Bearer token authentication patch
#
# This patch allows Bearer tokens to bypass CORS/CSRF checks, fixing
# authentication issues with app-specific APIs (Notes, Calendar, etc.)
# when using OAuth/OIDC Bearer tokens.
#
# Upstream PR: https://github.com/nextcloud/server/pull/55878
# Commit: 8fb5e77db82 (fix(cors): Allow Bearer token authentication)
#
set -e
PATCH_FILE="/docker-entrypoint-hooks.d/patches/cors-bearer-token.patch"
TARGET_FILE="/var/www/html/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php"
echo "===================================================================="
echo "Applying CORSMiddleware Bearer token authentication patch..."
echo "===================================================================="
# Check if patch file exists
if [ ! -f "$PATCH_FILE" ]; then
echo "⚠ Warning: Patch file not found: $PATCH_FILE"
echo " Skipping CORS Bearer token patch"
exit 0
fi
# Check if target file exists
if [ ! -f "$TARGET_FILE" ]; then
echo "⚠ Warning: Target file not found: $TARGET_FILE"
echo " Skipping CORS Bearer token patch"
exit 0
fi
# Check if already patched
if grep -q "Allow Bearer token authentication for CORS requests" "$TARGET_FILE"; then
echo "✓ CORSMiddleware already patched for Bearer token support"
exit 0
fi
echo "Applying patch to CORSMiddleware.php..."
# Apply the patch
cd /var/www/html
if patch -p1 --dry-run < "$PATCH_FILE" > /dev/null 2>&1; then
patch -p1 < "$PATCH_FILE"
echo "✓ Patch applied successfully"
else
echo "⚠ Warning: Patch failed to apply (may already be applied or file changed)"
echo " This is expected if using a Nextcloud version that already includes the fix"
exit 0
fi
echo ""
echo "===================================================================="
echo "✓ CORSMiddleware Bearer token patch applied"
echo "===================================================================="
echo ""
echo "Benefits:"
echo " • Bearer tokens now work with app-specific APIs (Notes, Calendar, etc.)"
echo " • OAuth/OIDC authentication works without CORS errors"
echo " • Stateless API authentication is properly supported"
echo ""
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.58.26"
version = "0.58.28"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+14
View File
@@ -14,6 +14,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable resource limits
- Grafana dashboard annotations
## nextcloud-mcp-server-0.58.28 (2026-04-05)
### Fix
- address PR review feedback for client registry and DCR proxy
- support cloud OAuth clients and graceful DCR fallback
### Refactor
- remove ALLOWED_MCP_CLOUD_CLIENTS and add keycloak CI profile
- consolidate ALLOWED_MCP_CLIENTS and add redirect URI validation
## nextcloud-mcp-server-0.58.27 (2026-04-05)
## nextcloud-mcp-server-0.58.26 (2026-04-04)
### Fix
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.58.26
appVersion: "0.68.2"
version: 0.58.28
appVersion: "0.68.3"
keywords:
- nextcloud
- mcp
+4
View File
@@ -230,6 +230,10 @@ services:
- ENABLE_TOKEN_EXCHANGE=true
- TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens for 5 minutes (default)
# Login Flow v2 (ADR-022) with external IdP
- ENABLE_LOGIN_FLOW=true
- ENABLE_DCR=true
# OAuth scopes (optional - uses defaults if not specified)
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
+73 -17
View File
@@ -36,6 +36,12 @@ class ClientRegistry:
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):
@@ -50,25 +56,72 @@ 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
"""
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"],
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: {client_id}")
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:
@@ -78,6 +131,7 @@ class ClientRegistry:
"""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",
@@ -92,7 +146,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"},
),
@@ -100,7 +154,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"},
),
@@ -163,6 +217,8 @@ class ClientRegistry:
"""
# Parse the redirect URI
parsed = urlparse(redirect_uri)
if not parsed.hostname:
return False
# Check against registered patterns
for pattern in client.redirect_uris:
@@ -171,7 +227,7 @@ class ClientRegistry:
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"]:
if parsed.hostname in ("localhost", "127.0.0.1", "::1"):
return True
elif redirect_uri == pattern:
return True
+19 -12
View File
@@ -1228,7 +1228,6 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
)
oauth_config = oauth_ctx["config"]
nextcloud_host = oauth_config["nextcloud_host"]
# Rate limit DCR requests per client IP
client_ip = request.client.host if request.client else "unknown"
@@ -1249,21 +1248,29 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
timestamps.append(now)
_dcr_rate_limit[client_ip] = timestamps
# Discover registration endpoint from OIDC discovery (prefer over hardcoded path)
# Discover registration endpoint from OIDC discovery
discovery_url = oauth_config.get("discovery_url")
registration_endpoint = None
if discovery_url:
try:
discovery = await _get_cached_discovery(discovery_url)
registration_endpoint = discovery.get(
"registration_endpoint", f"{nextcloud_host}/apps/oidc/register"
)
registration_endpoint = discovery.get("registration_endpoint")
except Exception:
logger.warning(
"Failed to fetch OIDC discovery for DCR endpoint, using fallback"
)
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
else:
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
logger.warning("Failed to fetch OIDC discovery for DCR endpoint")
if not registration_endpoint:
logger.warning(
"DCR proxy: Upstream IdP does not support dynamic client registration"
)
return 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.",
},
status_code=400,
)
logger.info(f"DCR proxy: Forwarding registration to {registration_endpoint}")
@@ -1276,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()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.68.2"
version = "0.68.3"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
View File
@@ -0,0 +1,218 @@
"""
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"]
try:
# 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
except Exception:
# Best-effort cleanup if assertion failed
try:
await http.delete(
f"{KEYCLOAK_BASE_URL}/realms/{KEYCLOAK_REALM}"
f"/clients-registrations/openid-connect/{client_id}",
headers={"Authorization": f"Bearer {rat}"},
)
except Exception:
pass
raise
# --- 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
else:
# 302 redirect must carry an error parameter
location = resp.headers.get("location", "")
assert "error=" in location, (
f"302 redirect should contain error= in Location, got: {location}"
)
+200
View File
@@ -0,0 +1,200 @@
"""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)
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_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"
def test_ipv6_loopback_allowed(monkeypatch):
registry = _get_registry(monkeypatch, "ipv6-app|http://[::1]:3000/cb")
client = registry.get_client("ipv6-app")
assert client is not None
assert client.redirect_uris == ["http://[::1]:3000/cb"]
def test_malformed_uri_no_hostname_skipped(monkeypatch, caplog):
with caplog.at_level(logging.WARNING):
registry = _get_registry(monkeypatch, "bad|http:///no-host")
assert registry.get_client("bad") is None
assert "cannot parse hostname" in caplog.text
def test_validate_redirect_uri_ipv6_loopback(monkeypatch):
"""IPv6 loopback redirect URIs should match wildcard localhost patterns."""
registry = _get_registry(monkeypatch, "ipv6-app|http://[::1]:3000/cb")
valid, err = registry.validate_client(
"ipv6-app", redirect_uri="http://[::1]:3000/cb"
)
assert valid is True
assert err is None
def test_validate_redirect_uri_no_hostname(monkeypatch):
"""Redirect URIs with no parseable hostname should be rejected."""
registry = _get_registry(monkeypatch, "test-client")
valid, err = registry.validate_client("test-client", redirect_uri="not-a-uri")
assert valid is False
assert "redirect_uri" in err.lower()
+65
View File
@@ -0,0 +1,65 @@
"""Unit tests for DCR proxy registration_not_supported path."""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nextcloud_mcp_server.auth.oauth_routes import oauth_register_proxy
pytestmark = pytest.mark.unit
def _make_request(body: dict, oauth_config: dict) -> MagicMock:
"""Create a mock Starlette Request."""
request = AsyncMock()
request.json = AsyncMock(return_value=body)
request.client = MagicMock()
request.client.host = "127.0.0.1"
request.app = MagicMock()
request.app.state.oauth_context = {"config": oauth_config}
return request
_DCR_BODY = {
"client_name": "test",
"redirect_uris": ["http://localhost:9999/cb"],
}
async def test_registration_not_supported_when_no_endpoint():
"""When discovery doc lacks registration_endpoint, return 400."""
request = _make_request(
body=_DCR_BODY,
oauth_config={
"discovery_url": "https://idp.example.com/.well-known/openid-configuration"
},
)
discovery_doc = {
"issuer": "https://idp.example.com",
"authorization_endpoint": "https://idp.example.com/auth",
}
with patch(
"nextcloud_mcp_server.auth.oauth_routes._get_cached_discovery",
new_callable=AsyncMock,
return_value=discovery_doc,
):
response = await oauth_register_proxy(request)
assert response.status_code == 400
body = json.loads(response.body)
assert body["error"] == "registration_not_supported"
assert "ALLOWED_MCP_CLIENTS" in body["error_description"]
async def test_registration_not_supported_when_no_discovery_url():
"""When no discovery_url is configured, return 400."""
request = _make_request(body=_DCR_BODY, oauth_config={})
response = await oauth_register_proxy(request)
assert response.status_code == 400
body = json.loads(response.body)
assert body["error"] == "registration_not_supported"
Generated
+1 -1
View File
@@ -2095,7 +2095,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.68.2"
version = "0.68.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },