Skip to content

Commit cd22f04

Browse files
committed
test: re-pin WWW-Authenticate assertions to the scope-bearing challenge
The interaction and docs_src suites pinned the old scope-less challenge as a recorded divergence (hosting:auth:scope-403 and the scope half of hosting:auth:missing-401 / invalid-401 / expired-401). Now that the middleware emits the RFC 6750 scope attribute, follow the divergence lifecycle: re-pin those tests to the spec-correct output, drop the resolved Divergence records, keep the still-open no-credentials error-code divergence on hosting:auth:missing-401, and refresh the docstrings and docs page that described the old behaviour.
1 parent 4ff2ab8 commit cd22f04

6 files changed

Lines changed: 32 additions & 48 deletions

File tree

docs/run/authorization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ This document is how a client that has never heard of your server finds its way
6868

6969
```text
7070
HTTP/1.1 401 Unauthorized
71-
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
71+
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"
7272

7373
{"error": "invalid_token", "error_description": "Authentication required"}
7474
```

tests/docs_src/test_authorization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
6262
assert response.status_code == 401
6363
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
6464
assert response.headers["www-authenticate"] == (
65-
'Bearer error="invalid_token", error_description="Authentication required", '
65+
'Bearer error="invalid_token", error_description="Authentication required", scope="notes:read", '
6666
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
6767
)
6868

