Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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.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.server.lowlevel.server import Server

Check warning on line 38 in tests/client/test_auth.py

View check run for this annotation

Claude / Claude Code Review

[quality] test_auth.py imports Server via private path instead of public mcp.server

Code quality: this new import uses the private module path `from mcp.server.lowlevel.server import Server` instead of the public re-export `from mcp.server import Server` (listed in `__all__`), which every sibling test file in `tests/client/` and 40+ other test files use. A one-line switch to the public import keeps the test decoupled from the internal module layout.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
Expand Down Expand Up @@ -1593,6 +1596,101 @@
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
7 changes: 4 additions & 3 deletions tests/interaction/auth/_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,12 +309,13 @@


def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -> AppShim:
"""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.

Check warning on line 318 in tests/interaction/auth/_harness.py

View check run for this annotation

Claude / Claude Code Review

[quality] Stale _FirstChallenge docstring still claims the bearer middleware cannot emit scope=

The `_FirstChallenge` docstring (~line 281) still claims the initial 401 needs the shim to carry "parameters (such as `scope=`) that the SDK's own bearer middleware cannot be configured to emit" — after this PR the middleware does emit `scope=` whenever `required_scopes` is non-empty, so that example is now false. Consider rewording it the same way this PR reworded the sibling `step_up_shim` docstring (e.g. "a `scope` differing from the configured `required_scopes`").
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