Validate the authorization server metadata issuer on every discovery path (#3398)
This commit is contained in:
parent
d2290ca343
commit
d060b36e1d
3 changed files with 760 additions and 135 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue