chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""Regression test for issue #1630: OAuth2 scope incorrectly set to resource_metadata URL.
|
||||
|
||||
This test verifies that when a 401 response contains both resource_metadata and scope
|
||||
in the WWW-Authenticate header, the actual scope is used (not the resource_metadata URL).
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.shared.auth import (
|
||||
AuthorizationCodeResult,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthToken,
|
||||
)
|
||||
|
||||
|
||||
class MockTokenStorage:
|
||||
"""Mock token storage for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tokens: OAuthToken | None = None
|
||||
self._client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return self._tokens # pragma: no cover
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
self._tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return self._client_info # pragma: no cover
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
self._client_info = client_info # pragma: no cover
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_401_uses_www_auth_scope_not_resource_metadata_url():
|
||||
"""Regression test for #1630: Ensure scope is extracted from WWW-Authenticate header,
|
||||
not the resource_metadata URL.
|
||||
|
||||
When a 401 response contains:
|
||||
WWW-Authenticate: Bearer resource_metadata="https://...", scope="read write"
|
||||
|
||||
The client should use "read write" as the scope, NOT the resource_metadata URL.
|
||||
"""
|
||||
|
||||
async def redirect_handler(url: str) -> None:
|
||||
pass # pragma: no cover
|
||||
|
||||
async def callback_handler() -> AuthorizationCodeResult:
|
||||
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||
|
||||
client_metadata = OAuthClientMetadata(
|
||||
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
|
||||
client_name="Test Client",
|
||||
)
|
||||
|
||||
provider = OAuthClientProvider(
|
||||
server_url="https://api.example.com/mcp",
|
||||
client_metadata=client_metadata,
|
||||
storage=MockTokenStorage(),
|
||||
redirect_handler=redirect_handler,
|
||||
callback_handler=callback_handler,
|
||||
)
|
||||
|
||||
provider.context.current_tokens = None
|
||||
provider.context.token_expiry_time = None
|
||||
provider._initialized = True
|
||||
|
||||
# Pre-set client info to skip DCR
|
||||
provider.context.client_info = OAuthClientInformationFull(
|
||||
client_id="test_client",
|
||||
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
|
||||
)
|
||||
|
||||
test_request = httpx.Request("GET", "https://api.example.com/mcp")
|
||||
auth_flow = provider.async_auth_flow(test_request)
|
||||
|
||||
# First request (no auth header yet)
|
||||
await auth_flow.__anext__()
|
||||
|
||||
# 401 response with BOTH resource_metadata URL and scope in WWW-Authenticate
|
||||
# This is the key: the bug would use the URL as scope instead of "read write"
|
||||
resource_metadata_url = "https://api.example.com/.well-known/oauth-protected-resource"
|
||||
expected_scope = "read write"
|
||||
|
||||
response_401 = httpx.Response(
|
||||
401,
|
||||
headers={"WWW-Authenticate": (f'Bearer resource_metadata="{resource_metadata_url}", scope="{expected_scope}"')},
|
||||
request=test_request,
|
||||
)
|
||||
|
||||
# Send 401, expect PRM discovery request
|
||||
prm_request = await auth_flow.asend(response_401)
|
||||
assert ".well-known/oauth-protected-resource" in str(prm_request.url)
|
||||
|
||||
# PRM response with scopes_supported (these should be overridden by WWW-Auth scope)
|
||||
prm_response = httpx.Response(
|
||||
200,
|
||||
content=(
|
||||
b'{"resource": "https://api.example.com/mcp", '
|
||||
b'"authorization_servers": ["https://auth.example.com"], '
|
||||
b'"scopes_supported": ["fallback:scope1", "fallback:scope2"]}'
|
||||
),
|
||||
request=prm_request,
|
||||
)
|
||||
|
||||
# Send PRM response, expect OAuth metadata discovery
|
||||
oauth_metadata_request = await auth_flow.asend(prm_response)
|
||||
assert ".well-known/oauth-authorization-server" in str(oauth_metadata_request.url)
|
||||
|
||||
# OAuth metadata response
|
||||
oauth_metadata_response = httpx.Response(
|
||||
200,
|
||||
content=(
|
||||
b'{"issuer": "https://auth.example.com", '
|
||||
b'"authorization_endpoint": "https://auth.example.com/authorize", '
|
||||
b'"token_endpoint": "https://auth.example.com/token"}'
|
||||
),
|
||||
request=oauth_metadata_request,
|
||||
)
|
||||
|
||||
# Mock authorization to skip interactive flow
|
||||
provider._perform_authorization_code_grant = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
|
||||
|
||||
# Send OAuth metadata response, expect token request
|
||||
token_request = await auth_flow.asend(oauth_metadata_response)
|
||||
assert "token" in str(token_request.url)
|
||||
|
||||
# NOW CHECK: The scope should be the WWW-Authenticate scope, NOT the URL
|
||||
# This is where the bug manifested - scope was set to resource_metadata_url
|
||||
actual_scope = provider.context.client_metadata.scope
|
||||
|
||||
# This assertion would FAIL on main (scope would be the URL)
|
||||
# but PASS on the fix branch (scope is "read write")
|
||||
assert actual_scope == expected_scope, (
|
||||
f"Expected scope to be '{expected_scope}' from WWW-Authenticate header, "
|
||||
f"but got '{actual_scope}'. "
|
||||
f"If scope is '{resource_metadata_url}', the bug from #1630 is present."
|
||||
)
|
||||
|
||||
# Verify it's definitely not the URL (explicit check for the bug)
|
||||
assert actual_scope != resource_metadata_url, (
|
||||
f"BUG #1630: Scope was incorrectly set to resource_metadata URL '{resource_metadata_url}' "
|
||||
f"instead of the actual scope '{expected_scope}'"
|
||||
)
|
||||
|
||||
# Complete the flow to properly release the lock
|
||||
token_response = httpx.Response(
|
||||
200,
|
||||
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
|
||||
request=token_request,
|
||||
)
|
||||
|
||||
final_request = await auth_flow.asend(token_response)
|
||||
assert final_request.headers["Authorization"] == "Bearer test_token"
|
||||
|
||||
# Finish the flow
|
||||
final_response = httpx.Response(200, request=final_request)
|
||||
try:
|
||||
await auth_flow.asend(final_response)
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
Reference in New Issue
Block a user