Files
modelcontextprotocol--pytho…/tests/interaction/auth/test_as_handlers.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

301 lines
12 KiB
Python

"""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