Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions docs/run/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 36 additions & 6 deletions src/mcp/server/auth/middleware/bearer_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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(
{
Expand Down
13 changes: 9 additions & 4 deletions tests/docs_src/test_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
19 changes: 0 additions & 19 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
47 changes: 25 additions & 22 deletions tests/interaction/auth/test_bearer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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,
}


Expand All @@ -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")

Expand All @@ -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")
Expand Down Expand Up @@ -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
Loading