Skip to content

Commit 9333a51

Browse files
committed
fix(auth): no error code for a request that presented no credentials
RFC 6750 Section 3.1 says a request that carried no credentials SHOULD NOT be answered with an error code, but the bearer gate collapsed the no-header case into the same challenge as an invalid token and emitted error="invalid_token". The challenge also never carried the `scope` parameter, which the spec SHOULD include for both the discovery-time 401 and the runtime 403 -- and which this SDK's own client reads (utils.py extract_scope_from_www_auth) to drive step-up, so the gap was a resource-server/client asymmetry. Split the two cases: * no Authorization header -> 401 with resource_metadata and scope, no error code, and an empty body * presented but invalid token -> 401 with error="invalid_token", plus scope * insufficient scope -> 403 with error="insufficient_scope" and the required scope named The scope parameter is optional on _send_auth_error and is joined outside the f-string: nested same-type quotes in an f-string need PEP 701 (3.12+) and this project supports 3.11. Resolves the divergences recorded on hosting:auth:missing-401, hosting:auth:scope-403 and hosting:auth:invalid-401; those entries no longer carry a divergence, and the tests that pinned the old challenge now pin the corrected one. Closes #3769
1 parent af4c6f4 commit 9333a51

5 files changed

Lines changed: 84 additions & 54 deletions

File tree

‎docs/run/authorization.md‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,28 @@ You registered one tool. The second route is the SDK's.
6767
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.
6868

6969
!!! check
70-
Call `/mcp` with no token (or with one your verifier returned `None` for) and the request is
70+
Call `/mcp` with no token and the request is
7171
stopped at the door:
7272

7373
```text
7474
HTTP/1.1 401 Unauthorized
75-
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
75+
WWW-Authenticate: Bearer resour…ata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp", scope="notes:read"
76+
```
77+
78+
Nothing was parsed and no tool ran. The challenge carries no error code, because no
79+
credentials were presented (RFC 6750 Section 3.1), and it names the `scope` a token needs.
80+
81+
A token your verifier returned `None` for is a different case: a credential *was* presented
82+
and was rejected, so it is answered with the error code.
83+
84+
```text
85+
HTTP/1.1 401 Unauthorized
86+
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"
7687

7788
{"error": "invalid_token", "error_description": "Authentication required"}
7889
```
7990

80-
Nothing was parsed and no tool ran. And that `resource_metadata` pointer in `WWW-Authenticate` is
91+
And that `resource_metadata` pointer in `WWW-Authenticate` is
8192
what makes discovery automatic: 401 -> metadata document -> authorization server -> token -> retry.
8293

8394
!!! warning

‎src/mcp/server/auth/middleware/bearer_auth.py‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pydantic import AnyHttpUrl, ValidationError
77
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
8+
from starlette.datastructures import Headers
89
from starlette.requests import HTTPConnection
910
from starlette.types import Receive, Scope, Send
1011

@@ -115,8 +116,17 @@ def __init__(
115116
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
116117
auth_user = scope.get("user")
117118
if not isinstance(auth_user, AuthenticatedUser):
119+
# RFC 6750 3.1: a request with no credentials SHOULD NOT include an
120+
# error code. Only a presented-but-invalid token gets
121+
# error="invalid_token". Advertise the required scopes either way.
122+
headers = Headers(scope=scope)
123+
presented = headers.get("authorization") is not None
118124
await self._send_auth_error(
119-
send, status_code=401, error="invalid_token", description="Authentication required"
125+
send,
126+
status_code=401,
127+
error="invalid_token" if presented else None,
128+
description="Authentication required" if presented else None,
129+
scope=self.required_scopes or None,
120130
)
121131
return
122132

@@ -126,24 +136,44 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
126136
# auth_credentials should always be provided; this is just paranoia
127137
if auth_credentials is None or required_scope not in auth_credentials.scopes:
128138
await self._send_auth_error(
129-
send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}"
139+
send,
140+
status_code=403,
141+
error="insufficient_scope",
142+
description=f"Required scope: {required_scope}",
143+
scope=[required_scope],
130144
)
131145
return
132146

133147
await self.app(scope, receive, send)
134148

