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
View File
+484
View File
@@ -0,0 +1,484 @@
"""In-process harness for the auth interaction tests.
Co-hosts the SDK's authorization-server routes, protected-resource metadata route, and the
bearer-gated MCP endpoint on one Starlette app via `Server.streamable_http_app(auth=...,
token_verifier=..., auth_server_provider=...)`, drives that app through the streaming bridge
on a single `httpx.AsyncClient` carrying `auth=OAuthClientProvider(...)`, and completes the
authorize redirect headlessly by GETing the URL through the same bridge and parsing the code
from the 302 `Location`. The whole authorization-code flow runs in one event loop with no
sockets, no threads, and no real time.
"""
import json
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import parse_qs, parse_qsl, urlsplit
import httpx
from pydantic import AnyHttpUrl, AnyUrl, BaseModel
from starlette.types import ASGIApp, Receive, Scope, Send
from mcp.client.auth import OAuthClientProvider
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.auth.provider import AccessToken, ProviderTokenVerifier
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
from tests.interaction.transports._bridge import StreamingASGITransport
REDIRECT_URI = f"{BASE_URL}/oauth/callback"
AppShim = Callable[[ASGIApp], ASGIApp]
@dataclass
class RecordedRequest:
"""A snapshot of an `httpx.Request` at the moment it was sent.
The auth flow re-yields the same `httpx.Request` object after mutating its headers in
place for the retry, so tests that need to assert on the first attempt's headers must
capture a copy rather than a live reference. `record_requests` produces these.
"""
method: str
url: httpx.URL
headers: dict[str, str]
content: bytes
@property
def path(self) -> str:
return self.url.path
def record_requests() -> tuple[list[RecordedRequest], Callable[[httpx.Request], None]]:
"""Build an `on_request` callback that snapshots each request, and the list it appends to."""
recorded: list[RecordedRequest] = []
def on_request(request: httpx.Request) -> None:
recorded.append(
RecordedRequest(
method=request.method,
url=request.url,
headers=dict(request.headers),
content=bytes(request.content),
)
)
return recorded, on_request
def metadata_body(model: BaseModel, **extra: object) -> bytes:
"""Serialize a metadata model to a JSON body for `shimmed_app(serve=...)`.
`extra` keys are merged into the serialized object so a test can inject fields the model
does not declare (e.g. an unknown extension field, to prove the client's parser tolerates
unrecognized members per RFC 8414/9728 §3.2). The model itself would silently drop such
fields at construction, so they have to be added after serialization.
"""
document = model.model_dump(by_alias=True, mode="json", exclude_none=True)
document.update(extra)
return json.dumps(document).encode()
class StaticTokenVerifier:
"""A `TokenVerifier` backed by a fixed token→`AccessToken` mapping.
Any token string not in the mapping verifies to `None`, which the bearer middleware treats
as an unrecognized token. Tests seed the mapping with the exact token shapes (valid, expired,
wrong scope, wrong audience) they need so the resource-server gate's behaviour is asserted in
isolation from the authorization-server provider.
"""
def __init__(self, tokens: Mapping[str, AccessToken]) -> None:
self._tokens = dict(tokens)
async def verify_token(self, token: str) -> AccessToken | None:
return self._tokens.get(token)
class InMemoryTokenStorage:
"""A `TokenStorage` that holds tokens and client info as instance attributes.
Tests pre-seed `client_info` (via the constructor or by assignment) to drive the
pre-registered path, and read both attributes after the flow to assert what the SDK
persisted.
"""
def __init__(self, *, client_info: OAuthClientInformationFull | None = None) -> None:
self.tokens: OAuthToken | None = None
self.client_info: OAuthClientInformationFull | None = client_info
async def get_tokens(self) -> OAuthToken | None:
return self.tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
self.tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
return self.client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self.client_info = client_info
class HeadlessOAuth:
"""Completes the authorize step in-process by following the redirect through the bridge.
`redirect_handler` GETs the authorize URL on the bound client (with `auth=None` so the
request does not re-enter the locked auth flow), parses `code` and `state` from the 302
`Location`, and stashes them; `callback_handler` returns the stashed pair. Tests inspect
`authorize_url` to assert what the SDK put on the authorize request.
`state_override`: when set, `callback_handler` returns this value as the state instead of
the one parsed from the redirect, so tests can drive the state-mismatch path.
`iss_override`: when set, `callback_handler` returns this value as the RFC 9207 issuer
instead of the one parsed from the redirect, so tests can drive the iss-mismatch path.
"""
def __init__(self, *, state_override: str | None = None, iss_override: str | None = None) -> None:
self.authorize_url: str | None = None
self.authorize_urls: list[str] = []
self.error: str | None = None
self._state_override = state_override
self._iss_override = iss_override
self._http: httpx.AsyncClient | None = None
self._code: str = ""
self._state: str | None = None
self._iss: str | None = None
def bind(self, http_client: httpx.AsyncClient) -> None:
self._http = http_client
async def redirect_handler(self, authorization_url: str) -> None:
assert self._http is not None
self.authorize_url = authorization_url
self.authorize_urls.append(authorization_url)
# auth=None is load-bearing: without it the GET re-enters OAuthClientProvider.async_auth_flow
# through its context lock and the flow deadlocks.
response = await self._http.get(authorization_url, follow_redirects=False, auth=None)
assert response.status_code == 302, f"authorize endpoint returned {response.status_code}: {response.text}"
params = parse_qs(urlsplit(response.headers["location"]).query)
self._code = params.get("code", [""])[0]
self._state = params.get("state", [None])[0]
self._iss = params.get("iss", [None])[0]
self.error = params.get("error", [None])[0]
async def callback_handler(self) -> AuthorizationCodeResult:
return AuthorizationCodeResult(
code=self._code,
state=self._state_override if self._state_override is not None else self._state,
iss=self._iss_override if self._iss_override is not None else self._iss,
)
def auth_settings(
*,
required_scopes: Sequence[str] = ("mcp",),
valid_scopes: Sequence[str] | None = None,
identity_assertion_enabled: bool = False,
) -> AuthSettings:
"""Build `AuthSettings` for the co-hosted authorization + resource server.
The issuer and resource URLs use the suite's loopback origin, which `validate_issuer_url`
accepts in lieu of HTTPS. Dynamic client registration is enabled. `valid_scopes` defaults
to `required_scopes` so a client requesting exactly those passes registration scope
validation; tests pass a wider set when they need the protected-resource metadata's
`scopes_supported` (which mirrors `required_scopes`) to differ from what the client may
register or when AS metadata should advertise additional scopes such as `offline_access`.
`identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523
jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to
issue tokens.
"""
required = list(required_scopes)
valid = list(valid_scopes) if valid_scopes is not None else required
return AuthSettings(
issuer_url=AnyHttpUrl(BASE_URL),
resource_server_url=AnyHttpUrl(f"{BASE_URL}/mcp"),
required_scopes=required,
client_registration_options=ClientRegistrationOptions(
enabled=True, valid_scopes=valid, default_scopes=required
),
revocation_options=RevocationOptions(enabled=False),
identity_assertion_enabled=identity_assertion_enabled,
)
def oauth_client_metadata() -> OAuthClientMetadata:
"""Build the client's registration metadata.
`scope` is left unset so the SDK's scope-selection strategy chooses one from the server's
metadata before registration.
"""
return OAuthClientMetadata(
client_name="interaction-suite",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
)
def shimmed_app(
app: ASGIApp,
*,
not_found: frozenset[str] = frozenset(),
serve: Mapping[str, bytes | tuple[int, bytes]] | None = None,
) -> ASGIApp:
"""Wrap an ASGI app so specific paths return canned responses before reaching the real app.
Paths in `serve` return the given body as `application/json` (status 200, or the supplied
status when the value is a `(status, body)` pair); paths in `not_found` return 404;
everything else reaches the wrapped app unchanged. Used by the discovery tests to make a
well-known endpoint 404 or return alternate metadata while keeping the real authorization
and MCP endpoints behind it.
"""
overrides: dict[str, tuple[int, bytes]] = {
path: value if isinstance(value, tuple) else (200, value) for path, value in (serve or {}).items()
}
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
path = scope["path"]
if path in overrides:
status, body = overrides[path]
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
}
)
await send({"type": "http.response.body", "body": body})
return
if path in not_found:
await send({"type": "http.response.start", "status": 404, "headers": []})
await send({"type": "http.response.body", "body": b""})
return
await app(scope, receive, send)
return wrapped
def shim(
*, not_found: frozenset[str] = frozenset(), serve: Mapping[str, bytes | tuple[int, bytes]] | None = None
) -> AppShim:
"""Build an `app_shim` for `connect_with_oauth` that applies `shimmed_app` with these overrides."""
return lambda app: shimmed_app(app, not_found=not_found, serve=serve)
@dataclass
class _FirstChallenge:
"""ASGI shim that answers the first request to a path with 401 + a given WWW-Authenticate.
Subsequent requests pass through to the wrapped app. Used to make the initial 401 carry
parameters (such as `scope=`) that the SDK's own bearer middleware cannot be configured
to emit, so client behaviour driven by those parameters is reachable end to end. Reserve
this pattern for behaviour the real server cannot be made to produce.
"""
app: ASGIApp
path: str
www_authenticate: str
_seen: set[str] = field(default_factory=set[str])
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["path"] == self.path and self.path not in self._seen:
self._seen.add(self.path)
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [(b"www-authenticate", self.www_authenticate.encode())],
}
)
await send({"type": "http.response.body", "body": b""})
return
await self.app(scope, receive, send)
def first_challenge_shim(www_authenticate: str, *, path: str = "/mcp") -> Callable[[ASGIApp], ASGIApp]:
"""Build an `app_shim` that 401s the first request to `path` with the given header value."""
return lambda app: _FirstChallenge(app, path, www_authenticate)
def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -> AppShim:
"""Build an `app_shim` that 403s the Nth authenticated POST to `/mcp` with the given challenge.
Subsequent requests pass through. Used to drive the client's `insufficient_scope` step-up
handling: the SDK's bearer middleware never emits `scope=` in its 403 challenge (see the
divergence on `hosting:auth:scope-403`), so the test supplies the 403 itself. Reserve this
pattern for behaviour the real server cannot be made to produce.
The default `on_nth_authenticated_post=2` targets the `notifications/initialized` POST: the
first authenticated POST is the auth flow's retry of the original initialize request (yielded
after the 401 branch, where the generator ends without inspecting the response), so a 403
there would not reach the step-up handler.
"""
seen = 0
fired = False
def factory(app: ASGIApp) -> ASGIApp:
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
nonlocal seen, fired
if (
not fired
and scope["type"] == "http"
and scope["path"] == "/mcp"
and scope["method"] == "POST"
and any(name == b"authorization" for name, _ in scope["headers"])
):
seen += 1
if seen < on_nth_authenticated_post:
await app(scope, receive, send)
return
fired = True
await send(
{
"type": "http.response.start",
"status": 403,
"headers": [(b"www-authenticate", www_authenticate.encode())],
}
)
await send({"type": "http.response.body", "body": b""})
return
await app(scope, receive, send)
return wrapped
return factory
def m2m_token_shim(provider: InMemoryAuthorizationServerProvider, *, scopes: list[str]) -> AppShim:
"""Build an `app_shim` that handles `grant_type=client_credentials` at `/token`.
The SDK server's `TokenHandler` only routes `authorization_code` and `refresh_token`, so a
`client_credentials` request would fail discriminator validation. This shim mints a token via
`provider.mint_access_token` so the M2M client providers can complete e2e against the real
bearer middleware. The shim is harness; the SDK-under-test is the client provider, whose
outbound `/token` body the test asserts. The shim does not authenticate the client (no
credential check) because the test asserts the credentials on the recorded request, not on
the server's acceptance.
"""
def factory(app: ASGIApp) -> ASGIApp:
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["path"] == "/token" and scope["method"] == "POST":
# The streaming bridge buffers the request body and delivers it in a single
# http.request event, so one receive is sufficient.
message = await receive()
assert not message.get("more_body", False)
form = dict(parse_qsl(message.get("body", b"").decode()))
assert form.get("grant_type") == "client_credentials", (
f"m2m_token_shim only handles client_credentials; got {form.get('grant_type')!r}"
)
access = provider.mint_access_token(client_id="m2m", scopes=scopes, resource=form.get("resource"))
token = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
response_body = token.model_dump_json(exclude_none=True).encode()
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(response_body)).encode()),
(b"cache-control", b"no-store"),
],
}
)
await send({"type": "http.response.body", "body": response_body})
return
await app(scope, receive, send)
return wrapped
return factory
@asynccontextmanager
async def connect_with_oauth(
server: Server,
*,
provider: InMemoryAuthorizationServerProvider,
settings: AuthSettings | None = None,
storage: InMemoryTokenStorage | None = None,
client_metadata: OAuthClientMetadata | None = None,
client_metadata_url: str | None = None,
headless: HeadlessOAuth | None = None,
auth: httpx.Auth | None = None,
verify_tokens: bool = True,
app_shim: Callable[[ASGIApp], ASGIApp] | None = None,
on_request: Callable[[httpx.Request], None] | None = None,
) -> AsyncIterator[tuple[Client, HeadlessOAuth]]:
"""Connect a `Client` to a server's bearer-gated streamable-HTTP app, completing OAuth in process.
Yields the connected `Client` and the `HeadlessOAuth` whose `authorize_url` records what the
SDK put on the authorize request. `on_request` records every HTTP request the underlying
`httpx.AsyncClient` issues, including those yielded from inside the auth flow.
`headless`: supply a pre-configured `HeadlessOAuth` to override the callback behaviour
(state mismatch, error redirects). `verify_tokens=False` mounts the MCP endpoint without
the bearer middleware so a flow driven by a shimmed 401 completes regardless of the granted
scopes. `app_shim` wraps the built Starlette app before it reaches the bridge transport,
for tests that need to intercept or rewrite specific server responses.
`auth`: supply a pre-built `httpx.Auth` (such as `ClientCredentialsOAuthProvider`) to use
instead of constructing the default `OAuthClientProvider`; in that case `storage`,
`client_metadata`, `client_metadata_url`, and `headless` are unused (the yielded
`HeadlessOAuth` is never invoked and its `authorize_url` stays None).
"""
settings = settings if settings is not None else auth_settings()
storage = storage if storage is not None else InMemoryTokenStorage()
client_metadata = client_metadata if client_metadata is not None else oauth_client_metadata()
headless = headless if headless is not None else HeadlessOAuth()
oauth = (
auth
if auth is not None
else OAuthClientProvider(
server_url=f"{BASE_URL}/mcp",
client_metadata=client_metadata,
storage=storage,
redirect_handler=headless.redirect_handler,
callback_handler=headless.callback_handler,
client_metadata_url=client_metadata_url,
)
)
app: ASGIApp = server.streamable_http_app(
auth=settings,
token_verifier=ProviderTokenVerifier(provider) if verify_tokens else None,
auth_server_provider=provider,
transport_security=NO_DNS_REBINDING_PROTECTION,
)
if app_shim is not None:
app = app_shim(app)
event_hooks: dict[str, list[Callable[..., Any]]] | None = None
if on_request is not None:
record = on_request
async def hook(request: httpx.Request) -> None:
record(request)
event_hooks = {"request": [hook]}
async with AsyncExitStack() as stack:
await stack.enter_async_context(server.session_manager.run())
http_client = await stack.enter_async_context(
httpx.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, auth=oauth, event_hooks=event_hooks
)
)
headless.bind(http_client)
client = await stack.enter_async_context(
# The auth flow tests snapshot the legacy initialize-handshake HTTP shape.
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), mode="legacy")
)
yield client, headless
+222
View File
@@ -0,0 +1,222 @@
"""An in-memory implementation of the SDK's OAuth authorization-server provider protocol.
The provider holds clients, authorization codes, refresh tokens and access tokens in plain
instance dicts so tests can inspect them; tokens are minted from `secrets.token_hex` so the
values are unique without being predictable. The behaviour mirrors what the SDK's authorization
handlers expect: `authorize` immediately mints a code and returns the redirect, `exchange_*`
issue and rotate tokens, and `load_*` are simple lookups. Only the parts the auth interaction
suite drives are implemented; methods the suite does not exercise raise `NotImplementedError`.
"""
import secrets
import time
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
RefreshToken,
TokenError,
construct_redirect_uri,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from tests.interaction._connect import BASE_URL
_TOKEN_LIFETIME_SECONDS = 3600
# The only ID-JAG assertion the in-memory provider accepts; any other value is rejected with
# invalid_grant, standing in for the signature/policy validation a real AS performs.
VALID_ASSERTION = "valid-id-jag"
class InMemoryAuthorizationServerProvider(
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
):
"""An OAuth authorization-server provider backed by in-memory dicts.
Holds registered clients, issued codes, refresh tokens and access tokens as instance state
so tests can both drive the SDK's authorization handlers and inspect what was issued.
Knobs:
`default_scopes`: scopes granted when an authorize request supplies none.
`deny_authorize`: every authorize request returns an `error=access_denied` redirect.
`issue_expired_first`: the first issued token's `expires_in` is in the past so the
client immediately considers it expired and refreshes; the server-side
`AccessToken.expires_at` stays in the future so the bearer middleware accepts it
on the retry that completes the connect.
`fail_next_refresh`: the next refresh-token exchange raises `invalid_grant` once.
`reject_all_tokens`: `load_access_token` returns None for every token, so the bearer
middleware 401s every authenticated request.
"""
def __init__(
self,
*,
default_scopes: list[str] | None = None,
deny_authorize: bool = False,
issue_expired_first: bool = False,
fail_next_refresh: bool = False,
reject_all_tokens: bool = False,
issuer: str | None = None,
) -> None:
self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"]
# The authorization-response iss must equal the AS metadata issuer the client recorded
# (RFC 9207 simple string comparison). `real_asm` builds the issuer from an AnyHttpUrl
# object, so it carries the trailing slash; the redirect iss matches it. Path-issuer
# tests pass the recorded issuer explicitly.
self._issuer = issuer if issuer is not None else f"{BASE_URL}/"
self._deny_authorize = deny_authorize
self._issue_expired_first = issue_expired_first
self._fail_next_refresh = fail_next_refresh
self._reject_all_tokens = reject_all_tokens
self._tokens_issued = 0
self.clients: dict[str, OAuthClientInformationFull] = {}
self.codes: dict[str, AuthorizationCode] = {}
self.refresh_tokens: dict[str, RefreshToken] = {}
self.access_tokens: dict[str, AccessToken] = {}
# The most recent jwt-bearer request the SDK handler passed to exchange_identity_assertion,
# for tests to assert what the client sent (None until the first exchange).
self.last_assertion_params: IdentityAssertionParams | None = None
def _next_expires_in(self) -> int:
self._tokens_issued += 1
if self._issue_expired_first and self._tokens_issued == 1:
return -_TOKEN_LIFETIME_SECONDS
return _TOKEN_LIFETIME_SECONDS
def mint_access_token(self, *, client_id: str, scopes: list[str], resource: str | None = None) -> str:
"""Mint and store an access token, returning its value.
Used by the auth-code and refresh exchanges and by the M2M `/token` shim. The
server-side `expires_at` is always in the future regardless of `issue_expired_first`,
which only affects what the client is told.
"""
access = f"access_{secrets.token_hex(16)}"
self.access_tokens[access] = AccessToken(
token=access,
client_id=client_id,
scopes=scopes,
expires_at=int(time.time()) + _TOKEN_LIFETIME_SECONDS,
resource=resource,
)
return access
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
assert client_info.client_id is not None
self.clients[client_info.client_id] = client_info
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
"""Mint an authorization code immediately and return the redirect carrying it.
A real provider would interpose user consent here; the test provider grants
unconditionally so the headless redirect handler can complete the flow in-process.
When `deny_authorize` is set, returns an `error=access_denied` redirect instead.
"""
assert client.client_id is not None
if self._deny_authorize:
return construct_redirect_uri(
str(params.redirect_uri), error="access_denied", error_description="user denied", state=params.state
)
code = AuthorizationCode(
code=f"code_{secrets.token_hex(16)}",
client_id=client.client_id,
scopes=params.scopes or self._default_scopes,
expires_at=time.time() + 300,
code_challenge=params.code_challenge,
redirect_uri=params.redirect_uri,
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
resource=params.resource,
)
self.codes[code.code] = code
# `iss` is RFC 9207's authorization-response issuer identifier — an extra parameter many
# real authorization servers send. Including it on every success redirect proves the
# client tolerates unrecognized callback parameters (RFC 6749 §4.1.2 MUST) by virtue of
# every flow test passing unchanged.
return construct_redirect_uri(str(params.redirect_uri), code=code.code, state=params.state, iss=self._issuer)
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
return self.codes.get(authorization_code)
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
"""Mint an access token and a refresh token for a valid authorization code, then consume the code."""
assert client.client_id is not None
access = self.mint_access_token(
client_id=client.client_id, scopes=authorization_code.scopes, resource=authorization_code.resource
)
refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[refresh] = RefreshToken(
token=refresh,
client_id=client.client_id,
scopes=authorization_code.scopes,
)
del self.codes[authorization_code.code]
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(authorization_code.scopes),
refresh_token=refresh,
)
async def load_access_token(self, token: str) -> AccessToken | None:
if self._reject_all_tokens:
return None
return self.access_tokens.get(token)
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
return self.refresh_tokens.get(refresh_token)
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
"""Mint a new access token and rotate the refresh token, consuming the old one."""
assert client.client_id is not None
if self._fail_next_refresh:
self._fail_next_refresh = False
raise TokenError(error="invalid_grant", error_description="refresh denied by harness")
access = self.mint_access_token(client_id=client.client_id, scopes=scopes)
new_refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes)
del self.refresh_tokens[refresh_token.token]
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
refresh_token=new_refresh,
)
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
"""Validate the ID-JAG assertion and mint an MCP access token (RFC 7523 jwt-bearer / SEP-990).
Records `params` for inspection and rejects any assertion other than `VALID_ASSERTION` with
invalid_grant (standing in for signature/policy validation). The granted scopes are exactly
those the client requested; a real provider would derive them from the validated ID-JAG.
"""
self.last_assertion_params = params
assert client.client_id is not None
if params.assertion != VALID_ASSERTION:
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
scopes = params.scopes if params.scopes is not None else self._default_scopes
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=params.resource)
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
)
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
raise NotImplementedError
+300
View File
@@ -0,0 +1,300 @@
"""Error-plane behaviour of the SDK's bundled OAuth authorization-server handlers.
The end-to-end OAuth tests prove the handlers' happy paths; these tests drive the same
mounted authorization server directly with raw httpx so the assertions are the HTTP
semantics (status, redirect target, error body, headers) the OAuth RFCs mandate. Almost
every behaviour here is enforced by the SDK's own handlers; where the pinned output
deviates from the RFC, the manifest entry carries the divergence.
"""
import base64
import hashlib
import secrets
from collections.abc import AsyncIterator
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from inline_snapshot import snapshot
from mcp.server import Server
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.shared.auth import OAuthClientInformationFull
from tests.interaction._connect import mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import REDIRECT_URI, auth_settings, oauth_client_metadata
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
@pytest.fixture
async def as_app() -> AsyncIterator[tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider]]:
"""Co-host the SDK's authorization-server routes and yield a raw httpx client against them."""
provider = InMemoryAuthorizationServerProvider()
settings = auth_settings()
async with mounted_app(
Server("guarded"),
auth=settings,
token_verifier=ProviderTokenVerifier(provider),
auth_server_provider=provider,
) as (http, _):
yield http, provider
def _pkce_pair() -> tuple[str, str]:
"""Generate a (code_verifier, code_challenge) pair the same way the SDK client does."""
verifier = secrets.token_urlsafe(48)[:64]
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
return verifier, challenge
async def _register_client(http: httpx.AsyncClient) -> OAuthClientInformationFull:
"""Dynamically register a client and return its full credentials."""
response = await http.post("/register", content=oauth_client_metadata().model_dump_json())
assert response.status_code == 201
return OAuthClientInformationFull.model_validate_json(response.content)
async def _mint_code(http: httpx.AsyncClient) -> tuple[OAuthClientInformationFull, str, str]:
"""Register a client, complete a valid authorize step, and return (client_info, code, verifier)."""
client_info = await _register_client(http)
assert client_info.client_id is not None
verifier, challenge = _pkce_pair()
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": REDIRECT_URI,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": "s",
},
follow_redirects=False,
)
assert response.status_code == 302
redirect = urlsplit(response.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
code = parse_qs(redirect.query)["code"][0]
return client_info, code, verifier
def _token_form(client_info: OAuthClientInformationFull, **overrides: str) -> dict[str, str]:
"""Build the form body for an authorization-code token request, with the defaults a real client would send."""
assert client_info.client_id is not None
assert client_info.client_secret is not None
form = {
"grant_type": "authorization_code",
"client_id": client_info.client_id,
"client_secret": client_info.client_secret,
"redirect_uri": REDIRECT_URI,
}
form.update(overrides)
return form
@requirement("hosting:auth:as:authorize-requires-pkce")
async def test_authorize_without_a_code_challenge_is_rejected_with_invalid_request(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request omitting `code_challenge` is redirected back with `error=invalid_request`.
PKCE is mandatory: the bundled authorize handler models `code_challenge` as a required field, so
a code without a stored challenge can never be issued. That makes the PKCE-downgrade attack (a
token request carrying a verifier for a code minted without a challenge) structurally impossible
through these handlers, so no separate downgrade-guard test is needed.
"""
http, _ = as_app
client_info = await _register_client(http)
assert client_info.client_id is not None
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": REDIRECT_URI,
"state": "abc",
},
follow_redirects=False,
)
assert response.status_code == 302
redirect = urlsplit(response.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
params = parse_qs(redirect.query)
assert params["error"] == ["invalid_request"]
assert params["state"] == ["abc"]
assert "code_challenge" in params["error_description"][0]
@requirement("hosting:auth:as:verifier-mismatch")
async def test_a_mismatched_code_verifier_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `code_verifier` does not hash to the stored challenge is rejected."""
http, _ = as_app
client_info, code, _ = await _mint_code(http)
response = await http.post("/token", data=_token_form(client_info, code=code, code_verifier="0" * 64))
assert response.status_code == 400
assert response.json() == snapshot({"error": "invalid_grant", "error_description": "incorrect code_verifier"})
@requirement("hosting:auth:as:code-single-use")
async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorization code can be exchanged exactly once; a second exchange is `invalid_grant`.
The handler does not track used codes itself: it returns `invalid_grant` whenever the provider's
`load_authorization_code` returns None, and the in-memory provider deletes the code on first
exchange. The test proves the combination enforces single-use; a provider that did not consume
codes would not get this guarantee from the handler.
"""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
form = _token_form(client_info, code=code, code_verifier=verifier)
first = await http.post("/token", data=form)
assert first.status_code == 200
assert first.json()["token_type"] == "Bearer"
second = await http.post("/token", data=form)
assert second.status_code == 400
assert second.json() == snapshot(
{"error": "invalid_grant", "error_description": "authorization code does not exist"}
)
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_endpoint(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A token exchange whose `redirect_uri` differs from the one used at authorize is rejected.
This is the security-critical half of redirect-URI binding: a code intercepted via redirect
substitution cannot be redeemed because the attacker cannot reproduce the original authorize
redirect URI at the token endpoint. RFC 6749 §5.2 specifies `invalid_grant` for this case;
the SDK returns `invalid_request` (see the divergence on the requirement). The rejection
itself is the security property and is correct.
"""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
response = await http.post(
"/token",
data=_token_form(client_info, code=code, code_verifier=verifier, redirect_uri=f"{REDIRECT_URI}/different"),
)
assert response.status_code == 400
assert response.json() == snapshot(
{
"error": "invalid_request",
"error_description": "redirect_uri did not match the one used when creating auth code",
}
)
@requirement("hosting:auth:as:token-cache-headers")
async def test_token_responses_carry_cache_control_no_store(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Every token-endpoint response (success and error) carries `Cache-Control: no-store`."""
http, _ = as_app
client_info, code, verifier = await _mint_code(http)
form = _token_form(client_info, code=code, code_verifier=verifier)
success = await http.post("/token", data=form)
assert success.status_code == 200
assert success.headers["cache-control"] == "no-store"
assert success.headers["pragma"] == "no-cache"
failure = await http.post("/token", data=form)
assert failure.status_code == 400
assert failure.headers["cache-control"] == "no-store"
assert failure.headers["pragma"] == "no-cache"
@requirement("hosting:auth:as:register-error-response")
async def test_registration_with_invalid_metadata_is_rejected_with_400(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""Invalid client metadata at the registration endpoint returns 400 with an RFC 7591 error body."""
http, _ = as_app
malformed = await http.post("/register", json={"redirect_uris": ["not-a-url"]})
assert malformed.status_code == 400
assert malformed.json()["error"] == "invalid_client_metadata"
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
no_auth_code = await http.post("/register", json=body | {"grant_types": ["refresh_token"]})
assert no_auth_code.status_code == 400
assert no_auth_code.json() == snapshot(
{"error": "invalid_client_metadata", "error_description": "grant_types must include 'authorization_code'"}
)
bad_scope = await http.post("/register", json=body | {"scope": "forbidden"})
assert bad_scope.status_code == 400
body = bad_scope.json()
assert body["error"] == "invalid_client_metadata"
# The description embeds a set difference whose ordering is not stable, so assert the prefix.
assert body["error_description"].startswith("Requested scopes are not valid: ")
@requirement("hosting:auth:as:redirect-uri-binding")
async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""An authorize request naming an unregistered `redirect_uri` returns 400 without redirecting to it.
The security property is that the authorization server never redirects to an unvalidated URI:
the response is a direct JSON error to the user agent, not a 302 to the attacker's host.
"""
http, _ = as_app
client_info = await _register_client(http)
assert client_info.client_id is not None
_, challenge = _pkce_pair()
response = await http.get(
"/authorize",
params={
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": "http://127.0.0.1:8000/evil",
"code_challenge": challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
assert response.status_code == 400
assert "location" not in response.headers
body = response.json()
assert body["error"] == "invalid_request"
assert "not registered" in body["error_description"]
@requirement("hosting:auth:as:redirect-uri-scheme")
async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
) -> None:
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.
The spec requires every redirect URI to be either HTTPS or a loopback host; the bundled
registration handler does not enforce this and registers `http://evil.example/callback`
successfully. See the divergence on the requirement.
"""
http, provider = as_app
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
body["redirect_uris"] = ["http://evil.example/callback"]
response = await http.post("/register", json=body)
assert response.status_code == 201
info = OAuthClientInformationFull.model_validate_json(response.content)
assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"]
assert info.client_id in provider.clients
@@ -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") == []
+189
View File
@@ -0,0 +1,189 @@
"""Resource-server bearer-token gate: status codes and `WWW-Authenticate` for each token shape.
These tests mount only the resource-server side of the auth wiring (a `StaticTokenVerifier`
seeded with hand-built tokens, no authorization-server provider) and speak raw HTTP, since
every assertion is about HTTP semantics the SDK `Client` cannot observe: the 401/403 status,
the `WWW-Authenticate` header structure, and that a wrong-audience token reaches the MCP
endpoint behind the gate. The flow side of the same 401 is `test_flow.py`'s flagship test.
"""
import time
from collections.abc import AsyncIterator
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import JSONRPCResponse
from mcp.server import Server
from mcp.server.auth.provider import AccessToken
from tests.interaction._connect import base_headers, initialize_body, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import StaticTokenVerifier, auth_settings
pytestmark = pytest.mark.anyio
REQUIRED_SCOPE = "mcp:read"
RESOURCE_METADATA_URL = "http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
_FUTURE = int(time.time()) + 3600
_PAST = int(time.time()) - 3600
TOKENS = {
"tok-valid": AccessToken(token="tok-valid", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_FUTURE),
"tok-expired": AccessToken(token="tok-expired", client_id="c", scopes=[REQUIRED_SCOPE], expires_at=_PAST),
"tok-noscope": AccessToken(token="tok-noscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE),
"tok-wrong-aud": AccessToken(
token="tok-wrong-aud",
client_id="c",
scopes=[REQUIRED_SCOPE],
expires_at=_FUTURE,
resource="https://other.example/mcp",
),
}
@pytest.fixture
async def protected() -> AsyncIterator[httpx.AsyncClient]:
"""A bearer-gated streamable-HTTP app (resource server only) on the in-process bridge."""
server = Server("rs")
settings = auth_settings(required_scopes=[REQUIRED_SCOPE])
async with mounted_app(server, auth=settings, token_verifier=StaticTokenVerifier(TOKENS)) as (http, _):
yield http
async def post_mcp(
http: httpx.AsyncClient, *, bearer: str | None = None, query: dict[str, str] | None = None
) -> httpx.Response:
"""POST an initialize body to `/mcp`, optionally with a bearer token and/or a query string."""
headers = base_headers()
if bearer is not None:
headers["authorization"] = f"Bearer {bearer}"
return await http.post("/mcp", headers=headers, params=query, json=initialize_body())
def parse_www_authenticate(value: str) -> dict[str, str]:
"""Parse a `Bearer k="v", k="v"` challenge into a dict.
The SDK emits each parameter exactly once, comma-space separated, with double-quoted
values that contain no quotes themselves; this helper relies on that and would fail
visibly if the format changed.
"""
scheme, _, params = value.partition(" ")
assert scheme == "Bearer"
return {key: quoted.strip('"') for key, _, quoted in (pair.partition("=") for pair in params.split(", "))}
@requirement("hosting:auth:missing-401")
async def test_a_request_with_no_authorization_header_is_challenged_with_resource_metadata(
protected: httpx.AsyncClient,
) -> None:
"""No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata`.
The snapshot pins current behaviour: the SDK collapses the no-header, unknown-token, and
expired-token cases into one challenge (`error="invalid_token"`, no `scope` parameter). The
spec says the discovery-time challenge SHOULD include `scope` and RFC 6750 says the
no-credentials case SHOULD NOT carry an error code; both gaps are recorded as the divergence
on this requirement. Asserting the dict equals an exact key set also pins that no parameter
appears twice.
"""
response = await post_mcp(protected)
assert response.status_code == 401
assert response.headers["www-authenticate"] == snapshot(
'Bearer error="invalid_token", error_description="Authentication required", '
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
)
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "invalid_token",
"error_description": "Authentication required",
"resource_metadata": RESOURCE_METADATA_URL,
}
assert response.json() == snapshot({"error": "invalid_token", "error_description": "Authentication required"})
@requirement("hosting:auth:invalid-401")
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx.AsyncClient) -> None:
"""A token the verifier does not recognize is answered 401 `invalid_token`.
The challenge is identical to the no-header case (the backend returns `None` for both); the
missing `scope` parameter is the recorded divergence on this requirement.
"""
response = await post_mcp(protected, bearer="tok-unknown")
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "invalid_token",
"error_description": "Authentication required",
"resource_metadata": RESOURCE_METADATA_URL,
}
@requirement("hosting:auth:expired-401")
async def test_an_expired_token_is_answered_401(protected: httpx.AsyncClient) -> None:
"""A token whose `expires_at` is in the past is answered 401 `invalid_token`.
The expiry check is the bearer backend's, against the wall clock; the test seeds a concrete
past timestamp so no time mocking is involved. The missing `scope` parameter is the recorded
divergence on this requirement.
"""
response = await post_mcp(protected, bearer="tok-expired")
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token"
@requirement("hosting:auth:scope-403")
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_without_a_scope_param(
protected: httpx.AsyncClient,
) -> None:
"""A token lacking the required scope is answered 403 `insufficient_scope`, with no `scope` parameter.
The spec's runtime-insufficient-scope guidance says the challenge SHOULD include `scope`
naming the required scope; the SDK never emits it, recorded as the divergence on this
requirement. The SDK client reads `scope` from this header to drive step-up, so the gap is
a resource-server/client asymmetry.
"""
response = await post_mcp(protected, bearer="tok-noscope")
assert response.status_code == 403
parsed = parse_www_authenticate(response.headers["www-authenticate"])
assert parsed == {
"error": "insufficient_scope",
"error_description": f"Required scope: {REQUIRED_SCOPE}",
"resource_metadata": RESOURCE_METADATA_URL,
}
assert "scope" not in parsed
@requirement("hosting:auth:aud-validation")
async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.AsyncClient) -> None:
"""A token whose `resource` does not match the server's resource identifier is accepted.
The spec mandates the resource server validate the token's audience; the bearer backend
never inspects `AccessToken.resource`, so the request passes the gate and the MCP endpoint
serves it. This pins current behaviour with the divergence recorded on the requirement.
"""
response = await post_mcp(protected, bearer="tok-wrong-aud")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
# The body is finite SSE: a result event followed by stream close. Pull the JSON-RPC response
# out of the buffered text to prove the MCP endpoint actually answered the initialize request.
[data] = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")]
assert "protocolVersion" in JSONRPCResponse.model_validate_json(data).result
@requirement("hosting:auth:query-token-ignored")
async def test_an_access_token_in_the_query_string_is_not_accepted(protected: httpx.AsyncClient) -> None:
"""A valid token presented in the URI query string is treated as no authentication.
The bearer backend reads only the `Authorization` header, so `?access_token=...` is never
consulted; the request is treated as unauthenticated and answered 401. This satisfies, by
absence, the security best-practice that resource servers must not accept query-string
tokens.
"""
response = await post_mcp(protected, query={"access_token": "tok-valid"})
assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token"
+339
View File
@@ -0,0 +1,339 @@
"""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")] == []
+240
View File
@@ -0,0 +1,240 @@
"""End-to-end OAuth authorization-code flow against the SDK's own server, fully in process.
Auth is HTTP-only so these tests are not transport-parametrized; each connects via
`connect_with_oauth`, which co-hosts the SDK's authorization server, protected-resource
metadata, and bearer-gated MCP endpoint on one bridge-backed Starlette app and drives the
whole flow through one `httpx.AsyncClient` carrying the SDK's `OAuthClientProvider`. The
authorize redirect completes headlessly through the same bridge, so every request the flow
makes is observable via `on_request`.
"""
import json
from collections import Counter
from urllib.parse import parse_qs, urlsplit
import anyio
import httpx
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ListToolsResult, TextContent, Tool
from pydantic import AnyUrl
from mcp.server import Server, ServerRequestContext
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.shared.auth import OAuthClientInformationFull
from tests.interaction._connect import BASE_URL
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
REDIRECT_URI,
InMemoryTokenStorage,
auth_settings,
connect_with_oauth,
oauth_client_metadata,
shimmed_app,
)
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
from tests.interaction.transports._bridge import StreamingASGITransport
pytestmark = pytest.mark.anyio
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="whoami", input_schema={"type": "object"})])
@requirement("flow:oauth:authorization-code-roundtrip")
@requirement("client-auth:401-triggers-flow")
@requirement("hosting:auth:missing-401")
async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow_connects() -> None:
"""Connecting to a bearer-gated server walks the full authorization-code flow and succeeds.
Three requirements are proven by one connect: the flow runs end to end (authorization-code
roundtrip), it was triggered by a 401 on the first MCP request (401-triggers-flow), and
that 401 carried `resource_metadata` in `WWW-Authenticate` for discovery (missing-401).
The flagship test pins the recorded request sequence so the discovery → registration →
authorize → token → retry order is asserted explicitly.
Steps the SDK is expected to perform:
1. POST /mcp without a token → 401 with `WWW-Authenticate: Bearer resource_metadata=...`.
2. GET the protected-resource metadata.
3. GET the authorization-server metadata.
4. POST /register (dynamic client registration).
5. GET /authorize → 302 with code+state (completed by the headless redirect).
6. POST /token (authorization-code exchange).
7. Retry POST /mcp with `Authorization: Bearer <access_token>` → succeeds.
"""
requests: list[httpx.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=requests.append) as (
client,
headless,
):
result = await client.list_tools()
assert result == snapshot(ListToolsResult(tools=[Tool(name="whoami", input_schema={"type": "object"})]))
assert headless.authorize_url is not None
paths = [(r.method, r.url.path) for r in requests]
assert Counter(paths) == snapshot(
Counter(
{
("POST", "/mcp"): 4,
("GET", "/.well-known/oauth-protected-resource/mcp"): 1,
("GET", "/.well-known/oauth-authorization-server"): 1,
("POST", "/register"): 1,
("GET", "/authorize"): 1,
("POST", "/token"): 1,
("GET", "/mcp"): 1,
("DELETE", "/mcp"): 1,
}
)
)
assert (requests[0].method, requests[0].url.path) == ("POST", "/mcp")
# The recorded Request objects are live references: the auth flow mutates the original
# request's headers in place when it adds the bearer token for the retry, so the first
# entry's headers cannot be used to assert "no Authorization on the first attempt". The
# path multiset above proving discovery happened is the evidence the first attempt was 401.
# The first PRM discovery GET carries the protocol-version header (an SDK behaviour, not a
# spec requirement on discovery requests).
prm_get = next(r for r in requests if r.url.path == "/.well-known/oauth-protected-resource/mcp")
assert prm_get.headers.get("mcp-protocol-version") == snapshot("2026-07-28")
authorize = parse_qs(urlsplit(headless.authorize_url).query)
assert authorize["response_type"] == ["code"]
assert authorize["code_challenge_method"] == ["S256"]
assert authorize["client_id"][0] in provider.clients
assert storage.tokens is not None
bearer = f"Bearer {storage.tokens.access_token}"
authed_mcp = [r for r in requests if r.url.path == "/mcp" and r.headers.get("authorization") == bearer]
assert len(authed_mcp) > 0
assert storage.tokens.access_token in provider.access_tokens
@requirement("hosting:auth:authinfo-propagates")
async def test_the_access_token_reaches_the_tool_handler_via_get_access_token() -> None:
"""A tool handler reads the request's access token through `get_access_token()`."""
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "whoami"
token = get_access_token()
assert token is not None
return CallToolResult(content=[TextContent(text=" ".join(token.scopes))])
server = Server("guarded", on_list_tools=list_tools, on_call_tool=call_tool)
provider = InMemoryAuthorizationServerProvider()
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider) as (client, _):
result = await client.call_tool("whoami", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="mcp")]))
@requirement("client-auth:pre-registration")
async def test_a_preregistered_client_skips_registration() -> None:
"""A client whose storage already holds client info uses it instead of registering.
The provider holds the same registration server-side so the authorize and token steps
accept it; the recorded requests prove no `/register` call was made.
"""
requests: list[httpx.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
client_info = OAuthClientInformationFull(
client_id="preregistered",
client_secret="s3cret",
token_endpoint_auth_method="client_secret_post",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
scope="mcp",
)
await provider.register_client(client_info)
storage.client_info = client_info
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=requests.append) as (
client,
_,
):
await client.list_tools()
assert [r.url.path for r in requests].count("/register") == 0
assert list(provider.clients) == ["preregistered"]
@requirement("client-auth:dcr")
async def test_the_dcr_request_carries_the_client_metadata() -> None:
"""Dynamic registration sends the client's metadata and persists what the server issued.
The body of the recorded `/register` POST carries the metadata the test supplied (with the
scope filled in from server discovery), and the server's issued client_id and secret are
persisted to storage and held by the provider.
"""
requests: list[httpx.Request] = []
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
client_metadata = oauth_client_metadata()
client_metadata.software_id = "interaction-test-suite"
with anyio.fail_after(5):
async with connect_with_oauth(
server, provider=provider, storage=storage, client_metadata=client_metadata, on_request=requests.append
) as (client, _):
await client.list_tools()
register = next(r for r in requests if r.url.path == "/register")
assert register.headers["content-type"] == "application/json"
body = json.loads(register.content)
assert body == snapshot(
{
"redirect_uris": ["http://127.0.0.1:8000/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "mcp",
"application_type": "native",
"client_name": "interaction-suite",
"software_id": "interaction-test-suite",
}
)
assert storage.client_info is not None
assert storage.client_info.client_id is not None
assert storage.client_info.client_secret is not None
assert list(provider.clients) == [storage.client_info.client_id]
async def test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_wrapped_app() -> None:
"""Harness self-test: `shimmed_app` serves canned bodies, 404s, and forwards everything else.
Wraps a real auth-hosting Starlette app so the forward path is exercised against the SDK's
own routing; provided here so the discovery tests can rely on the shim without each adding
their own contract test.
"""
server = Server("bare")
provider = InMemoryAuthorizationServerProvider()
real_app = server.streamable_http_app(auth=auth_settings(), auth_server_provider=provider)
app = shimmed_app(real_app, not_found=frozenset({"/missing"}), serve={"/override": b'{"shimmed": true}'})
async with server.session_manager.run():
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
served = await http.get("/override")
assert served.status_code == 200
assert served.headers["content-type"] == "application/json"
assert served.json() == {"shimmed": True}
assert (await http.get("/missing")).status_code == 404
forwarded = await http.get("/.well-known/oauth-authorization-server")
assert forwarded.status_code == 200
assert forwarded.json()["issuer"] == "http://127.0.0.1:8000/"
@@ -0,0 +1,302 @@
"""End-to-end SEP-990 Identity Assertion (RFC 7523 jwt-bearer) flows.
These exercise the FULL stack: the real `IdentityAssertionOAuthProvider` on the client and the real
authorization-server token endpoint on the server, with `InMemoryAuthorizationServerProvider`
implementing `exchange_identity_assertion`. The client supplies the ID-JAG through its
`assertion_provider` callback; the provider validates it and the issued bearer authorizes the MCP
request.
Recording-first: the recorded `/token` request is asserted before the call result, so a surprise in
the exchange path produces a readable diff of what fired.
"""
from urllib.parse import parse_qsl
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import ListToolsResult, Tool
from mcp.client.auth import OAuthFlowError, OAuthTokenError
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.server import Server, ServerRequestContext
from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata
from tests.interaction._connect import BASE_URL, mounted_app
from tests.interaction._requirements import requirement
from tests.interaction.auth._harness import (
InMemoryTokenStorage,
RecordedRequest,
auth_settings,
connect_with_oauth,
record_requests,
)
from tests.interaction.auth._provider import VALID_ASSERTION, InMemoryAuthorizationServerProvider
pytestmark = pytest.mark.anyio
ASM_ROOT = "/.well-known/oauth-authorization-server"
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
CLIENT_ID = "enterprise-mcp-client"
CLIENT_SECRET = "enterprise-secret"
# The AS metadata issuer carries a trailing slash (built from an AnyHttpUrl object); the client
# pins against exactly that.
EXPECTED_ISSUER = f"{BASE_URL}/"
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
return [r for r in recorded if r.method == method and r.path == path]
def form_body(request: RecordedRequest) -> dict[str, str]:
return dict(parse_qsl(request.content.decode()))
def preregister_confidential_client(provider: InMemoryAuthorizationServerProvider) -> None:
"""Seed a pre-registered confidential client allowed to use the identity-assertion grant.
SEP-990 clients are provisioned out of band (DCR refuses the grant), so the server already knows
the client; `IdentityAssertionOAuthProvider` presents the same id + secret without registering.
"""
provider.clients[CLIENT_ID] = OAuthClientInformationFull(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
redirect_uris=None,
grant_types=[JWT_BEARER_GRANT_TYPE],
token_endpoint_auth_method="client_secret_post",
scope="mcp",
)
def identity_assertion_provider(
storage: InMemoryTokenStorage,
*,
assertion: str = VALID_ASSERTION,
issuer: str = EXPECTED_ISSUER,
record: list[tuple[str, str]] | None = None,
) -> IdentityAssertionOAuthProvider:
async def assertion_provider(audience: str, resource: str) -> str:
if record is not None:
record.append((audience, resource))
return assertion
return IdentityAssertionOAuthProvider(
server_url=f"{BASE_URL}/mcp",
storage=storage,
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
issuer=issuer,
assertion_provider=assertion_provider,
scope="mcp",
)
@requirement("client-auth:identity-assertion")
async def test_identity_assertion_obtains_a_token_and_authorizes_the_request() -> None:
"""The identity-assertion provider connects end to end with no authorize/register step.
The full stack runs: the client posts grant_type=jwt-bearer with the ID-JAG as `assertion` to the
real token endpoint, the provider validates it and issues a bearer, and the bearer authorizes
list_tools. The recorded sequence proves no /authorize or /register request was made.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
storage = InMemoryTokenStorage()
auth = identity_assertion_provider(storage)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=True),
auth=auth,
on_request=on_request,
) as (client, headless):
result = await client.list_tools()
# Recording-first: assert what fired before the call result.
assert headless.authorize_url is None
assert find(recorded, "GET", "/authorize") == []
assert find(recorded, "POST", "/register") == []
# The AS is configuration: PRM is never fetched, so the resource server has no input into where
# the credentials go.
assert not any(r.path.startswith("/.well-known/oauth-protected-resource") for r in recorded)
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert body == snapshot(
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "valid-id-jag",
"client_id": "enterprise-mcp-client",
"resource": "http://127.0.0.1:8000/mcp",
"scope": "mcp",
"client_secret": "enterprise-secret",
}
)
assert result.tools[0].name == "echo"
assert provider.last_assertion_params is not None
assert provider.last_assertion_params.assertion == VALID_ASSERTION
assert storage.tokens is not None
assert storage.tokens.access_token in provider.access_tokens
@requirement("client-auth:identity-assertion")
async def test_configured_scope_is_sent_regardless_of_server_advertised_scopes() -> None:
"""The caller's configured scope reaches the wire; server-advertised scopes have no effect.
The provider has no scope-selection step, so this is true by construction; the test pins it.
Here the AS advertises `mcp extra` but the client requested only `mcp`, so the recorded `/token`
body must carry `scope=mcp`, not the advertised superset.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
auth = identity_assertion_provider(InMemoryTokenStorage())
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
# AS metadata advertises a broader scopes_supported than the client requests.
settings=auth_settings(identity_assertion_enabled=True, valid_scopes=["mcp", "extra"]),
auth=auth,
on_request=on_request,
) as (client, _):
await client.list_tools()
[token_req] = find(recorded, "POST", "/token")
assert form_body(token_req)["scope"] == "mcp"
assert provider.last_assertion_params is not None
assert provider.last_assertion_params.scopes == ["mcp"]
@requirement("client-auth:identity-assertion:assertion-callback")
async def test_assertion_callback_receives_issuer_audience_and_resource() -> None:
"""The assertion_provider gets the AS issuer as audience and the MCP resource identifier."""
record: list[tuple[str, str]] = []
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
auth = identity_assertion_provider(InMemoryTokenStorage(), record=record)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=True),
auth=auth,
) as (client, _):
await client.list_tools()
assert record == [(EXPECTED_ISSUER, f"{BASE_URL}/mcp")]
@requirement("client-auth:identity-assertion:issuer-pinning")
async def test_unexpected_issuer_aborts_before_sending_credentials() -> None:
"""When the configured issuer's metadata fails RFC 8414 validation, no credential is sent.
The AS issuer is configuration; metadata is fetched from that issuer's well-known and validated
per RFC 8414 section 3.3. Here the in-process server's metadata has a different issuer than the
one the client is configured for, so validation fails before the assertion callback is invoked
or any credential is posted. PRM is never fetched and no DCR is attempted.
"""
recorded, on_request = record_requests()
record: list[tuple[str, str]] = []
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
# The served AS metadata has issuer BASE_URL/, but the client is configured for a different one.
auth = identity_assertion_provider(InMemoryTokenStorage(), issuer="https://corp-as.example/", record=record)
with anyio.fail_after(5):
with pytest.RaisesGroup(pytest.RaisesExc(OAuthFlowError, match="issuer mismatch"), flatten_subgroups=True):
await connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=True),
auth=auth,
on_request=on_request,
).__aenter__()
assert record == []
assert provider.last_assertion_params is None
assert find(recorded, "POST", "/token") == []
assert find(recorded, "POST", "/register") == []
assert not any(r.path.startswith("/.well-known/oauth-protected-resource") for r in recorded)
@requirement("client-auth:identity-assertion:disabled-rejected")
async def test_identity_assertion_is_rejected_when_disabled_on_the_server() -> None:
"""With the grant disabled, the token endpoint returns unsupported_grant_type and the flow fails."""
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
auth = identity_assertion_provider(InMemoryTokenStorage())
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*unsupported_grant_type"),
flatten_subgroups=True,
):
await connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=False),
auth=auth,
).__aenter__()
assert provider.last_assertion_params is None
@requirement("client-auth:identity-assertion:invalid-assertion")
async def test_a_rejected_assertion_aborts_the_flow() -> None:
"""An ID-JAG the provider rejects surfaces as OAuthTokenError; no bearer is issued."""
provider = InMemoryAuthorizationServerProvider()
preregister_confidential_client(provider)
server = Server("guarded", on_list_tools=list_tools)
storage = InMemoryTokenStorage()
auth = identity_assertion_provider(storage, assertion="forged-id-jag")
with anyio.fail_after(5):
with pytest.RaisesGroup(
pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*invalid_grant"),
flatten_subgroups=True,
):
await connect_with_oauth(
server,
provider=provider,
settings=auth_settings(identity_assertion_enabled=True),
auth=auth,
).__aenter__()
assert provider.last_assertion_params is not None
assert provider.last_assertion_params.assertion == "forged-id-jag"
assert storage.tokens is None
@requirement("client-auth:identity-assertion:metadata-advertised")
async def test_metadata_advertises_jwt_bearer_grant_and_id_jag_profile() -> None:
"""When enabled, AS metadata lists the jwt-bearer grant and the id-jag grant profile."""
server = Server("bare")
provider = InMemoryAuthorizationServerProvider()
async with mounted_app(
server, auth=auth_settings(identity_assertion_enabled=True), auth_server_provider=provider
) as (http, _):
response = await http.get(ASM_ROOT)
assert response.status_code == 200
metadata = OAuthMetadata.model_validate_json(response.content)
assert metadata.grant_types_supported is not None
assert JWT_BEARER_GRANT_TYPE in metadata.grant_types_supported
assert metadata.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE]
+509
View File
@@ -0,0 +1,509 @@
"""Token lifecycle, step-up, and registration-variant flows of the SDK's OAuth client.
Every test connects end to end via `connect_with_oauth`; the assertions are recording-first
(the recorded request sequence is asserted before, or independently of, the call result), so a
surprise in the refresh or step-up paths produces a readable diff of what fired rather than an
opaque failure. The provider knobs that drive each scenario are documented per test.
"""
import base64
from collections import Counter
from urllib.parse import parse_qsl, urlsplit
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import INTERNAL_ERROR, ListToolsResult, Tool
from pydantic import AnyHttpUrl, AnyUrl
from mcp import MCPError
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider
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,
InMemoryTokenStorage,
RecordedRequest,
auth_settings,
connect_with_oauth,
m2m_token_shim,
metadata_body,
record_requests,
shim,
step_up_shim,
)
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"
CIMD_URL = "https://client.example/.well-known/mcp-client"
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
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 authorize_params(authorize_url: str) -> dict[str, str]:
"""Parse the authorize URL's query string into a flat dict."""
return dict(parse_qsl(urlsplit(authorize_url).query))
def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]:
return [r for r in recorded if r.method == method and r.path == path]
def path_counts(recorded: list[RecordedRequest]) -> Counter[tuple[str, str]]:
return Counter((r.method, r.path) for r in recorded)
def cimd_supported_metadata() -> bytes:
"""AS metadata advertising `client_id_metadata_document_supported: true` (the SDK server never sets it)."""
metadata = 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"],
response_types_supported=["code"],
grant_types_supported=["authorization_code", "refresh_token"],
code_challenge_methods_supported=["S256"],
client_id_metadata_document_supported=True,
)
return metadata_body(metadata)
def seeded_client(provider: InMemoryAuthorizationServerProvider, **kwargs: object) -> OAuthClientInformationFull:
"""Register a client with the provider and return its info, for pre-registration and CIMD scenarios."""
base: dict[str, object] = {
"client_id": "preregistered",
"token_endpoint_auth_method": "none",
"redirect_uris": [AnyUrl(REDIRECT_URI)],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "mcp",
}
base.update(kwargs)
info = OAuthClientInformationFull.model_validate(base)
assert info.client_id is not None
provider.clients[info.client_id] = info
return info
@requirement("client-auth:refresh:transparent")
async def test_an_expired_access_token_is_transparently_refreshed_before_the_next_request() -> None:
"""An access token the client considers expired is refreshed and the new bearer is used.
The provider tells the client `expires_in=-3600` for the first token while keeping the
server-side `expires_at` in the future, so the connect's retry succeeds and the next
request finds the token expired and refreshes. The recorded requests prove exactly one
`grant_type=refresh_token` exchange carrying the resource indicator, and the bearer used
after the refresh is the second access token, which is the one persisted to storage.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(issue_expired_first=True)
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
result = await client.list_tools()
assert result.tools[0].name == "echo"
token_posts = find(recorded, "POST", "/token")
bodies = [form_body(r) for r in token_posts]
assert [b["grant_type"] for b in bodies] == snapshot(["authorization_code", "refresh_token"])
refresh_body = bodies[1]
assert sorted(refresh_body) == snapshot(["client_id", "client_secret", "grant_type", "refresh_token", "resource"])
assert refresh_body["refresh_token"].startswith("refresh_")
assert refresh_body["resource"].startswith(BASE_URL)
bearers = {r.headers["authorization"] for r in recorded if r.path == "/mcp" and "authorization" in r.headers}
assert len(bearers) == 2
assert storage.tokens is not None
assert f"Bearer {storage.tokens.access_token}" in bearers
assert storage.tokens.expires_in == 3600
@requirement("client-auth:403-scope-upgrade")
async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challenged_scope() -> None:
"""A 403 `insufficient_scope` challenge is answered by one re-authorize with the challenge's scope.
The shim 403s the second authenticated `/mcp` POST (the `notifications/initialized` request,
which reaches the auth flow's step-up handler; the first authenticated POST is the post-401
retry, after which the generator ends without inspecting the response). The challenge names a
wider scope; step-up reuses cached metadata and the existing client registration,
re-authorizes with the new scope, and the connect completes. The client is pre-registered
with both scopes so the server's authorize handler accepts the wider second request. One
re-authorize, one retry; the spec's SHOULD-retry-limit ("a few") is not enforced.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
challenge = 'Bearer error="insufficient_scope", scope="mcp write"'
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
settings=settings,
app_shim=step_up_shim(challenge),
on_request=on_request,
) as (client, headless):
result = await client.list_tools()
assert result.tools[0].name == "echo"
assert len(headless.authorize_urls) == 2
assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp"
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"
counts = path_counts(recorded)
assert counts[("GET", PRM_PATH)] == 1
assert counts[("GET", ASM_PATH)] == 1
assert counts[("POST", "/register")] == 0
assert counts[("GET", "/authorize")] == 2
assert counts[("POST", "/token")] == 2
@requirement("client-auth:403-scope-union")
async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenged_scopes() -> None:
"""The step-up re-authorize requests the union of the previously requested and challenged scopes.
The first authorization requests `mcp`; the 403 challenges a disjoint `write` (not naming
`mcp`). Per SEP-2350 the client must re-authorize with `mcp write`, not drop `mcp`. The client
is pre-registered with both scopes so the server's authorize handler accepts the wider request.
"""
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
challenge = 'Bearer error="insufficient_scope", scope="write"'
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
settings=settings,
app_shim=step_up_shim(challenge),
) as (client, headless):
await client.list_tools()
assert len(headless.authorize_urls) == 2
assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp"
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"
@requirement("client-auth:as-binding")
async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_client_re_registers() -> None:
"""Credentials bound to a stale issuer are dropped and re-registered against the current AS.
The stored client is bound (SEP-2352) to a different issuer than the one the server's PRM
advertises, simulating an authorization-server migration. The client must discard it, perform
Dynamic Client Registration with the current AS, and never present the stale `client_id` at the
authorize or token endpoints.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
stale = seeded_client(provider, client_id="stale-as-client", issuer="https://old-as.example.com")
storage = InMemoryTokenStorage(client_info=stale)
server = Server("guarded", on_list_tools=list_tools)
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()
# The client re-registered with the current AS...
assert path_counts(recorded)[("POST", "/register")] == 1
# ...and the stale client_id never reached the authorize or token endpoints.
authorize_and_token = find(recorded, "GET", "/authorize") + find(recorded, "POST", "/token")
assert all("stale-as-client" not in r.url.query.decode() for r in authorize_and_token)
assert all("stale-as-client" not in r.content.decode() for r in find(recorded, "POST", "/token"))
# The persisted client is now bound to the current AS.
assert storage.client_info is not None
assert storage.client_info.client_id != "stale-as-client"
assert storage.client_info.issuer == f"{BASE_URL}/"
@requirement("client-auth:401-after-auth-throws")
async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None:
"""A 401 on the post-auth retry surfaces as an error rather than re-entering discovery.
The provider rejects every token at verification, so the full flow runs once and the retry
is 401'd. The auth-flow generator ends after that retry, so the 401 propagates and the
transport converts it to an INTERNAL_ERROR result, raising during connect. Discovery,
registration, authorize, and token each ran exactly once: no loop.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(reject_all_tokens=True)
server = Server("guarded", on_list_tools=list_tools)
def is_internal_error(error: MCPError) -> bool:
return error.error.code == INTERNAL_ERROR
with anyio.fail_after(5):
with pytest.RaisesGroup(pytest.RaisesExc(MCPError, check=is_internal_error), 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, on_request=on_request).__aenter__()
counts = path_counts(recorded)
assert counts[("GET", PRM_PATH)] == 1
assert counts[("GET", ASM_PATH)] == 1
assert counts[("POST", "/register")] == 1
assert counts[("GET", "/authorize")] == 1
assert counts[("POST", "/token")] == 1
assert counts[("POST", "/mcp")] == 2
@requirement("client-auth:cimd")
async def test_cimd_is_selected_when_the_as_advertises_support_and_a_metadata_url_is_supplied() -> None:
"""A client-ID metadata-document URL is used as `client_id` instead of registering.
AS metadata is shimmed to advertise `client_id_metadata_document_supported: true`; the
provider is pre-seeded so the server's authorize and token handlers accept the URL as a
client_id (the SDK server has no CIMD-aware client lookup of its own). The recorded
requests prove no `/register` call, the authorize URL's `client_id` is the CIMD URL, the
token request uses `token_endpoint_auth_method=none`, and storage persists the URL as
`client_id`.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
seeded_client(provider, client_id=CIMD_URL)
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
client_metadata_url=CIMD_URL,
app_shim=shim(serve={ASM_PATH: cimd_supported_metadata()}),
on_request=on_request,
) as (client, headless):
await client.list_tools()
assert find(recorded, "POST", "/register") == []
assert headless.authorize_url is not None
assert authorize_params(headless.authorize_url)["client_id"] == CIMD_URL
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert body["client_id"] == CIMD_URL
assert "client_secret" not in body
assert "authorization" not in token_req.headers
assert storage.client_info is not None
assert storage.client_info.client_id == CIMD_URL
assert storage.client_info.token_endpoint_auth_method == "none"
@requirement("client-auth:invalid-grant-clears-tokens")
async def test_a_failed_refresh_clears_stored_tokens_and_restarts_the_full_flow() -> None:
"""A non-200 refresh response clears the in-memory tokens and the flow re-runs from discovery.
The first token is reported expired so the next request refreshes; the provider denies the
refresh once with `invalid_grant`, the auth flow clears its tokens, the unauthenticated
request 401s, and discovery, authorize, and token run again. The original registration is
preserved (`client_info` is not cleared). The SDK clears tokens on any non-200 refresh
response, not specifically `error=invalid_grant`; `source="sdk"` so this is a precision
note rather than a divergence.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(issue_expired_first=True, fail_next_refresh=True)
storage = InMemoryTokenStorage()
server = Server("guarded", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _):
result = await client.list_tools()
assert result.tools[0].name == "echo"
token_posts = find(recorded, "POST", "/token")
assert [form_body(r)["grant_type"] for r in token_posts] == snapshot(
["authorization_code", "refresh_token", "authorization_code"]
)
counts = path_counts(recorded)
assert counts[("POST", "/register")] == 1
assert counts[("GET", "/authorize")] == 2
assert counts[("GET", PRM_PATH)] == 2
assert counts[("GET", ASM_PATH)] == 2
assert storage.client_info is not None
assert storage.tokens is not None
assert storage.tokens.access_token in provider.access_tokens
@requirement("client-auth:client-credentials")
async def test_client_credentials_provider_obtains_a_token_without_an_authorize_step() -> None:
"""The client-credentials provider connects with no authorize step and a `client_credentials` grant.
The SDK server's `TokenHandler` does not route `client_credentials`, so the harness shim
handles it (the shim is harness; the SDK-under-test is the client provider). The recorded
`/token` body proves the grant type, scope, resource indicator, and HTTP-Basic client
authentication; no `/authorize` or `/register` request was made.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
auth = ClientCredentialsOAuthProvider(
server_url=f"{BASE_URL}/mcp",
storage=InMemoryTokenStorage(),
client_id="m2m-client",
client_secret="m2m-secret",
scopes="mcp",
)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
auth=auth,
app_shim=m2m_token_shim(provider, scopes=["mcp"]),
on_request=on_request,
) as (client, headless):
result = await client.list_tools()
assert result.tools[0].name == "echo"
assert headless.authorize_url is None
assert find(recorded, "GET", "/authorize") == []
assert find(recorded, "POST", "/register") == []
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert body == snapshot(
{"grant_type": "client_credentials", "resource": "http://127.0.0.1:8000/mcp", "scope": "mcp"}
)
decoded = base64.b64decode(token_req.headers["authorization"].removeprefix("Basic ")).decode()
assert decoded == "m2m-client:m2m-secret"
@requirement("client-auth:private-key-jwt")
async def test_private_key_jwt_provider_authenticates_the_token_request_with_an_assertion() -> None:
"""The private-key-JWT provider sends a `client_assertion` on the token request, with the issuer as audience.
The assertion provider is a closure that records the audience it was called with and returns
a fixed opaque value (the JWT contents are not the SDK's concern here); the test asserts the
`client_assertion`/`client_assertion_type` form fields and that the audience matches the AS
metadata's issuer.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
audiences: list[str] = []
async def assertion_provider(audience: str) -> str:
audiences.append(audience)
return "header.payload.sig"
auth = PrivateKeyJWTOAuthProvider(
server_url=f"{BASE_URL}/mcp",
storage=InMemoryTokenStorage(),
client_id="m2m-jwt-client",
assertion_provider=assertion_provider,
scopes="mcp",
)
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
auth=auth,
app_shim=m2m_token_shim(provider, scopes=["mcp"]),
on_request=on_request,
) as (client, _):
result = await client.list_tools()
assert result.tools[0].name == "echo"
assert audiences == [f"{BASE_URL}/"]
[token_req] = find(recorded, "POST", "/token")
body = form_body(token_req)
assert body == snapshot(
{
"grant_type": "client_credentials",
"client_assertion": "header.payload.sig",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"resource": "http://127.0.0.1:8000/mcp",
"scope": "mcp",
}
)
assert "client_secret" not in body
assert "authorization" not in token_req.headers
@pytest.mark.parametrize(
("case", "preseed_storage", "advertise_cimd"),
[("cimd_unsupported_falls_through_to_dcr", False, False), ("preregistered_beats_cimd", True, True)],
ids=["cimd_unsupported_falls_through_to_dcr", "preregistered_beats_cimd"],
)
@requirement("client-auth:cimd")
async def test_registration_priority_prefers_preregistered_then_cimd_then_dcr(
case: str, preseed_storage: bool, advertise_cimd: bool
) -> None:
"""The client picks pre-registration over CIMD over DCR, falling through when each is unavailable.
Two priority edges are exercised: with a CIMD URL configured but no AS support, DCR runs and
the registered `client_id` is used; with a CIMD URL configured and AS support but a
pre-registered client in storage, the stored `client_id` is used and neither CIMD nor DCR
runs. (The positive CIMD case and pre-registration over DCR are covered by their own tests.)
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider()
server = Server("guarded", on_list_tools=list_tools)
storage = InMemoryTokenStorage()
expected_client_id: str
if preseed_storage:
info = seeded_client(provider)
storage.client_info = info
assert info.client_id is not None
expected_client_id = info.client_id
else:
expected_client_id = ""
app_shim = shim(serve={ASM_PATH: cimd_supported_metadata()}) if advertise_cimd else None
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
client_metadata_url=CIMD_URL,
app_shim=app_shim,
on_request=on_request,
) as (client, headless):
await client.list_tools()
assert headless.authorize_url is not None
chosen_client_id = authorize_params(headless.authorize_url)["client_id"]
assert chosen_client_id != CIMD_URL
if case == "cimd_unsupported_falls_through_to_dcr":
assert len(find(recorded, "POST", "/register")) == 1
assert chosen_client_id in provider.clients
else:
assert find(recorded, "POST", "/register") == []
assert chosen_client_id == expected_client_id