tests/interaction/_requirements.py

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2859,18 +2859,12 @@ def __post_init__(self) -> None:
28592859
behavior="An expired token returns 401 invalid_token.",
28602860
transports=("streamable-http",),
28612861
note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.",
2862-
divergence=Divergence(
2863-
note="The challenge carries no `scope` parameter; see the note on hosting:auth:missing-401.",
2864-
),
28652862
),
28662863
"hosting:auth:invalid-401": Requirement(
28672864
source=f"{SPEC_BASE_URL}/basic/authorization#token-handling",
28682865
behavior="A malformed bearer token or token-verification failure returns 401 with WWW-Authenticate.",
28692866
transports=("streamable-http",),
28702867
note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.",
2871-
divergence=Divergence(
2872-
note="The challenge carries no `scope` parameter; see the note on hosting:auth:missing-401.",
2873-
),
28742868
),
28752869
"hosting:auth:metadata-endpoints": Requirement(
28762870
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location",
@@ -2892,11 +2886,8 @@ def __post_init__(self) -> None:
28922886
note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.",
28932887
divergence=Divergence(
28942888
note=(
2895-
"The SDK never emits a `scope` parameter in any WWW-Authenticate challenge — neither the "
2896-
"discovery-time 401 (#protected-resource-metadata-discovery-requirements SHOULD) nor the "
2897-
"runtime 403 (#runtime-insufficient-scope-errors SHOULD); and for the no-credentials case "
2898-
'it emits error="invalid_token", which RFC 6750 Section 3.1 says SHOULD NOT appear when no '
2899-
"authentication information was presented."
2889+
'For the no-credentials case the SDK emits error="invalid_token", which RFC 6750 '
2890+
"Section 3.1 says SHOULD NOT appear when no authentication information was presented."
29002891
),
29012892
),
29022893
),
@@ -2925,13 +2916,6 @@ def __post_init__(self) -> None:
29252916
),
29262917
transports=("streamable-http",),
29272918
note="Auth is enforced at the HTTP layer; 403 is an HTTP status code.",
2928-
divergence=Divergence(
2929-
note=(
2930-
'The SDK emits error="insufficient_scope" and error_description but never the `scope` '
2931-
"parameter the spec SHOULD include; the SDK client reads `scope` from this header to drive "
2932-
"step-up (utils.py extract_scope_from_www_auth) — a resource-server/client asymmetry."
2933-
),
2934-
),
29352919
),
29362920
"hosting:auth:as:authorize-requires-pkce": Requirement(
29372921
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection",

tests/interaction/auth/_harness.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,10 @@ def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -
312312
"""Build an `app_shim` that 403s the Nth authenticated POST to `/mcp` with the given challenge.
313313
314314
Subsequent requests pass through. Used to drive the client's `insufficient_scope` step-up
315-
handling: the SDK's bearer middleware never emits `scope=` in its 403 challenge (see the
316-
divergence on `hosting:auth:scope-403`), so the test supplies the 403 itself. Reserve this
317-
pattern for behaviour the real server cannot be made to produce.
315+
handling with a challenge shape the real bearer middleware cannot be made to produce for
316+
the scenario under test (e.g. a `scope` differing from the configured `required_scopes`,
317+
or a challenge on a request the middleware would let through). Reserve this pattern for
318+
behaviour the real server cannot be made to produce.
318319
319320
The default `on_nth_authenticated_post=2` targets the `notifications/initialized` POST: the
320321
first authenticated POST is the auth flow's retry of the original initialize request (yielded

tests/interaction/auth/test_authorize_token.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -328,12 +328,12 @@ async def test_the_registered_auth_method_is_used_regardless_of_as_metadata_adve
328328
async def test_scope_is_selected_from_the_www_authenticate_challenge_over_prm_metadata() -> None:
329329
"""When the 401 challenge carries `scope=`, that value is requested instead of the PRM scopes.
330330
331-
The SDK's bearer middleware never emits `scope=` in WWW-Authenticate (see the divergence
332-
on `hosting:auth:scope-403`), so the test supplies the first 401 itself via
333-
`first_challenge_shim` and disables token verification so the post-auth retry succeeds
334-
regardless of the granted scope. PRM advertises `["from-prm"]` (it mirrors
335-
`required_scopes`); the challenge says `from-header`; the authorize URL must carry
336-
`from-header`.
331+
The bearer middleware's own challenge would carry the configured `required_scopes`, which
332+
PRM `scopes_supported` mirrors — indistinguishable from the PRM fallback — so the test
333+
supplies the first 401 itself via `first_challenge_shim` with a `scope` that differs from
334+
PRM, and disables token verification so the post-auth retry succeeds regardless of the
335+
granted scope. PRM advertises `["from-prm"]` (it mirrors `required_scopes`); the challenge
336+
says `from-header`; the authorize URL must carry `from-header`.
337337
"""
338338
recorded, on_request = record_requests()
339339
provider = InMemoryAuthorizationServerProvider(default_scopes=["from-header"])

tests/interaction/auth/test_bearer.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -81,22 +81,23 @@ async def test_a_request_with_no_authorization_header_is_challenged_with_resourc
8181
"""No `Authorization` header → 401 with a `WWW-Authenticate` carrying `resource_metadata`.
8282
8383
The snapshot pins current behaviour: the SDK collapses the no-header, unknown-token, and
84-
expired-token cases into one challenge (`error="invalid_token"`, no `scope` parameter). The
85-
spec says the discovery-time challenge SHOULD include `scope` and RFC 6750 says the
86-
no-credentials case SHOULD NOT carry an error code; both gaps are recorded as the divergence
87-
on this requirement. Asserting the dict equals an exact key set also pins that no parameter
88-
appears twice.
84+
expired-token cases into one challenge. The `scope` parameter carries the configured required
85+
scopes (spec SHOULD, RFC 6750 section 3; #3103). RFC 6750 also says the no-credentials case
86+
SHOULD NOT carry an error code; that remaining gap is recorded as the divergence on this
87+
requirement. Asserting the dict equals an exact key set also pins that no parameter appears
88+
twice.
8989
"""
9090
response = await post_mcp(protected)
9191

9292
assert response.status_code == 401
9393
assert response.headers["www-authenticate"] == snapshot(
94-
'Bearer error="invalid_token", error_description="Authentication required", '
94+
'Bearer error="invalid_token", error_description="Authentication required", scope="mcp:read", '
9595
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
9696
)
9797
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
9898
"error": "invalid_token",
9999
"error_description": "Authentication required",
100+
"scope": REQUIRED_SCOPE,
100101
"resource_metadata": RESOURCE_METADATA_URL,
101102
}
102103
assert response.json() == snapshot({"error": "invalid_token", "error_description": "Authentication required"})
@@ -106,15 +107,16 @@ async def test_a_request_with_no_authorization_header_is_challenged_with_resourc
106107
async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protected: httpx2.AsyncClient) -> None:
107108
"""A token the verifier does not recognize is answered 401 `invalid_token`.
108109
109-
The challenge is identical to the no-header case (the backend returns `None` for both); the
110-
missing `scope` parameter is the recorded divergence on this requirement.
110+
The challenge is identical to the no-header case (the backend returns `None` for both),
111+
including the `scope` parameter carrying the configured required scopes (#3103).
111112
"""
112113
response = await post_mcp(protected, bearer="tok-unknown")
113114

114115
assert response.status_code == 401
115116
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
116117
"error": "invalid_token",
117118
"error_description": "Authentication required",
119+
"scope": REQUIRED_SCOPE,
118120
"resource_metadata": RESOURCE_METADATA_URL,
119121
}
120122

@@ -124,8 +126,7 @@ async def test_an_expired_token_is_answered_401(protected: httpx2.AsyncClient) -
124126
"""A token whose `expires_at` is in the past is answered 401 `invalid_token`.
125127
126128
The expiry check is the bearer backend's, against the wall clock; the test seeds a concrete
127-
past timestamp so no time mocking is involved. The missing `scope` parameter is the recorded
128-
divergence on this requirement.
129+
past timestamp so no time mocking is involved.
129130
"""
130131
response = await post_mcp(protected, bearer="tok-expired")
131132

@@ -134,26 +135,24 @@ async def test_an_expired_token_is_answered_401(protected: httpx2.AsyncClient) -
134135

135136

136137
@requirement("hosting:auth:scope-403")
137-
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_without_a_scope_param(
138+
async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_scope_with_a_scope_param(
138139
protected: httpx2.AsyncClient,
139140
) -> None:
140-
"""A token lacking the required scope is answered 403 `insufficient_scope`, with no `scope` parameter.
141+
"""A token lacking the required scope is answered 403 `insufficient_scope` with a `scope` parameter.
141142
142-
The spec's runtime-insufficient-scope guidance says the challenge SHOULD include `scope`
143-
naming the required scope; the SDK never emits it, recorded as the divergence on this
144-
requirement. The SDK client reads `scope` from this header to drive step-up, so the gap is
145-
a resource-server/client asymmetry.
143+
The spec's runtime-insufficient-scope guidance (and RFC 6750 section 3.1) says the challenge
144+
SHOULD include `scope` naming the required scope; the SDK client reads it from this header to
145+
drive step-up authorization (#3103).
146146
"""
147147
response = await post_mcp(protected, bearer="tok-noscope")
148148

149149
assert response.status_code == 403
150-
parsed = parse_www_authenticate(response.headers["www-authenticate"])
151-
assert parsed == {
150+
assert parse_www_authenticate(response.headers["www-authenticate"]) == {
152151
"error": "insufficient_scope",
153152
"error_description": f"Required scope: {REQUIRED_SCOPE}",
153+
"scope": REQUIRED_SCOPE,
154154
"resource_metadata": RESOURCE_METADATA_URL,
155155
}
156-
assert "scope" not in parsed
157156

158157

159158
@requirement("hosting:auth:aud-validation")

0 commit comments

Comments
 (0)