Skip to content

Commit 1331131

Browse files
authored
Union previously requested scopes on step-up re-authorization (SEP-2350) (#2931)
1 parent 4573e4a commit 1331131

6 files changed

Lines changed: 155 additions & 8 deletions

File tree

docs/migration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,10 @@ If you relied on extra fields round-tripping through MCP types, move that data i
13221322

13231323
## New Features
13241324

1325+
### Step-up authorization unions previously requested scopes (SEP-2350)
1326+
1327+
When a `403 insufficient_scope` challenge triggers step-up re-authorization, the OAuth client now requests the union of the previously requested scopes and the newly challenged scopes, instead of replacing the scope with only the challenged ones. This keeps permissions granted for earlier operations from being dropped when a later operation escalates. No API change; the wider scope is sent automatically on the re-authorization request.
1328+
13251329
### OAuth Dynamic Client Registration sends `application_type` (SEP-837)
13261330

13271331
`OAuthClientMetadata` now carries an `application_type` field that is sent during Dynamic Client Registration. It defaults to `"native"`, which suits MCP clients that use loopback redirect URIs (CLI and desktop apps); browser-based clients served from a non-local host should set it to `"web"`:

src/mcp/client/auth/oauth2.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
handle_token_response_scopes,
3636
is_valid_client_metadata_url,
3737
should_use_client_metadata_url,
38+
union_scopes,
3839
validate_authorization_response_iss,
3940
validate_metadata_issuer,
4041
)
@@ -634,13 +635,19 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
634635
# Step 2: Check if we need to step-up authorization
635636
if error == "insufficient_scope": # pragma: no branch
636637
try:
637-
# Step 2a: Update the required scopes
638-
self.context.client_metadata.scope = get_client_metadata_scopes(
638+
# Step 2a: Union previously requested scopes with the newly challenged
639+
# scopes (SEP-2350) so escalating one operation keeps the others' grants.
640+
# Fold in the stored token's scope too: on a restart the token is reloaded
641+
# but client_metadata.scope is not, so it would otherwise be the only basis.
642+
challenged_scope = get_client_metadata_scopes(
639643
extract_scope_from_www_auth(response),
640644
self.context.protected_resource_metadata,
641645
self.context.oauth_metadata,
642646
self.context.client_metadata.grant_types,
643647
)
648+
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
649+
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
650+
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
644651

645652
# Step 2b: Perform (re-)authorization and token exchange
646653
token_response = yield await self._perform_authorization()

src/mcp/client/auth/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,28 @@ def get_client_metadata_scopes(
131131
return selected_scope
132132

133133

134+
def union_scopes(previous_scope: str | None, new_scope: str | None) -> str | None:
135+
"""Merge two space-delimited scope strings, preserving order and dropping duplicates.
136+
137+
SEP-2350: on step-up re-authorization the client requests the union of previously requested
138+
scopes and the newly challenged scopes, so escalating one operation does not drop the
139+
permissions granted for another. Previously requested scopes come first; new scopes are
140+
appended in order.
141+
"""
142+
if not previous_scope:
143+
return new_scope
144+
if not new_scope:
145+
return previous_scope
146+
147+
merged = previous_scope.split()
148+
seen = set(merged)
149+
for scope in new_scope.split():
150+
if scope not in seen:
151+
merged.append(scope)
152+
seen.add(scope)
153+
return " ".join(merged)
154+
155+
134156
def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]:
135157
"""Generate an ordered list of URLs for authorization server metadata discovery.
136158

tests/client/test_auth.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
handle_registration_response,
2727
is_valid_client_metadata_url,
2828
should_use_client_metadata_url,
29+
union_scopes,
2930
validate_authorization_response_iss,
3031
validate_metadata_issuer,
3132
)
@@ -1387,10 +1388,9 @@ async def test_403_insufficient_scope_updates_scope_from_header(
13871388
async def capture_redirect(url: str) -> None:
13881389
nonlocal redirect_captured, captured_state
13891390
redirect_captured = True
1390-
# Verify the new scope is included in authorization URL
1391-
assert "scope=admin%3Awrite+admin%3Adelete" in url or "scope=admin:write+admin:delete" in url.replace(
1392-
"%3A", ":"
1393-
).replace("+", " ")
1391+
# SEP-2350: the authorization URL carries the union of the prior and challenged scopes
1392+
scope = parse_qs(urlparse(url).query)["scope"][0]
1393+
assert scope == "read write admin:write admin:delete"
13941394
# Extract state from redirect URL
13951395
parsed = urlparse(url)
13961396
params = parse_qs(parsed.query)
@@ -1420,8 +1420,8 @@ async def mock_callback() -> AuthorizationCodeResult:
14201420
# Trigger step-up - should get token exchange request
14211421
token_exchange_request = await auth_flow.asend(response_403)
14221422

1423-
# Verify scope was updated
1424-
assert oauth_provider.context.client_metadata.scope == "admin:write admin:delete"
1423+
# Verify scope was updated to the union of prior and challenged scopes (SEP-2350)
1424+
assert oauth_provider.context.client_metadata.scope == "read write admin:write admin:delete"
14251425
assert redirect_captured
14261426

14271427
# Complete the flow with successful token response
@@ -1447,6 +1447,65 @@ async def mock_callback() -> AuthorizationCodeResult:
14471447
except StopAsyncIteration:
14481448
pass # Expected
14491449

1450+
@pytest.mark.anyio
1451+
async def test_403_step_up_preserves_scope_from_stored_token(
1452+
self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage
1453+
):
1454+
"""SEP-2350: a restart-loaded token's scope is folded into the step-up union.
1455+
1456+
On restart only the token is reloaded (not client_metadata.scope), so the stored token's
1457+
granted scope must seed the union, or the challenge would re-authorize for less.
1458+
"""
1459+
client_info = OAuthClientInformationFull(
1460+
client_id="test_client_id",
1461+
client_secret="test_client_secret",
1462+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
1463+
)
1464+
# Simulate a restart: a token granted "read" is loaded, but client_metadata carries no scope.
1465+
oauth_provider.context.current_tokens = OAuthToken(access_token="t", scope="read")
1466+
oauth_provider.context.token_expiry_time = time.time() + 1800
1467+
oauth_provider.context.client_info = client_info
1468+
oauth_provider.context.client_metadata.scope = None
1469+
oauth_provider._initialized = True
1470+
1471+
captured_state: str | None = None
1472+
reauthorize_scope: str | None = None
1473+
1474+
async def capture_redirect(url: str) -> None:
1475+
nonlocal captured_state, reauthorize_scope
1476+
params = parse_qs(urlparse(url).query)
1477+
reauthorize_scope = params["scope"][0]
1478+
captured_state = params.get("state", [None])[0]
1479+
1480+
async def mock_callback() -> AuthorizationCodeResult:
1481+
return AuthorizationCodeResult(code="auth_code", state=captured_state)
1482+
1483+
oauth_provider.context.redirect_handler = capture_redirect
1484+
oauth_provider.context.callback_handler = mock_callback
1485+
1486+
auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/mcp"))
1487+
request = await auth_flow.__anext__()
1488+
response_403 = httpx.Response(
1489+
403,
1490+
headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="write"'},
1491+
request=request,
1492+
)
1493+
token_exchange_request = await auth_flow.asend(response_403)
1494+
1495+
assert reauthorize_scope == "read write"
1496+
1497+
# Drive the flow to completion so the context lock is released cleanly
1498+
token_response = httpx.Response(
1499+
200,
1500+
json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600, "scope": "read write"},
1501+
request=token_exchange_request,
1502+
)
1503+
final_request = await auth_flow.asend(token_response)
1504+
try:
1505+
await auth_flow.asend(httpx.Response(200, request=final_request))
1506+
except StopAsyncIteration:
1507+
pass
1508+
14501509

14511510
@pytest.mark.parametrize(
14521511
(
@@ -2717,3 +2776,20 @@ def test_validate_metadata_issuer_accepts_match():
27172776
def test_validate_metadata_issuer_rejects_mismatch():
27182777
with pytest.raises(OAuthFlowError, match="metadata issuer mismatch"):
27192778
validate_metadata_issuer(_issuer_metadata(issuer="https://attacker.example.com"), _ISSUER)
2779+
2780+
2781+
@pytest.mark.parametrize(
2782+
("previous", "new", "expected"),
2783+
[
2784+
pytest.param("mcp:basic", "mcp:write", "mcp:basic mcp:write", id="disjoint-union-order"),
2785+
pytest.param(
2786+
"mcp:basic offline_access", "mcp:write mcp:basic", "mcp:basic offline_access mcp:write", id="dedup"
2787+
),
2788+
pytest.param(None, "mcp:write", "mcp:write", id="no-previous"),
2789+
pytest.param("mcp:basic", None, "mcp:basic", id="no-new"),
2790+
pytest.param(None, None, None, id="both-empty"),
2791+
],
2792+
)
2793+
def test_union_scopes(previous: str | None, new: str | None, expected: str | None):
2794+
"""SEP-2350: union merges previous and new scopes, dedups, and preserves order."""
2795+
assert union_scopes(previous, new) == expected

tests/interaction/_requirements.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3265,6 +3265,15 @@ def __post_init__(self) -> None:
32653265
transports=("streamable-http",),
32663266
note="OAuth is HTTP-only.",
32673267
),
3268+
"client-auth:403-scope-union": Requirement(
3269+
source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow",
3270+
behavior=(
3271+
"On a 403 insufficient_scope step-up, the re-authorization request carries the union of the "
3272+
"previously requested scopes and the newly challenged scopes (SEP-2350)."
3273+
),
3274+
transports=("streamable-http",),
3275+
note="OAuth is HTTP-only.",
3276+
),
32683277
"client-auth:as-metadata-discovery:priority-order": Requirement(
32693278
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery",
32703279
behavior=(

tests/interaction/auth/test_lifecycle.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,35 @@ async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challe
178178
assert counts[("POST", "/token")] == 2
179179

180180

181+
@requirement("client-auth:403-scope-union")
182+
async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenged_scopes() -> None:
183+
"""The step-up re-authorize requests the union of the previously requested and challenged scopes.
184+
185+
The first authorization requests `mcp`; the 403 challenges a disjoint `write` (not naming
186+
`mcp`). Per SEP-2350 the client must re-authorize with `mcp write`, not drop `mcp`. The client
187+
is pre-registered with both scopes so the server's authorize handler accepts the wider request.
188+
"""
189+
provider = InMemoryAuthorizationServerProvider()
190+
storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write"))
191+
server = Server("guarded", on_list_tools=list_tools)
192+
settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"])
193+
challenge = 'Bearer error="insufficient_scope", scope="write"'
194+
195+
with anyio.fail_after(5):
196+
async with connect_with_oauth(
197+
server,
198+
provider=provider,
199+
storage=storage,
200+
settings=settings,
201+
app_shim=step_up_shim(challenge),
202+
) as (client, headless):
203+
await client.list_tools()
204+
205+
assert len(headless.authorize_urls) == 2
206+
assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp"
207+
assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write"
208+
209+
181210
@requirement("client-auth:401-after-auth-throws")
182211
async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None:
183212
"""A 401 on the post-auth retry surfaces as an error rather than re-entering discovery.

0 commit comments

Comments
 (0)