@@ -858,6 +858,98 @@ async def test_auth_flow_no_unnecessary_retry_after_oauth(
858
858
# Verify exactly one request was yielded (no double-sending)
859
859
assert request_yields == 1 , f"Expected 1 request yield, got { request_yields } "
860
860
861
+ @pytest .mark .anyio
862
+ async def test_403_insufficient_scope_updates_scope_from_header (
863
+ self ,
864
+ oauth_provider : OAuthClientProvider ,
865
+ mock_storage : MockTokenStorage ,
866
+ valid_tokens : OAuthToken ,
867
+ ):
868
+ """Test that 403 response correctly updates scope from WWW-Authenticate header."""
869
+ # Pre-store valid tokens and client info
870
+ client_info = OAuthClientInformationFull (
871
+ client_id = "test_client_id" ,
872
+ client_secret = "test_client_secret" ,
873
+ redirect_uris = [AnyUrl ("http://localhost:3030/callback" )],
874
+ )
875
+ await mock_storage .set_tokens (valid_tokens )
876
+ await mock_storage .set_client_info (client_info )
877
+ oauth_provider .context .current_tokens = valid_tokens
878
+ oauth_provider .context .token_expiry_time = time .time () + 1800
879
+ oauth_provider .context .client_info = client_info
880
+ oauth_provider ._initialized = True
881
+
882
+ # Original scope
883
+ assert oauth_provider .context .client_metadata .scope == "read write"
884
+
885
+ redirect_captured = False
886
+ captured_state = None
887
+
888
+ async def capture_redirect (url : str ) -> None :
889
+ nonlocal redirect_captured , captured_state
890
+ redirect_captured = True
891
+ # Verify the new scope is included in authorization URL
892
+ assert "scope=admin%3Awrite+admin%3Adelete" in url or "scope=admin:write+admin:delete" in url .replace (
893
+ "%3A" , ":"
894
+ ).replace ("+" , " " )
895
+ # Extract state from redirect URL
896
+ from urllib .parse import parse_qs , urlparse
897
+
898
+ parsed = urlparse (url )
899
+ params = parse_qs (parsed .query )
900
+ captured_state = params .get ("state" , [None ])[0 ]
901
+
902
+ oauth_provider .context .redirect_handler = capture_redirect
903
+
904
+ # Mock callback
905
+ async def mock_callback () -> tuple [str , str | None ]:
906
+ return "auth_code" , captured_state
907
+
908
+ oauth_provider .context .callback_handler = mock_callback
909
+
910
+ test_request = httpx .Request ("GET" , "https://api.example.com/mcp" )
911
+ auth_flow = oauth_provider .async_auth_flow (test_request )
912
+
913
+ # First request
914
+ request = await auth_flow .__anext__ ()
915
+
916
+ # Send 403 with new scope requirement
917
+ response_403 = httpx .Response (
918
+ 403 ,
919
+ headers = {"WWW-Authenticate" : 'Bearer error="insufficient_scope", scope="admin:write admin:delete"' },
920
+ request = request ,
921
+ )
922
+
923
+ # Trigger step-up - should get token exchange request
924
+ token_exchange_request = await auth_flow .asend (response_403 )
925
+
926
+ # Verify scope was updated
927
+ assert oauth_provider .context .client_metadata .scope == "admin:write admin:delete"
928
+ assert redirect_captured
929
+
930
+ # Complete the flow with successful token response
931
+ token_response = httpx .Response (
932
+ 200 ,
933
+ json = {
934
+ "access_token" : "new_token_with_new_scope" ,
935
+ "token_type" : "Bearer" ,
936
+ "expires_in" : 3600 ,
937
+ "scope" : "admin:write admin:delete" ,
938
+ },
939
+ request = token_exchange_request ,
940
+ )
941
+
942
+ # Should get final retry request
943
+ final_request = await auth_flow .asend (token_response )
944
+
945
+ # Send success response - flow should complete
946
+ success_response = httpx .Response (200 , request = final_request )
947
+ try :
948
+ await auth_flow .asend (success_response )
949
+ pytest .fail ("Should have stopped after successful response" )
950
+ except StopAsyncIteration :
951
+ pass # Expected
952
+
861
953
862
954
@pytest .mark .parametrize (
863
955
(
0 commit comments