Skip to content

Commit 33bcb51

Browse files
committed
Fold stored token scope into step-up union (SEP-2350)
On restart only the persisted token is reloaded, not client_metadata.scope, so the step-up union would re-authorize for less than was previously granted. Seed the union with the stored token's scope as well so prior grants survive a restart. Caught in review by Codex.
1 parent 87d50f9 commit 33bcb51

2 files changed

Lines changed: 65 additions & 4 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -636,16 +636,18 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
636636
if error == "insufficient_scope": # pragma: no branch
637637
try:
638638
# Step 2a: Union previously requested scopes with the newly challenged
639-
# scopes (SEP-2350) so escalating one operation keeps the others' grants
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.
640642
challenged_scope = get_client_metadata_scopes(
641643
extract_scope_from_www_auth(response),
642644
self.context.protected_resource_metadata,
643645
self.context.oauth_metadata,
644646
self.context.client_metadata.grant_types,
645647
)
646-
self.context.client_metadata.scope = union_scopes(
647-
self.context.client_metadata.scope, challenged_scope
648-
)
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)
649651

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

tests/client/test_auth.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
(

0 commit comments

Comments
 (0)