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():
27172776def 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
0 commit comments