49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
340 lines
15 KiB
Python
340 lines
15 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 httpx 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
|
|
|
|
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
|
|
from tests.interaction._connect import BASE_URL, mounted_app
|
|
from tests.interaction._requirements import requirement
|
|
from tests.interaction.auth._harness import (
|
|
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: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")] == []
|