Skip to content

Commit fab54d4

Browse files
rayluolocalden
andcommitted
CAE for MIv1
CAE team and MSI team are working on turning on CAE by default for MSI v1. So what that means is, App developers will start seeing CAE even without setting the capability - "CP1". Update msal/application.py Co-authored-by: Den Delimarsky <[email protected]> Update msal/application.py Co-authored-by: Den Delimarsky <[email protected]> Update msal/application.py Co-authored-by: Den Delimarsky <[email protected]> Update msal/managed_identity.py Co-authored-by: Den Delimarsky <[email protected]> Update msal/managed_identity.py Co-authored-by: Den Delimarsky <[email protected]> Update msal/managed_identity.py Co-authored-by: Den Delimarsky <[email protected]> Update msal/managed_identity.py Co-authored-by: Den Delimarsky <[email protected]>
1 parent 14ef644 commit fab54d4

File tree

3 files changed

+41
-15
lines changed

3 files changed

+41
-15
lines changed

msal/application.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,11 @@ def __init__(
411411
(STS) what this client is capable for,
412412
so STS can decide to turn on certain features.
413413
For example, if client is capable to handle *claims challenge*,
414-
STS can then issue CAE access tokens to resources
415-
knowing when the resource emits *claims challenge*
416-
the client will be capable to handle.
414+
STS may issue
415+
`Continuous Access Evaluation (CAE) <https://learn.microsoft.com/entra/identity/conditional-access/concept-continuous-access-evaluation>`_
416+
access tokens to resources,
417+
knowing that when the resource emits a *claims challenge*
418+
the client will be able to handle those challenges.
417419
418420
Implementation details:
419421
Client capability is implemented using "claims" parameter on the wire,

msal/managed_identity.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import time
1111
from urllib.parse import urlparse # Python 3+
1212
from collections import UserDict # Python 3+
13-
from typing import Union # Needed in Python 3.7 & 3.8
13+
from typing import Optional, Union # Needed in Python 3.7 & 3.8
1414
from .token_cache import TokenCache
1515
from .individual_cache import _IndividualCache as IndividualCache
1616
from .throttled_http_client import ThrottledHttpClientBase, RetryAfterParser
@@ -145,6 +145,9 @@ class ManagedIdentityClient(object):
145145
not a token with application permissions for an app.
146146
"""
147147
__instance, _tenant = None, "managed_identity" # Placeholders
148+
_TOKEN_SOURCE = "token_source"
149+
_TOKEN_SOURCE_IDP = "identity_provider"
150+
_TOKEN_SOURCE_CACHE = "cache"
148151

149152
def __init__(
150153
self,
@@ -237,12 +240,31 @@ def _get_instance(self):
237240
self.__instance = socket.getfqdn() # Moved from class definition to here
238241
return self.__instance
239242

240-
def acquire_token_for_client(self, *, resource): # We may support scope in the future
243+
def acquire_token_for_client(
244+
self,
245+
*,
246+
resource: str, # If/when we support scope, resource will become optional
247+
claims_challenge: Optional[str] = None,
248+
):
241249
"""Acquire token for the managed identity.
242250
243251
The result will be automatically cached.
244252
Subsequent calls will automatically search from cache first.
245253
254+
:param resource: The resource for which the token is acquired.
255+
256+
:param claims_challenge:
257+
Optional.
258+
It is a string representation of a JSON object
259+
(which contains lists of claims being requested).
260+
261+
The tenant admin may choose to revoke all Managed Identity tokens,
262+
and then a *claims challenge* will be returned by the target resource,
263+
as a `claims_challenge` directive in the `www-authenticate` header,
264+
even if the app developer did not opt in for the "CP1" client capability.
265+
Upon receiving a `claims_challenge`, MSAL will skip a token cache read,
266+
and will attempt to acquire a new token.
267+
246268
.. note::
247269
248270
Known issue: When an Azure VM has only one user-assigned managed identity,
@@ -255,8 +277,8 @@ def acquire_token_for_client(self, *, resource): # We may support scope in the
255277
access_token_from_cache = None
256278
client_id_in_cache = self._managed_identity.get(
257279
ManagedIdentity.ID, "SYSTEM_ASSIGNED_MANAGED_IDENTITY")
258-
if True: # Does not offer an "if not force_refresh" option, because
259-
# there would be built-in token cache in the service side anyway
280+
now = time.time()
281+
if not claims_challenge: # Then attempt token cache search
260282
matches = self._token_cache.find(
261283
self._token_cache.CredentialType.ACCESS_TOKEN,
262284
target=[resource],
@@ -267,7 +289,6 @@ def acquire_token_for_client(self, *, resource): # We may support scope in the
267289
home_account_id=None,
268290
),
269291
)
270-
now = time.time()
271292
for entry in matches:
272293
expires_in = int(entry["expires_on"]) - now
273294
if expires_in < 5*60: # Then consider it expired
@@ -277,6 +298,7 @@ def acquire_token_for_client(self, *, resource): # We may support scope in the
277298
"access_token": entry["secret"],
278299
"token_type": entry.get("token_type", "Bearer"),
279300
"expires_in": int(expires_in), # OAuth2 specs defines it as int
301+
self._TOKEN_SOURCE: self._TOKEN_SOURCE_CACHE,
280302
}
281303
if "refresh_on" in entry:
282304
access_token_from_cache["refresh_on"] = int(entry["refresh_on"])
@@ -300,6 +322,7 @@ def acquire_token_for_client(self, *, resource): # We may support scope in the
300322
))
301323
if "refresh_in" in result:
302324
result["refresh_on"] = int(now + result["refresh_in"])
325+
result[self._TOKEN_SOURCE] = self._TOKEN_SOURCE_IDP
303326
if (result and "error" not in result) or (not access_token_from_cache):
304327
return result
305328
except: # The exact HTTP exception is transportation-layer dependent

