[v1.x] Follow redirects only within the MCP endpoint's origin (#3448)

This commit is contained in:
Max 2026-09-05 02:35:49 +01:00 • committed by GitHub
commit 2106335745
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -5,6 +5,7 @@ Tests for refactored OAuth client authentication implementation.
import base64
import json
import time
from collections.abc import AsyncGenerator
from unittest import mock
from urllib.parse import parse_qs, unquote, urlparse
@ -26,6 +27,7 @@ from mcp.client.auth.utils import (
extract_resource_metadata_from_www_auth,
extract_scope_from_www_auth,
get_client_metadata_scopes,
handle_auth_metadata_response,
handle_registration_response,
is_valid_client_metadata_url,
should_use_client_metadata_url,
@ -824,40 +826,129 @@ class TestProtectedResourceMetadata:
assert "resource=" in content
async def _start_discovery(
provider: OAuthClientProvider,
) -> tuple[AsyncGenerator[httpx.Request, httpx.Response], httpx.Request]:
"""Drive `provider`'s auth flow to the point where it has sent the MCP request, seen a 401 and
issued its first protected-resource-metadata request; returns (flow, that request)."""
provider.context.current_tokens = None
provider.context.token_expiry_time = None
provider._initialized = True
mcp_request = httpx.Request("POST", "https://api.example.com/v1/mcp")
flow = provider.async_auth_flow(mcp_request)
sent = await flow.__anext__()
assert sent is mcp_request
# No resource_metadata hint, so discovery tries the path-based well-known URL, then the root one.
unauthorized = httpx.Response(401, request=mcp_request)
prm_request = await flow.asend(unauthorized)
assert (prm_request.method, str(prm_request.url)) == (
"GET",
"https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
)
return flow, prm_request
async def _redirect(request: httpx.Request, status: int, location: str) -> httpx.Response:
"""A redirect answer to `request`, as httpx hands it back when it does not follow it."""
transport = httpx.MockTransport(lambda r: httpx.Response(status, headers={"location": location}))
async with httpx.AsyncClient(transport=transport) as client:
return await client.send(request)
@pytest.mark.anyio
async def test_auth_flow_follows_a_same_origin_redirect_of_its_own_request(oauth_provider: OAuthClientProvider):
"""SDK-defined: a request the OAuth flow makes (here protected-resource metadata discovery)
follows a redirect that stays within its origin and keeps its method, like an MCP request."""
flow, prm_request = await _start_discovery(oauth_provider)
follow_up = await flow.asend(await _redirect(prm_request, 307, "/.well-known/oauth-protected-resource/v1/mcp/"))
assert (follow_up.method, str(follow_up.url)) == (
"GET",
"https://api.example.com/.well-known/oauth-protected-resource/v1/mcp/",
)
await flow.aclose()
@pytest.mark.anyio
async def test_auth_flow_does_not_follow_a_redirect_of_its_own_request_to_another_origin(
oauth_provider: OAuthClientProvider,
):
"""SDK-defined: a redirect of a flow request to another origin is handed to the flow unfollowed,
which treats it as "not served here" and moves to its next discovery URL."""
flow, prm_request = await _start_discovery(oauth_provider)
next_request = await flow.asend(await _redirect(prm_request, 307, "https://elsewhere.example/prm"))
assert (next_request.method, str(next_request.url)) == (
"GET",
"https://api.example.com/.well-known/oauth-protected-resource",
)
await flow.aclose()
@pytest.mark.anyio
async def test_auth_flow_stops_following_a_redirecting_request_after_a_few_hops(
oauth_provider: OAuthClientProvider,
):
"""SDK-defined: a flow request that keeps redirecting within its origin is followed a bounded
number of times; the redirect after that is handed to the flow unfollowed."""
flow, request = await _start_discovery(oauth_provider)
hops = 0
while str(request.url) != "https://api.example.com/.well-known/oauth-protected-resource":
request = await flow.asend(
await _redirect(request, 307, f"/.well-known/oauth-protected-resource/v1/mcp/{hops}")
)
hops += 1
assert hops == 6 # five followed, the sixth handed back and taken as "try the next URL"
await flow.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize(("status", "keep_trying"), [(404, True), (307, True), (500, False)])
async def test_auth_metadata_response_says_whether_to_try_the_next_discovery_url(
status: int, keep_trying: bool
) -> None:
"""SDK-defined: a 4xx or a 3xx (redirects are not followed on these requests) from a discovery
candidate means the metadata is not served there and the next well-known URL is tried; a 5xx
stops discovery."""
assert await handle_auth_metadata_response(httpx.Response(status)) == (keep_trying, None)
class TestRegistrationResponse:
"""Test client registration response handling."""
@pytest.mark.anyio
async def test_handle_registration_response_reads_before_accessing_text(self):
"""Test that response.aread() is called before accessing response.text."""
"""The registration error carries the response text, which for a streamed response means
reading it first (a streamed httpx response raises ResponseNotRead otherwise)."""
response = httpx.Response(400, stream=httpx.ByteStream(b"Registration failed with error"))
# Track if aread() was called
class MockResponse(httpx.Response):
def __init__(self):
self.status_code = 400
self._aread_called = False
self._text = "Registration failed with error"
with pytest.raises(OAuthRegistrationError) as exc_info:
await handle_registration_response(response)
async def aread(self):
self._aread_called = True
return b"test content"
assert str(exc_info.value) == snapshot("Registration failed: 400 Registration failed with error")
@property
def text(self):
if not self._aread_called:
raise RuntimeError("Response.text accessed before response.aread()") # pragma: no cover
return self._text
@pytest.mark.anyio
async def test_registration_error_names_an_unfollowed_redirect(self):
"""SDK-defined: when the registration endpoint answered with a redirect that was not followed,
the error says where it pointed (without userinfo or query) instead of only the bare status."""
request = httpx.Request("POST", "https://as.example/register")
async with httpx.AsyncClient(
transport=httpx.MockTransport(
lambda r: httpx.Response(307, headers={"location": "https://u:p@elsewhere.example/register?state=x"})
)
) as client:
response = await client.send(request)
mock_response = MockResponse()
with pytest.raises(OAuthRegistrationError) as exc_info:
await handle_registration_response(response)
# This should call aread() before accessing text
with pytest.raises(Exception) as exc_info:
await handle_registration_response(mock_response)
# Verify aread() was called
assert mock_response._aread_called
# Verify the error message includes the response text
assert "Registration failed: 400" in str(exc_info.value)
assert str(exc_info.value) == snapshot(
"Registration failed: 307 (redirected to https://elsewhere.example/register; not followed) "
)
class TestCreateClientRegistrationRequest: