Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/run/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ This document is how a client that has never heard of your server finds its way

```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 error="invalid_token", error_description="Authentication required", scope="notes:read", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"

{"error": "invalid_token", "error_description": "Authentication required"}
```
Expand Down
6 changes: 6 additions & 0 deletions src/mcp/server/auth/middleware/bearer_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ async def _send_auth_error(self, send: Send, status_code: int, error: str, descr
"""Send an authentication error response with WWW-Authenticate header."""
# Build WWW-Authenticate header value
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
# RFC 6750 section 3: the challenge's `scope` attribute advertises the scope
# needed to access the resource (section 3.1: an insufficient_scope response
# MAY carry it). Clients read it as the highest-priority scope source, both
# for initial authorization (401) and for step-up on 403 insufficient_scope.
if self.required_scopes:
www_auth_parts.append(f'scope="{" ".join(self.required_scopes)}"')
Comment thread
claude[bot] marked this conversation as resolved.
if self.resource_metadata_url:
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')

Expand Down
100 changes: 99 additions & 1 deletion tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from unittest import mock
from urllib.parse import parse_qs, quote, unquote, urlparse

import anyio
import httpx2
import pytest
from inline_snapshot import Is, snapshot
Expand All @@ -31,8 +32,10 @@
validate_authorization_response_iss,
validate_metadata_issuer,
)
from mcp.server import Server
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.routes import build_metadata
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
Expand Down Expand Up @@ -1593,6 +1596,101 @@ async def mock_callback() -> AuthorizationCodeResult:
pass


@pytest.mark.anyio
async def test_403_step_up_consumes_scope_emitted_by_require_auth_middleware(oauth_provider: OAuthClientProvider):
"""End-to-end #3103 regression: the `scope` attribute the SDK server emits in its
insufficient_scope challenge (RFC 6750 section 3.1) is what the client's step-up union
consumes, without falling back to protected-resource metadata.

Steps:
1. An SDK server app requiring "read admin" rejects a token granting only "read" with 403.
2. The server's real WWW-Authenticate challenge is replayed into the client's auth flow.
3. The client re-authorizes with the union of the granted and challenged scopes.
"""

class ReadScopedVerifier:
"""Accepts any token, granting only the "read" scope."""

async def verify_token(self, token: str) -> AccessToken:
return AccessToken(token=token, client_id="test_client_id", scopes=["read"])

server_app = Server("step-up-repro").streamable_http_app(
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
required_scopes=["read", "admin"],
),
token_verifier=ReadScopedVerifier(),
)
transport = httpx2.ASGITransport(app=server_app)
async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
with anyio.fail_after(5):
server_response = await http_client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
headers={
"accept": "application/json, text/event-stream",
"authorization": "Bearer read-only-token",
},
)
assert server_response.status_code == 403
assert 'scope="read admin"' in server_response.headers["WWW-Authenticate"]

# Client state: a stored token granted "read"; client_metadata carries no scope, as after a
# restart, so the challenge is the only source for the missing "admin" scope.
client_info = OAuthClientInformationFull(
client_id="test_client_id",
client_secret="test_client_secret",
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
oauth_provider.context.current_tokens = OAuthToken(access_token="read-only-token", scope="read")
oauth_provider.context.token_expiry_time = time.time() + 1800
oauth_provider.context.client_info = client_info
oauth_provider.context.client_metadata.scope = None
oauth_provider._initialized = True

captured_state: str | None = None
reauthorize_scope: str | None = None

async def capture_redirect(url: str) -> None:
nonlocal captured_state, reauthorize_scope
params = parse_qs(urlparse(url).query)
reauthorize_scope = params["scope"][0]
captured_state = params.get("state", [None])[0]

async def mock_callback() -> AuthorizationCodeResult:
return AuthorizationCodeResult(code="auth_code", state=captured_state)

oauth_provider.context.redirect_handler = capture_redirect
oauth_provider.context.callback_handler = mock_callback

auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/mcp"))
with anyio.fail_after(5):
request = await auth_flow.__anext__()
response_403 = httpx2.Response(
403,
headers={"WWW-Authenticate": server_response.headers["WWW-Authenticate"]},
request=request,
)
token_exchange_request = await auth_flow.asend(response_403)

# SEP-2350: the union of the stored token's grant and the server-advertised requirement
assert reauthorize_scope == "read admin"

# Drive the flow to completion so the context lock is released cleanly
token_response = httpx2.Response(
200,
json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600, "scope": "read admin"},
request=token_exchange_request,
)
with anyio.fail_after(5):
final_request = await auth_flow.asend(token_response)
try:
await auth_flow.asend(httpx2.Response(200, request=final_request))
except StopAsyncIteration:
pass


@pytest.mark.parametrize(
(
"issuer_url",
Expand Down
2 changes: 1 addition & 1 deletion tests/docs_src/test_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
assert response.status_code == 401
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
assert response.headers["www-authenticate"] == (
'Bearer error="invalid_token", error_description="Authentication required", '
'Bearer error="invalid_token", error_description="Authentication required", scope="notes:read", '
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
)

Expand Down
20 changes: 2 additions & 18 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -2859,18 +2859,12 @@ def __post_init__(self) -> None:
behavior="An expired token returns 401 invalid_token.",
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:invalid-401": Requirement(
source=f"{SPEC_BASE_URL}/basic/authorization#token-handling",
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 @@ -2892,11 +2886,8 @@ def __post_init__(self) -> None:
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."
'For the no-credentials case the SDK emits error="invalid_token", which RFC 6750 '
"Section 3.1 says SHOULD NOT appear when no authentication information was presented."
),
),
),
Expand Down Expand Up @@ -2925,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
14 changes: 8 additions & 6 deletions tests/interaction/auth/_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,10 @@ class _FirstChallenge:
"""ASGI shim that answers the first request to a path with 401 + a given WWW-Authenticate.

Subsequent requests pass through to the wrapped app. Used to make the initial 401 carry
parameters (such as `scope=`) that the SDK's own bearer middleware cannot be configured
to emit, so client behaviour driven by those parameters is reachable end to end. Reserve
this pattern for behaviour the real server cannot be made to produce.
a challenge shape the SDK's own bearer middleware cannot be configured to emit for the
scenario under test (e.g. a `scope` differing from the configured `required_scopes`), so
client behaviour driven by those parameters is reachable end to end. Reserve this pattern
for behaviour the real server cannot be made to produce.
"""

app: ASGIApp
Expand Down Expand Up @@ -312,9 +313,10 @@ def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -
"""Build an `app_shim` that 403s the Nth authenticated POST to `/mcp` with the given challenge.

Subsequent requests pass through. Used to drive the client's `insufficient_scope` step-up
handling: the SDK's bearer middleware never emits `scope=` in its 403 challenge (see the
divergence on `hosting:auth:scope-403`), so the test supplies the 403 itself. Reserve this
pattern for behaviour the real server cannot be made to produce.
handling with a challenge shape the real bearer middleware cannot be made to produce for
the scenario under test (e.g. a `scope` differing from the configured `required_scopes`,
or a challenge on a request the middleware would let through). Reserve this pattern for
behaviour the real server cannot be made to produce.
Comment thread
claude[bot] marked this conversation as resolved.

The default `on_nth_authenticated_post=2` targets the `notifications/initialized` POST: the
first authenticated POST is the auth flow's retry of the original initialize request (yielded
Expand Down
12 changes: 6 additions & 6 deletions tests/interaction/auth/test_authorize_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,12 +328,12 @@ async def test_the_registered_auth_method_is_used_regardless_of_as_metadata_adve
async def test_scope_is_selected_from_the_www_authenticate_challenge_over_prm_metadata() -> None:
"""When the 401 challenge carries `scope=`, that value is requested instead of the PRM scopes.

The SDK's bearer middleware never emits `scope=` in WWW-Authenticate (see the divergence
on `hosting:auth:scope-403`), so the test supplies the first 401 itself via
`first_challenge_shim` and disables token verification so the post-auth retry succeeds
regardless of the granted scope. PRM advertises `["from-prm"]` (it mirrors
`required_scopes`); the challenge says `from-header`; the authorize URL must carry
`from-header`.
The bearer middleware's own challenge would carry the configured `required_scopes`, which
PRM `scopes_supported` mirrors — indistinguishable from the PRM fallback — so the test
supplies the first 401 itself via `first_challenge_shim` with a `scope` that differs from
PRM, and disables token verification so the post-auth retry succeeds regardless of the
granted scope. PRM advertises `["from-prm"]` (it mirrors `required_scopes`); the challenge
says `from-header`; the authorize URL must carry `from-header`.
"""
recorded, on_request = record_requests()
provider = InMemoryAuthorizationServerProvider(default_scopes=["from-header"])
Expand Down
37 changes: 18 additions & 19 deletions tests/interaction/auth/test_bearer.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,23 @@ async def test_a_request_with_no_authorization_header_is_challenged_with_resourc
"""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.
expired-token cases into one challenge. The `scope` parameter carries the configured required
scopes (spec SHOULD, RFC 6750 section 3; #3103). RFC 6750 also says the no-credentials case
SHOULD NOT carry an error code; that remaining gap is recorded as the divergence on this
requirement. 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", '
'Bearer error="invalid_token", error_description="Authentication required", scope="mcp:read", '
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
)
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "invalid_token",
"error_description": "Authentication required",
"scope": REQUIRED_SCOPE,
"resource_metadata": RESOURCE_METADATA_URL,
}
assert response.json() == snapshot({"error": "invalid_token", "error_description": "Authentication required"})
Expand All @@ -106,15 +107,16 @@ async def test_a_request_with_no_authorization_header_is_challenged_with_resourc
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.
The challenge is identical to the no-header case (the backend returns `None` for both),
including the `scope` parameter carrying the configured required scopes (#3103).
"""
response = await post_mcp(protected, bearer="tok-unknown")

assert response.status_code == 401
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "invalid_token",
"error_description": "Authentication required",
"scope": REQUIRED_SCOPE,
"resource_metadata": RESOURCE_METADATA_URL,
}

Expand All @@ -124,8 +126,7 @@ async def test_an_expired_token_is_answered_401(protected: httpx2.AsyncClient) -
"""A token whose `expires_at` is in the past is answered 401 `invalid_token`.

The expiry check is the bearer backend's, against the wall clock; the test seeds a concrete
past timestamp so no time mocking is involved. The missing `scope` parameter is the recorded
divergence on this requirement.
past timestamp so no time mocking is involved.
"""
response = await post_mcp(protected, bearer="tok-expired")

Expand All @@ -134,26 +135,24 @@ 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_with_a_scope_param(
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` with a `scope` parameter.

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.
The spec's runtime-insufficient-scope guidance (and RFC 6750 section 3.1) says the challenge
SHOULD include `scope` naming the required scope; the SDK client reads it from this header to
drive step-up authorization (#3103).
"""
response = await post_mcp(protected, bearer="tok-noscope")

assert response.status_code == 403
parsed = parse_www_authenticate(response.headers["www-authenticate"])
assert parsed == {
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
"error": "insufficient_scope",
"error_description": f"Required scope: {REQUIRED_SCOPE}",
"scope": REQUIRED_SCOPE,
"resource_metadata": RESOURCE_METADATA_URL,
}
assert "scope" not in parsed


@requirement("hosting:auth:aud-validation")
Expand Down
Loading
Loading