Skip to content

Commit 4ff2ab8

Browse files
committed
fix(auth): include RFC 6750 scope attribute in WWW-Authenticate challenges
RequireAuthMiddleware built its 401/403 WWW-Authenticate challenges with error/error_description (and optional resource_metadata) but never the scope attribute, even though required_scopes is configured on the middleware instance. Clients therefore could not discover the required scopes from the challenge: the SDK client reads scope from WWW-Authenticate as the highest-priority source both for initial authorization (401) and for SEP-2350 step-up on 403 insufficient_scope, so that path was always empty and fell back to protected resource metadata scopes_supported. Emit scope="<space-delimited required_scopes>" whenever required_scopes is non-empty, per RFC 6750 section 3 (section 3.1 for the insufficient_scope case). Fixes #3103
1 parent a4f4ccd commit 4ff2ab8

3 files changed

Lines changed: 174 additions & 1 deletion

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ async def _send_auth_error(self, send: Send, status_code: int, error: str, descr
114114
"""Send an authentication error response with WWW-Authenticate header."""
115115
# Build WWW-Authenticate header value
116116
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
117+
# RFC 6750 section 3: the challenge's `scope` attribute advertises the scope
118+
# needed to access the resource (section 3.1: an insufficient_scope response
119+
# MAY carry it). Clients read it as the highest-priority scope source, both
120+
# for initial authorization (401) and for step-up on 403 insufficient_scope.
121+
if self.required_scopes:
122+
www_auth_parts.append(f'scope="{" ".join(self.required_scopes)}"')
117123
if self.resource_metadata_url:
118124
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
119125

tests/client/test_auth.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest import mock
77
from urllib.parse import parse_qs, quote, unquote, urlparse
88

9+
import anyio
910
import httpx2
1011
import pytest
1112
from inline_snapshot import Is, snapshot
@@ -31,8 +32,10 @@
3132
validate_authorization_response_iss,
3233
validate_metadata_issuer,
3334
)
35+
from mcp.server.auth.provider import AccessToken
3436
from mcp.server.auth.routes import build_metadata
35-
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
37+
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
38+
from mcp.server.lowlevel.server import Server
3639
from mcp.shared.auth import (
3740
AuthorizationCodeResult,
3841
OAuthClientInformationFull,
@@ -1593,6 +1596,101 @@ async def mock_callback() -> AuthorizationCodeResult:
15931596
pass
15941597

15951598

1599+
@pytest.mark.anyio
1600+
async def test_403_step_up_consumes_scope_emitted_by_require_auth_middleware(oauth_provider: OAuthClientProvider):
1601+
"""End-to-end #3103 regression: the `scope` attribute the SDK server emits in its
1602+
insufficient_scope challenge (RFC 6750 section 3.1) is what the client's step-up union
1603+
consumes, without falling back to protected-resource metadata.
1604+
1605+
Steps:
1606+
1. An SDK server app requiring "read admin" rejects a token granting only "read" with 403.
1607+
2. The server's real WWW-Authenticate challenge is replayed into the client's auth flow.
1608+
3. The client re-authorizes with the union of the granted and challenged scopes.
1609+
"""
1610+
1611+
class ReadScopedVerifier:
1612+
"""Accepts any token, granting only the "read" scope."""
1613+
1614+
async def verify_token(self, token: str) -> AccessToken:
1615+
return AccessToken(token=token, client_id="test_client_id", scopes=["read"])
1616+
1617+
server_app = Server("step-up-repro").streamable_http_app(
1618+
auth=AuthSettings(
1619+
issuer_url=AnyHttpUrl("https://auth.example.com"),
1620+
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
1621+
required_scopes=["read", "admin"],
1622+
),
1623+
token_verifier=ReadScopedVerifier(),
1624+
)
1625+
transport = httpx2.ASGITransport(app=server_app)
1626+
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
1627+
with anyio.fail_after(5):
1628+
server_response = await http_client.post(
1629+
"/mcp",
1630+
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
1631+
headers={
1632+
"accept": "application/json, text/event-stream",
1633+
"authorization": "Bearer read-only-token",
1634+
},
1635+
)
1636+
assert server_response.status_code == 403
1637+
assert 'scope="read admin"' in server_response.headers["WWW-Authenticate"]
1638+
1639+
# Client state: a stored token granted "read"; client_metadata carries no scope, as after a
1640+
# restart, so the challenge is the only source for the missing "admin" scope.
1641+
client_info = OAuthClientInformationFull(
1642+
client_id="test_client_id",
1643+
client_secret="test_client_secret",
1644+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
1645+
)
1646+
oauth_provider.context.current_tokens = OAuthToken(access_token="read-only-token", scope="read")
1647+
oauth_provider.context.token_expiry_time = time.time() + 1800
1648+
oauth_provider.context.client_info = client_info
1649+
oauth_provider.context.client_metadata.scope = None
1650+
oauth_provider._initialized = True
1651+
1652+
captured_state: str | None = None
1653+
reauthorize_scope: str | None = None
1654+
1655+
async def capture_redirect(url: str) -> None:
1656+
nonlocal captured_state, reauthorize_scope
1657+
params = parse_qs(urlparse(url).query)
1658+
reauthorize_scope = params["scope"][0]
1659+
captured_state = params.get("state", [None])[0]
1660+
1661+
async def mock_callback() -> AuthorizationCodeResult:
1662+
return AuthorizationCodeResult(code="auth_code", state=captured_state)
1663+
1664+
oauth_provider.context.redirect_handler = capture_redirect
1665+
oauth_provider.context.callback_handler = mock_callback
1666+
1667+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/mcp"))
1668+
with anyio.fail_after(5):
1669+
request = await auth_flow.__anext__()
1670+
response_403 = httpx2.Response(
1671+
403,
1672+
headers={"WWW-Authenticate": server_response.headers["WWW-Authenticate"]},
1673+
request=request,
1674+
)
1675+
token_exchange_request = await auth_flow.asend(response_403)
1676+
1677+
# SEP-2350: the union of the stored token's grant and the server-advertised requirement
1678+
assert reauthorize_scope == "read admin"
1679+
1680+
# Drive the flow to completion so the context lock is released cleanly
1681+
token_response = httpx2.Response(
1682+
200,
1683+
json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600, "scope": "read admin"},
1684+
request=token_exchange_request,
1685+
)
1686+
with anyio.fail_after(5):
1687+
final_request = await auth_flow.asend(token_response)
1688+
try:
1689+
await auth_flow.asend(httpx2.Response(200, request=final_request))
1690+
except StopAsyncIteration:
1691+
pass
1692+
1693+
15961694
@pytest.mark.parametrize(
15971695
(
15981696
"issuer_url",

tests/server/auth/middleware/test_bearer_auth.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import time
44
from typing import Any, cast
55

6+
import anyio
7+
import httpx2
68
import pytest
79
from starlette.authentication import AuthCredentials
810
from starlette.datastructures import Headers
11+
from starlette.middleware.authentication import AuthenticationMiddleware
912
from starlette.requests import Request
1013
from starlette.types import Message, Receive, Scope, Send
1114

@@ -458,6 +461,72 @@ async def send(message: Message) -> None: # pragma: no cover
458461
assert app.send == send
459462

460463

464+
@pytest.mark.anyio
465+
async def test_insufficient_scope_challenge_advertises_required_scopes(
466+
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any], valid_access_token: AccessToken
467+
):
468+
"""The 403 insufficient_scope challenge carries a `scope` attribute listing the configured
469+
required scopes, per RFC 6750 section 3.1, so clients can step-up (#3103)."""
470+
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
471+
inner_app = MockApp()
472+
# Production wiring: the authentication middleware populates the connection's user/auth
473+
# from the bearer token, then RequireAuthMiddleware enforces the required scopes.
474+
app = AuthenticationMiddleware(
475+
RequireAuthMiddleware(inner_app, required_scopes=["read", "admin"]),
476+
backend=BearerAuthBackend(ProviderTokenVerifier(mock_oauth_provider)),
477+
)
478+
479+
transport = httpx2.ASGITransport(app=app)
480+
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as client:
481+
with anyio.fail_after(5):
482+
# valid_access_token grants read/write, so the required "admin" scope is missing
483+
response = await client.get("/", headers={"Authorization": "Bearer valid_token"})
484+
485+
assert response.status_code == 403
486+
assert response.headers["WWW-Authenticate"] == (
487+
'Bearer error="insufficient_scope", error_description="Required scope: admin", scope="read admin"'
488+
)
489+
assert not inner_app.called
490+
491+
492+
@pytest.mark.anyio
493+
async def test_unauthenticated_challenge_advertises_required_scopes():
494+
"""The 401 challenge carries a `scope` attribute (RFC 6750 section 3) when required scopes
495+
are configured, so clients can request them on initial authorization (#3103)."""
496+
inner_app = MockApp()
497+
middleware = RequireAuthMiddleware(inner_app, required_scopes=["read", "admin"])
498+
499+
transport = httpx2.ASGITransport(app=middleware)
500+
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as client:
501+
with anyio.fail_after(5):
502+
response = await client.get("/")
503+
504+
assert response.status_code == 401
505+
assert response.headers["WWW-Authenticate"] == (
506+
'Bearer error="invalid_token", error_description="Authentication required", scope="read admin"'
507+
)
508+
assert not inner_app.called
509+
510+
511+
@pytest.mark.anyio
512+
async def test_challenge_omits_scope_when_no_scopes_configured():
513+
"""A challenge from a middleware with no required scopes carries no `scope` attribute —
514+
there is nothing to advertise, and RFC 6750 section 3 makes the attribute optional."""
515+
inner_app = MockApp()
516+
middleware = RequireAuthMiddleware(inner_app, required_scopes=[])
517+
518+
transport = httpx2.ASGITransport(app=middleware)
519+
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as client:
520+
with anyio.fail_after(5):
521+
response = await client.get("/")
522+
523+
assert response.status_code == 401
524+
assert response.headers["WWW-Authenticate"] == (
525+
'Bearer error="invalid_token", error_description="Authentication required"'
526+
)
527+
assert not inner_app.called
528+
529+
461530
def test_authorization_context_is_built_from_principal_components() -> None:
462531
"""Session ownership identifies the principal via the shared principal_components triple."""
463532
token = AccessToken(

0 commit comments

Comments
 (0)