Validate the authorization server metadata issuer on every discovery path (#3398)

This commit is contained in:
Max 2026-09-02 16:51:22 +01:00 • committed by GitHub
commit d060b36e1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 760 additions and 135 deletions

View file

@ -1,9 +1,13 @@
import urllib.parse
from collections.abc import AsyncGenerator
import httpx2
import jwt
import pytest
from inline_snapshot import snapshot
from pydantic import AnyHttpUrl
from mcp.client.auth import OAuthClientProvider, OAuthFlowError
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
PrivateKeyJWTOAuthProvider,
@ -27,7 +31,7 @@ class MockTokenStorage:
async def get_tokens(self) -> OAuthToken | None:
return self._tokens
async def set_tokens(self, tokens: OAuthToken) -> None: # pragma: no cover
async def set_tokens(self, tokens: OAuthToken) -> None:
self._tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None: # pragma: no cover
@ -325,3 +329,206 @@ class TestStaticAssertionProvider:
assert result1 == token
assert result2 == token
_SERVER_URL = "https://api.example.com/v1/mcp"
_CONFIGURED_ISSUER = "https://auth.example.com"
def _metadata_for(issuer: str) -> dict[str, str]:
return {"issuer": issuer, "authorization_endpoint": f"{issuer}/authorize", "token_endpoint": f"{issuer}/token"}
def _provider_with_issuer(kind: str, storage: MockTokenStorage, audiences: list[str]) -> OAuthClientProvider:
"""A ClientCredentials ("secret") or PrivateKeyJWT ("jwt") provider configured for _CONFIGURED_ISSUER;
`audiences` records every audience an assertion is minted for."""
if kind == "secret":
return ClientCredentialsOAuthProvider(
server_url=_SERVER_URL, storage=storage, client_id="cid", client_secret="csecret", issuer=_CONFIGURED_ISSUER
)
async def assertion_provider(audience: str) -> str:
audiences.append(audience)
return "signed-assertion"
return PrivateKeyJWTOAuthProvider(
server_url=_SERVER_URL,
storage=storage,
client_id="cid",
assertion_provider=assertion_provider,
issuer=_CONFIGURED_ISSUER,
)
async def _answer_discovery(
flow: AsyncGenerator[httpx2.Request, httpx2.Response],
*,
authorization_server: str | list[str] | None,
metadata: dict[str, str] | None,
) -> httpx2.Request:
"""Answer the provider's first request with a 401 and its discovery requests as described;
return the request it builds once discovery is over.
`authorization_server` is what protected-resource metadata advertises (None: no PRM is
served); `metadata` is the authorization server metadata document (None: every well-known
404s).
"""
request = await flow.__anext__()
request = await flow.asend(httpx2.Response(401, request=request))
while "/.well-known/oauth-protected-resource" in str(request.url):
if authorization_server is None:
response = httpx2.Response(404, request=request)
else:
advertised = authorization_server if isinstance(authorization_server, list) else [authorization_server]
prm = {"resource": _SERVER_URL, "authorization_servers": advertised}
response = httpx2.Response(200, json=prm, request=request)
request = await flow.asend(response)
while "/.well-known/" in str(request.url):
if metadata is None:
response = httpx2.Response(404, request=request)
else:
response = httpx2.Response(200, json=metadata, request=request)
request = await flow.asend(response)
return request
@pytest.mark.anyio
@pytest.mark.parametrize(
"served_issuer", [_CONFIGURED_ISSUER, f"{_CONFIGURED_ISSUER}/"], ids=["as-configured", "root-slash"]
)
@pytest.mark.parametrize("kind", ["secret", "jwt"])
async def test_provider_with_configured_issuer_exchanges_at_that_issuer(
mock_storage: MockTokenStorage, kind: str, served_issuer: str
):
"""SDK-defined: with `issuer=` set and metadata discovered for that issuer (a root issuer served with
its trailing slash is the same server), the token request goes to its token endpoint (positive
control for the refusals below)."""
audiences: list[str] = []
provider = _provider_with_issuer(kind, mock_storage, audiences)
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
metadata = {**_metadata_for(_CONFIGURED_ISSUER), "issuer": served_issuer}
token_request = await _answer_discovery(flow, authorization_server=served_issuer, metadata=metadata)
assert (token_request.method, str(token_request.url)) == ("POST", "https://auth.example.com/token")
assert audiences == ([] if kind == "secret" else [served_issuer])
await flow.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["secret", "jwt"])
async def test_provider_picks_its_configured_issuer_among_several_advertised_servers(
mock_storage: MockTokenStorage, kind: str
):
"""SDK-defined: when the resource lists several authorization servers, the one matching `issuer=` is
discovered and used even if it is not listed first."""
provider = _provider_with_issuer(kind, mock_storage, [])
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
token_request = await _answer_discovery(
flow,
authorization_server=["https://other-as.example.com", _CONFIGURED_ISSUER],
metadata=_metadata_for(_CONFIGURED_ISSUER),
)
assert provider.context.auth_server_url == _CONFIGURED_ISSUER
assert str(token_request.url) == "https://auth.example.com/token"
await flow.aclose()
def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None:
"""SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration
error on both machine-to-machine providers."""
with pytest.raises(ValueError) as cc_error:
ClientCredentialsOAuthProvider(
server_url=_SERVER_URL, storage=mock_storage, client_id="cid", client_secret="s", issuer="auth.example.com"
)
with pytest.raises(ValueError) as jwt_error:
PrivateKeyJWTOAuthProvider(
server_url=_SERVER_URL,
storage=mock_storage,
client_id="cid",
assertion_provider=static_assertion_provider("jwt"),
issuer="auth.example.com",
)
assert (
str(cc_error.value)
== str(jwt_error.value)
== snapshot("issuer must be the authorization server's http(s) issuer URL, got 'auth.example.com'")
)
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["secret", "jwt"])
async def test_provider_refuses_metadata_for_a_different_issuer(mock_storage: MockTokenStorage, kind: str):
"""SDK-defined: when discovery ends at an authorization server other than the configured `issuer`,
no token request is built and no assertion is minted."""
audiences: list[str] = []
provider = _provider_with_issuer(kind, mock_storage, audiences)
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
with pytest.raises(OAuthFlowError) as exc_info:
await _answer_discovery(
flow,
authorization_server="https://other-as.example.com",
metadata=_metadata_for("https://other-as.example.com"),
)
assert str(exc_info.value) == snapshot(
"Authorization server metadata issuer mismatch: https://other-as.example.com != https://auth.example.com"
)
assert audiences == []
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["secret", "jwt"])
async def test_provider_refuses_to_exchange_without_metadata_when_issuer_configured(
mock_storage: MockTokenStorage, kind: str
):
"""SDK-defined: with `issuer=` set, the 2025-03-26 default `/token` on the resource origin is not
used when no authorization server metadata could be discovered."""
audiences: list[str] = []
provider = _provider_with_issuer(kind, mock_storage, audiences)
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
with pytest.raises(OAuthFlowError) as exc_info:
await _answer_discovery(flow, authorization_server=None, metadata=None)
assert str(exc_info.value) == snapshot(
"No authorization server metadata discovered for configured issuer https://auth.example.com"
)
assert audiences == []
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["secret", "jwt"])
async def test_a_refused_authorization_server_is_forgotten_so_the_next_request_rediscovers(
mock_storage: MockTokenStorage, kind: str
):
"""SDK-defined: when the exchange is refused because discovery ended somewhere other than the
configured issuer, the refused metadata and any token held are dropped; the next request goes out
unauthenticated and discovery starts again, rather than a refresh being built from what was refused."""
provider = _provider_with_issuer(kind, mock_storage, [])
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
token_request = await _answer_discovery(
flow, authorization_server=_CONFIGURED_ISSUER, metadata=_metadata_for(_CONFIGURED_ISSUER)
)
token = {"access_token": "first", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rt"}
retried = await flow.asend(httpx2.Response(200, json=token, request=token_request))
with pytest.raises(StopAsyncIteration):
await flow.asend(httpx2.Response(200, request=retried))
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
with pytest.raises(OAuthFlowError):
await _answer_discovery(
flow,
authorization_server="https://other-as.example.com",
metadata=_metadata_for("https://other-as.example.com"),
)
assert provider.context.oauth_metadata is None
assert provider.context.current_tokens is None
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
request = await flow.__anext__()
assert (str(request.url), request.headers.get("Authorization")) == (_SERVER_URL, None)
await flow.aclose()

View file

@ -1502,8 +1502,11 @@ class TestAuthFlow:
request=request,
)
# Trigger step-up - should get token exchange request
token_exchange_request = await auth_flow.asend(response_403)
# Trigger step-up - discovery runs first (nothing published here), then the token exchange
prm_request = await auth_flow.asend(response_403)
prm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request))
asm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request))
token_exchange_request = await auth_flow.asend(httpx2.Response(404, request=asm_request))
# Verify scope was updated to the union of prior and challenged scopes (SEP-2350)
assert oauth_provider.context.client_metadata.scope == "read write admin:write admin:delete"
@ -1576,7 +1579,10 @@ async def test_403_step_up_preserves_scope_from_stored_token(
headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="write"'},
request=request,
)
token_exchange_request = await auth_flow.asend(response_403)
prm_request = await auth_flow.asend(response_403)
prm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request))
asm_request = await auth_flow.asend(httpx2.Response(404, request=prm_request))
token_exchange_request = await auth_flow.asend(httpx2.Response(404, request=asm_request))
assert reauthorize_scope == "read write"
@ -1593,6 +1599,101 @@ async def test_403_step_up_preserves_scope_from_stored_token(
pass
@pytest.mark.anyio
async def test_scope_step_up_discovers_the_authorization_server_before_reauthorizing(
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken
):
"""SDK-defined: a 403 scope challenge runs discovery first when no metadata is held, so
re-authorization targets the advertised server.
Steps:
1. A restarted client holds a token and a registration but no authorization server metadata.
2. The first response is 403 insufficient_scope -> the next requests are PRM (at the challenge's
`resource_metadata` URL) then ASM discovery.
3. The authorization redirect and the token request use the discovered server's endpoints, and
the requested scope is the SEP-2350 union.
"""
await mock_storage.set_tokens(valid_tokens)
await mock_storage.set_client_info(
OAuthClientInformationFull(
client_id="registered-client", redirect_uris=[AnyUrl("http://localhost:3030/callback")]
)
)
redirects: list[str] = []
async def record_redirect(url: str) -> None:
redirects.append(url)
async def echo_callback() -> AuthorizationCodeResult:
state = parse_qs(urlparse(redirects[-1]).query)["state"][0]
return AuthorizationCodeResult(code="auth_code", state=state)
oauth_provider.context.redirect_handler = record_redirect
oauth_provider.context.callback_handler = echo_callback
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
response_403 = httpx2.Response(
403,
headers={
"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="admin",'
' resource_metadata="https://api.example.com/v1/mcp/resource-metadata"'
},
request=request,
)
prm_request = await auth_flow.asend(response_403)
assert prm_request.method == "GET"
assert str(prm_request.url) == "https://api.example.com/v1/mcp/resource-metadata"
prm_response = httpx2.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
request=prm_request,
)
asm_request = await auth_flow.asend(prm_response)
assert str(asm_request.url) == "https://auth.example.com/.well-known/oauth-authorization-server"
asm_response = httpx2.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=asm_request,
)
token_request = await auth_flow.asend(asm_response)
assert redirects[-1].startswith("https://auth.example.com/authorize?")
assert parse_qs(urlparse(redirects[-1]).query)["scope"] == ["read write admin"]
assert token_request.method == "POST"
assert str(token_request.url) == "https://auth.example.com/token"
final_request = await auth_flow.asend(
httpx2.Response(
200, json={"access_token": "stepped_up", "token_type": "Bearer", "expires_in": 3600}, request=token_request
)
)
assert final_request.headers["Authorization"] == "Bearer stepped_up"
with pytest.raises(StopAsyncIteration):
await auth_flow.asend(httpx2.Response(200, request=final_request))
@pytest.mark.anyio
async def test_403_without_a_scope_challenge_is_returned_to_the_caller(
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken
):
"""SDK-defined: a 403 that is not an insufficient_scope challenge ends the flow; the request is
not retried."""
await mock_storage.set_tokens(valid_tokens)
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
response_403 = httpx2.Response(403, headers={"WWW-Authenticate": 'Bearer error="access_denied"'}, request=request)
with pytest.raises(StopAsyncIteration):
await auth_flow.asend(response_403)
@pytest.mark.parametrize(
(
"issuer_url",
@ -1818,8 +1919,8 @@ class TestLegacyServerFallback:
prm_request_1 = await auth_flow.asend(response)
assert str(prm_request_1.url) == "https://custom.prm.com/.well-known/oauth-protected-resource"
# Returns 500
prm_response_1 = httpx2.Response(500, request=prm_request_1)
# Not served there
prm_response_1 = httpx2.Response(404, request=prm_request_1)
# Try path-based fallback
prm_request_2 = await auth_flow.asend(prm_response_1)
@ -2891,6 +2992,25 @@ def test_credentials_match_issuer_different_issuer():
assert credentials_match_issuer(info, "https://other", None) is False
@pytest.mark.parametrize(
("recorded", "current"),
[("https://as.example.com", "https://as.example.com/"), ("https://as.example.com/", "https://as.example.com")],
ids=["current-has-root-slash", "recorded-has-root-slash"],
)
def test_credentials_match_issuer_treats_root_slash_as_the_same_issuer(recorded: str, current: str):
"""SDK-defined: a root issuer recorded with and without its trailing slash names the same server."""
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer=recorded)
assert credentials_match_issuer(info, current, None) is True
def test_credentials_match_issuer_root_slash_tolerance_does_not_extend_to_other_paths():
"""SDK-defined: a trailing slash on a non-root path is a different issuer."""
info = OAuthClientInformationFull(
client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")], issuer="https://as.example.com/tenant"
)
assert credentials_match_issuer(info, "https://as.example.com/tenant/", None) is False
def test_credentials_match_issuer_no_recorded_issuer_is_left_alone():
"""Credentials with no bound issuer (pre-registered / legacy) carry no binding to enforce."""
info = OAuthClientInformationFull(client_id="c", redirect_uris=[AnyUrl("http://localhost/cb")])
@ -3008,15 +3128,14 @@ async def test_handle_refresh_response_adopts_rotated_refresh_token_when_returne
@pytest.mark.anyio
async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed(
async def test_issuer_binding_evaluated_against_the_server_origin_when_prm_discovery_failed(
oauth_provider: OAuthClientProvider,
):
"""SEP-2352: on the legacy no-PRM path the binding check uses the ASM-discovered issuer.
"""SEP-2352: on the legacy no-PRM path the binding check uses the resource server's origin.
PRM discovery fails (404) so ``auth_server_url`` stays ``None`` and the post-PRM check is
skipped; when ASM discovery then succeeds via the root well-known fallback, the discovered
metadata's issuer is compared against the stored credentials' bound issuer and a mismatch
triggers re-registration.
PRM discovery fails (404) so ``auth_server_url`` stays ``None``; the expected issuer is then the
origin the legacy well-known URL is built from, so stored credentials bound to another issuer are
discarded before ASM discovery runs, and re-registration follows.
"""
oauth_provider.context.current_tokens = None
oauth_provider.context.token_expiry_time = None
@ -3037,9 +3156,11 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed(
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource"
# ASM discovery via root fallback (no auth_server_url) succeeds with a different issuer.
# ASM discovery via root fallback (no auth_server_url): the stale credentials are already
# gone when the request is issued.
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server"
assert oauth_provider.context.client_info is None
asm_response = httpx2.Response(
200,
content=(
@ -3060,6 +3181,208 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed(
await auth_flow.aclose()
@pytest.mark.anyio
async def test_legacy_fallback_metadata_naming_a_different_issuer_is_refused(oauth_provider: OAuthClientProvider):
"""RFC 8414 section 3.3 on the legacy no-PRM path: metadata served from the resource server's
own well-known must name that origin as its issuer; anything else stops the flow before any
authorization or token request is built."""
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
# 401 without WWW-Authenticate; both PRM well-knowns 404; legacy root ASM discovery.
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server"
asm_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://other-as.example.com", '
b'"authorization_endpoint": "https://other-as.example.com/authorize", '
b'"token_endpoint": "https://other-as.example.com/token"}'
),
request=asm_req,
)
with pytest.raises(OAuthFlowError) as exc_info:
await auth_flow.asend(asm_response)
assert str(exc_info.value) == snapshot(
"Authorization server metadata issuer mismatch: https://other-as.example.com != https://api.example.com"
)
@pytest.mark.anyio
@pytest.mark.parametrize(
"first_response",
[(401, {}), (403, {"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="admin"'})],
ids=["401", "403-insufficient-scope"],
)
async def test_legacy_fallback_without_metadata_re_registers_instead_of_presenting_credentials_bound_elsewhere(
oauth_provider: OAuthClientProvider,
mock_storage: MockTokenStorage,
valid_tokens: OAuthToken,
first_response: tuple[int, dict[str, str]],
):
"""SEP-2352 on the legacy no-PRM path when no metadata is served at all, whether the flow starts
from a 401 or from a 403 scope challenge with no metadata held.
Steps:
1. Storage holds a token and a confidential client bound to a different authorization server.
2. Both PRM well-knowns 404 -> the expected issuer is the resource server's origin, so the stored
client is discarded before ASM discovery.
3. The origin's ASM well-known 404s too -> the flow registers a fresh client at the
origin's default `/register` and authorizes with it; the token request to the origin's
default `/token` carries the new client and none of the discarded credentials.
"""
await mock_storage.set_tokens(valid_tokens)
await mock_storage.set_client_info(
OAuthClientInformationFull(
client_id="confidential-client",
client_secret="stored-secret",
token_endpoint_auth_method="client_secret_post",
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
issuer="https://other-as.example.com",
)
)
redirects: list[str] = []
async def record_redirect(url: str) -> None:
redirects.append(url)
async def echo_callback() -> AuthorizationCodeResult:
state = parse_qs(urlparse(redirects[-1]).query)["state"][0]
return AuthorizationCodeResult(code="auth_code", state=state)
oauth_provider.context.redirect_handler = record_redirect
oauth_provider.context.callback_handler = echo_callback
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
status, headers = first_response
prm_req = await auth_flow.asend(httpx2.Response(status, headers=headers, request=request))
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server"
# No metadata at the origin either: register at the origin's default endpoint.
register_req = await auth_flow.asend(httpx2.Response(404, request=asm_req))
assert register_req.method == "POST"
assert str(register_req.url) == "https://api.example.com/register"
register_response = httpx2.Response(
201,
json={
"client_id": "origin-client",
"redirect_uris": ["http://localhost:3030/callback"],
"token_endpoint_auth_method": "none",
},
request=register_req,
)
token_req = await auth_flow.asend(register_response)
assert str(token_req.url) == "https://api.example.com/token"
assert redirects[-1].startswith("https://api.example.com/authorize?")
token_form = parse_qs(token_req.content.decode())
assert token_form["client_id"] == ["origin-client"]
assert "client_secret" not in token_form
await auth_flow.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize("status", [500, 503, 429])
async def test_a_failing_resource_metadata_request_stops_the_flow_and_keeps_stored_credentials(
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken, status: int
):
"""SDK-defined: a server error (or 429) on a protected resource metadata request says nothing about
whether the server publishes that metadata. The remaining well-known locations are still tried, but
when none answers the flow stops instead of taking the legacy path, and a registration bound to the
advertised authorization server and its tokens stay as they were."""
bound = OAuthClientInformationFull(
client_id="registered-client",
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
issuer="https://auth.example.com",
)
await mock_storage.set_client_info(bound)
await mock_storage.set_tokens(valid_tokens)
flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await flow.__anext__()
prm_request = await flow.asend(httpx2.Response(401, request=request))
root_prm_request = await flow.asend(httpx2.Response(status, request=prm_request))
assert str(root_prm_request.url) == "https://api.example.com/.well-known/oauth-protected-resource"
with pytest.raises(OAuthFlowError) as exc_info:
await flow.asend(httpx2.Response(404, request=root_prm_request))
assert str(exc_info.value) == f"Protected resource metadata request failed: HTTP {status}"
assert oauth_provider.context.client_info == bound
assert oauth_provider.context.current_tokens == valid_tokens
assert await mock_storage.get_client_info() == bound
@pytest.mark.anyio
async def test_a_failing_resource_metadata_location_does_not_matter_when_another_one_answers(
oauth_provider: OAuthClientProvider,
):
"""SDK-defined: the well-known locations are tried in order; an error at one of them is forgotten
once a later one returns the metadata."""
flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await flow.__anext__()
prm_request = await flow.asend(httpx2.Response(401, request=request))
root_prm_request = await flow.asend(httpx2.Response(503, request=prm_request))
prm = b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}'
asm_request = await flow.asend(httpx2.Response(200, content=prm, request=root_prm_request))
assert str(asm_request.url) == "https://auth.example.com/.well-known/oauth-authorization-server"
await flow.aclose()
@pytest.mark.anyio
async def test_legacy_fallback_accepts_a_root_slash_issuer_for_a_server_url_in_any_spelling(
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
):
"""SDK-defined: on the legacy no-PRM path the expected issuer is the resource server's origin; a
`server_url` written with an upper-case host and an explicit default port still matches metadata
naming that origin, with or without the trailing slash a root issuer is often rendered with, and
the flow proceeds to registration."""
async def redirect_handler(url: str) -> None:
raise NotImplementedError
async def callback_handler() -> AuthorizationCodeResult:
raise NotImplementedError
provider = OAuthClientProvider(
server_url="https://API.Example.com:443/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
)
auth_flow = provider.async_auth_flow(httpx2.Request("GET", "https://API.Example.com:443/v1/mcp"))
request = await auth_flow.__anext__()
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
asm_response = httpx2.Response(
200,
content=(
b'{"issuer": "https://api.example.com/", '
b'"authorization_endpoint": "https://api.example.com/authorize", '
b'"token_endpoint": "https://api.example.com/token", '
b'"registration_endpoint": "https://api.example.com/register"}'
),
request=asm_req,
)
register_req = await auth_flow.asend(asm_response)
assert register_req.method == "POST"
assert str(register_req.url) == "https://api.example.com/register"
await auth_flow.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize(
"asm_responses",

View file

@ -9,6 +9,7 @@ from pydantic import AnyUrl, ValidationError
from docs_src.oauth_clients import tutorial001, tutorial002
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
PrivateKeyJWTOAuthProvider,
static_assertion_provider,
)
@ -80,6 +81,14 @@ async def test_client_credentials_provider_builds_its_own_metadata() -> None:
assert metadata.scope == "user"
@pytest.mark.parametrize("provider_class", [ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider])
async def test_issuer_is_an_optional_keyword_on_both_machine_to_machine_providers(provider_class: type) -> None:
"""tutorial002 passes `issuer=`; the page says leaving it out is allowed, on either provider."""
issuer = inspect.signature(provider_class.__init__).parameters["issuer"]
assert issuer.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD
assert issuer.default is None
async def test_the_two_remaining_keyword_arguments_have_defaults() -> None:
"""The page names `client_metadata_url` and `validate_resource_url` as the remainder."""
parameters = inspect.signature(OAuthClientProvider.__init__).parameters