diff --git a/tests/test_application.py b/tests/test_application.py index 16e512c4..556750fa 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -875,3 +875,50 @@ def test_app_did_not_register_redirect_uri_should_error_out(self): parent_window_handle=app.CONSOLE_WINDOW_HANDLE, ) self.assertEqual(result.get("error"), "broker_error") + + +class MismatchingScopeTestCase(unittest.TestCase): + """Test cache behavior when HTTP response scope differs from requested scope""" + + def test_token_should_be_cached_with_response_scope(self): + """Based on https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 + authorization server may issue an access token with different scope. + For example, eSTS normalizes scopes by adding or removing trailing slash. + Calling app is supposed to use the normalized scope for subsequent calls. + """ + + # Create a fresh app instance + app = ConfidentialClientApplication( + "client_id", client_credential="secret", + authority="https://login.microsoftonline.com/common") + + # Mocked request: ask for "invalid_scope" scope but receive "valid_scope1 valid_scope2" scope in response + def mock_post(url, headers=None, *args, **kwargs): + return MinimalResponse(status_code=200, text=json.dumps({ + "access_token": "AT_with_valid_scope1_valid_scope2_scopes", + "expires_in": 3600, + "scope": "valid_scope1 valid_scope2", # Response scope differs from requested scope + "token_type": "Bearer" + })) + + result1 = app.acquire_token_for_client(["invalid_scope"], post=mock_post) + self.assertEqual(result1[app._TOKEN_SOURCE], app._TOKEN_SOURCE_IDP) + self.assertEqual("AT_with_valid_scope1_valid_scope2_scopes", result1.get("access_token")) + self.assertEqual(["valid_scope1", "valid_scope2"], result1.get("scope").split()) # Scope from response + + # Second request: ask for same "invalid_scope" scope again + # Since cached token has "valid_scope1 valid_scope2" scopes, it shouldn't match the "invalid_scope" request + # This should go to IDP again and receive the same response + result2 = app.acquire_token_for_client(["invalid_scope"], post=mock_post) + # Should get a new token from IDP, not from cache + self.assertEqual(result2[app._TOKEN_SOURCE], app._TOKEN_SOURCE_IDP) + self.assertEqual("AT_with_valid_scope1_valid_scope2_scopes", result2.get("access_token")) + self.assertEqual(["valid_scope1", "valid_scope2"], result2.get("scope").split()) + + # Third and fourth requests: ask for individual valid scopes + # Should hit cache for the token that has "valid_scope1 valid_scope2" scopes + for scope in ["valid_scope1", "valid_scope2"]: + result = app.acquire_token_for_client([scope]) + self.assertEqual(result[app._TOKEN_SOURCE], app._TOKEN_SOURCE_CACHE) + self.assertEqual("AT_with_valid_scope1_valid_scope2_scopes", result.get("access_token")) + self.assertIsNone(result.get("scope"), "scope field is not returned when token comes from cache") \ No newline at end of file