Union previously requested scopes on step-up re-authorization (SEP-2350) (#2931)

This commit is contained in:
Marcelo Trylesinski 2026-06-20 17:45:04 +01:00 • committed by GitHub
commit 1331131650
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 155 additions and 8 deletions

View file

@ -3265,6 +3265,15 @@ REQUIREMENTS: dict[str, Requirement] = {
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:403-scope-union": Requirement(
source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow",
behavior=(
"On a 403 insufficient_scope step-up, the re-authorization request carries the union of the "
"previously requested scopes and the newly challenged scopes (SEP-2350)."
),
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:as-metadata-discovery:priority-order": Requirement(
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery",
behavior=(

View file

@ -178,6 +178,35 @@ async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challe
assert counts[("POST", "/token")] == 2
@requirement("client-auth:403-scope-union")
async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenged_scopes() -> None:
"""The step-up re-authorize requests the union of the previously requested and challenged scopes.
The first authorization requests `mcp`; the 403 challenges a disjoint `write` (not naming
`mcp`). Per SEP-2350 the client must re-authorize with `mcp write`, not drop `mcp`. The client
is pre-registered with both scopes so the server's authorize handler accepts the wider request.
"""
provider = InMemoryAuthorizationServerProvider()
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
server = Server("guarded", on_list_tools=list_tools)
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
challenge = 'Bearer error="insufficient_scope", scope="write"'
with anyio.fail_after(5):
async with connect_with_oauth(
server,
provider=provider,
storage=storage,
settings=settings,
app_shim=step_up_shim(challenge),
) as (client, headless):
await client.list_tools()
assert len(headless.authorize_urls) == 2
assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp"
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"
@requirement("client-auth:401-after-auth-throws")
async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None:
"""A 401 on the post-auth retry surfaces as an error rather than re-entering discovery.