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