@@ -121,21 +121,6 @@ def prm_metadata_without_scopes():
121121 )
122122
123123
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-
139124class TestPKCEParameters :
140125 """Test PKCE parameter generation."""
141126
@@ -449,60 +434,63 @@ async def test_handle_metadata_response_success(self, oauth_provider: OAuthClien
449434 assert str (oauth_provider .context .oauth_metadata .issuer ) == "https://auth.example.com/"
450435
451436 @pytest .mark .anyio
452- async def test_prioritize_prm_scopes_over_oauth_metadata (
437+ async def test_prioritize_www_auth_scope_over_prm (
453438 self ,
454439 oauth_provider_without_scope : OAuthClientProvider ,
455440 oauth_metadata_response : httpx .Response ,
456441 prm_metadata : ProtectedResourceMetadata ,
457442 ):
458- """Test that PRM scopes are prioritized over auth server metadata scopes."""
443+ """Test that WWW-Authenticate scope is prioritized over PRM scopes."""
459444 provider = oauth_provider_without_scope
460445
461- # Set up PRM metadata with specific scopes
446+ # Set up PRM metadata with scopes
462447 provider .context .protected_resource_metadata = prm_metadata
463448
449+ # Set WWW-Authenticate scope (priority 1)
450+ provider .context .www_authenticate_scope = "special:scope from:www-authenticate"
451+
464452 # Process the OAuth metadata
465453 await provider ._handle_oauth_metadata_response (oauth_metadata_response )
466454
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 "
469457
470458 @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 (
472460 self ,
473461 oauth_provider_without_scope : OAuthClientProvider ,
474462 oauth_metadata_response : httpx .Response ,
475- prm_metadata_without_scopes : ProtectedResourceMetadata ,
463+ prm_metadata : ProtectedResourceMetadata ,
476464 ):
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."""
478466 provider = oauth_provider_without_scope
479467
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
482470
483- # Process the OAuth metadata
471+ # Process the OAuth metadata (no WWW-Authenticate scope)
484472 await provider ._handle_oauth_metadata_response (oauth_metadata_response )
485473
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"
488476
489477 @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 (
491479 self ,
492480 oauth_provider_without_scope : OAuthClientProvider ,
481+ oauth_metadata_response : httpx .Response ,
493482 prm_metadata_without_scopes : ProtectedResourceMetadata ,
494- oauth_metadata_response_without_scopes : httpx .Response ,
495483 ):
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 ."""
497485 provider = oauth_provider_without_scope
498486
499487 # Set up PRM metadata without scopes
500488 provider .context .protected_resource_metadata = prm_metadata_without_scopes
501489
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 )
504492
505- # Verify that scope remains None
493+ # Verify that scope is omitted
506494 assert provider .context .client_metadata .scope is None
507495
508496 @pytest .mark .anyio
@@ -515,6 +503,9 @@ async def test_preserve_existing_client_scope(
515503 """Test that existing client scope is preserved regardless of metadata."""
516504 provider = oauth_provider
517505
506+ # Set WWW-Authenticate scope
507+ provider .context .www_authenticate_scope = "special:scope from:www-authenticate"
508+
518509 # Set up PRM metadata with scopes
519510 provider .context .protected_resource_metadata = prm_metadata
520511
@@ -1092,3 +1083,98 @@ async def callback_handler() -> tuple[str, str | None]:
10921083
10931084 result = provider ._extract_resource_metadata_from_www_auth (init_response )
10941085 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