fix: Use occ-created OAuth clients with allowed_scopes for all tests
The shared_oauth_client_credentials fixture was using Dynamic Client Registration which doesn't support Nextcloud's allowed_scopes parameter. This caused tokens to lack proper scope configuration, resulting in empty tool lists when the server validated scopes. Changes: 1. Updated shared_oauth_client_credentials to use occ oidc:create with allowed_scopes="openid profile email nc:read nc:write" 2. Created opaque token client (not JWT) for port 8001 compatibility 3. Enhanced _create_oauth_client_with_scopes to support both JWT and opaque token types via token_type parameter This ensures: - Regular OAuth tests (port 8001) get opaque tokens with proper scopes - JWT OAuth tests (port 8002) get JWT tokens with embedded scopes - Both token types have allowed_scopes configured on the OAuth client Fixes test_mcp_oauth_server_connection which was getting empty tool list 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+47
-56
@@ -883,16 +883,13 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
|||||||
"""
|
"""
|
||||||
Fixture to obtain shared OAuth client credentials that will be reused for all users.
|
Fixture to obtain shared OAuth client credentials that will be reused for all users.
|
||||||
|
|
||||||
This registers a single OAuth client with Nextcloud that matches the MCP server's
|
Creates an opaque token OAuth client with allowed_scopes for the standard OAuth MCP
|
||||||
registration, allowing all test users to authenticate using the same client_id/secret.
|
server (port 8001). While opaque tokens don't embed scopes, the allowed_scopes
|
||||||
|
configuration ensures tokens have proper scopes when introspected.
|
||||||
Uses regular (opaque token) OAuth client for the standard OAuth MCP server (port 8001).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
|
|
||||||
|
|
||||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
if not nextcloud_host:
|
if not nextcloud_host:
|
||||||
pytest.skip("Shared OAuth client requires NEXTCLOUD_HOST")
|
pytest.skip("Shared OAuth client requires NEXTCLOUD_HOST")
|
||||||
@@ -911,7 +908,6 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
|||||||
oidc_config = discovery_response.json()
|
oidc_config = discovery_response.json()
|
||||||
|
|
||||||
token_endpoint = oidc_config.get("token_endpoint")
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
|
||||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||||
|
|
||||||
if not token_endpoint or not authorization_endpoint:
|
if not token_endpoint or not authorization_endpoint:
|
||||||
@@ -919,39 +915,23 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
|||||||
"OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)"
|
"OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to load existing client first
|
# Create opaque token client with allowed_scopes (not JWT)
|
||||||
from pathlib import Path
|
# This ensures the token has proper scopes even though they're not embedded
|
||||||
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
|
callback_url=callback_url,
|
||||||
|
client_name="Pytest - Shared Test Client (Opaque)",
|
||||||
|
allowed_scopes="openid profile email nc:read nc:write",
|
||||||
|
token_type="opaque", # Opaque tokens for port 8001
|
||||||
|
)
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.client_registration import load_client_from_file
|
logger.info(f"Shared OAuth client ready: {client_id[:16]}...")
|
||||||
|
logger.info(
|
||||||
storage_path = Path(".nextcloud_oauth_shared_test_client.json")
|
"This opaque token client with full scopes will be reused for all test user authentications"
|
||||||
client_info = load_client_from_file(storage_path)
|
)
|
||||||
|
|
||||||
if not client_info and not registration_endpoint:
|
|
||||||
raise ValueError(
|
|
||||||
"Cannot create OAuth client: registration_endpoint not available and no pre-existing credentials found at .nextcloud_oauth_shared_test_client.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not client_info:
|
|
||||||
# Register or load shared OAuth client (matches MCP server registration)
|
|
||||||
client_info = await load_or_register_client(
|
|
||||||
nextcloud_url=nextcloud_host,
|
|
||||||
registration_endpoint=registration_endpoint,
|
|
||||||
storage_path=".nextcloud_oauth_shared_test_client.json",
|
|
||||||
client_name="Pytest - Shared Test Client",
|
|
||||||
redirect_uris=[callback_url],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"Using existing shared OAuth client: {client_info.client_id[:16]}..."
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Shared OAuth client ready: {client_info.client_id[:16]}...")
|
|
||||||
logger.info("This client will be reused for all test user authentications")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
client_info.client_id,
|
client_id,
|
||||||
client_info.client_secret,
|
client_secret,
|
||||||
callback_url,
|
callback_url,
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
authorization_endpoint,
|
authorization_endpoint,
|
||||||
@@ -1019,11 +999,16 @@ async def _create_oauth_client_with_scopes(
|
|||||||
callback_url: str,
|
callback_url: str,
|
||||||
client_name: str,
|
client_name: str,
|
||||||
allowed_scopes: str,
|
allowed_scopes: str,
|
||||||
|
token_type: str = "jwt",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Helper function to create an OAuth client with specific allowed_scopes using occ.
|
Helper function to create an OAuth client with specific allowed_scopes using occ.
|
||||||
|
|
||||||
Creates JWT clients (not opaque) so that scope information is embedded in the token.
|
Args:
|
||||||
|
callback_url: OAuth callback URL
|
||||||
|
client_name: Name of the OAuth client
|
||||||
|
allowed_scopes: Space-separated list of allowed scopes
|
||||||
|
token_type: Either "jwt" (default) or "opaque"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (client_id, client_secret)
|
Tuple of (client_id, client_secret)
|
||||||
@@ -1032,32 +1017,38 @@ async def _create_oauth_client_with_scopes(
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Creating JWT OAuth client '{client_name}' with scopes: {allowed_scopes}"
|
f"Creating {token_type.upper()} OAuth client '{client_name}' with scopes: {allowed_scopes}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use occ oidc:create to create JWT client with specific allowed_scopes
|
# Build occ command based on token type
|
||||||
# JWT tokens are required for scope enforcement (scopes are embedded in token claims)
|
cmd = [
|
||||||
result = subprocess.run(
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"-u",
|
||||||
|
"www-data",
|
||||||
|
"app",
|
||||||
|
"php",
|
||||||
|
"/var/www/html/occ",
|
||||||
|
"oidc:create",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add token_type flag for JWT clients
|
||||||
|
if token_type == "jwt":
|
||||||
|
cmd.append("--token_type=jwt")
|
||||||
|
|
||||||
|
# Add allowed_scopes for both JWT and opaque clients
|
||||||
|
cmd.extend(
|
||||||
[
|
[
|
||||||
"docker",
|
|
||||||
"compose",
|
|
||||||
"exec",
|
|
||||||
"-T",
|
|
||||||
"-u",
|
|
||||||
"www-data",
|
|
||||||
"app",
|
|
||||||
"php",
|
|
||||||
"/var/www/html/occ",
|
|
||||||
"oidc:create",
|
|
||||||
"--token_type=jwt",
|
|
||||||
f"--allowed_scopes={allowed_scopes}",
|
f"--allowed_scopes={allowed_scopes}",
|
||||||
client_name,
|
client_name,
|
||||||
callback_url,
|
callback_url,
|
||||||
],
|
]
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Failed to create OAuth client: {result.stderr}\nStdout: {result.stdout}"
|
f"Failed to create OAuth client: {result.stderr}\nStdout: {result.stdout}"
|
||||||
|
|||||||
Reference in New Issue
Block a user