@@ -121,21 +121,6 @@ def prm_metadata_without_scopes():
121
121
)
122
122
123
123
124
- @pytest .fixture
125
- def oauth_metadata_response_without_scopes ():
126
- """OAuth metadata response without scopes."""
127
- return httpx .Response (
128
- 200 ,
129
- content = (
130
- b'{"issuer": "https://auth.example.com", '
131
- b'"authorization_endpoint": "https://auth.example.com/authorize", '
132
- b'"token_endpoint": "https://auth.example.com/token", '
133
- b'"registration_endpoint": "https://auth.example.com/register"}'
134
- # No scopes_supported field
135
- ),
136
- )
137
-
138
-
139
124
class TestPKCEParameters :
140
125
"""Test PKCE parameter generation."""
141
126
@@ -449,60 +434,63 @@ async def test_handle_metadata_response_success(self, oauth_provider: OAuthClien
449
434
assert str (oauth_provider .context .oauth_metadata .issuer ) == "https://auth.example.com/"
450
435
451
436
@pytest .mark .anyio
452
- async def test_prioritize_prm_scopes_over_oauth_metadata (
437
+ async def test_prioritize_www_auth_scope_over_prm (
453
438
self ,
454
439
oauth_provider_without_scope : OAuthClientProvider ,
455
440
oauth_metadata_response : httpx .Response ,
456
441
prm_metadata : ProtectedResourceMetadata ,
457
442
):
458
- """Test that PRM scopes are prioritized over auth server metadata scopes."""
443
+ """Test that WWW-Authenticate scope is prioritized over PRM scopes."""
459
444
provider = oauth_provider_without_scope
460
445
461
- # Set up PRM metadata with specific scopes
446
+ # Set up PRM metadata with scopes
462
447
provider .context .protected_resource_metadata = prm_metadata
463
448
449
+ # Set WWW-Authenticate scope (priority 1)
450
+ provider .context .www_authenticate_scope = "special:scope from:www-authenticate"
451
+
464
452
# Process the OAuth metadata
465
453
await provider ._handle_oauth_metadata_response (oauth_metadata_response )
466
454
467
- # Verify that PRM scopes are used (not OAuth metadata scopes)
468
- assert provider .context .client_metadata .scope == "resource:read resource:write "
455
+ # Verify that WWW-Authenticate scope is used (not PRM scopes)
456
+ assert provider .context .client_metadata .scope == "special:scope from:www-authenticate "
469
457
470
458
@pytest .mark .anyio
471
- async def test_fallback_to_oauth_metadata_scopes_when_no_prm_scopes (
459
+ async def test_prioritize_prm_scopes_when_no_www_auth_scope (
472
460
self ,
473
461
oauth_provider_without_scope : OAuthClientProvider ,
474
462
oauth_metadata_response : httpx .Response ,
475
- prm_metadata_without_scopes : ProtectedResourceMetadata ,
463
+ prm_metadata : ProtectedResourceMetadata ,
476
464
):
477
- """Test fallback to OAuth metadata scopes when PRM has no scopes."""
465
+ """Test that PRM scopes are prioritized when WWW-Authenticate header has no scopes."""
478
466
provider = oauth_provider_without_scope
479
467
480
- # Set up PRM metadata without scopes
481
- provider .context .protected_resource_metadata = prm_metadata_without_scopes
468
+ # Set up PRM metadata with specific scopes
469
+ provider .context .protected_resource_metadata = prm_metadata
482
470
483
- # Process the OAuth metadata
471
+ # Process the OAuth metadata (no WWW-Authenticate scope)
484
472
await provider ._handle_oauth_metadata_response (oauth_metadata_response )
485
473
486
- # Verify that OAuth metadata scopes are used as fallback
487
- assert provider .context .client_metadata .scope == "read write admin "
474
+ # Verify that PRM scopes are used
475
+ assert provider .context .client_metadata .scope == "resource: read resource: write"
488
476
489
477
@pytest .mark .anyio
490
- async def test_no_scope_changes_when_both_missing (
478
+ async def test_omit_scope_when_no_prm_scopes_or_www_auth (
491
479
self ,
492
480
oauth_provider_without_scope : OAuthClientProvider ,
481
+ oauth_metadata_response : httpx .Response ,
493
482
prm_metadata_without_scopes : ProtectedResourceMetadata ,
494
- oauth_metadata_response_without_scopes : httpx .Response ,
495
483
):
496
- """Test that no scope changes occur when both PRM and OAuth metadata lack scopes ."""
484
+ """Test that scope is omitted when PRM has no scopes and WWW-Authenticate doesn't specify scope ."""
497
485
provider = oauth_provider_without_scope
498
486
499
487
# Set up PRM metadata without scopes
500
488
provider .context .protected_resource_metadata = prm_metadata_without_scopes
501
489
502
- # Process the OAuth metadata
503
- await provider ._handle_oauth_metadata_response (oauth_metadata_response_without_scopes )
490
+ # Process the OAuth metadata (no WWW-Authenticate scope set)
491
+ await provider ._handle_oauth_metadata_response (oauth_metadata_response )
504
492
505
- # Verify that scope remains None
493
+ # Verify that scope is omitted
506
494
assert provider .context .client_metadata .scope is None
507
495
508
496
@pytest .mark .anyio
@@ -515,6 +503,9 @@ async def test_preserve_existing_client_scope(
515
503
"""Test that existing client scope is preserved regardless of metadata."""
516
504
provider = oauth_provider
517
505
506
+ # Set WWW-Authenticate scope
507
+ provider .context .www_authenticate_scope = "special:scope from:www-authenticate"
508
+
518
509
# Set up PRM metadata with scopes
519
510
provider .context .protected_resource_metadata = prm_metadata
520
511
@@ -1092,3 +1083,98 @@ async def callback_handler() -> tuple[str, str | None]:
1092
1083
1093
1084
result = provider ._extract_resource_metadata_from_www_auth (init_response )
1094
1085
assert result is None , f"Should return None for { description } "
1086
+
1087
+ @pytest .mark .parametrize (
1088
+ "www_auth_header,expected_scope" ,
1089
+ [
1090
+ # Quoted scope
1091
+ ('Bearer scope="read write"' , "read write" ),
1092
+ # Unquoted scope
1093
+ ("Bearer scope=read" , "read" ),
1094
+ # Multiple parameters with quoted scope
1095
+ ('Bearer realm="api", scope="admin:write resource:read"' , "admin:write resource:read" ),
1096
+ # Multiple parameters with unquoted scope
1097
+ ('Bearer realm="api", scope=basic' , "basic" ),
1098
+ # Scope with special characters (colons, underscores)
1099
+ ('Bearer scope="resource:read resource:write user_profile"' , "resource:read resource:write user_profile" ),
1100
+ ],
1101
+ )
1102
+ def test_extract_scope_from_www_auth_valid_cases (
1103
+ self ,
1104
+ client_metadata : OAuthClientMetadata ,
1105
+ mock_storage : MockTokenStorage ,
1106
+ www_auth_header : str ,
1107
+ expected_scope : str ,
1108
+ ):
1109
+ """Test extraction of scope from various valid WWW-Authenticate headers."""
1110
+
1111
+ async def redirect_handler (url : str ) -> None :
1112
+ pass
1113
+
1114
+ async def callback_handler () -> tuple [str , str | None ]:
1115
+ return "test_auth_code" , "test_state"
1116
+
1117
+ provider = OAuthClientProvider (
1118
+ server_url = "https://api.example.com/v1/mcp" ,
1119
+ client_metadata = client_metadata ,
1120
+ storage = mock_storage ,
1121
+ redirect_handler = redirect_handler ,
1122
+ callback_handler = callback_handler ,
1123
+ )
1124
+
1125
+ init_response = httpx .Response (
1126
+ status_code = 401 ,
1127
+ headers = {"WWW-Authenticate" : www_auth_header },
1128
+ request = httpx .Request ("GET" , "https://api.example.com/test" ),
1129
+ )
1130
+
1131
+ result = provider ._extract_scope_from_www_auth (init_response )
1132
+ assert result == expected_scope
1133
+
1134
+ @pytest .mark .parametrize (
1135
+ "status_code,www_auth_header,description" ,
1136
+ [
1137
+ # No header
1138
+ (401 , None , "no WWW-Authenticate header" ),
1139
+ # Empty header
1140
+ (401 , "" , "empty WWW-Authenticate header" ),
1141
+ # Header without scope
1142
+ (401 , 'Bearer realm="api", error="insufficient_scope"' , "no scope parameter" ),
1143
+ # Malformed header
1144
+ (401 , "Bearer scope=" , "malformed scope parameter" ),
1145
+ # Non-401 status code
1146
+ (200 , 'Bearer scope="read write"' , "200 OK response" ),
1147
+ (500 , 'Bearer scope="read write"' , "500 error response" ),
1148
+ ],
1149
+ )
1150
+ def test_extract_scope_from_www_auth_invalid_cases (
1151
+ self ,
1152
+ client_metadata : OAuthClientMetadata ,
1153
+ mock_storage : MockTokenStorage ,
1154
+ status_code : int ,
1155
+ www_auth_header : str | None ,
1156
+ description : str ,
1157
+ ):
1158
+ """Test extraction returns None for invalid cases."""
1159
+
1160
+ async def redirect_handler (url : str ) -> None :
1161
+ pass
1162
+
1163
+ async def callback_handler () -> tuple [str , str | None ]:
1164
+ return "test_auth_code" , "test_state"
1165
+
1166
+ provider = OAuthClientProvider (
1167
+ server_url = "https://api.example.com/v1/mcp" ,
1168
+ client_metadata = client_metadata ,
1169
+ storage = mock_storage ,
1170
+ redirect_handler = redirect_handler ,
1171
+ callback_handler = callback_handler ,
1172
+ )
1173
+
1174
+ headers = {"WWW-Authenticate" : www_auth_header } if www_auth_header is not None else {}
1175
+ init_response = httpx .Response (
1176
+ status_code = status_code , headers = headers , request = httpx .Request ("GET" , "https://api.example.com/test" )
1177
+ )
1178
+
1179
+ result = provider ._extract_scope_from_www_auth (init_response )
1180
+ assert result is None , f"Should return None for { description } "
0 commit comments