Skip to content

Commit 224a003

Browse files
Enumerate key-bound ATs on sign-out and account removal
Gate the ext_cache_key and key_id cache-isolation filters in TokenCache.search() on a non-empty target. Scoped token retrieval keeps the mtls_pop / FMI isolation intact, but broad target-less searches (used by _sign_out and remove_account) now enumerate key-bound access tokens so they are removed from the cache instead of being left behind. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 81c3eb7 commit 224a003

2 files changed

Lines changed: 33 additions & 0 deletions

File tree

msal/token_cache.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,15 +288,21 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n)
288288
):
289289
# Cache isolation for extended cache keys (e.g., FMI path).
290290
# Entries with ext_cache_key must not match queries without one.
291+
# Gated on target so broad target-less searches (sign-out /
292+
# account removal) still enumerate these ATs and can delete them.
291293
if (credential_type == self.CredentialType.ACCESS_TOKEN
294+
and target
292295
and "ext_cache_key" in entry
293296
and "ext_cache_key" not in (query or {})
294297
):
295298
continue
296299
# Cache isolation for key-bound tokens (e.g. mtls_pop, SSH-cert).
297300
# An entry bound to a key_id must not satisfy a query without
298301
# one, so a Bearer lookup never returns a PoP/mtls_pop token.
302+
# Gated on target so broad target-less searches (sign-out /
303+
# account removal) still enumerate key-bound ATs and can delete them.
299304
if (credential_type == self.CredentialType.ACCESS_TOKEN
305+
and target
300306
and "key_id" in entry
301307
and "key_id" not in (query or {})
302308
):

tests/test_token_cache.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,33 @@ def test_bearer_and_mtls_pop_tokens_coexist_and_isolate(self):
294294
self.assertEqual("mtls_at", pop["secret"])
295295
self.assertEqual("mtls_pop", pop["token_type"])
296296

297+
def test_broad_search_without_target_enumerates_key_bound_tokens(self):
298+
# Removal paths (e.g. ClientApplication._sign_out) enumerate ATs by
299+
# account with no target and no key_id. Such a broad search must still
300+
# surface key-bound (mtls_pop) ATs; otherwise sign-out / remove_account
301+
# would leave them behind. Regression guard for the key_id isolation gate.
302+
scopes = ["s2", "s1", "s3"]
303+
now = 1000
304+
common = dict(
305+
client_id="my_client_id", scope=scopes,
306+
token_endpoint="https://login.example.com/contoso/v2/token")
307+
self.cache.add(dict(common, data={}, response=build_response(
308+
uid="uid", utid="utid", expires_in=3600,
309+
access_token="bearer_at", token_type="Bearer")), now=now)
310+
self.cache.add(dict(common, data={"key_id": "THUMB"}, response=build_response(
311+
uid="uid", utid="utid", expires_in=3600,
312+
access_token="mtls_at", token_type="mtls_pop")), now=now)
313+
owned_by_account = dict(
314+
client_id="my_client_id", environment="login.example.com",
315+
realm="contoso", home_account_id="uid.utid")
316+
# Broad search: no target/scopes and no key_id -> BOTH ATs, incl. mtls_pop
317+
found = list(self.cache.search(
318+
TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_account, now=now))
319+
secrets = {at["secret"] for at in found}
320+
self.assertIn("mtls_at", secrets,
321+
"A target-less search must enumerate key-bound ATs so they can be removed")
322+
self.assertIn("bearer_at", secrets)
323+
297324
def test_refresh_in_should_be_recorded_as_refresh_on(self): # Sounds weird. Yep.
298325
scopes = ["s2", "s1", "s3"] # Not in particular order
299326
self.cache.add({

0 commit comments

Comments
 (0)