6b0166f3d1
OAuthClientInformationFull, the client's parse of the authorization server's Dynamic Client Registration response, inherited from OAuthClientMetadata, the request the client sends. That typed the response as though it had to be a request this SDK would send. RFC 7591 3.2.1 says otherwise: the server may reject or replace any requested metadata value, and real servers echo an application_type outside OIDC Registration's web/native, an explicit null, an auth method the SDK does not implement, or an empty redirect_uris. Each raised ValidationError on a 2xx response, after the server had already provisioned the client, so the registration was discarded and orphaned. Make the two models siblings over a shared OAuthClientMetadataBase. The request keeps its strict types, so the SDK still refuses to send an unregistered application_type. The record accepts what a server may echo: application_type and token_endpoint_auth_method are str | None (with an echoed "" read as absent, as the optional URL fields already were), grant_types is list[str], and redirect_uris may be absent or empty. client_id is now required, as RFC 7591 3.2.1 makes it in the response. Whether a substituted value is usable is judged where it matters, not at parse: an auth method the client cannot apply is reported as an OAuthRegistrationError when the registration completes, before the record is stored or any interactive authorization begins, and prepare_token_auth reports the same for a stored record. The recognized set is derived from the one TokenEndpointAuthMethod type so the two cannot drift. The bundled registration endpoint now returns all registered metadata in its 201 response, building the record from the validated request's dump so a field can no longer be silently dropped from the echo; it previously omitted application_type, reporting the default in place of a client's "web".
415 lines
19 KiB
Python
415 lines
19 KiB
Python
"""Protected-resource and authorization-server metadata discovery, end to end.
|
|
|
|
Every client-side test connects a real `Client` via `connect_with_oauth` and asserts on the
|
|
recorded request paths the discovery probes produced; the discovery URL ordering is a wire
|
|
detail `Client` cannot observe directly but the recording can. Tests that need a metadata
|
|
endpoint to 404 or return alternate content wrap the SDK's app in `shimmed_app` while leaving
|
|
the real authorize and token endpoints behind it, so the rest of the flow runs unaltered.
|
|
|
|
The two server-side tests (#5, #6) drive raw httpx2 against `mounted_app` because their
|
|
assertions are the metadata response bodies and headers, which `Client` does not surface.
|
|
"""
|
|
|
|
import json
|
|
|
|
import anyio
|
|
import mcp_types as types
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
from mcp_types import ListToolsResult, Tool
|
|
from pydantic import AnyHttpUrl, AnyUrl
|
|
|
|
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, ProtectedResourceMetadata
|
|
from tests.interaction._connect import BASE_URL, mounted_app
|
|
from tests.interaction._requirements import requirement
|
|
from tests.interaction.auth._harness import (
|
|
REDIRECT_URI,
|
|
InMemoryTokenStorage,
|
|
RecordedRequest,
|
|
auth_settings,
|
|
connect_with_oauth,
|
|
metadata_body,
|
|
record_requests,
|
|
shim,
|
|
)
|
|
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
PRM_PATH_SUFFIXED = "/.well-known/oauth-protected-resource/mcp"
|
|
PRM_ROOT = "/.well-known/oauth-protected-resource"
|
|
ASM_ROOT = "/.well-known/oauth-authorization-server"
|
|
OIDC_ROOT = "/.well-known/openid-configuration"
|
|
|
|
|
|
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
|
return ListToolsResult(tools=[Tool(name="probe", input_schema={"type": "object"})])
|
|
|
|
|
|
def discovery_gets(recorded: list[RecordedRequest]) -> list[str]:
|
|
"""Return the well-known GET paths in recorded order, ignoring everything else."""
|
|
return [r.path for r in recorded if r.method == "GET" and "/.well-known/" in r.path]
|
|
|
|
|
|
def real_asm() -> OAuthMetadata:
|
|
"""Build an authorization-server metadata document pointing at the real co-hosted endpoints."""
|
|
return OAuthMetadata(
|
|
issuer=AnyHttpUrl(BASE_URL),
|
|
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"),
|
|
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
|
|
registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"),
|
|
scopes_supported=["mcp"],
|
|
grant_types_supported=["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported=["S256"],
|
|
)
|
|
|
|
|
|
@requirement("client-auth:prm-discovery:fallback-order")
|
|
async def test_prm_discovery_uses_the_resource_metadata_url_from_www_authenticate() -> None:
|
|
"""The first protected-resource probe is the URL the 401's `WWW-Authenticate` header supplied.
|
|
|
|
With co-hosted defaults the header carries the path-suffixed well-known URL; the client
|
|
fetches that one first and, because it succeeds, never falls back. The single-probe
|
|
sequence proves priority 1.
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
|
|
with anyio.fail_after(5):
|
|
async with connect_with_oauth(server, provider=provider, on_request=on_request) as (client, _):
|
|
await client.list_tools()
|
|
|
|
assert discovery_gets(recorded) == snapshot([PRM_PATH_SUFFIXED, ASM_ROOT])
|
|
assert (recorded[0].method, recorded[0].path) == ("POST", "/mcp")
|
|
assert (recorded[1].method, recorded[1].path) == ("GET", PRM_PATH_SUFFIXED)
|
|
|
|
|
|
@requirement("client-auth:prm-discovery:fallback-order")
|
|
async def test_prm_discovery_falls_back_from_path_well_known_to_root_on_404() -> None:
|
|
"""When the path-suffixed PRM well-known 404s, the client falls back to the root well-known.
|
|
|
|
The exact GET count is not asserted: the WWW-Authenticate URL equals the path well-known
|
|
here, so the SDK probes it twice (once as priority 1, once as priority 2) before reaching
|
|
root. Asserting "path before root, root reached, then the flow proceeds" pins the spec
|
|
invariant; the duplicate probe is an implementation detail. The served PRM body carries an
|
|
unrecognized field to prove the client's parser ignores unknown members (RFC 9728 §3.2).
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
|
|
prm = ProtectedResourceMetadata(
|
|
resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(BASE_URL)]
|
|
)
|
|
app_shim = shim(
|
|
not_found=frozenset({PRM_PATH_SUFFIXED}),
|
|
serve={PRM_ROOT: metadata_body(prm, x_unknown_extension="ignored")},
|
|
)
|
|
|
|
with anyio.fail_after(5):
|
|
async with connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request) as (
|
|
client,
|
|
_,
|
|
):
|
|
await client.list_tools()
|
|
|
|
well_known = discovery_gets(recorded)
|
|
assert PRM_PATH_SUFFIXED in well_known
|
|
assert PRM_ROOT in well_known
|
|
assert well_known.index(PRM_PATH_SUFFIXED) < well_known.index(PRM_ROOT)
|
|
assert any(r.path == "/authorize" for r in recorded)
|
|
|
|
|
|
@requirement("client-auth:prm-discovery:no-prm-fallback")
|
|
async def test_when_every_prm_probe_fails_the_client_discovers_as_metadata_at_the_server_origin() -> None:
|
|
"""When every protected-resource metadata probe 404s, the client falls back to the legacy path.
|
|
|
|
The legacy 2025-03-26 behaviour: with no PRM document available, treat the MCP server's
|
|
origin as the authorization server and fetch its `/.well-known/oauth-authorization-server`
|
|
directly. The real co-hosted ASM endpoint is at exactly that location, so the flow completes.
|
|
The recorded sequence shows both PRM well-known paths probed (and failed) before ASM_ROOT.
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
app_shim = shim(not_found=frozenset({PRM_PATH_SUFFIXED, PRM_ROOT}))
|
|
|
|
with anyio.fail_after(5):
|
|
async with connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request) as (
|
|
client,
|
|
_,
|
|
):
|
|
result = await client.list_tools()
|
|
|
|
well_known = discovery_gets(recorded)
|
|
assert PRM_PATH_SUFFIXED in well_known
|
|
assert PRM_ROOT in well_known
|
|
assert well_known[-1] == ASM_ROOT
|
|
assert all(well_known.index(prm) < well_known.index(ASM_ROOT) for prm in (PRM_PATH_SUFFIXED, PRM_ROOT))
|
|
assert result.tools[0].name == "probe"
|
|
|
|
|
|
@requirement("client-auth:dcr:registration-error-surfaces")
|
|
async def test_a_400_from_the_registration_endpoint_surfaces_as_a_registration_error() -> None:
|
|
"""A 400 from `/register` surfaces as `OAuthRegistrationError` carrying the server's body.
|
|
|
|
The shim makes `/register` return RFC 7591's `invalid_client_metadata`; the SDK reads the
|
|
body and raises with the status and text in the message, before any authorize or token
|
|
request is made.
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
error_body = json.dumps({"error": "invalid_client_metadata", "error_description": "no"}).encode()
|
|
app_shim = shim(serve={"/register": (400, error_body)})
|
|
|
|
with anyio.fail_after(5):
|
|
with pytest.RaisesGroup(
|
|
pytest.RaisesExc(OAuthRegistrationError, match=r"^Registration failed: 400 .*invalid_client_metadata"),
|
|
flatten_subgroups=True,
|
|
):
|
|
await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()
|
|
|
|
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
|
|
|
|
|
|
@requirement("client-auth:dcr:substituted-metadata")
|
|
async def test_a_registration_response_with_substituted_metadata_completes_the_flow() -> None:
|
|
"""A 201 whose echoed metadata differs from the request still yields a working client.
|
|
|
|
The shim replaces the real `/register` with a body that echoes an `application_type`
|
|
outside OIDC Registration's set, a null `redirect_uris`, and an extra grant type - a
|
|
substitution RFC 7591 §3.2.1 permits. The registration proceeds and, after `/register`
|
|
stopped answering, the client authorizes and exchanges a token as normal.
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
client_id = "substituted-client"
|
|
provider.clients[client_id] = OAuthClientInformationFull(
|
|
client_id=client_id,
|
|
client_secret="s3cr3t",
|
|
redirect_uris=[AnyUrl(REDIRECT_URI)],
|
|
token_endpoint_auth_method="client_secret_post",
|
|
scope="mcp",
|
|
)
|
|
body = json.dumps(
|
|
{
|
|
"client_id": client_id,
|
|
"client_secret": "s3cr3t",
|
|
"token_endpoint_auth_method": "client_secret_post",
|
|
"application_type": "confidential",
|
|
"redirect_uris": None,
|
|
"grant_types": ["authorization_code", "refresh_token", "client_credentials"],
|
|
}
|
|
).encode()
|
|
app_shim = shim(serve={"/register": (201, body)})
|
|
storage = InMemoryTokenStorage()
|
|
|
|
with anyio.fail_after(5):
|
|
async with connect_with_oauth(
|
|
server, provider=provider, storage=storage, app_shim=app_shim, on_request=on_request
|
|
) as (client, _):
|
|
result = await client.list_tools()
|
|
|
|
assert result.tools[0].name == snapshot("probe")
|
|
assert storage.client_info is not None
|
|
assert storage.client_info.client_id == client_id
|
|
assert storage.client_info.application_type == "confidential"
|
|
assert [r.path for r in recorded].index("/register") < [r.path for r in recorded].index("/token")
|
|
|
|
|
|
@requirement("client-auth:dcr:substituted-metadata")
|
|
async def test_a_registration_assigning_an_unusable_auth_method_surfaces_as_a_registration_error() -> None:
|
|
"""A 201 assigning an auth method the client cannot apply is a registration error.
|
|
|
|
RFC 7591 §3.2.1 leaves it to the client to judge whether a substituted value makes the
|
|
registration usable; an unimplemented token-endpoint auth method does not, and is
|
|
reported before the record is stored or any authorize/token request is made.
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
body = json.dumps(
|
|
{"client_id": "jwt-only", "client_secret": "s3cr3t", "token_endpoint_auth_method": "client_secret_jwt"}
|
|
).encode()
|
|
app_shim = shim(serve={"/register": (201, body)})
|
|
storage = InMemoryTokenStorage()
|
|
|
|
with anyio.fail_after(5):
|
|
with pytest.RaisesGroup(pytest.RaisesExc(OAuthRegistrationError), flatten_subgroups=True):
|
|
await connect_with_oauth(
|
|
server, provider=provider, storage=storage, app_shim=app_shim, on_request=on_request
|
|
).__aenter__()
|
|
|
|
assert storage.client_info is None
|
|
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
|
|
|
|
|
|
@requirement("client-auth:prm-resource-mismatch")
|
|
async def test_prm_with_a_mismatched_resource_aborts_the_flow_before_authorize() -> None:
|
|
"""A PRM document whose `resource` does not cover the server URL aborts the flow.
|
|
|
|
The shim serves PRM at the URL the WWW-Authenticate header supplies, but with a `resource`
|
|
on a different path; `check_resource_allowed` rejects it and `OAuthFlowError` is raised
|
|
before any authorize or token request is made. The error reaches the test wrapped in nested
|
|
single-element exception groups by the streamable-HTTP client's task group.
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
|
|
prm = ProtectedResourceMetadata(
|
|
resource=AnyHttpUrl(f"{BASE_URL}/other"), authorization_servers=[AnyHttpUrl(BASE_URL)]
|
|
)
|
|
app_shim = shim(serve={PRM_PATH_SUFFIXED: metadata_body(prm)})
|
|
|
|
with anyio.fail_after(5):
|
|
with pytest.RaisesGroup(
|
|
pytest.RaisesExc(OAuthFlowError, match="^Protected resource .* does not match expected"),
|
|
flatten_subgroups=True,
|
|
):
|
|
await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()
|
|
|
|
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
|
|
|
|
|
|
@requirement("client-auth:as-metadata-discovery:priority-order")
|
|
@pytest.mark.parametrize(
|
|
("authorization_server", "not_found", "serve_at", "expected_order"),
|
|
[
|
|
pytest.param(
|
|
f"{BASE_URL}/",
|
|
frozenset({ASM_ROOT}),
|
|
OIDC_ROOT,
|
|
[ASM_ROOT, OIDC_ROOT],
|
|
id="root-issuer",
|
|
),
|
|
pytest.param(
|
|
f"{BASE_URL}/tenant",
|
|
frozenset({f"{ASM_ROOT}/tenant", f"{OIDC_ROOT}/tenant"}),
|
|
"/tenant/.well-known/openid-configuration",
|
|
[f"{ASM_ROOT}/tenant", f"{OIDC_ROOT}/tenant", "/tenant/.well-known/openid-configuration"],
|
|
id="path-issuer",
|
|
),
|
|
],
|
|
)
|
|
async def test_as_metadata_discovery_falls_back_through_the_spec_endpoint_order(
|
|
authorization_server: str, not_found: frozenset[str], serve_at: str, expected_order: list[str]
|
|
) -> None:
|
|
"""Authorization-server metadata is fetched at the spec's endpoints in the spec's order.
|
|
|
|
The shim 404s every endpoint before the last so the recording proves each probe and its
|
|
position. For an issuer URL with no path the order is OAuth root then OIDC root; for an
|
|
issuer URL with a path component it is OAuth path-inserted, OIDC path-inserted, then OIDC
|
|
path-appended (the spec's three-endpoint MUST). The path-issuer case is driven by serving
|
|
a PRM whose `authorization_servers` carries the path; the SDK's own AS routes stay at root
|
|
(the served body points at the real `/authorize` and `/token`). The served bodies carry an
|
|
unrecognized field to prove the client's parser ignores unknown members (RFC 8414 §3.2).
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
asm = real_asm()
|
|
asm.issuer = AnyHttpUrl(authorization_server)
|
|
# The redirect iss must equal the issuer the client records from this metadata.
|
|
provider = InMemoryAuthorizationServerProvider(issuer=str(asm.issuer))
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
|
|
prm = ProtectedResourceMetadata(
|
|
resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(authorization_server)]
|
|
)
|
|
app_shim = shim(
|
|
not_found=not_found,
|
|
serve={
|
|
PRM_PATH_SUFFIXED: metadata_body(prm),
|
|
serve_at: metadata_body(asm, x_unknown_extension="ignored"),
|
|
},
|
|
)
|
|
|
|
with anyio.fail_after(5):
|
|
async with connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request) as (
|
|
client,
|
|
_,
|
|
):
|
|
await client.list_tools()
|
|
|
|
assert discovery_gets(recorded) == [PRM_PATH_SUFFIXED, *expected_order]
|
|
|
|
|
|
@requirement("hosting:auth:metadata-endpoints")
|
|
@requirement("hosting:auth:prm:authorization-servers-field")
|
|
async def test_the_prm_endpoint_serves_the_resource_url_and_at_least_one_authorization_server() -> None:
|
|
"""The protected-resource metadata document the SDK serves identifies the resource and an authorization server.
|
|
|
|
Also asserts the response is `application/json` (RFC 9728 §3.2) and that fields the SDK has
|
|
no value for are absent rather than null (`PydanticJSONResponse` serializes with
|
|
`exclude_none=True`, satisfying RFC 9728 §3.2's omit-zero-value rule).
|
|
"""
|
|
server = Server("bare")
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
|
|
async with mounted_app(server, auth=auth_settings(), auth_server_provider=provider) as (http, _):
|
|
response = await http.get(PRM_PATH_SUFFIXED)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("application/json")
|
|
|
|
document = json.loads(response.content)
|
|
assert "resource_documentation" not in document
|
|
assert "scopes_supported" in document
|
|
|
|
metadata = ProtectedResourceMetadata.model_validate(document)
|
|
assert str(metadata.resource).rstrip("/") == f"{BASE_URL}/mcp"
|
|
assert len(metadata.authorization_servers) >= 1
|
|
assert metadata.bearer_methods_supported == ["header"]
|
|
|
|
|
|
@requirement("hosting:auth:as-router")
|
|
async def test_as_metadata_advertises_authorize_token_registration_and_s256() -> None:
|
|
"""The authorization-server metadata document the SDK serves names the required endpoints and S256."""
|
|
server = Server("bare")
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
|
|
async with mounted_app(server, auth=auth_settings(), auth_server_provider=provider) as (http, _):
|
|
response = await http.get(ASM_ROOT)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("application/json")
|
|
|
|
metadata = OAuthMetadata.model_validate_json(response.content)
|
|
assert str(metadata.issuer).rstrip("/") == BASE_URL
|
|
assert str(metadata.authorization_endpoint) == f"{BASE_URL}/authorize"
|
|
assert str(metadata.token_endpoint) == f"{BASE_URL}/token"
|
|
assert str(metadata.registration_endpoint) == f"{BASE_URL}/register"
|
|
assert metadata.response_types_supported == ["code"]
|
|
assert metadata.code_challenge_methods_supported is not None
|
|
assert "S256" in metadata.code_challenge_methods_supported
|
|
|
|
|
|
@requirement("client-auth:as-metadata-discovery:issuer-validation")
|
|
async def test_as_metadata_with_a_mismatched_issuer_aborts_the_flow() -> None:
|
|
"""Authorization-server metadata whose `issuer` does not match the discovery URL is rejected.
|
|
|
|
RFC 8414 §3.3 / SEP-2468 require the client to reject the document; the SDK compares `issuer`
|
|
to the URL the metadata was fetched from and raises `OAuthFlowError` before any authorize or
|
|
token request is made.
|
|
"""
|
|
recorded, on_request = record_requests()
|
|
provider = InMemoryAuthorizationServerProvider()
|
|
server = Server("guarded", on_list_tools=list_tools)
|
|
|
|
metadata = real_asm()
|
|
metadata.issuer = AnyHttpUrl(f"{BASE_URL}/wrong-issuer")
|
|
app_shim = shim(serve={ASM_ROOT: metadata_body(metadata)})
|
|
|
|
with anyio.fail_after(5):
|
|
with pytest.RaisesGroup(
|
|
pytest.RaisesExc(OAuthFlowError, match="^Authorization server metadata issuer mismatch"),
|
|
flatten_subgroups=True,
|
|
):
|
|
await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()
|
|
|
|
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
|