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

199 statements  

1import base64 

2import hashlib 

3import json 

4import threading 

5import time 

6import logging 

7import warnings 

8 

9from .authority import canonicalize 

10from .oauth2cli.oidc import decode_part, _decode_id_token_claims 

11from .oauth2cli.oauth2 import Client 

12 

13 

14logger = logging.getLogger(__name__) 

15_GRANT_TYPE_BROKER = "broker" 

16 

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}) 

74 

75 

76def _compute_ext_cache_key(data): 

77 """Compute an extended cache key hash from extra body parameters in *data*. 

78 

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. 

83 

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. 

88 

89 Returns an empty string when *data* has no hashable fields. 

90 

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. 

97 

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() 

126 

127 

128def _parse_claims_or_raise(claims): 

129 """Parse a claims JSON string into a dict, or raise a friendly ``ValueError``. 

130 

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 

151 

152 

153def _deep_merge_dict(base, overlay): 

154 """Recursively merge ``overlay`` into ``base``, returning a new dict. 

155 

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 

166 

167 

168def _merge_claims(claims_a, claims_b): 

169 """Merge two claims JSON strings into a single JSON string. 

170 

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) 

181 

182 

183def is_subdict_of(small, big): 

184 return dict(big, **small) == big 

185 

186def _get_username(id_token_claims): 

187 return id_token_claims.get( 

188 "preferred_username", # AAD 

189 id_token_claims.get("upn")) # ADFS 2019 

190 

191class TokenCache(object): 

192 """This is considered as a base class containing minimal cache behavior. 

193 

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 """ 

198 

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" 

206 

207 class AuthorityType: 

208 ADFS = "ADFS" 

209 MSSTS = "MSSTS" # MSSTS means AAD v2 for both AAD & MSA 

210 

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 } 

270 

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) 

288 

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) 

297 

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) 

301 

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) 

313 

314 def search(self, credential_type, target=None, query=None, *, now=None): # O(n) generator 

315 """Returns a generator of matching entries. 

316 

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" 

322 

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 

338 

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) 

368 

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)) 

375 

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) 

407 

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 

420 

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) 

439 

440 target = ' '.join(sorted(event.get("scope") or [])) # Schema should have required sorting 

441 

442 with self._lock: 

443 now = int(time.time() if now is None else now) 

444 

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) 

474 

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) 

481 

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) 

507 

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) 

519 

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) 

533 

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) 

541 

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. 

545 

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) 

559 

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) 

563 

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 }) 

570 

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) 

574 

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) 

578 

579 def remove_account(self, account_item): 

580 assert "authority_type" in account_item 

581 return self.modify(self.CredentialType.ACCOUNT, account_item) 

582 

583 

584class SerializableTokenCache(TokenCache): 

585 """This serialization can be a starting point to implement your own persistence. 

586 

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:: 

590 

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 ... 

608 

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. 

612 

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 

618 

619 def add(self, event, **kwargs): 

620 super(SerializableTokenCache, self).add(event, **kwargs) 

621 self.has_state_changed = True 

622 

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 

627 

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 

634 

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) 

641