chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
@@ -0,0 +1,417 @@
"""Authorization-request, token-request, and PKCE wire-level invariants of the SDK's OAuth client.
Every test connects a real `Client` end to end via `connect_with_oauth`; the assertions are on
the parsed authorize URL and the recorded `/token` form body, because those wire shapes are what
the spec mandates and `Client` cannot observe them. The recording uses `record_requests`, which
snapshots each request at send time so the auth flow's in-place header mutation on retry never
affects what was captured for the first attempt.
Tests #1/#2/#4/#5 share one `recorded_oauth_flow` fixture (one connect, several disjoint
assertions on its recording); the others connect fresh because each needs a different harness
configuration.
"""
import base64
import hashlib
import json
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass
from urllib.parse import parse_qsl, quote, urlsplit
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
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from tests.interaction._connect import BASE_URL
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
REDIRECT_URI,
HeadlessOAuth,
InMemoryTokenStorage,
RecordedRequest,
auth_settings,
connect_with_oauth,
first_challenge_shim,
record_requests,
shimmed_app,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
PRM_PATH = "/.well-known/oauth-protected-resource/mcp"
ASM_PATH = "/.well-known/oauth-authorization-server"
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
def authorize_params(authorize_url: str) -> dict[str, str]:
"""Parse the authorize URL's query string into a flat dict (one value per key)."""
return dict(parse_qsl(urlsplit(authorize_url).query))
def form_body(request: RecordedRequest) -> dict[str, str]:
"""Parse an `application/x-www-form-urlencoded` request body into a flat dict."""
return dict(parse_qsl(request.content.decode()))
def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
"""Filter recorded requests by method and exact path."""
return [r for r in recorded if r.method == method and r.path == path]
@dataclass
class RecordedFlow:
"""One completed OAuth connect: every recorded request, plus the parsed authorize URL params."""
requests: list[RecordedRequest]
authorize_url: str
@property
def authorize(self) -> dict[str, str]:
return authorize_params(self.authorize_url)
@property
def token_request(self) -> RecordedRequest:
token_posts = find(self.requests, "POST", "/token")
assert len(token_posts) == 1
return token_posts[0]
@pytest.fixture
async def recorded_oauth_flow() -> AsyncIterator[RecordedFlow]:
"""Run one full OAuth connect with default configuration and yield its recorded wire traffic.
`valid_scopes` includes `offline_access` so the AS metadata advertises it and the SDK's
SEP-2207 auto-append (and the resulting `prompt=consent`) is exercised; `required_scopes`
stays at `["mcp"]` so the issued token still passes the bearer middleware.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "offline_access"])
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, settings=settings, on_request=on_request) as (
client,
headless,
):
await client.list_tools()
assert headless.authorize_url is not None
yield RecordedFlow(requests=recorded, authorize_url=headless.authorize_url)
@requirement("client-auth:pkce:s256")
@requirement("client-auth:resource-parameter")
@requirement("client-auth:authorize:offline-access-consent")
async def test_the_authorize_url_carries_s256_pkce_and_the_resource_indicator(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""Every spec-mandated parameter appears on the authorize URL with the right value.
The full key set is snapshotted so a parameter added or dropped fails the test. The
`code_challenge` length bound is the RFC 7636 §4.2 grammar; an S256 challenge is in
practice always 43 characters, so the upper bound is never approached.
"""
params = recorded_oauth_flow.authorize
assert sorted(params) == snapshot(
[
"client_id",
"code_challenge",
"code_challenge_method",
"prompt",
"redirect_uri",
"resource",
"response_type",
"scope",
"state",
]
)
assert params["response_type"] == "code"
assert params["code_challenge_method"] == "S256"
assert 43 <= len(params["code_challenge"]) <= 128
# The exact resource value depends on canonical-URI normalisation (a spec ambiguity); pin
# the stable prefix so the test does not lock in a trailing-slash decision.
assert params["resource"].startswith(BASE_URL)
assert params["state"] != ""
assert params["scope"].split(" ") == snapshot(["mcp", "offline_access"])
assert params["prompt"] == "consent"
@requirement("client-auth:pkce:s256")
async def test_the_code_verifier_on_the_token_request_hashes_to_the_code_challenge(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""The PKCE verifier sent on /token is the S256 pre-image of the challenge sent on /authorize.
The verifier is also checked against RFC 7636 §4.1's length and `unreserved` charset.
"""
challenge = recorded_oauth_flow.authorize["code_challenge"]
verifier = form_body(recorded_oauth_flow.token_request)["code_verifier"]
assert re.fullmatch(r"[A-Za-z0-9._~-]{43,128}", verifier)
assert base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") == challenge
@requirement("client-auth:state:verify")
async def test_a_mismatched_state_on_the_callback_aborts_the_flow() -> None:
"""A callback whose state does not match the value sent on /authorize raises and stops the flow.
The auth flow runs inside the streamable-HTTP client's task group, so the `OAuthFlowError`
reaches the test wrapped in nested single-element exception groups; `pytest.RaisesGroup`
asserts the leaf type and the SDK-authored message prefix (the full message embeds two
random tokens).
"""
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth(state_override="wrong-state")
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^State parameter mismatch:"), flatten_subgroups=True
):
# Entering the connect raises during the OAuth handshake (inside `Client.__aenter__`),
# so an `async with` body would be unreachable; entering explicitly avoids dead code.
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
@requirement("client-auth:authorization-response:iss-verify")
async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None:
"""A callback whose RFC 9207 iss does not match the authorization server issuer aborts the flow.
`iss_override` makes the headless callback return an issuer the AS never advertised; the SDK
compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange.
"""
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth(iss_override="https://attacker.example.com")
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^Authorization response iss mismatch:"), flatten_subgroups=True
):
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
@requirement("client-auth:resource-parameter")
async def test_the_authorization_code_token_request_carries_grant_type_code_redirect_and_resource(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""The /token form body has exactly the auth-code grant fields, with redirect_uri and resource matching /authorize.
`client_secret` is present because the SDK's dynamic-registration handler issues a secret
and the client defaults to `client_secret_post`.
"""
token_req = recorded_oauth_flow.token_request
body = form_body(token_req)
assert sorted(body) == snapshot(
["client_id", "client_secret", "code", "code_verifier", "grant_type", "redirect_uri", "resource"]
)
assert body["grant_type"] == "authorization_code"
assert body["code"] != ""
assert body["redirect_uri"] == recorded_oauth_flow.authorize["redirect_uri"]
assert body["resource"] == recorded_oauth_flow.authorize["resource"]
assert token_req.headers["content-type"] == "application/x-www-form-urlencoded"
@requirement("client-auth:bearer-header:every-request")
async def test_every_mcp_request_after_auth_carries_the_bearer_header_and_never_a_query_token(
recorded_oauth_flow: RecordedFlow,
) -> None:
"""Every MCP request after the flow has `Authorization: Bearer ...` and never `?access_token=`.
The first /mcp POST is the unauthenticated trigger and is asserted to carry no Authorization
header; that assertion is only meaningful because the recording snapshots requests at send
time (the SDK mutates the same request object in place for the retry).
"""
mcp_posts = find(recorded_oauth_flow.requests, "POST", "/mcp")
assert len(mcp_posts) >= 3
assert "authorization" not in mcp_posts[0].headers
for r in mcp_posts[1:]:
assert r.headers["authorization"].startswith("Bearer ")
assert r.headers["authorization"] != "Bearer "
assert "access_token" not in dict(r.url.params)
@requirement("client-auth:token-endpoint-auth-method")
async def test_a_client_with_a_secret_authenticates_the_token_request_with_http_basic() -> None:
"""A `client_secret_basic` client sends URL-encoded credentials in HTTP Basic, not the body.
Credentials are URL-encoded before base64 per RFC 6749 §2.3.1; the secret contains `/` so
the encoding is observable.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
client_info = OAuthClientInformationFull(
client_id="cid",
client_secret="s/cret",
token_endpoint_auth_method="client_secret_basic",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
scope="mcp",
)
await provider.register_client(client_info)
storage = InMemoryTokenStorage(client_info=client_info)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
await client.list_tools()
assert find(recorded, "POST", "/register") == []
[token_req] = find(recorded, "POST", "/token")
decoded = base64.b64decode(token_req.headers["authorization"].removeprefix("Basic ")).decode()
assert decoded == f"{quote('cid', safe='')}:{quote('s/cret', safe='')}"
assert "client_secret" not in form_body(token_req)
@requirement("client-auth:token-endpoint-auth-method")
async def test_the_registered_auth_method_is_used_regardless_of_as_metadata_advertised_methods() -> None:
"""The token-endpoint auth method comes from the registered client info, not from AS metadata.
The shim serves AS metadata advertising only `client_secret_basic`; the client dynamically
registers and the SDK's registration handler issues `client_secret_post`. The client uses
`client_secret_post` (secret in the body, no Basic header) because the SDK reads the
registered `token_endpoint_auth_method`, not `token_endpoint_auth_methods_supported`. Other
SDKs (TypeScript, Go) do consult the AS metadata; this test pins where the python SDK's
selection point lives.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
override = OAuthMetadata(
issuer=AnyHttpUrl(f"{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"],
token_endpoint_auth_methods_supported=["client_secret_basic"],
)
serve = {ASM_PATH: override.model_dump_json(exclude_none=True).encode()}
with anyio.fail_after(5):
async with connect_with_oauth(
server, provider=provider, app_shim=lambda app: shimmed_app(app, serve=serve), on_request=on_request
) as (client, _):
await client.list_tools()
[register] = find(recorded, "POST", "/register")
assert json.loads(register.content).get("token_endpoint_auth_method") is None
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert "client_secret" in body
assert body["client_secret"] != ""
assert "authorization" not in token_req.headers
@requirement("client-auth:scope-selection:priority")
async def test_scope_is_selected_from_the_www_authenticate_challenge_over_prm_metadata() -> None:
"""When the 401 challenge carries `scope=`, that value is requested instead of the PRM scopes.
The SDK's bearer middleware never emits `scope=` in WWW-Authenticate (see the divergence
on `hosting:auth:scope-403`), so the test supplies the first 401 itself via
`first_challenge_shim` and disables token verification so the post-auth retry succeeds
regardless of the granted scope. PRM advertises `["from-prm"]` (it mirrors
`required_scopes`); the challenge says `from-header`; the authorize URL must carry
`from-header`.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(default_scopes=["from-header"])
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["from-prm"], valid_scopes=["from-header", "from-prm"])
challenge = f'Bearer scope="from-header", resource_metadata="{BASE_URL}{PRM_PATH}"'
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
settings=settings,
verify_tokens=False,
app_shim=first_challenge_shim(challenge),
on_request=on_request,
) as (client, headless):
await client.list_tools()
assert headless.authorize_url is not None
assert authorize_params(headless.authorize_url)["scope"] == "from-header"
[register] = find(recorded, "POST", "/register")
assert json.loads(register.content)["scope"] == "from-header"
@requirement("client-auth:pkce:refuse-if-unsupported")
async def test_pkce_is_still_sent_when_as_metadata_omits_code_challenge_methods_supported() -> None:
"""AS metadata without `code_challenge_methods_supported` does not stop the client sending PKCE.
The spec says the client MUST refuse to proceed in this case; the SDK proceeds and the flow
completes. See the divergence on the requirement.
"""
override = OAuthMetadata(
issuer=AnyHttpUrl(f"{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"],
)
assert override.code_challenge_methods_supported is None
serve = {ASM_PATH: override.model_dump_json(exclude_none=True).encode()}
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(
server, provider=provider, app_shim=lambda app: shimmed_app(app, serve=serve)
) as (client, headless):
result = await client.list_tools()
assert headless.authorize_url is not None
params = authorize_params(headless.authorize_url)
assert params["code_challenge_method"] == "S256"
assert params["code_challenge"] != ""
assert result.tools[0].name == "echo"
@requirement("client-auth:authorize:error-surfaces")
async def test_an_authorize_error_on_the_callback_aborts_the_flow_before_the_token_request() -> None:
"""An `error=` redirect from /authorize aborts the flow with no /token request issued.
The SDK's callback contract is `() -> (code, state)` with no error form, so the failure is
observed as an empty code reaching the SDK and `OAuthFlowError("No authorization code
received")` being raised. The actual `error` value from the redirect is not surfaced to the
caller; that gap is noted in the manifest.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(deny_authorize=True)
server = Server("guarded", on_list_tools=list_tools)
headless = HeadlessOAuth()
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthFlowError, match="^No authorization code received$"), flatten_subgroups=True
):
await connect_with_oauth(server, provider=provider, headless=headless, on_request=on_request).__aenter__()
assert headless.error == "access_denied"
assert find(recorded, "POST", "/token") == []