|
26 | 26 | handle_registration_response, |
27 | 27 | is_valid_client_metadata_url, |
28 | 28 | should_use_client_metadata_url, |
| 29 | + union_scopes, |
29 | 30 | validate_authorization_response_iss, |
30 | 31 | validate_metadata_issuer, |
31 | 32 | ) |
@@ -1387,10 +1388,9 @@ async def test_403_insufficient_scope_updates_scope_from_header( |
1387 | 1388 | async def capture_redirect(url: str) -> None: |
1388 | 1389 | nonlocal redirect_captured, captured_state |
1389 | 1390 | 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" |
1394 | 1394 | # Extract state from redirect URL |
1395 | 1395 | parsed = urlparse(url) |
1396 | 1396 | params = parse_qs(parsed.query) |
@@ -1420,8 +1420,8 @@ async def mock_callback() -> AuthorizationCodeResult: |
1420 | 1420 | # Trigger step-up - should get token exchange request |
1421 | 1421 | token_exchange_request = await auth_flow.asend(response_403) |
1422 | 1422 |
|
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" |
1425 | 1425 | assert redirect_captured |
1426 | 1426 |
|
1427 | 1427 | # Complete the flow with successful token response |
@@ -2717,3 +2717,20 @@ def test_validate_metadata_issuer_accepts_match(): |
2717 | 2717 | def test_validate_metadata_issuer_rejects_mismatch(): |
2718 | 2718 | with pytest.raises(OAuthFlowError, match="metadata issuer mismatch"): |
2719 | 2719 | validate_metadata_issuer(_issuer_metadata(issuer="https://attacker.example.com"), _ISSUER) |
| 2720 | + |
| 2721 | + |
| 2722 | +@pytest.mark.parametrize( |
| 2723 | + ("previous", "new", "expected"), |
| 2724 | + [ |
| 2725 | + pytest.param("mcp:basic", "mcp:write", "mcp:basic mcp:write", id="disjoint-union-order"), |
| 2726 | + pytest.param( |
| 2727 | + "mcp:basic offline_access", "mcp:write mcp:basic", "mcp:basic offline_access mcp:write", id="dedup" |
| 2728 | + ), |
| 2729 | + pytest.param(None, "mcp:write", "mcp:write", id="no-previous"), |
| 2730 | + pytest.param("mcp:basic", None, "mcp:basic", id="no-new"), |
| 2731 | + pytest.param(None, None, None, id="both-empty"), |
| 2732 | + ], |
| 2733 | +) |
| 2734 | +def test_union_scopes(previous: str | None, new: str | None, expected: str | None): |
| 2735 | + """SEP-2350: union merges previous and new scopes, dedups, and preserves order.""" |
| 2736 | + assert union_scopes(previous, new) == expected |
0 commit comments