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:
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user