@@ -875,3 +875,52 @@ def test_app_did_not_register_redirect_uri_should_error_out(self):
875
875
parent_window_handle = app .CONSOLE_WINDOW_HANDLE ,
876
876
)
877
877
self .assertEqual (result .get ("error" ), "broker_error" )
878
+
879
+
880
+ class MismatchingScopeTestCase (unittest .TestCase ):
881
+ """Test cache behavior when HTTP response scope differs from requested scope"""
882
+
883
+ @classmethod
884
+ @patch (_OIDC_DISCOVERY , new = _OIDC_DISCOVERY_MOCK )
885
+ def setUpClass (cls ):
886
+ cls .app = ConfidentialClientApplication (
887
+ "client_id" , client_credential = "secret" ,
888
+ authority = "https://login.microsoftonline.com/common" )
889
+
890
+ def test_mismatching_scope_should_be_cached_with_response_scope (self ):
891
+ """Based on https://datatracker.ietf.org/doc/html/rfc6749#section-3.3
892
+ authorization server may issue an access token with different scope.
893
+ For example, eSTS normalizes scopes by adding or removing trailing slash.
894
+ Calling app is supposed to use the normalized scope for subsequent calls.
895
+ """
896
+
897
+ # Mocked request: ask for "foo" scope but receive "bar" scope in response
898
+ def mock_post (url , headers = None , * args , ** kwargs ):
899
+ return MinimalResponse (status_code = 200 , text = json .dumps ({
900
+ "access_token" : "AT_with_bar_scope" ,
901
+ "expires_in" : 3600 ,
902
+ "scope" : "bar" , # Response scope differs from requested scope
903
+ "token_type" : "Bearer"
904
+ }))
905
+
906
+ result1 = self .app .acquire_token_for_client (["foo" ], post = mock_post )
907
+ self .assertEqual (result1 [self .app ._TOKEN_SOURCE ], self .app ._TOKEN_SOURCE_IDP )
908
+ self .assertEqual ("AT_with_bar_scope" , result1 .get ("access_token" ))
909
+ self .assertEqual (["bar" ], result1 .get ("scope" ).split ()) # Scope from response
910
+
911
+ # Second request: ask for same "foo" scope again
912
+ # Since cached token has "bar" scope, it shouldn't match the "foo" request
913
+ # This should go to IDP again and receive the same response
914
+ result2 = self .app .acquire_token_for_client (["foo" ], post = mock_post )
915
+ # Should get a new token from IDP, not from cache
916
+ self .assertEqual (result2 [self .app ._TOKEN_SOURCE ], self .app ._TOKEN_SOURCE_IDP )
917
+ self .assertEqual ("AT_with_bar_scope" , result2 .get ("access_token" ))
918
+ self .assertEqual (["bar" ], result2 .get ("scope" ).split ())
919
+
920
+ # Third request: ask for "bar" scope
921
+ # Should hit cache for the token that had "bar" scope
922
+ result3 = self .app .acquire_token_for_client (["bar" ])
923
+ self .assertEqual (result3 [self .app ._TOKEN_SOURCE ], self .app ._TOKEN_SOURCE_CACHE )
924
+ self .assertEqual ("AT_with_bar_scope" , result3 .get ("access_token" ))
925
+ # Implementation detail: scope field is not returned when token comes from cache
926
+ self .assertIsNone (result3 .get ("scope" ))
0 commit comments