diff --git a/docs/run/authorization.md b/docs/run/authorization.md index fefd0ed34a..7a7a5dd2fd 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -67,17 +67,28 @@ You registered one tool. The second route is the SDK's. This document is how a client that has never heard of your server finds its way in: it reads `authorization_servers` and goes there for a token. You wrote none of it. !!! check - Call `/mcp` with no token (or with one your verifier returned `None` for) and the request is + Call `/mcp` with no token and the request is stopped at the door: ```text HTTP/1.1 401 Unauthorized - WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp" + WWW-Authenticate: Bearer resour…ata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp", scope="notes:read" + ``` + + Nothing was parsed and no tool ran. The challenge carries no error code, because no + credentials were presented (RFC 6750 Section 3.1), and it names the `scope` a token needs. + + A token your verifier returned `None` for is a different case: a credential *was* presented + and was rejected, so it is answered with the error code. + + ```text + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp", scope="notes:read" {"error": "invalid_token", "error_description": "Authentication required"} ``` - Nothing was parsed and no tool ran. And that `resource_metadata` pointer in `WWW-Authenticate` is + And that `resource_metadata` pointer in `WWW-Authenticate` is what makes discovery automatic: 401 -> metadata document -> authorization server -> token -> retry. !!! warning diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 1a070cd29d..0e76e80fd1 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -5,6 +5,7 @@ from pydantic import AnyHttpUrl, ValidationError from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser +from starlette.datastructures import Headers from starlette.requests import HTTPConnection from starlette.types import Receive, Scope, Send @@ -115,8 +116,17 @@ def __init__( async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: auth_user = scope.get("user") if not isinstance(auth_user, AuthenticatedUser): + # RFC 6750 3.1: a request with no credentials SHOULD NOT include an + # error code. Only a presented-but-invalid token gets + # error="invalid_token". Advertise the required scopes either way. + headers = Headers(scope=scope) + presented = headers.get("authorization") is not None await self._send_auth_error( - send, status_code=401, error="invalid_token", description="Authentication required" + send, + status_code=401, + error="invalid_token" if presented else None, + description="Authentication required" if presented else None, + scope=self.required_scopes or None, ) return @@ -126,24 +136,44 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # auth_credentials should always be provided; this is just paranoia if auth_credentials is None or required_scope not in auth_credentials.scopes: await self._send_auth_error( - send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}" + send, + status_code=403, + error="insufficient_scope", + description=f"Required scope: {required_scope}", + scope=[required_scope], ) return await self.app(scope, receive, send) - async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None: + async def _send_auth_error( + self, + send: Send, + status_code: int, + error: str | None = None, + description: str | None = None, + scope: list[str] | None = None, + ) -> None: """Send an authentication error response with WWW-Authenticate header.""" # Build WWW-Authenticate header value - www_auth_parts = [f'error="{error}"', f'error_description="{description}"'] + www_auth_parts = [] + if error: + www_auth_parts.append(f'error="{error}"') + if description: + www_auth_parts.append(f'error_description="{description}"') if self.resource_metadata_url: www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') + if scope: + # Join outside the f-string: nested same-type quotes in an f-string + # need PEP 701 (Python 3.12+), and this project supports 3.11. + scope_value = " ".join(scope) + www_auth_parts.append(f'scope="{scope_value}"') www_authenticate = f"Bearer {', '.join(www_auth_parts)}" # Send response - body = {"error": error, "error_description": description} - body_bytes = json.dumps(body).encode() + body = {} if error is None else {"error": error, "error_description": description} + body_bytes = json.dumps(body).encode() if body else b"" await send( { diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py index 00c9adc81c..832c98d018 100644 --- a/tests/docs_src/test_authorization.py +++ b/tests/docs_src/test_authorization.py @@ -60,15 +60,20 @@ async def test_a_request_without_a_token_never_reaches_the_protocol() -> None: async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: response = await http_client.post("/mcp", json={}) assert response.status_code == 401 - assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"} + # No credentials were presented, so RFC 6750 Section 3.1 says the challenge + # carries no error code and the body is empty; the required scope is advertised. + assert response.content == b"" assert response.headers["www-authenticate"] == ( - 'Bearer error="invalid_token", error_description="Authentication required", ' - 'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"' + 'Bearer resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp", scope="notes:read"' ) async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None: - """tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller.""" + """tutorial001: a rejected token is answered 401 `invalid_token`. + + A credential WAS presented, so RFC 6750 Section 3.1 keeps the error code; the challenge + additionally advertises the required scope. + """ transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app()) async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"}) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 1d9033b307..807b1ec907 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2871,9 +2871,6 @@ def __post_init__(self) -> None: behavior="A malformed bearer token or token-verification failure returns 401 with WWW-Authenticate.", transports=("streamable-http",), note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.", - divergence=Divergence( - note="The challenge carries no `scope` parameter; see the note on hosting:auth:missing-401.", - ), ), "hosting:auth:metadata-endpoints": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location", @@ -2893,15 +2890,6 @@ def __post_init__(self) -> None: ), transports=("streamable-http",), note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.", - divergence=Divergence( - note=( - "The SDK never emits a `scope` parameter in any WWW-Authenticate challenge — neither the " - "discovery-time 401 (#protected-resource-metadata-discovery-requirements SHOULD) nor the " - "runtime 403 (#runtime-insufficient-scope-errors SHOULD); and for the no-credentials case " - 'it emits error="invalid_token", which RFC 6750 Section 3.1 says SHOULD NOT appear when no ' - "authentication information was presented." - ), - ), ), "hosting:auth:prm:authorization-servers-field": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location", @@ -2928,13 +2916,6 @@ def __post_init__(self) -> None: ), transports=("streamable-http",), note="Auth is enforced at the HTTP layer; 403 is an HTTP status code.", - divergence=Divergence( - note=( - 'The SDK emits error="insufficient_scope" and error_description but never the `scope` ' - "parameter the spec SHOULD include; the SDK client reads `scope` from this header to drive " - "step-up (utils.py extract_scope_from_www_auth) — a resource-server/client asymmetry." - ), - ), ), "hosting:auth:as:authorize-requires-pkce": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection", diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index aa42c50dfb..e0d65df488 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -87,36 +87,33 @@ def parse_www_authenticate(value: str) -> dict[str, str]: async def test_a_request_with_no_authorization_header_is_challenged_with_resource_metadata( protected: httpx2.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. + """No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata` and `scope`. + + RFC 6750 Section 3.1 says a request that carried no credentials SHOULD NOT be answered with an + error code, and the spec's discovery-time requirement says the challenge SHOULD advertise the + required `scope`. Both hold here: the challenge carries `resource_metadata` and `scope` and no + `error`/`error_description`, and the body is empty. 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"' + 'Bearer resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp", scope="mcp:read"' ) assert parse_www_authenticate(response.headers["www-authenticate"]) == { - "error": "invalid_token", - "error_description": "Authentication required", "resource_metadata": RESOURCE_METADATA_URL, + "scope": REQUIRED_SCOPE, } - assert response.json() == snapshot({"error": "invalid_token", "error_description": "Authentication required"}) + assert response.content == b"" @requirement("hosting:auth:invalid-401") async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx2.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. + A credential WAS presented, so RFC 6750 Section 3.1 keeps the `invalid_token` error code; the + challenge also advertises the required `scope`. """ response = await post_mcp(protected, bearer="tok-unknown") @@ -125,6 +122,7 @@ async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protec "error": "invalid_token", "error_description": "Authentication required", "resource_metadata": RESOURCE_METADATA_URL, + "scope": REQUIRED_SCOPE, } @@ -143,15 +141,15 @@ async def test_an_expired_token_is_answered_401(protected: httpx2.AsyncClient) - @requirement("hosting:auth:scope-403") -async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_without_a_scope_param( +async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope( protected: httpx2.AsyncClient, ) -> None: - """A token lacking the required scope is answered 403 `insufficient_scope`, with no `scope` parameter. + """A token lacking the required scope is answered 403 `insufficient_scope`, naming the scope. 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. + naming the required scope, and the SDK client reads `scope` from this header to drive + step-up (utils.py extract_scope_from_www_auth) — a resource-server/client asymmetry the + SDK now closes. """ response = await post_mcp(protected, bearer="tok-noscope") @@ -161,8 +159,8 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco "error": "insufficient_scope", "error_description": f"Required scope: {REQUIRED_SCOPE}", "resource_metadata": RESOURCE_METADATA_URL, + "scope": REQUIRED_SCOPE, } - assert "scope" not in parsed @requirement("hosting:auth:aud-validation") @@ -214,4 +212,9 @@ async def test_an_access_token_in_the_query_string_is_not_accepted(protected: ht 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" + # No credentials were presented (the query string is never consulted), so the + # challenge carries no error code at all (RFC 6750 Section 3.1) and advertises + # the required scope instead. + parsed = parse_www_authenticate(response.headers["www-authenticate"]) + assert "error" not in parsed + assert parsed["scope"] == REQUIRED_SCOPE