135-
async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None:
149+
async def _send_auth_error(
150+
self,
151+
send: Send,
152+
status_code: int,
153+
error: str | None = None,
154+
description: str | None = None,
155+
scope: list[str] | None = None,
156+
) -> None:
136157
"""Send an authentication error response with WWW-Authenticate header."""
137158
# Build WWW-Authenticate header value
138-
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
159+
www_auth_parts = []
160+
if error:
161+
www_auth_parts.append(f'error="{error}"')
162+
if description:
163+
www_auth_parts.append(f'error_description="{description}"')
139164
if self.resource_metadata_url:
140165
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
166+
if scope:
167+
# Join outside the f-string: nested same-type quotes in an f-string
168+
# need PEP 701 (Python 3.12+), and this project supports 3.11.
169+
scope_value = " ".join(scope)
170+
www_auth_parts.append(f'scope="{scope_value}"')
141171

142172
www_authenticate = f"Bearer {', '.join(www_auth_parts)}"
143173

144174
# Send response
145-
body = {"error": error, "error_description": description}
146-
body_bytes = json.dumps(body).encode()
175+
body = {} if error is None else {"error": error, "error_description": description}
176+
body_bytes = json.dumps(body).encode() if body else b""
147177

148178
await send(
149179
{

‎tests/docs_src/test_authorization.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,20 @@ async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
6060
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
6161
response = await http_client.post("/mcp", json={})
6262
assert response.status_code == 401
63-
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
63+
# No credentials were presented, so RFC 6750 Section 3.1 says the challenge
64+
# carries no error code and the body is empty; the required scope is advertised.
65+
assert response.content == b""
6466
assert response.headers["www-authenticate"] == (
65-
'Bearer error="invalid_token", error_description="Authentication required", '
66-
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
67+
'Bearer resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp", scope="notes:read"'
6768
)
6869

6970

7071
async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None:
71-
"""tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller."""
72+
"""tutorial001: a rejected token is answered 401 `invalid_token`.
73+
74+
A credential WAS presented, so RFC 6750 Section 3.1 keeps the error code; the challenge
75+
additionally advertises the required scope.
76+
"""
7277
transport = httpx2.ASGITransport(app=tutorial001.mcp.streamable_http_app())
7378
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
7479
response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"})

‎tests/interaction/_requirements.py‎

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2871,9 +2871,6 @@ def __post_init__(self) -> None:
28712871
behavior="A malformed bearer token or token-verification failure returns 401 with WWW-Authenticate.",
28722872
transports=("streamable-http",),
28732873
note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.",
2874-
divergence=Divergence(
2875-
note="The challenge carries no `scope` parameter; see the note on hosting:auth:missing-401.",
2876-
),
28772874
),
28782875
"hosting:auth:metadata-endpoints": Requirement(
28792876
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location",
@@ -2893,15 +2890,6 @@ def __post_init__(self) -> None:
28932890
),
28942891
transports=("streamable-http",),
28952892
note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.",
2896-
divergence=Divergence(
2897-
note=(
2898-
"The SDK never emits a `scope` parameter in any WWW-Authenticate challenge — neither the "
2899-
"discovery-time 401 (#protected-resource-metadata-discovery-requirements SHOULD) nor the "
2900-
"runtime 403 (#runtime-insufficient-scope-errors SHOULD); and for the no-credentials case "
2901-
'it emits error="invalid_token", which RFC 6750 Section 3.1 says SHOULD NOT appear when no '
2902-
"authentication information was presented."
2903-
),
2904-
),
29052893
),
29062894
"hosting:auth:prm:authorization-servers-field": Requirement(
29072895
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location",
@@ -2928,13 +2916,6 @@ def __post_init__(self) -> None:
29282916
),
29292917
transports=("streamable-http",),
29302918
note="Auth is enforced at the HTTP layer; 403 is an HTTP status code.",
2931-
divergence=Divergence(
2932-
note=(
2933-
'The SDK emits error="insufficient_scope" and error_description but never the `scope` '
2934-
"parameter the spec SHOULD include; the SDK client reads `scope` from this header to drive "
2935-
"step-up (utils.py extract_scope_from_www_auth) — a resource-server/client asymmetry."
2936-
),
2937-
),
29382919
),
29392920
"hosting:auth:as:authorize-requires-pkce": Requirement(
29402921
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection",

‎tests/interaction/auth/test_bearer.py‎

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -87,36 +87,33 @@ def parse_www_authenticate(value: str) -> dict[str, str]:
8787
async def test_a_request_with_no_authorization_header_is_challenged_with_resource_metadata(
8888
protected: httpx2.AsyncClient,
8989
) -> None:
90-
"""No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata`.
91-
92-
The snapshot pins current behaviour: the SDK collapses the no-header, unknown-token, and
93-
expired-token cases into one challenge (`error="invalid_token"`, no `scope` parameter). The
94-
spec says the discovery-time challenge SHOULD include `scope` and RFC 6750 says the
95-
no-credentials case SHOULD NOT carry an error code; both gaps are recorded as the divergence
96-
on this requirement. Asserting the dict equals an exact key set also pins that no parameter
97-
appears twice.
90+
"""No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata` and `scope`.
91+
92+
RFC 6750 Section 3.1 says a request that carried no credentials SHOULD NOT be answered with an
93+
error code, and the spec's discovery-time requirement says the challenge SHOULD advertise the
94+
required `scope`. Both hold here: the challenge carries `resource_metadata` and `scope` and no
95+
`error`/`error_description`, and the body is empty. Asserting the dict equals an exact key set
96+
also pins that no parameter appears twice.
9897
"""
9998
response = await post_mcp(protected)
10099

101100
assert response.status_code == 401
102101
assert response.headers["www-authenticate"] == snapshot(
103-
'Bearer error="invalid_token", error_description="Authentication required", '
104-
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
102+
'Bearer resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp", scope="mcp:read"'
105103
)
106104
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
107-
"error": "invalid_token",
108-
"error_description": "Authentication required",
109105
"resource_metadata": RESOURCE_METADATA_URL,
106+
"scope": REQUIRED_SCOPE,
110107
}
111-
assert response.json() == snapshot({"error": "invalid_token", "error_description": "Authentication required"})
108+
assert response.content == b""
112109

113110

114111
@requirement("hosting:auth:invalid-401")
115112
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx2.AsyncClient) -> None:
116113
"""A token the verifier does not recognize is answered 401 `invalid_token`.
117114
118-
The challenge is identical to the no-header case (the backend returns `None` for both); the
119-
missing `scope` parameter is the recorded divergence on this requirement.
115+
A credential WAS presented, so RFC 6750 Section 3.1 keeps the `invalid_token` error code; the
116+
challenge also advertises the required `scope`.
120117
"""
121118
response = await post_mcp(protected, bearer="tok-unknown")
122119

@@ -125,6 +122,7 @@ async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protec
125122
"error": "invalid_token",
126123
"error_description": "Authentication required",
127124
"resource_metadata": RESOURCE_METADATA_URL,
125+
"scope": REQUIRED_SCOPE,
128126
}
129127

130128

@@ -143,15 +141,15 @@ async def test_an_expired_token_is_answered_401(protected: httpx2.AsyncClient) -
143141

144142

145143
@requirement("hosting:auth:scope-403")
146-
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_without_a_scope_param(
144+
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope(
147145
protected: httpx2.AsyncClient,
148146
) -> None:
149-
"""A token lacking the required scope is answered 403 `insufficient_scope`, with no `scope` parameter.
147+
"""A token lacking the required scope is answered 403 `insufficient_scope`, naming the scope.
150148
151149
The spec's runtime-insufficient-scope guidance says the challenge SHOULD include `scope`
152-
naming the required scope; the SDK never emits it, recorded as the divergence on this
153-
requirement. The SDK client reads `scope` from this header to drive step-up, so the gap is
154-
a resource-server/client asymmetry.
150+
naming the required scope, and the SDK client reads `scope` from this header to drive
151+
step-up (utils.py extract_scope_from_www_auth) — a resource-server/client asymmetry the
152+
SDK now closes.
155153
"""
156154
response = await post_mcp(protected, bearer="tok-noscope")
157155

@@ -161,8 +159,8 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco
161159
"error": "insufficient_scope",
162160
"error_description": f"Required scope: {REQUIRED_SCOPE}",
163161
"resource_metadata": RESOURCE_METADATA_URL,
162+
"scope": REQUIRED_SCOPE,
164163
}
165-
assert "scope" not in parsed
166164

167165

168166
@requirement("hosting:auth:aud-validation")
@@ -214,4 +212,9 @@ async def test_an_access_token_in_the_query_string_is_not_accepted(protected: ht
214212
response = await post_mcp(protected, query={"access_token": "tok-valid"})
215213

216214
assert response.status_code == 401
217-
assert parse_www_authenticate(response.headers["www-authenticate"])["error"] == "invalid_token"
215+
# No credentials were presented (the query string is never consulted), so the
216+
# challenge carries no error code at all (RFC 6750 Section 3.1) and advertises
217+
# the required scope instead.
218+
parsed = parse_www_authenticate(response.headers["www-authenticate"])
219+
assert "error" not in parsed
220+
assert parsed["scope"] == REQUIRED_SCOPE

0 commit comments

Comments
 (0)