Skip to content

Commit f26bb2c

Browse files
committed
fix: include RFC 6750 scope parameter in insufficient_scope challenge
RequireAuthMiddleware._send_auth_error built the WWW-Authenticate header for both the 401 invalid_token and 403 insufficient_scope cases without ever including the scope attribute, even though required_scopes was already available on the middleware instance. RFC 6750 3.1 says the insufficient_scope response "MAY include the 'scope' attribute with the scope necessary to access the protected resource." Emitting it lets a client discover the correct scope to request directly from the challenge instead of falling back to protected-resource-metadata scopes_supported, which the SDK's client already treats as a lower-priority signal in get_client_metadata_scopes(). The 401 cases (no credentials, invalid token) are left unchanged: per RFC 6750 3.1 scope is specific to the insufficient_scope error and doesn't apply when the resource server hasn't yet determined what scope would even satisfy the request. Reported-by: velias Github-Issue: #3103
1 parent 5f0b6af commit f26bb2c

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,26 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
113113
# auth_credentials should always be provided; this is just paranoia
114114
if auth_credentials is None or required_scope not in auth_credentials.scopes:
115115
await self._send_auth_error(
116-
send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}"
116+
send,
117+
status_code=403,
118+
error="insufficient_scope",
119+
description=f"Required scope: {required_scope}",
120+
# RFC 6750 3.1: on insufficient_scope, the challenge MAY carry the
121+
# scope necessary to access the resource.
122+
scope=" ".join(self.required_scopes),
117123
)
118124
return
119125

120126
await self.app(scope, receive, send)
121127

122-
async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None:
128+
async def _send_auth_error(
129+
self, send: Send, status_code: int, error: str, description: str, scope: str | None = None
130+
) -> None:
123131
"""Send an authentication error response with WWW-Authenticate header."""
124132
# Build WWW-Authenticate header value
125133
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
134+
if scope:
135+
www_auth_parts.append(f'scope="{scope}"')
126136
if self.resource_metadata_url: # pragma: no cover
127137
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
128138

tests/server/auth/middleware/test_bearer_auth.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,54 @@ async def send(message: Message) -> None:
346346
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
347347
assert not app.called
348348

349+
async def test_missing_required_scope_includes_scope_in_challenge(self, valid_access_token: AccessToken):
350+
"""RFC 6750 3.1: an insufficient_scope challenge MAY include the required scope."""
351+
app = MockApp()
352+
middleware = RequireAuthMiddleware(app, required_scopes=["admin", "superuser"])
353+
354+
# Create a user with read/write scopes but neither required scope
355+
user = AuthenticatedUser(valid_access_token)
356+
auth = AuthCredentials(["read", "write"])
357+
358+
scope: Scope = {"type": "http", "user": user, "auth": auth}
359+
360+
async def receive() -> Message: # pragma: no cover
361+
return {"type": "http.request"}
362+
363+
sent_messages: list[Message] = []
364+
365+
async def send(message: Message) -> None:
366+
sent_messages.append(message)
367+
368+
await middleware(scope, receive, send)
369+
370+
assert sent_messages[0]["status"] == 403
371+
www_authenticate = next(h[1] for h in sent_messages[0]["headers"] if h[0] == b"www-authenticate")
372+
assert www_authenticate == (
373+
b'Bearer error="insufficient_scope", error_description="Required scope: admin", scope="admin superuser"'
374+
)
375+
assert not app.called
376+
377+
async def test_no_user_challenge_omits_scope(self):
378+
"""RFC 6750 3.1: scope is specific to insufficient_scope; a bare 401 challenge should not carry it."""
379+
app = MockApp()
380+
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
381+
scope: Scope = {"type": "http"}
382+
383+
async def receive() -> Message: # pragma: no cover
384+
return {"type": "http.request"}
385+
386+
sent_messages: list[Message] = []
387+
388+
async def send(message: Message) -> None:
389+
sent_messages.append(message)
390+
391+
await middleware(scope, receive, send)
392+
393+
assert sent_messages[0]["status"] == 401
394+
www_authenticate = next(h[1] for h in sent_messages[0]["headers"] if h[0] == b"www-authenticate")
395+
assert b"scope=" not in www_authenticate
396+
349397
async def test_no_auth_credentials(self, valid_access_token: AccessToken):
350398
"""Test middleware with no auth credentials in scope."""
351399
app = MockApp()

0 commit comments

Comments
 (0)