fix: correct OAuth token audience validation using RFC 8707 resource parameter

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=<client-id> in OAuth authorization request (RFC 8707)
4. Nextcloud OIDC issues tokens with aud: [<client-id>]
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 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-04 03:06:11 +01:00
parent 01d1cf9190
commit 192c4bf009
2 changed files with 81 additions and 8 deletions
+5 -7
View File
@@ -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"],
+76 -1
View File
@@ -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()