fix: pass conformance auth scenarios, add RFC 8707 resource validation (#2010)
This commit is contained in:
parent
7c7c13b648
commit
239d682b6f
1 changed files with 187 additions and 8 deletions
|
|
@ -11,6 +11,7 @@ from inline_snapshot import Is, snapshot
|
|||
from pydantic import AnyHttpUrl, AnyUrl
|
||||
|
||||
from mcp.client.auth import OAuthClientProvider, PKCEParameters
|
||||
from mcp.client.auth.exceptions import OAuthFlowError
|
||||
from mcp.client.auth.utils import (
|
||||
build_oauth_authorization_server_metadata_discovery_urls,
|
||||
build_protected_resource_metadata_discovery_urls,
|
||||
|
|
@ -818,6 +819,136 @@ class TestProtectedResourceMetadata:
|
|||
assert "resource=" in content
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_resource_rejects_mismatched_resource(
|
||||
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
|
||||
) -> None:
|
||||
"""Client must reject PRM resource that doesn't match server URL."""
|
||||
provider = OAuthClientProvider(
|
||||
server_url="https://api.example.com/v1/mcp",
|
||||
client_metadata=client_metadata,
|
||||
storage=mock_storage,
|
||||
)
|
||||
provider._initialized = True
|
||||
|
||||
prm = ProtectedResourceMetadata(
|
||||
resource=AnyHttpUrl("https://evil.example.com/mcp"),
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
)
|
||||
with pytest.raises(OAuthFlowError, match="does not match expected"):
|
||||
await provider._validate_resource_match(prm)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_resource_accepts_matching_resource(
|
||||
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
|
||||
) -> None:
|
||||
"""Client must accept PRM resource that matches server URL."""
|
||||
provider = OAuthClientProvider(
|
||||
server_url="https://api.example.com/v1/mcp",
|
||||
client_metadata=client_metadata,
|
||||
storage=mock_storage,
|
||||
)
|
||||
provider._initialized = True
|
||||
|
||||
prm = ProtectedResourceMetadata(
|
||||
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
)
|
||||
# Should not raise
|
||||
await provider._validate_resource_match(prm)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_resource_custom_callback(
|
||||
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
|
||||
) -> None:
|
||||
"""Custom callback overrides default validation."""
|
||||
callback_called_with: list[tuple[str, str | None]] = []
|
||||
|
||||
async def custom_validate(server_url: str, prm_resource: str | None) -> None:
|
||||
callback_called_with.append((server_url, prm_resource))
|
||||
|
||||
provider = OAuthClientProvider(
|
||||
server_url="https://api.example.com/v1/mcp",
|
||||
client_metadata=client_metadata,
|
||||
storage=mock_storage,
|
||||
validate_resource_url=custom_validate,
|
||||
)
|
||||
provider._initialized = True
|
||||
|
||||
# This would normally fail default validation (different origin),
|
||||
# but custom callback accepts it
|
||||
prm = ProtectedResourceMetadata(
|
||||
resource=AnyHttpUrl("https://evil.example.com/mcp"),
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
)
|
||||
await provider._validate_resource_match(prm)
|
||||
assert callback_called_with == snapshot([("https://api.example.com/v1/mcp", "https://evil.example.com/mcp")])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_resource_accepts_root_url_with_trailing_slash(
|
||||
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
|
||||
) -> None:
|
||||
"""Root URLs with trailing slash normalization should match."""
|
||||
provider = OAuthClientProvider(
|
||||
server_url="https://api.example.com",
|
||||
client_metadata=client_metadata,
|
||||
storage=mock_storage,
|
||||
)
|
||||
provider._initialized = True
|
||||
|
||||
prm = ProtectedResourceMetadata(
|
||||
resource=AnyHttpUrl("https://api.example.com/"),
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
)
|
||||
# Should not raise despite trailing slash difference
|
||||
await provider._validate_resource_match(prm)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_resource_accepts_server_url_with_trailing_slash(
|
||||
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
|
||||
) -> None:
|
||||
"""Server URL with trailing slash should match PRM resource."""
|
||||
provider = OAuthClientProvider(
|
||||
server_url="https://api.example.com/v1/mcp/",
|
||||
client_metadata=client_metadata,
|
||||
storage=mock_storage,
|
||||
)
|
||||
provider._initialized = True
|
||||
|
||||
prm = ProtectedResourceMetadata(
|
||||
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
)
|
||||
# Should not raise - both normalize to the same URL with trailing slash
|
||||
await provider._validate_resource_match(prm)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_resource_url_uses_canonical_when_prm_mismatches(
|
||||
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
|
||||
) -> None:
|
||||
"""get_resource_url falls back to canonical URL when PRM resource doesn't match."""
|
||||
provider = OAuthClientProvider(
|
||||
server_url="https://api.example.com/v1/mcp",
|
||||
client_metadata=client_metadata,
|
||||
storage=mock_storage,
|
||||
)
|
||||
provider._initialized = True
|
||||
|
||||
# Set PRM with a resource that is NOT a parent of the server URL
|
||||
provider.context.protected_resource_metadata = ProtectedResourceMetadata(
|
||||
resource=AnyHttpUrl("https://other.example.com/mcp"),
|
||||
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
||||
)
|
||||
|
||||
# get_resource_url should return the canonical server URL, not the PRM resource
|
||||
assert provider.context.get_resource_url() == snapshot("https://api.example.com/v1/mcp")
|
||||
|
||||
|
||||
class TestRegistrationResponse:
|
||||
"""Test client registration response handling."""
|
||||
|
||||
|
|
@ -963,7 +1094,7 @@ class TestAuthFlow:
|
|||
# Send a successful discovery response with minimal protected resource metadata
|
||||
discovery_response = httpx.Response(
|
||||
200,
|
||||
content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}',
|
||||
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
|
||||
request=discovery_request,
|
||||
)
|
||||
|
||||
|
|
@ -1116,7 +1247,7 @@ class TestAuthFlow:
|
|||
# Send a successful discovery response with minimal protected resource metadata
|
||||
discovery_response = httpx.Response(
|
||||
200,
|
||||
content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}',
|
||||
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
|
||||
request=discovery_request,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue