Skip to content

Commit f8040e4

Browse files
Revert ext-cache-key to MSAL .NET byte-parity (undo hardening fix #1)
Restore _compute_ext_cache_key to MSAL .NET's ComputeAccessTokenExtCacheKey encoding: sorted, separator-less key+value concatenation -> SHA-256 -> base64url. This makes the ext cache key byte-identical to MSAL .NET again. The earlier hardening commit (4fc3639) had switched to Go's post-#629 length-prefixed encoding to make the key injective. Per maintainer decision, msal-python should match MSAL .NET, not current Go, so that change is reverted: - token_cache.py: restore plain key+value concatenation; docstring now notes the .NET match and the deliberate divergence from Go's #629 length-prefixed form. - test_token_cache.py: restore the .NET parity hashes (bns2ytmx..., 3-rg6_wy..., rn_gkpxx...) and rename the parity tests to *_matches_dotnet; remove the two length-prefix boundary-collision regression tests (they asserted the injective property that .NET's encoding does not provide). Hardening fixes #2-#6 (MI allow-list removal, MI source pre-validation, merge conflict-precedence tests, generic docs, send-on-every-request docs) are unchanged. 202 tests pass across test_token_cache.py, test_mi.py, test_application.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4fc3639 commit f8040e4

2 files changed

Lines changed: 47 additions & 78 deletions

File tree

msal/token_cache.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,15 @@ def _compute_ext_cache_key(data):
8383
8484
Returns an empty string when *data* has no hashable fields.
8585
86-
The algorithm matches the Go MSAL implementation (CacheExtKeyGenerator,
87-
post-collision-fix): length-prefixed key/value pairs (sorted by key) are
88-
concatenated and SHA256 hashed, then base64url encoded. The length prefixes
89-
make the encoding injective, so two distinct component sets can never collide
90-
onto the same cache key. (MSAL .NET's ``ComputeAccessTokenExtCacheKey`` still
91-
uses an unprefixed concatenation, so the hash is intentionally not
92-
byte-identical to current .NET; the cache *key format* still matches both.
93-
Caches are not shared across languages, so this only affects within-process
94-
isolation, where injectivity is what matters.)
86+
The algorithm matches MSAL .NET's ``ComputeAccessTokenExtCacheKey``: sorted
87+
key+value pairs are concatenated (no separators) and SHA256 hashed, then
88+
base64url encoded. This keeps the hash byte-identical to MSAL .NET.
89+
90+
MSAL Go's ``CacheExtKeyGenerator`` has since switched to a length-prefixed
91+
encoding (AzureAD/microsoft-authentication-library-for-go#629) to make it
92+
injective; Python deliberately tracks .NET instead, so these hashes are not
93+
byte-identical to current Go. Caches are not shared across languages, so the
94+
difference does not affect runtime correctness.
9595
"""
9696
if not data:
9797
return ""
@@ -101,16 +101,11 @@ def _compute_ext_cache_key(data):
101101
}
102102
if not cache_components:
103103
return ""
104-
# Concatenate length-prefixed key/value pairs so component boundaries are
105-
# unambiguous (matches Go's CacheExtKeyGenerator). A plain key+value
106-
# concatenation with no separators can collide when one value happens to
107-
# contain another component's key or value -- and client_claims is arbitrary
108-
# caller-supplied JSON that may embed e.g. "fmi_path" at a boundary -- mapping
109-
# two distinct component sets onto the same hash and returning the wrong
110-
# cached token. Length prefixes make the encoding injective.
104+
# Sort keys, then concatenate key+value pairs with no separators. This
105+
# matches MSAL .NET's ComputeAccessTokenExtCacheKey byte-for-byte. (See the
106+
# docstring re: the Go #629 length-prefixed divergence.)
111107
key_str = "".join(
112-
"{}:{}{}:{}".format(len(k), k, len(v), v)
113-
for k, v in sorted(cache_components.items())
108+
k + cache_components[k] for k in sorted(cache_components.keys())
114109
)
115110
hash_bytes = hashlib.sha256(key_str.encode("utf-8")).digest()
116111
return base64.urlsafe_b64encode(hash_bytes).rstrip(b"=").decode("ascii").lower()

tests/test_token_cache.py

Lines changed: 34 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -392,29 +392,6 @@ def test_different_client_claims_produce_different_hashes(self):
392392
def test_empty_client_claims_value_is_ignored(self):
393393
self.assertEqual("", _compute_ext_cache_key({"client_claims": ""}))
394394

395-
def test_length_prefixed_encoding_avoids_boundary_collision(self):
396-
# Mirrors Go's TestCacheKeyComponentHashNoBoundaryCollision. With a plain
397-
# key+value concatenation (no separators) these two distinct component
398-
# sets both render to "axbYbZ" (sorted keys "a","b") and would collide,
399-
# returning the wrong cached token. The length-prefixed encoding must keep
400-
# them distinct. This matters because client_claims is arbitrary caller
401-
# JSON that can embed another component's key (e.g. "fmi_path").
402-
h1 = _compute_ext_cache_key({"a": "xbY", "b": "Z"})
403-
h2 = _compute_ext_cache_key({"a": "x", "b": "YbZ"})
404-
self.assertNotEqual(
405-
h1, h2,
406-
"distinct cache key components must not produce the same hash")
407-
408-
def test_client_claims_and_fmi_path_do_not_collide_at_boundary(self):
409-
# Realistic surface: client_claims and fmi_path co-occur in
410-
# acquire_token_for_client. A claims value that happens to contain the
411-
# other component's key+value at a boundary must not collide.
412-
h1 = _compute_ext_cache_key(
413-
{"client_claims": "Xfmi_pathY", "fmi_path": "Z"})
414-
h2 = _compute_ext_cache_key(
415-
{"client_claims": "X", "fmi_path": "Yfmi_pathZ"})
416-
self.assertNotEqual(h1, h2)
417-
418395

419396
class TestClaimsHelpers(unittest.TestCase):
420397
"""Tests for the shared _parse_claims_or_raise / _merge_claims helpers."""
@@ -565,48 +542,45 @@ def test_non_fmi_tokens_not_affected_by_fmi_cache(self):
565542

566543

567544
class TestCrossMsalCacheKeyCompatibility(unittest.TestCase):
568-
"""Verify that _compute_ext_cache_key matches MSAL Go's CacheExtKeyGenerator
569-
(post collision-fix, AzureAD/microsoft-authentication-library-for-go#629).
545+
"""Verify that _compute_ext_cache_key produces hashes identical to MSAL .NET
546+
(CoreHelpers.ComputeAccessTokenExtCacheKey).
570547
571548
The algorithm:
572549
1. Sort key-value pairs alphabetically by key (ordinal / case-sensitive)
573-
2. Concatenate length-prefixed pairs ("{len(k)}:{k}{len(v)}:{v}" per pair;
574-
e.g. {"key1": "value1"} -> "4:key16:value1"). The length prefixes make
575-
the encoding injective -- see TestClientClaimsCacheKey for the collision
576-
guard.
550+
2. Concatenate them with no separators: "key1value1key2value2…"
577551
3. SHA-256 hash
578552
4. Base64url encode (no padding), lowercased
579553
580-
The expected hashes below are copied from MSAL Go's
581-
authority_ext_cachekey_test.go (TestAppKeyWithCacheKeyComponent).
554+
The expected hashes below are copied from MSAL .NET's CacheKeyExtensionTests.cs
555+
(RunHappyPathTest, CacheExtEnsurePopKeysFunctionAsync).
582556
583-
NOTE: MSAL .NET's ComputeAccessTokenExtCacheKey still uses an *unprefixed*
584-
concatenation, so these hashes are intentionally NOT byte-identical to current
585-
.NET. The cache *key format* (the 'atext' segment layout, asserted below) still
586-
matches both Go and .NET; only the trailing hash bytes differ. Caches are not
587-
shared across languages, so within-process injectivity -- not cross-language
588-
byte-parity -- is what matters for correctness.
557+
NOTE: MSAL Go's CacheExtKeyGenerator has since switched to a *length-prefixed*
558+
encoding (AzureAD/microsoft-authentication-library-for-go#629), so these hashes
559+
are intentionally NOT byte-identical to current Go; Python deliberately tracks
560+
.NET here. The cache *key format* (the 'atext' segment layout, asserted below)
561+
still matches both Go and .NET. Caches are not shared across languages, so this
562+
cross-language hash difference does not affect runtime correctness.
589563
"""
590564

591-
def test_two_params_hash_matches_go(self):
592-
"""Go expected: latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike"""
565+
def test_two_params_hash_matches_dotnet(self):
566+
""".NET expected: bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi"""
593567
result = _compute_ext_cache_key({"key1": "value1", "key2": "value2"})
594-
self.assertEqual("latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike", result)
568+
self.assertEqual("bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi", result)
595569

596-
def test_two_different_params_hash_matches_go(self):
597-
"""Go expected: jjoe9jgfmdtnj0rzuetsqy7kzs2m1xfnjjxwsfxsrxq"""
570+
def test_two_different_params_hash_matches_dotnet(self):
571+
""".NET expected: 3-rg6_wyjx5bcy0c3cqq7gajtzgsqy3oxqpwj4y8k4u"""
598572
result = _compute_ext_cache_key({"key3": "value3", "key4": "value4"})
599-
self.assertEqual("jjoe9jgfmdtnj0rzuetsqy7kzs2m1xfnjjxwsfxsrxq", result)
573+
self.assertEqual("3-rg6_wyjx5bcy0c3cqq7gajtzgsqy3oxqpwj4y8k4u", result)
600574

601-
def test_five_params_hash_matches_go(self):
602-
"""Go expected: prrdp31y37ufw3lo7hly0oimjjvg_34m9ji30ocu4tw"""
575+
def test_five_params_hash_matches_dotnet(self):
576+
""".NET expected (full hash): rn_gkpxxkkqjxcqnvnmr2duvxg66xanvkz6qfqpwp2e"""
603577
result = _compute_ext_cache_key({
604578
"key3": "value3", "key4": "value4",
605579
"key5": "value5", "key6": "value6", "key7": "value7",
606580
})
607-
self.assertEqual("prrdp31y37ufw3lo7hly0oimjjvg_34m9ji30ocu4tw", result)
581+
self.assertEqual("rn_gkpxxkkqjxcqnvnmr2duvxg66xanvkz6qfqpwp2e", result)
608582

609-
def test_order_independence_matches_go(self):
583+
def test_order_independence_matches_dotnet(self):
610584
"""Same keys in different insertion order must produce the same hash
611585
(mirrors TestCacheKeyComponentHashConsistency in Go)."""
612586
h1 = _compute_ext_cache_key({"key3": "value3", "key4": "value4",
@@ -627,9 +601,9 @@ def test_at_cache_key_uses_atext_credential_type(self):
627601
key = key_maker(
628602
home_account_id="hid", environment="env", client_id="cid",
629603
realm="realm", target="scope",
630-
ext_cache_key="latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike")
604+
ext_cache_key="bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi")
631605
self.assertEqual(
632-
"hid-env-atext-cid-realm-scope-latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike",
606+
"hid-env-atext-cid-realm-scope-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi",
633607
key)
634608

635609
def test_at_cache_key_without_ext_uses_accesstoken(self):
@@ -641,10 +615,9 @@ def test_at_cache_key_without_ext_uses_accesstoken(self):
641615
realm="realm", target="scope")
642616
self.assertEqual("hid-env-accesstoken-cid-realm-scope", key)
643617

644-
def test_atext_full_at_cache_key_format(self):
645-
"""The AT cache key *format* matches MSAL .NET's CacheKeyExtensionTests
646-
layout ('-{env}-atext-{clientId}-{tenant}-{scopes}-{hash}'); only the
647-
trailing hash now follows Go's length-prefixed encoding (see class note).
618+
def test_dotnet_style_full_at_cache_key(self):
619+
"""Reproduce the exact cache key from MSAL .NET CacheKeyExtensionTests:
620+
expectedCacheKey1 = '-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi'
648621
"""
649622
cache = TokenCache()
650623
key_maker = cache.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]
@@ -656,11 +629,11 @@ def test_atext_full_at_cache_key_format(self):
656629
realm="common",
657630
target="r1/scope1 r1/scope2",
658631
ext_cache_key=ext_hash)
659-
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike"
632+
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi"
660633
self.assertEqual(expected, key)
661634

662-
def test_atext_second_full_at_cache_key_format(self):
663-
"""Second key-format vector (mirrors CacheKeyExtensionTests expectedCacheKey2)."""
635+
def test_dotnet_style_second_cache_key(self):
636+
"""Reproduce CacheKeyExtensionTests expectedCacheKey2."""
664637
cache = TokenCache()
665638
key_maker = cache.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]
666639
ext_hash = _compute_ext_cache_key({"key3": "value3", "key4": "value4"})
@@ -671,12 +644,13 @@ def test_atext_second_full_at_cache_key_format(self):
671644
realm="common",
672645
target="r1/scope1 r1/scope2",
673646
ext_cache_key=ext_hash)
674-
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-jjoe9jgfmdtnj0rzuetsqy7kzs2m1xfnjjxwsfxsrxq"
647+
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-3-rg6_wyjx5bcy0c3cqq7gajtzgsqy3oxqpwj4y8k4u"
675648
self.assertEqual(expected, key)
676649

677650
def test_go_style_at_cache_key(self):
678-
"""Reproduce the Go AccessToken.Key() format with Go's post-#629 hash:
679-
'testhid-env-atext-clientid-realm-user.read-{hash}'.
651+
"""Reproduce the Go AccessToken.Key() *format* (segment layout):
652+
'testhid-env-atext-clientid-realm-user.read-{hash}'. The hash follows our
653+
.NET-matching encoding (see class note on the Go #629 divergence).
680654
"""
681655
cache = TokenCache()
682656
key_maker = cache.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]
@@ -688,5 +662,5 @@ def test_go_style_at_cache_key(self):
688662
realm="realm",
689663
target="user.read",
690664
ext_cache_key=ext_hash)
691-
expected = "testhid-env-atext-clientid-realm-user.read-latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike"
665+
expected = "testhid-env-atext-clientid-realm-user.read-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi"
692666
self.assertEqual(expected, key)

0 commit comments

Comments
 (0)