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
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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") == []
|
||||
@@ -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"
|
||||
@@ -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")] == []
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user