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) Has been cancelled
418 lines
18 KiB
Python
418 lines
18 KiB
Python
"""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") == []
|