From 192c4bf009ba47d9fc2daf8da067ab73196829a3 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 4 Nov 2025 03:06:11 +0100 Subject: [PATCH] fix: correct OAuth token audience validation using RFC 8707 resource parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test_mcp_oauth_server_connection test was failing because OAuth tokens had the wrong audience claim. The MCP server's progressive_token_verifier expects tokens with audience matching its OAuth client ID, but tokens were being issued with Nextcloud's default resource server audience. Changes: 1. Test fixtures (tests/conftest.py): - Add get_mcp_server_resource_metadata() helper to fetch PRM metadata - Update playwright_oauth_token to include resource parameter in auth requests - Update _get_oauth_token_with_scopes to support optional resource parameter - Automatically fetch resource ID from MCP server's PRM endpoint 2. MCP Server (nextcloud_mcp_server/app.py): - Fix Protected Resource Metadata endpoint to return OAuth client ID - Change "resource" field from URL to client ID for proper audience validation - Ensures tokens obtained with resource parameter have correct audience claim How it works: 1. Test fetches /.well-known/oauth-protected-resource from MCP server 2. Extracts resource field (MCP server's client ID) 3. Includes &resource= in OAuth authorization request (RFC 8707) 4. Nextcloud OIDC issues tokens with aud: [] 5. MCP server's progressive_token_verifier accepts tokens (audience matches) Fixes OAuth test failures: - test_mcp_oauth_server_connection - test_mcp_oauth_tool_execution - test_mcp_oauth_client_with_playwright 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- nextcloud_mcp_server/app.py | 12 +++--- tests/conftest.py | 77 ++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 1dd21fa..3e684ac 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -930,13 +930,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): Dynamically discovers supported scopes from registered MCP tools. This ensures the advertised scopes always match the actual tool requirements. - """ - mcp_server_url = os.getenv( - "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" - ) - # Append /mcp to match the actual resource path (FastMCP streamable-http endpoint) - resource_url = f"{mcp_server_url}/mcp" + The 'resource' field is set to the MCP server's OAuth client ID, which is + used as the audience claim in access tokens. This ensures tokens obtained + with the resource parameter match the audience validation in progressive_token_verifier. + """ # Use PUBLIC_ISSUER_URL for authorization server since external clients # (like Claude) need the publicly accessible URL, not internal Docker URLs public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") @@ -950,7 +948,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): return JSONResponse( { - "resource": resource_url, + "resource": client_id, # MCP server's OAuth client ID (for audience validation) "scopes_supported": supported_scopes, "authorization_servers": [public_issuer_url], "bearer_methods_supported": ["header"], diff --git a/tests/conftest.py b/tests/conftest.py index 880fe98..47f0f21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1120,6 +1120,37 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv ) +async def get_mcp_server_resource_metadata(mcp_base_url: str) -> dict: + """ + Fetch MCP server's Protected Resource Metadata (RFC 9470). + + This retrieves the MCP server's resource information including: + - resource: The MCP server's client ID (used as audience for tokens) + - authorization_servers: List of trusted OAuth servers + - scopes_supported: Available scopes + + Args: + mcp_base_url: Base URL of the MCP server (e.g., "http://localhost:8001") + WITHOUT the /mcp path component + + Returns: + Dict with resource metadata + + Raises: + HTTPStatusError: If metadata endpoint is not available + """ + async with httpx.AsyncClient(timeout=30.0) as http_client: + prm_url = f"{mcp_base_url}/.well-known/oauth-protected-resource" + logger.debug(f"Fetching resource metadata from: {prm_url}") + + response = await http_client.get(prm_url) + response.raise_for_status() + metadata = response.json() + + logger.debug(f"Resource metadata: {metadata}") + return metadata + + async def _create_oauth_client_with_scopes( callback_url: str, client_name: str, @@ -1514,11 +1545,24 @@ async def playwright_oauth_token( logger.info(f"Using shared OAuth client: {client_id[:16]}...") logger.info(f"Using real callback server at: {callback_url}") + # Fetch MCP server's resource metadata to get correct audience + mcp_server_base_url = "http://localhost:8001" + try: + resource_metadata = await get_mcp_server_resource_metadata(mcp_server_base_url) + resource_id = resource_metadata.get("resource") + if resource_id: + logger.info(f"MCP server resource ID (for audience): {resource_id[:16]}...") + else: + logger.warning("No resource ID in metadata - token may have wrong audience") + except Exception as e: + logger.warning(f"Failed to fetch resource metadata: {e}") + resource_id = None + # Generate unique state parameter for this OAuth flow state = secrets.token_urlsafe(32) logger.debug(f"Generated state: {state[:16]}...") - # Construct authorization URL with state parameter + # Construct authorization URL with state and resource parameters auth_url = ( f"{authorization_endpoint}?" f"response_type=code&" @@ -1528,6 +1572,11 @@ async def playwright_oauth_token( f"scope=openid%20profile%20email%20notes:read%20notes:write%20calendar:read%20calendar:write%20contacts:read%20contacts:write%20cookbook:read%20cookbook:write%20deck:read%20deck:write%20tables:read%20tables:write%20files:read%20files:write%20sharing:read%20sharing:write" ) + # Add resource parameter (RFC 8707) if available + if resource_id: + auth_url += f"&resource={quote(resource_id, safe='')}" + logger.debug(f"Added resource parameter to auth URL: {resource_id[:16]}...") + # Async browser automation using pytest-playwright's browser fixture context = await browser.new_context(ignore_https_errors=True) page = await context.new_page() @@ -1745,6 +1794,7 @@ async def _get_oauth_token_with_scopes( shared_oauth_client_credentials, oauth_callback_server, scopes: str, + resource: str | None = None, ) -> str: """ Helper function to obtain OAuth token with specific scopes. @@ -1754,6 +1804,7 @@ async def _get_oauth_token_with_scopes( shared_oauth_client_credentials: Tuple of OAuth client credentials oauth_callback_server: OAuth callback server fixture scopes: Space-separated list of scopes (e.g., "openid profile email notes:read") + resource: Optional resource parameter (RFC 8707) for token audience Returns: OAuth access token string with requested scopes @@ -1783,6 +1834,25 @@ async def _get_oauth_token_with_scopes( logger.info(f"Using shared OAuth client: {client_id[:16]}...") logger.info(f"Using real callback server at: {callback_url}") + # If no resource provided, fetch from MCP server metadata + if resource is None: + mcp_server_base_url = "http://localhost:8001" + try: + resource_metadata = await get_mcp_server_resource_metadata( + mcp_server_base_url + ) + resource = resource_metadata.get("resource") + if resource: + logger.info( + f"MCP server resource ID (for audience): {resource[:16]}..." + ) + else: + logger.warning( + "No resource ID in metadata - token may have wrong audience" + ) + except Exception as e: + logger.warning(f"Failed to fetch resource metadata: {e}") + # Generate unique state parameter for this OAuth flow state = secrets.token_urlsafe(32) logger.debug(f"Generated state: {state[:16]}...") @@ -1800,6 +1870,11 @@ async def _get_oauth_token_with_scopes( f"scope={scopes_encoded}" ) + # Add resource parameter (RFC 8707) if available + if resource: + auth_url += f"&resource={quote(resource, safe='')}" + logger.debug(f"Added resource parameter to auth URL: {resource[:16]}...") + # Async browser automation using pytest-playwright's browser fixture context = await browser.new_context(ignore_https_errors=True) page = await context.new_page()