Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.10/site-packages/msal/token_cache.py: 27%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import base64
2import hashlib
3import json
4import threading
5import time
6import logging
7import warnings
9from .authority import canonicalize
10from .oauth2cli.oidc import decode_part, _decode_id_token_claims
11from .oauth2cli.oauth2 import Client
14logger = logging.getLogger(__name__)
15_GRANT_TYPE_BROKER = "broker"
17# Fields in the request data dict that should NOT be included in the extended
18# cache key hash. Everything else in data IS included, because those are extra
19# body parameters going on the wire and must differentiate cached tokens.
20#
21# Excluded fields and reasons:
22# - "client_id" : Standard OAuth2 client identifier, same for every request
23# - "grant_type" : It is possible to combine grants to get tokens, e.g. obo + refresh_token, auth_code + refresh_token etc.
24# - "scope" : Already represented as "target" in the AT cache key
25# - "claims" : Handled separately; its presence forces a token refresh
26# - "username" : Standard ROPC grant parameter. Tokens are cached by user ID (subject or oid+tid) instead
27# - "password" : Standard ROPC grant parameter. Tokens are tied to credentials.
28# - "refresh_token" : Standard refresh grant parameter
29# - "code" : Standard authorization code grant parameter
30# - "redirect_uri" : Standard authorization code grant parameter
31# - "code_verifier" : Standard PKCE parameter
32# - "device_code" : Standard device flow parameter
33# - "assertion" : Standard OBO/SAML assertion (RFC 7521)
34# - "requested_token_use" : OBO indicator ("on_behalf_of"), not an extra param
35# - "client_assertion" : Client authentication credential (RFC 7521 §4.2)
36# - "client_assertion_type" : Client authentication type (RFC 7521 §4.2)
37# - "client_secret" : Client authentication secret
38# - "token_type" : Used for SSH-cert/POP detection; AT entry stores separately
39# - "req_cnf" : Ephemeral proof-of-possession nonce, changes per request
40# - "key_id" : Already handled as a separate cache lookup field
41#
42# Included fields (examples — anything NOT in this set is included):
43# - "fmi_path" : Federated Managed Identity credential path
44# - any future non-standard body parameter that should isolate cache entries
45_EXT_CACHE_KEY_EXCLUDED_FIELDS = frozenset({
46 # Standard OAuth2 body parameters — these appear in every token request
47 # and must NOT influence the extended cache key.
48 # Only non-standard fields (e.g. fmi_path) should contribute to the hash.
49 "client_id",
50 "grant_type",
51 "scope",
52 "claims",
53 "username",
54 "password",
55 "refresh_token",
56 "code",
57 "redirect_uri",
58 "code_verifier",
59 "device_code",
60 "assertion",
61 "requested_token_use",
62 "client_assertion",
63 "client_assertion_type",
64 "client_secret",
65 "token_type",
66 "req_cnf",
67 "key_id",
68 # user_fic grant parameters — these are standard body params for the
69 # user_fic flow; FIC tokens use normal user cache keys (not extended).
70 "user_federated_identity_credential",
71 "user_id",
72 "client_info",
73})
76def _compute_ext_cache_key(data):
77 """Compute an extended cache key hash from extra body parameters in *data*.
79 All fields in *data* that go on the wire are included in the hash,
80 EXCEPT those listed in ``_EXT_CACHE_KEY_EXCLUDED_FIELDS``.
81 This ensures tokens acquired with different parameter values
82 (e.g., different FMI paths) are cached separately.
84 The hash may also intentionally include cache-key-only pseudo-parameters
85 such as ``client_claims`` -- these are stripped from the wire body by the
86 oauth2 layer but are retained in *data* precisely so that different
87 client-originated claims route to separate cache entries.
89 Returns an empty string when *data* has no hashable fields.
91 The algorithm uses a length-prefixed ("netstring") serialization matching
92 MSAL Go's ``CacheExtKeyGenerator``
93 (AzureAD/microsoft-authentication-library-for-go#629): for each key sorted
94 ascending, ``<byteLen(key)>:<key><byteLen(value)>:<value>`` is appended and
95 the parts concatenated, then SHA256 hashed and base64url (no padding)
96 encoded and lowercased.
98 The length prefixes make the *serialization* injective: distinct component
99 sets can never produce the same pre-hash string, so they cannot collide at
100 the serialization layer. (The final cache key is still a SHA-256 digest, so
101 only a cryptographically negligible hash collision remains possible.) A plain
102 ``key + value`` concatenation, by contrast, is ambiguous: ``{"fmi_path":
103 "value"}`` and ``{"fmi_pat": "hvalue"}`` would both serialize to
104 ``fmi_pathvalue``. The byte length (``len(s.encode("utf-8"))``), not the
105 Unicode code-point count, is used so the hash stays byte-identical across the
106 MSAL SDK family (Go/.NET/Java/JS) as they converge on this scheme.
107 """
108 if not data:
109 return ""
110 cache_components = {
111 k: str(v) for k, v in data.items()
112 if k not in _EXT_CACHE_KEY_EXCLUDED_FIELDS and v
113 }
114 if not cache_components:
115 return ""
116 # Sort keys, then length-prefix each key and value so the serialization is
117 # injective (see docstring). Byte-identical to MSAL Go's netstring encoding.
118 key_str = "".join(
119 "{klen}:{k}{vlen}:{v}".format(
120 klen=len(k.encode("utf-8")), k=k,
121 vlen=len(cache_components[k].encode("utf-8")), v=cache_components[k])
122 for k in sorted(cache_components.keys())
123 )
124 hash_bytes = hashlib.sha256(key_str.encode("utf-8")).digest()
125 return base64.urlsafe_b64encode(hash_bytes).rstrip(b"=").decode("ascii").lower()
128def _parse_claims_or_raise(claims):
129 """Parse a claims JSON string into a dict, or raise a friendly ``ValueError``.
131 The raw claims value is never included in the error message because it may
132 contain sensitive data. Mirrors MSAL .NET's ``ClaimsHelper.ParseClaimsOrThrow``.
133 """
134 try:
135 parsed = json.loads(claims)
136 except (ValueError, TypeError) as ex:
137 # json.JSONDecodeError (malformed JSON) is a subclass of ValueError;
138 # TypeError is raised when *claims* is not a str/bytes/bytearray. Both
139 # are surfaced as the same friendly ValueError so every caller behaves
140 # consistently regardless of the bad input's type.
141 raise ValueError(
142 "The claims value is not valid JSON. "
143 "See https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter."
144 ) from ex
145 if not isinstance(parsed, dict):
146 # A valid JSON array, scalar, or the literal "null" is not a claims object.
147 raise ValueError(
148 "The claims value is not a valid JSON object. "
149 "See https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter.")
150 return parsed
153def _deep_merge_dict(base, overlay):
154 """Recursively merge ``overlay`` into ``base``, returning a new dict.
156 Nested dicts are merged; for any other value type, ``overlay`` wins.
157 """
158 result = dict(base)
159 for key, value in overlay.items():
160 if (key in result
161 and isinstance(result[key], dict) and isinstance(value, dict)):
162 result[key] = _deep_merge_dict(result[key], value)
163 else:
164 result[key] = value
165 return result
168def _merge_claims(claims_a, claims_b):
169 """Merge two claims JSON strings into a single JSON string.
171 If either side is empty/None, the other is returned as-is. Mirrors MSAL
172 .NET's ``ClaimsHelper.MergeClaimsObjects``.
173 """
174 if not claims_a:
175 return claims_b
176 if not claims_b:
177 return claims_a
178 merged = _deep_merge_dict(
179 _parse_claims_or_raise(claims_a), _parse_claims_or_raise(claims_b))
180 return json.dumps(merged)
183def is_subdict_of(small, big):
184 return dict(big, **small) == big
186def _get_username(id_token_claims):
187 return id_token_claims.get(
188 "preferred_username", # AAD
189 id_token_claims.get("upn")) # ADFS 2019
191class TokenCache(object):
192 """This is considered as a base class containing minimal cache behavior.
194 Although it maintains tokens using unified schema across all MSAL libraries,
195 this class does not serialize/persist them.
196 See subclass :class:`SerializableTokenCache` for details on serialization.
197 """
199 class CredentialType:
200 ACCESS_TOKEN = "AccessToken"
201 ACCESS_TOKEN_EXTENDED = "atext" # Used when ext_cache_key is present (matches Go/dotnet)
202 REFRESH_TOKEN = "RefreshToken"
203 ACCOUNT = "Account" # Not exactly a credential type, but we put it here
204 ID_TOKEN = "IdToken"
205 APP_METADATA = "AppMetadata"
207 class AuthorityType:
208 ADFS = "ADFS"
209 MSSTS = "MSSTS" # MSSTS means AAD v2 for both AAD & MSA
211 def __init__(self):
212 self._lock = threading.RLock()
213 self._cache = {}
214 self.key_makers = {
215 # Note: We have changed token key format before when ordering scopes;
216 # changing token key won't result in cache miss.
217 self.CredentialType.REFRESH_TOKEN:
218 lambda home_account_id=None, environment=None, client_id=None,
219 target=None, **ignored_payload_from_a_real_token:
220 "-".join([
221 home_account_id or "",
222 environment or "",
223 self.CredentialType.REFRESH_TOKEN,
224 client_id or "",
225 "", # RT is cross-tenant in AAD
226 target or "", # raw value could be None if deserialized from other SDK
227 ]).lower(),
228 self.CredentialType.ACCESS_TOKEN:
229 lambda home_account_id=None, environment=None, client_id=None,
230 realm=None, target=None,
231 ext_cache_key=None,
232 # Note: New field(s) can be added here
233 #key_id=None,
234 **ignored_payload_from_a_real_token:
235 "-".join([ # Note: Could use a hash here to shorten key length
236 home_account_id or "",
237 environment or "",
238 # Use "atext" credential type when ext_cache_key is
239 # present, matching MSAL Go and MSAL .NET behaviour.
240 "atext" if ext_cache_key else "AccessToken",
241 client_id or "",
242 realm or "",
243 target or "",
244 #key_id or "", # So ATs of different key_id can coexist
245 ] + ([ext_cache_key] if ext_cache_key else [])
246 ).lower(),
247 self.CredentialType.ID_TOKEN:
248 lambda home_account_id=None, environment=None, client_id=None,
249 realm=None, **ignored_payload_from_a_real_token:
250 "-".join([
251 home_account_id or "",
252 environment or "",
253 self.CredentialType.ID_TOKEN,
254 client_id or "",
255 realm or "",
256 "" # Albeit irrelevant, schema requires an empty scope here
257 ]).lower(),
258 self.CredentialType.ACCOUNT:
259 lambda home_account_id=None, environment=None, realm=None,
260 **ignored_payload_from_a_real_entry:
261 "-".join([
262 home_account_id or "",
263 environment or "",
264 realm or "",
265 ]).lower(),
266 self.CredentialType.APP_METADATA:
267 lambda environment=None, client_id=None, **kwargs:
268 "appmetadata-{}-{}".format(environment or "", client_id or ""),
269 }
271 def _get_access_token(
272 self,
273 home_account_id, environment, client_id, realm, target, # Together they form a compound key
274 ext_cache_key=None,
275 default=None,
276 ): # O(1)
277 return self._get(
278 self.CredentialType.ACCESS_TOKEN,
279 self.key_makers[TokenCache.CredentialType.ACCESS_TOKEN](
280 home_account_id=home_account_id,
281 environment=environment,
282 client_id=client_id,
283 realm=realm,
284 target=" ".join(target),
285 ext_cache_key=ext_cache_key,
286 ),
287 default=default)
289 def _get_app_metadata(self, environment, client_id, default=None): # O(1)
290 return self._get(
291 self.CredentialType.APP_METADATA,
292 self.key_makers[TokenCache.CredentialType.APP_METADATA](
293 environment=environment,
294 client_id=client_id,
295 ),
296 default=default)
298 def _get(self, credential_type, key, default=None): # O(1)
299 with self._lock:
300 return self._cache.get(credential_type, {}).get(key, default)
302 @staticmethod
303 def _is_matching(entry: dict, query: dict, target_set: set = None) -> bool:
304 query_with_lowercase_environment = {
305 # __add() canonicalized entry's environment value to lower case,
306 # so we do the same here.
307 k: v.lower() if k == "environment" and isinstance(v, str) else v
308 for k, v in query.items()
309 } if query else {}
310 return is_subdict_of(query_with_lowercase_environment, entry) and (
311 target_set <= set(entry.get("target", "").split())
312 if target_set else True)
314 def search(self, credential_type, target=None, query=None, *, now=None): # O(n) generator
315 """Returns a generator of matching entries.
317 It is O(1) for AT hits, and O(n) for other types.
318 Note that it holds a lock during the entire search.
319 """
320 target = sorted(target or []) # Match the order sorted by add()
321 assert isinstance(target, list), "Invalid parameter type"
323 preferred_result = None
324 if (credential_type == self.CredentialType.ACCESS_TOKEN
325 and isinstance(query, dict)
326 and "home_account_id" in query and "environment" in query
327 and "client_id" in query and "realm" in query and target
328 ): # Special case for O(1) AT lookup
329 preferred_result = self._get_access_token(
330 query["home_account_id"], query["environment"],
331 query["client_id"], query["realm"], target,
332 ext_cache_key=query.get("ext_cache_key"))
333 if preferred_result and self._is_matching(
334 preferred_result, query,
335 # Needs no target_set here because it is satisfied by dict key
336 ):
337 yield preferred_result
339 target_set = set(target)
340 with self._lock:
341 # O(n) search. The key is NOT used in search.
342 now = int(time.time() if now is None else now)
343 expired_access_tokens = [
344 # Especially when/if we key ATs by ephemeral fields such as key_id,
345 # stale ATs keyed by an old key_id would stay forever.
346 # Here we collect them for their removal.
347 ]
348 for entry in self._cache.get(credential_type, {}).values():
349 if ( # Automatically delete expired access tokens
350 credential_type == self.CredentialType.ACCESS_TOKEN
351 and int(entry["expires_on"]) < now
352 ):
353 expired_access_tokens.append(entry) # Can't delete them within current for-loop
354 continue
355 if (entry != preferred_result # Avoid yielding the same entry twice
356 and self._is_matching(entry, query, target_set=target_set)
357 ):
358 # Cache isolation for extended cache keys (e.g., FMI path).
359 # Entries with ext_cache_key must not match queries without one.
360 if (credential_type == self.CredentialType.ACCESS_TOKEN
361 and "ext_cache_key" in entry
362 and "ext_cache_key" not in (query or {})
363 ):
364 continue
365 yield entry
366 for at in expired_access_tokens:
367 self.remove_at(at)
369 def find(self, credential_type, target=None, query=None, *, now=None):
370 """Equivalent to list(search(...))."""
371 warnings.warn(
372 "Use list(search(...)) instead to explicitly get a list.",
373 DeprecationWarning)
374 return list(self.search(credential_type, target=target, query=query, now=now))
376 def add(self, event, now=None):
377 """Handle a token obtaining event, and add tokens into cache."""
378 def make_clean_copy(dictionary, sensitive_fields): # Masks sensitive info
379 return {
380 k: "********" if k in sensitive_fields else v
381 for k, v in dictionary.items()
382 }
383 clean_event = dict(
384 event,
385 data=make_clean_copy(event.get("data", {}), (
386 "password", "client_secret", "refresh_token", "assertion",
387 "user_federated_identity_credential",
388 # Client-originated claims may carry sensitive values; they are
389 # kept in data only for ext_cache_key computation, so redact them
390 # from the debug log (both the cache-key pseudo-param and the
391 # merged wire parameter).
392 "client_claims", "claims",
393 )),
394 response=make_clean_copy(event.get("response", {}), (
395 "id_token_claims", # Provided by broker
396 "access_token", "refresh_token", "id_token", "username",
397 )),
398 )
399 logger.debug("event=%s", json.dumps(
400 # We examined and concluded that this log won't have Log Injection risk,
401 # because the event payload is already in JSON so CR/LF will be escaped.
402 clean_event,
403 indent=4, sort_keys=True,
404 default=str, # assertion is in bytes in Python 3
405 ))
406 return self.__add(event, now=now)
408 def __parse_account(self, response, id_token_claims):
409 """Return client_info and home_account_id"""
410 if "client_info" in response: # It happens when client_info and profile are in request
411 client_info = json.loads(decode_part(response["client_info"]))
412 if "uid" in client_info and "utid" in client_info:
413 return client_info, "{uid}.{utid}".format(**client_info)
414 # https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/387
415 if id_token_claims: # This would be an end user on ADFS-direct scenario
416 sub = id_token_claims["sub"] # "sub" always exists, per OIDC specs
417 return {"uid": sub}, sub
418 # client_credentials flow will reach this code path
419 return {}, None
421 def __add(self, event, now=None):
422 # event typically contains: client_id, scope, token_endpoint,
423 # response, params, data, grant_type
424 environment = realm = None
425 if "token_endpoint" in event:
426 _, environment, realm = canonicalize(event["token_endpoint"])
427 if "environment" in event: # Always available unless in legacy test cases
428 environment = event["environment"] # Set by application.py
429 response = event.get("response", {})
430 data = event.get("data", {})
431 access_token = response.get("access_token")
432 refresh_token = response.get("refresh_token")
433 id_token = response.get("id_token")
434 id_token_claims = response.get("id_token_claims") or ( # Prefer the claims from broker
435 # MSAL does not validate the ID token; it only decodes the claims.
436 # https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/911
437 _decode_id_token_claims(id_token) if id_token else {})
438 client_info, home_account_id = self.__parse_account(response, id_token_claims)
440 target = ' '.join(sorted(event.get("scope") or [])) # Schema should have required sorting
442 with self._lock:
443 now = int(time.time() if now is None else now)
445 if access_token:
446 default_expires_in = ( # https://www.rfc-editor.org/rfc/rfc6749#section-5.1
447 int(response.get("expires_on")) - now # Some Managed Identity emits this
448 ) if response.get("expires_on") else 600
449 expires_in = int( # AADv1-like endpoint returns a string
450 response.get("expires_in", default_expires_in))
451 ext_expires_in = int( # AADv1-like endpoint returns a string
452 response.get("ext_expires_in", expires_in))
453 at = {
454 "credential_type": self.CredentialType.ACCESS_TOKEN,
455 "secret": access_token,
456 "home_account_id": home_account_id,
457 "environment": environment,
458 "client_id": event.get("client_id"),
459 "target": target,
460 "realm": realm,
461 "token_type": response.get("token_type", "Bearer"),
462 "cached_at": str(now), # Schema defines it as a string
463 "expires_on": str(now + expires_in), # Same here
464 "extended_expires_on": str(now + ext_expires_in) # Same here
465 }
466 at.update({k: data[k] for k in data if k in {
467 # Also store extra data which we explicitly allow
468 # So that we won't accidentally store a user's password etc.
469 "key_id", # It happens in SSH-cert or POP scenario
470 }})
471 # Compute and store extended cache key for cache isolation
472 # (e.g., different FMI paths should have separate cache entries)
473 ext_cache_key = _compute_ext_cache_key(data)
475 if ext_cache_key:
476 at["ext_cache_key"] = ext_cache_key
477 if "refresh_in" in response:
478 refresh_in = response["refresh_in"] # It is an integer
479 at["refresh_on"] = str(now + refresh_in) # Schema wants a string
480 self.modify(self.CredentialType.ACCESS_TOKEN, at, at)
482 if client_info and not event.get("skip_account_creation"):
483 account = {
484 "home_account_id": home_account_id,
485 "environment": environment,
486 "realm": realm,
487 "local_account_id": event.get(
488 "_account_id", # Came from mid-tier code path.
489 # Emperically, it is the oid in AAD or cid in MSA.
490 id_token_claims.get("oid", id_token_claims.get("sub"))),
491 "username": _get_username(id_token_claims)
492 or data.get("username") # Falls back to ROPC username
493 or event.get("username") # Falls back to Federated ROPC username
494 or "", # The schema does not like null
495 "authority_type": event.get(
496 "authority_type", # Honor caller's choice of authority_type
497 self.AuthorityType.ADFS if realm == "adfs"
498 else self.AuthorityType.MSSTS),
499 # "client_info": response.get("client_info"), # Optional
500 }
501 grant_types_that_establish_an_account = (
502 _GRANT_TYPE_BROKER, "authorization_code", "password",
503 Client.DEVICE_FLOW["GRANT_TYPE"], "user_fic")
504 if event.get("grant_type") in grant_types_that_establish_an_account:
505 account["account_source"] = event["grant_type"]
506 self.modify(self.CredentialType.ACCOUNT, account, account)
508 if id_token:
509 idt = {
510 "credential_type": self.CredentialType.ID_TOKEN,
511 "secret": id_token,
512 "home_account_id": home_account_id,
513 "environment": environment,
514 "realm": realm,
515 "client_id": event.get("client_id"),
516 # "authority": "it is optional",
517 }
518 self.modify(self.CredentialType.ID_TOKEN, idt, idt)
520 if refresh_token:
521 rt = {
522 "credential_type": self.CredentialType.REFRESH_TOKEN,
523 "secret": refresh_token,
524 "home_account_id": home_account_id,
525 "environment": environment,
526 "client_id": event.get("client_id"),
527 "target": target, # Optional per schema though
528 "last_modification_time": str(now), # Optional. Schema defines it as a string.
529 }
530 if "foci" in response:
531 rt["family_id"] = response["foci"]
532 self.modify(self.CredentialType.REFRESH_TOKEN, rt, rt)
534 app_metadata = {
535 "client_id": event.get("client_id"),
536 "environment": environment,
537 }
538 if "foci" in response:
539 app_metadata["family_id"] = response.get("foci")
540 self.modify(self.CredentialType.APP_METADATA, app_metadata, app_metadata)
542 def modify(self, credential_type, old_entry, new_key_value_pairs=None):
543 # Modify the specified old_entry with new_key_value_pairs,
544 # or remove the old_entry if the new_key_value_pairs is None.
546 # This helper exists to consolidate all token add/modify/remove behaviors,
547 # so that the sub-classes will have only one method to work on,
548 # instead of patching a pair of update_xx() and remove_xx() per type.
549 # You can monkeypatch self.key_makers to support more types on-the-fly.
550 key = self.key_makers[credential_type](**old_entry)
551 with self._lock:
552 if new_key_value_pairs: # Update with them
553 entries = self._cache.setdefault(credential_type, {})
554 entries[key] = dict(
555 old_entry, # Do not use entries[key] b/c it might not exist
556 **new_key_value_pairs)
557 else: # Remove old_entry
558 self._cache.setdefault(credential_type, {}).pop(key, None)
560 def remove_rt(self, rt_item):
561 assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN
562 return self.modify(self.CredentialType.REFRESH_TOKEN, rt_item)
564 def update_rt(self, rt_item, new_rt):
565 assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN
566 return self.modify(self.CredentialType.REFRESH_TOKEN, rt_item, {
567 "secret": new_rt,
568 "last_modification_time": str(int(time.time())), # Optional. Schema defines it as a string.
569 })
571 def remove_at(self, at_item):
572 assert at_item.get("credential_type") == self.CredentialType.ACCESS_TOKEN
573 return self.modify(self.CredentialType.ACCESS_TOKEN, at_item)
575 def remove_idt(self, idt_item):
576 assert idt_item.get("credential_type") == self.CredentialType.ID_TOKEN
577 return self.modify(self.CredentialType.ID_TOKEN, idt_item)
579 def remove_account(self, account_item):
580 assert "authority_type" in account_item
581 return self.modify(self.CredentialType.ACCOUNT, account_item)
584class SerializableTokenCache(TokenCache):
585 """This serialization can be a starting point to implement your own persistence.
587 This class does NOT actually persist the cache on disk/db/etc..
588 Depending on your need,
589 the following simple recipe for file-based, unencrypted persistence may be sufficient::
591 import os, atexit, msal
592 cache_filename = os.path.join( # Persist cache into this file
593 os.getenv(
594 # Automatically wipe out the cache from Linux when user's ssh session ends.
595 # See also https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/690
596 "XDG_RUNTIME_DIR", ""),
597 "my_cache.bin")
598 cache = msal.SerializableTokenCache()
599 if os.path.exists(cache_filename):
600 cache.deserialize(open(cache_filename, "r").read())
601 atexit.register(lambda:
602 open(cache_filename, "w").write(cache.serialize())
603 # Hint: The following optional line persists only when state changed
604 if cache.has_state_changed else None
605 )
606 app = msal.ClientApplication(..., token_cache=cache)
607 ...
609 Alternatively, you may use a more sophisticated cache persistence library,
610 `MSAL Extensions <https://github.com/AzureAD/microsoft-authentication-extensions-for-python>`_,
611 which provides token cache persistence with encryption, and more.
613 :var bool has_state_changed:
614 Indicates whether the cache state in the memory has changed since last
615 :func:`~serialize` or :func:`~deserialize` call.
616 """
617 has_state_changed = False
619 def add(self, event, **kwargs):
620 super(SerializableTokenCache, self).add(event, **kwargs)
621 self.has_state_changed = True
623 def modify(self, credential_type, old_entry, new_key_value_pairs=None):
624 super(SerializableTokenCache, self).modify(
625 credential_type, old_entry, new_key_value_pairs)
626 self.has_state_changed = True
628 def deserialize(self, state):
629 # type: (Optional[str]) -> None
630 """Deserialize the cache from a state previously obtained by serialize()"""
631 with self._lock:
632 self._cache = json.loads(state) if state else {}
633 self.has_state_changed = False # reset
635 def serialize(self):
636 # type: () -> str
637 """Serialize the current cache state into a string."""
638 with self._lock:
639 self.has_state_changed = False
640 return json.dumps(self._cache, indent=4)