tests/test_mi.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,20 +82,17 @@ def _test_happy_path(self, app, mocked_http, expires_in, resource="R"):
8282
self.assertTrue(
8383
is_subdict_of(expected_result, result), # We will test refresh_on later
8484
"Should obtain a token response")
85+
self.assertTrue(result["token_source"], "identity_provider")
8586
self.assertEqual(expires_in, result["expires_in"], "Should have expected expires_in")
8687
if expires_in >= 7200:
8788
expected_refresh_on = int(time.time() + expires_in / 2)
8889
self.assertTrue(
8990
expected_refresh_on - 1 <= result["refresh_on"] <= expected_refresh_on + 1,
9091
"Should have a refresh_on time around the middle of the token's life")
91-
self.assertEqual(
92-
result["access_token"],
93-
app.acquire_token_for_client(resource=resource).get("access_token"),
94-
"Should hit the same token from cache")
95-
96-
self.assertCacheStatus(app)
9792

9893
result = app.acquire_token_for_client(resource=resource)
94+
self.assertCacheStatus(app)
95+
self.assertEqual("cache", result["token_source"], "Should hit cache")
9996
self.assertEqual(
10097
call_count, mocked_http.call_count,
10198
"No new call to the mocked http should be made for a cache hit")
@@ -110,6 +107,9 @@ def _test_happy_path(self, app, mocked_http, expires_in, resource="R"):
110107
expected_refresh_on - 5 < result["refresh_on"] <= expected_refresh_on,
111108
"Should have a refresh_on time around the middle of the token's life")
112109

110+
result = app.acquire_token_for_client(resource=resource, claims_challenge="foo")
111+
self.assertEqual("identity_provider", result["token_source"], "Should miss cache")
112+
113113

114114
class VmTestCase(ClientTestCase):
115115

@@ -249,7 +249,8 @@ def test_happy_path(self, mocked_stat):
249249
status_code=200,
250250
text='{"access_token": "AT", "expires_in": "%s", "resource": "R"}' % expires_in,
251251
),
252-
]) as mocked_method:
252+
] * 2, # Duplicate a pair of mocks for _test_happy_path()'s CAE check
253+
) as mocked_method:
253254
try:
254255
self._test_happy_path(self.app, mocked_method, expires_in)
255256
mocked_stat.assert_called_with(os.path.join(

0 commit comments

Comments
 (0)