@@ -875,3 +875,58 @@ def test_app_did_not_register_redirect_uri_should_error_out(self):
875875 parent_window_handle = app .CONSOLE_WINDOW_HANDLE ,
876876 )
877877 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+ def test_token_should_be_cached_with_response_scope (self ):
884+ """Based on https://datatracker.ietf.org/doc/html/rfc6749#section-3.3
885+ authorization server may issue an access token with different scope.
886+ For example, eSTS normalizes scopes by adding or removing trailing slash.
887+ Calling app is supposed to use the normalized scope for subsequent calls.
888+ """
889+
890+ # Create a fresh app instance
891+ app = ConfidentialClientApplication (
892+ "client_id" , client_credential = "secret" ,
893+ authority = "https://login.microsoftonline.com/common" )
894+
895+ # Mocked request: ask for "foo" scope but receive "bar baz" scope in response
896+ def mock_post (url , headers = None , * args , ** kwargs ):
897+ return MinimalResponse (status_code = 200 , text = json .dumps ({
898+ "access_token" : "AT_with_bar_baz_scopes" ,
899+ "expires_in" : 3600 ,
900+ "scope" : "bar baz" , # Response scope differs from requested scope
901+ "token_type" : "Bearer"
902+ }))
903+
904+ result1 = app .acquire_token_for_client (["foo" ], post = mock_post )
905+ self .assertEqual (result1 [app ._TOKEN_SOURCE ], app ._TOKEN_SOURCE_IDP )
906+ self .assertEqual ("AT_with_bar_baz_scopes" , result1 .get ("access_token" ))
907+ self .assertEqual (["bar" , "baz" ], result1 .get ("scope" ).split ()) # Scope from response
908+
909+ # Second request: ask for same "foo" scope again
910+ # Since cached token has "bar baz" scopes, it shouldn't match the "foo" request
911+ # This should go to IDP again and receive the same response
912+ result2 = app .acquire_token_for_client (["foo" ], post = mock_post )
913+ # Should get a new token from IDP, not from cache
914+ self .assertEqual (result2 [app ._TOKEN_SOURCE ], app ._TOKEN_SOURCE_IDP )
915+ self .assertEqual ("AT_with_bar_baz_scopes" , result2 .get ("access_token" ))
916+ self .assertEqual (["bar" , "baz" ], result2 .get ("scope" ).split ())
917+
918+ # Third request: ask for "bar" scope
919+ # Should hit cache for the token that has "bar baz" scopes
920+ result3 = app .acquire_token_for_client (["bar" ])
921+ self .assertEqual (result3 [app ._TOKEN_SOURCE ], app ._TOKEN_SOURCE_CACHE )
922+ self .assertEqual ("AT_with_bar_baz_scopes" , result3 .get ("access_token" ))
923+ # Implementation detail: scope field is not returned when token comes from cache
924+ self .assertIsNone (result3 .get ("scope" ))
925+
926+ # Fourth request: ask for "baz" scope
927+ # Should hit cache for the token that has "bar baz" scopes
928+ result4 = app .acquire_token_for_client (["baz" ])
929+ self .assertEqual (result4 [app ._TOKEN_SOURCE ], app ._TOKEN_SOURCE_CACHE )
930+ self .assertEqual ("AT_with_bar_baz_scopes" , result4 .get ("access_token" ))
931+ # Implementation detail: scope field is not returned when token comes from cache
932+ self .assertIsNone (result4 .get ("scope" ))
0 commit comments