From 4ff2ab8e5492c06da634bc3633b1883b1ec2acf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 05:29:09 +0000 Subject: [PATCH 1/3] 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="" whenever required_scopes is non-empty, per RFC 6750 section 3 (section 3.1 for the insufficient_scope case). Fixes #3103 --- src/mcp/server/auth/middleware/bearer_auth.py | 6 ++ tests/client/test_auth.py | 100 +++++++++++++++++- .../auth/middleware/test_bearer_auth.py | 69 ++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 29413abf2b..f4b9280a1f 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -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)}"') if self.resource_metadata_url: www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index be96cc8eec..ff008f2232 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -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 @@ -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 from mcp.shared.auth import ( AuthorizationCodeResult, OAuthClientInformationFull, @@ -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", diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index 6ab3436771..e0743d86ca 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -3,9 +3,12 @@ import time from typing import Any, cast +import anyio +import httpx2 import pytest from starlette.authentication import AuthCredentials from starlette.datastructures import Headers +from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request from starlette.types import Message, Receive, Scope, Send @@ -458,6 +461,72 @@ async def send(message: Message) -> None: # pragma: no cover assert app.send == send +@pytest.mark.anyio +async def test_insufficient_scope_challenge_advertises_required_scopes( + mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any], valid_access_token: AccessToken +): + """The 403 insufficient_scope challenge carries a `scope` attribute listing the configured + required scopes, per RFC 6750 section 3.1, so clients can step-up (#3103).""" + add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token) + inner_app = MockApp() + # Production wiring: the authentication middleware populates the connection's user/auth + # from the bearer token, then RequireAuthMiddleware enforces the required scopes. + app = AuthenticationMiddleware( + RequireAuthMiddleware(inner_app, required_scopes=["read", "admin"]), + backend=BearerAuthBackend(ProviderTokenVerifier(mock_oauth_provider)), + ) + + transport = httpx2.ASGITransport(app=app) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as client: + with anyio.fail_after(5): + # valid_access_token grants read/write, so the required "admin" scope is missing + response = await client.get("/", headers={"Authorization": "Bearer valid_token"}) + + assert response.status_code == 403 + assert response.headers["WWW-Authenticate"] == ( + 'Bearer error="insufficient_scope", error_description="Required scope: admin", scope="read admin"' + ) + assert not inner_app.called + + +@pytest.mark.anyio +async def test_unauthenticated_challenge_advertises_required_scopes(): + """The 401 challenge carries a `scope` attribute (RFC 6750 section 3) when required scopes + are configured, so clients can request them on initial authorization (#3103).""" + inner_app = MockApp() + middleware = RequireAuthMiddleware(inner_app, required_scopes=["read", "admin"]) + + transport = httpx2.ASGITransport(app=middleware) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as client: + with anyio.fail_after(5): + response = await client.get("/") + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == ( + 'Bearer error="invalid_token", error_description="Authentication required", scope="read admin"' + ) + assert not inner_app.called + + +@pytest.mark.anyio +async def test_challenge_omits_scope_when_no_scopes_configured(): + """A challenge from a middleware with no required scopes carries no `scope` attribute — + there is nothing to advertise, and RFC 6750 section 3 makes the attribute optional.""" + inner_app = MockApp() + middleware = RequireAuthMiddleware(inner_app, required_scopes=[]) + + transport = httpx2.ASGITransport(app=middleware) + async with httpx2.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as client: + with anyio.fail_after(5): + response = await client.get("/") + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == ( + 'Bearer error="invalid_token", error_description="Authentication required"' + ) + assert not inner_app.called + + def test_authorization_context_is_built_from_principal_components() -> None: """Session ownership identifies the principal via the shared principal_components triple.""" token = AccessToken( From cd22f04c05305450e1226659f763484497f18670 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 05:37:09 +0000 Subject: [PATCH 2/3] 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. --- docs/run/authorization.md | 2 +- tests/docs_src/test_authorization.py | 2 +- tests/interaction/_requirements.py | 20 +--------- tests/interaction/auth/_harness.py | 7 ++-- .../interaction/auth/test_authorize_token.py | 12 +++--- tests/interaction/auth/test_bearer.py | 37 +++++++++---------- 6 files changed, 32 insertions(+), 48 deletions(-) diff --git a/docs/run/authorization.md b/docs/run/authorization.md index b7d731b1e2..be65f68c72 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -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"} ``` diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py index 00c9adc81c..0862b6da12 100644 --- a/tests/docs_src/test_authorization.py +++ b/tests/docs_src/test_authorization.py @@ -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"' ) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 964a1829d2..8deaadde56 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -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", @@ -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." ), ), ), @@ -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", diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index 856a1fe9a8..a9932fb4f0 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -312,9 +312,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. 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 diff --git a/tests/interaction/auth/test_authorize_token.py b/tests/interaction/auth/test_authorize_token.py index d4eb591b59..ce967f3257 100644 --- a/tests/interaction/auth/test_authorize_token.py +++ b/tests/interaction/auth/test_authorize_token.py @@ -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"]) diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index c70a27c52e..500a095e8f 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -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"}) @@ -106,8 +107,8 @@ 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") @@ -115,6 +116,7 @@ async def test_an_unrecognized_bearer_token_is_answered_401_invalid_token(protec assert parse_www_authenticate(response.headers["www-authenticate"]) == { "error": "invalid_token", "error_description": "Authentication required", + "scope": REQUIRED_SCOPE, "resource_metadata": RESOURCE_METADATA_URL, } @@ -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") @@ -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") From 958b49226aab2afaf79e636f870ebdb6d0918778 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 05:44:51 +0000 Subject: [PATCH 3/3] test: import Server from the public mcp.server surface; fix stale shim docstring Use the public 'from mcp.server import Server' in tests/client/test_auth.py, matching __all__ and the sibling test files, and reword the _FirstChallenge docstring which still claimed the bearer middleware cannot emit scope= in its challenge (no longer true since the scope attribute fix). --- tests/client/test_auth.py | 2 +- tests/interaction/auth/_harness.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index ff008f2232..7272ade930 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -32,10 +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 AuthSettings, ClientRegistrationOptions, RevocationOptions -from mcp.server.lowlevel.server import Server from mcp.shared.auth import ( AuthorizationCodeResult, OAuthClientInformationFull, diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index a9932fb4f0..1d7409fca4 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -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