Skip to content

Commit 65d0957

Browse files
fix: add RFC 8707 resource validation to OAuth client
Backport from main (PR #2010). The client now validates that the Protected Resource Metadata resource field matches the server URL before proceeding with authorization, rejecting mismatched resources per RFC 8707. This fixes the auth/resource-mismatch conformance test, bringing client conformance to 251/251 (100%) on v1.x.
1 parent d234633 commit 65d0957

File tree

3 files changed

+123
-8
lines changed

3 files changed

+123
-8
lines changed
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
11
# Known conformance test failures for v1.x
22
# These are tracked and should be removed as they're fixed.
3-
#
4-
# auth/resource-mismatch: Client must validate that the Protected Resource
5-
# Metadata (PRM) resource field matches the server URL before proceeding
6-
# with authorization (RFC 8707). Implemented on main (PR #2010), needs
7-
# backport to v1.x.
8-
client:
9-
- auth/resource-mismatch
3+
server: []
4+
client: []

src/mcp/client/auth/oauth2.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,21 @@ def __init__(
267267
)
268268
self._initialized = False
269269

270+
async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None:
271+
"""Validate that PRM resource matches the server URL per RFC 8707."""
272+
prm_resource = str(prm.resource) if prm.resource else None
273+
if not prm_resource:
274+
return # pragma: no cover
275+
default_resource = resource_url_from_server_url(self.context.server_url)
276+
# Normalize: Pydantic AnyHttpUrl adds trailing slash to root URLs
277+
# (e.g. "https://example.com/") while resource_url_from_server_url may not.
278+
if not default_resource.endswith("/"):
279+
default_resource += "/"
280+
if not prm_resource.endswith("/"):
281+
prm_resource += "/"
282+
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
283+
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
284+
270285
async def _handle_protected_resource_response(self, response: httpx.Response) -> bool:
271286
"""
272287
Handle protected resource metadata discovery response.
@@ -520,6 +535,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
520535

521536
prm = await handle_protected_resource_response(discovery_response)
522537
if prm:
538+
# Validate PRM resource matches server URL (RFC 8707)
539+
await self._validate_resource_match(prm)
523540
self.context.protected_resource_metadata = prm
524541

525542
# todo: try all authorization_servers to find the OASM

tests/client/test_auth.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from pydantic import AnyHttpUrl, AnyUrl
1414

1515
from mcp.client.auth import OAuthClientProvider, PKCEParameters
16+
from mcp.client.auth.exceptions import OAuthFlowError
1617
from mcp.client.auth.utils import (
1718
build_oauth_authorization_server_metadata_discovery_urls,
1819
build_protected_resource_metadata_discovery_urls,
@@ -965,7 +966,7 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide
965966
# Send a successful discovery response with minimal protected resource metadata
966967
discovery_response = httpx.Response(
967968
200,
968-
content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}',
969+
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
969970
request=discovery_request,
970971
)
971972

@@ -2030,3 +2031,105 @@ async def callback_handler() -> tuple[str, str | None]:
20302031
await auth_flow.asend(final_response)
20312032
except StopAsyncIteration:
20322033
pass
2034+
2035+
2036+
@pytest.mark.anyio
2037+
async def test_validate_resource_rejects_mismatched_resource(
2038+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
2039+
) -> None:
2040+
"""Client must reject PRM resource that doesn't match server URL."""
2041+
provider = OAuthClientProvider(
2042+
server_url="https://api.example.com/v1/mcp",
2043+
client_metadata=client_metadata,
2044+
storage=mock_storage,
2045+
)
2046+
provider._initialized = True
2047+
2048+
prm = ProtectedResourceMetadata(
2049+
resource=AnyHttpUrl("https://evil.example.com/mcp"),
2050+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
2051+
)
2052+
with pytest.raises(OAuthFlowError, match="does not match expected"):
2053+
await provider._validate_resource_match(prm)
2054+
2055+
2056+
@pytest.mark.anyio
2057+
async def test_validate_resource_accepts_matching_resource(
2058+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
2059+
) -> None:
2060+
"""Client must accept PRM resource that matches server URL."""
2061+
provider = OAuthClientProvider(
2062+
server_url="https://api.example.com/v1/mcp",
2063+
client_metadata=client_metadata,
2064+
storage=mock_storage,
2065+
)
2066+
provider._initialized = True
2067+
2068+
prm = ProtectedResourceMetadata(
2069+
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
2070+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
2071+
)
2072+
# Should not raise
2073+
await provider._validate_resource_match(prm)
2074+
2075+
2076+
@pytest.mark.anyio
2077+
async def test_validate_resource_accepts_root_url_with_trailing_slash(
2078+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
2079+
) -> None:
2080+
"""Root URLs with trailing slash normalization should match."""
2081+
provider = OAuthClientProvider(
2082+
server_url="https://api.example.com",
2083+
client_metadata=client_metadata,
2084+
storage=mock_storage,
2085+
)
2086+
provider._initialized = True
2087+
2088+
prm = ProtectedResourceMetadata(
2089+
resource=AnyHttpUrl("https://api.example.com/"),
2090+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
2091+
)
2092+
# Should not raise - both normalize to the same URL with trailing slash
2093+
await provider._validate_resource_match(prm)
2094+
2095+
2096+
@pytest.mark.anyio
2097+
async def test_validate_resource_match_when_resource_url_has_trailing_slash(
2098+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
2099+
) -> None:
2100+
"""Validation works when resource_url_from_server_url already returns a trailing slash."""
2101+
provider = OAuthClientProvider(
2102+
server_url="https://api.example.com/",
2103+
client_metadata=client_metadata,
2104+
storage=mock_storage,
2105+
)
2106+
provider._initialized = True
2107+
2108+
prm = ProtectedResourceMetadata(
2109+
resource=AnyHttpUrl("https://api.example.com/"),
2110+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
2111+
)
2112+
# Should not raise - default_resource already ends with /
2113+
await provider._validate_resource_match(prm)
2114+
2115+
2116+
@pytest.mark.anyio
2117+
async def test_get_resource_url_falls_back_when_prm_mismatches(
2118+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
2119+
) -> None:
2120+
"""get_resource_url returns canonical URL when PRM resource doesn't match."""
2121+
provider = OAuthClientProvider(
2122+
server_url="https://api.example.com/v1/mcp",
2123+
client_metadata=client_metadata,
2124+
storage=mock_storage,
2125+
)
2126+
provider._initialized = True
2127+
2128+
# Set PRM with a resource that is NOT a parent of the server URL
2129+
provider.context.protected_resource_metadata = ProtectedResourceMetadata(
2130+
resource=AnyHttpUrl("https://other.example.com/mcp"),
2131+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
2132+
)
2133+
2134+
# get_resource_url should return the canonical server URL, not the PRM resource
2135+
assert provider.context.get_resource_url() == "https://api.example.com/v1/mcp"

0 commit comments

Comments
 (0)