Skip to content

Commit 4fc3639

Browse files
Harden forwarded_client_claims (cross-MSAL review fixes)
Apply six fixes derived from the sibling MSAL PRs (go #629, java #1039, js #8686) to the forwarded_client_claims port: 1. Make _compute_ext_cache_key injective. Switch from separator-less key+value concatenation to length-prefixed pairs ("{len(k)}:{k}{len(v)}:{v}"), matching Go's post-collision-fix CacheExtKeyGenerator. Without this, fmi_path + client_claims (which now co-occur in acquire_token_for_client) could collide and return the wrong cached token. Adds boundary-collision regression tests. NOTE: hashes are now intentionally not byte-identical to MSAL .NET (which still uses unprefixed concat); caches are not shared across languages, so within-process injectivity is what matters. 2. Remove the MSIv1 client-side allow-list (_validate_msiv1_claims). Forward any JSON-object claims value as-is and let IMDS decide which keys it accepts, matching go/java. 3. Validate the managed-identity source before the cache read. Reject unsupported sources (Service Fabric, App Service, Machine Learning, Azure Arc) up front so an unsupported source never returns a cached client-claims token. _obtain_token keeps its per-source guards as a backstop. 4. Add merge-conflict precedence tests: on a direct leaf conflict the client-originated value wins (merged last); disjoint claims are preserved. 5. Drop the first-party xms_az_nwperimid example from public docstrings; use generic "client-originated claims" wording. 6. Document that the same forwarded_client_claims value must be sent on every request that should share the cached token (it is part of the cache key). 204 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 6b8fac0 commit 4fc3639

6 files changed

Lines changed: 203 additions & 95 deletions

File tree

msal/application.py

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ def _merge_claims_challenge_and_capabilities(capabilities, claims_challenge):
6767
def _stash_client_claims(forwarded_client_claims, data):
6868
"""Validate ``forwarded_client_claims`` and stash it into the request ``data``.
6969
70-
``forwarded_client_claims`` carries *client-originated* claims (for example a
71-
network security perimeter ``xms_az_nwperimid`` claim). The raw value is
70+
``forwarded_client_claims`` carries *client-originated* claims supplied by the
71+
caller. The raw value is
7272
stored in ``data`` (under the internal ``client_claims`` key) so that it
7373
(a) contributes to the extended cache key -- isolating cache entries by
7474
claims value -- and (b) is stripped from the request body by the oauth2
@@ -1303,11 +1303,12 @@ def acquire_token_by_authorization_code(
13031303
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
13041304
It is a string of a JSON object which contains lists of claims being requested from these locations.
13051305
:param str forwarded_client_claims:
1306-
Optional. A JSON string of *client-originated* claims (for example
1307-
a network security perimeter ``xms_az_nwperimid`` claim) to include
1308-
in the token request. Unlike ``claims_challenge`` (server-issued,
1309-
which bypasses the cache), tokens acquired with ``forwarded_client_claims``
1310-
**are cached** and keyed on the claims value, so use stable,
1306+
Optional. A JSON string of *client-originated* claims to include in
1307+
the token request. Unlike ``claims_challenge`` (server-issued, which
1308+
bypasses the cache), tokens acquired with ``forwarded_client_claims``
1309+
**are cached** and keyed on the claims value. Send the *same* value on
1310+
every request that should share the cached token; omitting or changing
1311+
it routes to a different cache entry (a cache miss), so use stable,
13111312
non-dynamic values. The value is merged into the standard OAuth
13121313
``claims`` request parameter sent on the wire.
13131314
@@ -1588,11 +1589,11 @@ def acquire_token_silent_with_error(
15881589
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
15891590
It is a string of a JSON object which contains lists of claims being requested from these locations.
15901591
:param str forwarded_client_claims:
1591-
Optional. A JSON string of *client-originated* claims (for example
1592-
a network security perimeter ``xms_az_nwperimid`` claim) to include
1593-
when a cached token is missing and a network request is made. Tokens
1594-
are **cached** and keyed on the claims value (different values yield
1595-
separate cache entries), so use stable, non-dynamic values.
1592+
Optional. A JSON string of *client-originated* claims to include when
1593+
a cached token is missing and a network request is made. Tokens are
1594+
**cached** and keyed on the claims value (different values yield
1595+
separate cache entries), so send the *same* value on every call that
1596+
should reuse the cached token, and use stable, non-dynamic values.
15961597
15971598
Not to be confused with the constructor ``client_claims`` parameter
15981599
(a ``dict`` of extra claims signed into the client-assertion JWT).
@@ -2592,16 +2593,16 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, fmi_path=None,
25922593
)
25932594
:param str forwarded_client_claims:
25942595
Optional. A JSON string containing *client-originated* claims to
2595-
include in the token request (for example a network security
2596-
perimeter ``xms_az_nwperimid`` claim).
2596+
include in the token request.
25972597
25982598
Unlike ``claims_challenge`` (which carries *server-issued* claims
25992599
challenges and bypasses the cache), tokens acquired with
26002600
``forwarded_client_claims`` **are cached**, and the cache entry is keyed on the
2601-
claims value. Different ``forwarded_client_claims`` values produce separate
2602-
cache entries, so use stable, non-dynamic values to avoid unbounded
2603-
cache growth. The value is merged into the standard OAuth ``claims``
2604-
request parameter sent on the wire.
2601+
claims value. Send the *same* value on every request that should share
2602+
the cached token; different values produce separate cache entries, so
2603+
use stable, non-dynamic values to avoid unbounded cache growth. The
2604+
value is merged into the standard OAuth ``claims`` request parameter
2605+
sent on the wire.
26052606
26062607
Not to be confused with the constructor ``client_claims`` parameter
26072608
(a ``dict`` of extra claims signed into the client-assertion JWT).
@@ -2699,11 +2700,12 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
26992700
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
27002701
It is a string of a JSON object which contains lists of claims being requested from these locations.
27012702
:param str forwarded_client_claims:
2702-
Optional. A JSON string of *client-originated* claims (for example
2703-
a network security perimeter ``xms_az_nwperimid`` claim) to include
2704-
in the token request. Unlike ``claims_challenge`` (server-issued,
2705-
which bypasses the cache), tokens acquired with ``forwarded_client_claims``
2706-
**are cached** and keyed on the claims value, so use stable,
2703+
Optional. A JSON string of *client-originated* claims to include in
2704+
the token request. Unlike ``claims_challenge`` (server-issued, which
2705+
bypasses the cache), tokens acquired with ``forwarded_client_claims``
2706+
**are cached** and keyed on the claims value. Send the *same* value on
2707+
every request that should share the cached token; omitting or changing
2708+
it routes to a different cache entry (a cache miss), so use stable,
27072709
non-dynamic values. The value is merged into the standard OAuth
27082710
``claims`` request parameter sent on the wire.
27092711
@@ -2770,11 +2772,12 @@ def acquire_token_by_user_federated_identity_credential(
27702772
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
27712773
It is a string of a JSON object which contains lists of claims being requested from these locations.
27722774
:param str forwarded_client_claims:
2773-
Optional. A JSON string of *client-originated* claims (for example
2774-
a network security perimeter ``xms_az_nwperimid`` claim) to include
2775-
in the token request. Unlike ``claims_challenge`` (server-issued,
2776-
which bypasses the cache), tokens acquired with ``forwarded_client_claims``
2777-
**are cached** and keyed on the claims value, so use stable,
2775+
Optional. A JSON string of *client-originated* claims to include in
2776+
the token request. Unlike ``claims_challenge`` (server-issued, which
2777+
bypasses the cache), tokens acquired with ``forwarded_client_claims``
2778+
**are cached** and keyed on the claims value. Send the *same* value on
2779+
every request that should share the cached token; omitting or changing
2780+
it routes to a different cache entry (a cache miss), so use stable,
27782781
non-dynamic values. The value is merged into the standard OAuth
27792782
``claims`` request parameter sent on the wire.
27802783

msal/managed_identity.py

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ class ManagedIdentityError(ValueError):
2626
pass
2727

2828

29-
_XMS_AZ_NWPERIMID = "xms_az_nwperimid"
30-
3129
_CLIENT_CLAIMS_UNSUPPORTED_SOURCE = (
3230
"forwarded_client_claims is only supported for the IMDS (Azure VM) managed identity "
3331
"source. The detected source ({source}) does not support forwarding "
@@ -292,18 +290,17 @@ def acquire_token_for_client(
292290
:param forwarded_client_claims:
293291
Optional.
294292
A string representation of a JSON object containing
295-
*client-originated* claims to forward to the identity endpoint
296-
(for example a network security perimeter ``xms_az_nwperimid`` claim).
293+
*client-originated* claims to forward to the identity endpoint.
297294
298295
Unlike ``claims_challenge`` (server-issued, which bypasses the cache),
299296
tokens acquired with ``forwarded_client_claims`` **are cached**, and the cache
300-
entry is keyed on the claims value. Different ``forwarded_client_claims`` values
301-
produce separate cache entries, so use stable, non-dynamic values to
302-
avoid unbounded cache growth.
297+
entry is keyed on the claims value. Send the *same* value on every
298+
request that should share the cached token; different values produce
299+
separate cache entries, so use stable, non-dynamic values to avoid
300+
unbounded cache growth.
303301
304302
Only the IMDS (Azure VM) managed identity source supports this
305-
parameter; other sources raise an error. On IMDS v1, the claims JSON
306-
may contain only the ``xms_az_nwperimid`` key.
303+
parameter; other sources raise an error.
307304
308305
.. note::
309306
@@ -325,6 +322,9 @@ def acquire_token_for_client(
325322
"forwarded_client_claims must be a string, got {}".format(
326323
type(forwarded_client_claims).__name__))
327324
_parse_claims_or_raise(forwarded_client_claims) # Fail fast on malformed JSON
325+
# Reject unsupported sources before any cache read, so an unsupported
326+
# source never returns a cached client-claims token.
327+
_raise_if_claims_unsupported_source()
328328
# Client-originated claims isolate the cache: a distinct claims value gets
329329
# a distinct cache entry. (Server-issued claims_challenge, by contrast,
330330
# bypasses the cache and is keyed normally.)
@@ -447,6 +447,29 @@ def get_managed_identity_source():
447447
return DEFAULT_TO_VM
448448

449449

450+
# Managed-identity sources that cannot forward client-originated claims. Keep in
451+
# sync with the per-source guards inside _obtain_token (the backstop). Cloud Shell
452+
# is intentionally absent: it falls through to the Azure VM / IMDS path, which
453+
# does support claims.
454+
_CLIENT_CLAIMS_UNSUPPORTED_SOURCES = {
455+
SERVICE_FABRIC: "Service Fabric",
456+
APP_SERVICE: "App Service",
457+
MACHINE_LEARNING: "Machine Learning",
458+
AZURE_ARC: "Azure Arc",
459+
}
460+
461+
462+
def _raise_if_claims_unsupported_source():
463+
"""Fail fast -- before any cache read -- when the detected managed-identity
464+
source cannot forward client-originated claims. ``_obtain_token`` enforces the
465+
same rule per source as a backstop, but validating up front avoids a cache
466+
lookup (and returning a cached token) for an unsupported source."""
467+
name = _CLIENT_CLAIMS_UNSUPPORTED_SOURCES.get(get_managed_identity_source())
468+
if name:
469+
raise ManagedIdentityError(
470+
_CLIENT_CLAIMS_UNSUPPORTED_SOURCE.format(source=name))
471+
472+
450473
def _obtain_token(
451474
http_client, managed_identity, resource,
452475
*,
@@ -521,22 +544,6 @@ def _adjust_param(params, managed_identity, types_mapping=None):
521544
if id_name:
522545
params[id_name] = managed_identity[ManagedIdentity.ID]
523546

524-
def _validate_msiv1_claims(client_claims):
525-
"""MSIv1 (IMDS v1) only supports the single ``xms_az_nwperimid`` custom claim.
526-
527-
Any other top-level key makes IMDS return HTTP 400 with no useful diagnostic,
528-
so validate early and raise a clear error. Mirrors MSAL .NET's
529-
``AbstractManagedIdentity.ValidateMsiv1Claims``.
530-
"""
531-
parsed = _parse_claims_or_raise(client_claims)
532-
for key in parsed:
533-
if key != _XMS_AZ_NWPERIMID:
534-
raise ManagedIdentityError(
535-
"MSIv1 (IMDS v1) only supports the `{expected}` custom claim. "
536-
"The claims JSON contained the unsupported key `{actual}`. "
537-
"Remove all keys other than `{expected}` when using forwarded_client_claims "
538-
"with MSIv1.".format(expected=_XMS_AZ_NWPERIMID, actual=key))
539-
540547

541548
def _obtain_token_on_azure_vm(http_client, managed_identity, resource, client_claims=None):
542549
# Based on https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http
@@ -547,8 +554,8 @@ def _obtain_token_on_azure_vm(http_client, managed_identity, resource, client_cl
547554
}
548555
_adjust_param(params, managed_identity)
549556
if client_claims:
550-
# IMDS v1 (MSIv1) only supports the single xms_az_nwperimid claim.
551-
_validate_msiv1_claims(client_claims)
557+
# Forward client-originated claims as-is; IMDS decides which keys it
558+
# accepts (no client-side allow-list, matching the other MSALs).
552559
params["claims"] = client_claims # http_client.get url-encodes query params
553560
resp = http_client.get(
554561
os.getenv(

msal/token_cache.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +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-
sorted key+value pairs are concatenated and SHA256 hashed, then base64url encoded.
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.)
8895
"""
8996
if not data:
9097
return ""
@@ -94,9 +101,16 @@ def _compute_ext_cache_key(data):
94101
}
95102
if not cache_components:
96103
return ""
97-
# Sort keys for consistent hashing (matches Go implementation)
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.
98111
key_str = "".join(
99-
k + cache_components[k] for k in sorted(cache_components.keys())
112+
"{}:{}{}:{}".format(len(k), k, len(v), v)
113+
for k, v in sorted(cache_components.items())
100114
)
101115
hash_bytes = hashlib.sha256(key_str.encode("utf-8")).digest()
102116
return base64.urlsafe_b64encode(hash_bytes).rstrip(b"=").decode("ascii").lower()

tests/test_application.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,32 @@ def mock_post(url, headers=None, data=None, *args, **kwargs):
10631063
"claims_challenge, capabilities, and forwarded_client_claims must all merge")
10641064
self.assertNotIn("client_claims", captured_data)
10651065

1066-
def test_same_client_claims_returns_cached_token(self):
1066+
def test_forwarded_client_claims_win_on_leaf_conflict_with_challenge(self):
1067+
# If the server-issued claims_challenge and forwarded_client_claims set
1068+
# the SAME claim, the client-originated value wins (it is merged in last),
1069+
# while disjoint claims from the challenge are preserved. Documents the
1070+
# conflict-resolution behavior the other MSAL reviewers asked about.
1071+
app = self._build_app()
1072+
captured_data = {}
1073+
1074+
def mock_post(url, headers=None, data=None, *args, **kwargs):
1075+
captured_data.update(data or {})
1076+
return MinimalResponse(status_code=200, text=json.dumps({
1077+
"access_token": "an AT", "expires_in": 3600}))
1078+
1079+
challenge = ('{"access_token": {"acrs": {"values": ["server"]},'
1080+
' "nbf": {"essential": true}}}')
1081+
client = '{"access_token": {"acrs": {"values": ["client"]}}}'
1082+
app.acquire_token_for_client(
1083+
["scope"], claims_challenge=challenge,
1084+
forwarded_client_claims=client, post=mock_post)
1085+
merged = json.loads(captured_data["claims"])["access_token"]
1086+
self.assertEqual(
1087+
{"values": ["client"]}, merged["acrs"],
1088+
"forwarded_client_claims must win a direct leaf conflict")
1089+
self.assertEqual(
1090+
{"essential": True}, merged["nbf"],
1091+
"Disjoint claims from the challenge must be preserved")
10671092
app = self._build_app()
10681093
call_count = [0]
10691094

tests/test_mi.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,11 +288,18 @@ def test_no_claims_param_when_client_claims_absent(self):
288288
self.app.acquire_token_for_client(resource="R")
289289
self.assertNotIn("claims", mock_get.call_args.kwargs["params"])
290290

291-
def test_msiv1_rejects_non_nwperimid_claim(self):
292-
with self.assertRaises(ManagedIdentityError):
293-
self.app.acquire_token_for_client(
294-
resource="R",
295-
forwarded_client_claims='{"some_other_claim": {"essential": true}}')
291+
def test_non_nwperimid_claim_is_forwarded_not_rejected(self):
292+
# MSAL no longer enforces a client-side allow-list (matching the other
293+
# MSALs); any JSON-object claims value is forwarded as-is and IMDS decides
294+
# which keys it accepts.
295+
other = '{"some_other_claim": {"essential": true}}'
296+
with self._mock_get() as mock_get:
297+
result = self.app.acquire_token_for_client(
298+
resource="R", forwarded_client_claims=other)
299+
self.assertIn("access_token", result)
300+
self.assertEqual(
301+
other, mock_get.call_args.kwargs["params"].get("claims"),
302+
"Non-nwperimid claims must be forwarded, not rejected")
296303

297304
def test_invalid_json_claims_raises(self):
298305
for bad in ["not json", "[1, 2]", "null"]:

0 commit comments

Comments
 (0)