77import warnings
88
99from .authority import canonicalize
10- from .oauth2cli .oidc import decode_part , decode_id_token
10+ from .oauth2cli .oidc import decode_part , _decode_id_token_claims
1111from .oauth2cli .oauth2 import Client
1212
1313
@@ -92,10 +92,22 @@ def _compute_ext_cache_key(data):
9292 This ensures tokens acquired with different parameter values
9393 (e.g., different FMI paths) are cached separately.
9494
95+ The hash may also intentionally include cache-key-only pseudo-parameters
96+ such as ``client_claims`` -- these are stripped from the wire body by the
97+ oauth2 layer but are retained in *data* precisely so that different
98+ client-originated claims route to separate cache entries.
99+
95100 Returns an empty string when *data* has no hashable fields.
96101
97- The algorithm matches the Go MSAL implementation (CacheExtKeyGenerator):
98- sorted key+value pairs are concatenated and SHA256 hashed, then base64url encoded.
102+ The algorithm matches MSAL .NET's ``ComputeAccessTokenExtCacheKey``: sorted
103+ key+value pairs are concatenated (no separators) and SHA256 hashed, then
104+ base64url encoded. This keeps the hash byte-identical to MSAL .NET.
105+
106+ MSAL Go's ``CacheExtKeyGenerator`` has since switched to a length-prefixed
107+ encoding (AzureAD/microsoft-authentication-library-for-go#629) to make it
108+ injective; Python deliberately tracks .NET instead, so these hashes are not
109+ byte-identical to current Go. Caches are not shared across languages, so the
110+ difference does not affect runtime correctness.
99111 """
100112 if not data :
101113 return ""
@@ -105,14 +117,71 @@ def _compute_ext_cache_key(data):
105117 }
106118 if not cache_components :
107119 return ""
108- # Sort keys for consistent hashing (matches Go implementation)
120+ # Sort keys, then concatenate key+value pairs with no separators. This
121+ # matches MSAL .NET's ComputeAccessTokenExtCacheKey byte-for-byte. (See the
122+ # docstring re: the Go #629 length-prefixed divergence.)
109123 key_str = "" .join (
110124 k + cache_components [k ] for k in sorted (cache_components .keys ())
111125 )
112126 hash_bytes = hashlib .sha256 (key_str .encode ("utf-8" )).digest ()
113127 return base64 .urlsafe_b64encode (hash_bytes ).rstrip (b"=" ).decode ("ascii" ).lower ()
114128
115129
130+ def _parse_claims_or_raise (claims ):
131+ """Parse a claims JSON string into a dict, or raise a friendly ``ValueError``.
132+
133+ The raw claims value is never included in the error message because it may
134+ contain sensitive data. Mirrors MSAL .NET's ``ClaimsHelper.ParseClaimsOrThrow``.
135+ """
136+ try :
137+ parsed = json .loads (claims )
138+ except (ValueError , TypeError ) as ex :
139+ # json.JSONDecodeError (malformed JSON) is a subclass of ValueError;
140+ # TypeError is raised when *claims* is not a str/bytes/bytearray. Both
141+ # are surfaced as the same friendly ValueError so every caller behaves
142+ # consistently regardless of the bad input's type.
143+ raise ValueError (
144+ "The claims value is not valid JSON. "
145+ "See https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter."
146+ ) from ex
147+ if not isinstance (parsed , dict ):
148+ # A valid JSON array, scalar, or the literal "null" is not a claims object.
149+ raise ValueError (
150+ "The claims value is not a valid JSON object. "
151+ "See https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter." )
152+ return parsed
153+
154+
155+ def _deep_merge_dict (base , overlay ):
156+ """Recursively merge ``overlay`` into ``base``, returning a new dict.
157+
158+ Nested dicts are merged; for any other value type, ``overlay`` wins.
159+ """
160+ result = dict (base )
161+ for key , value in overlay .items ():
162+ if (key in result
163+ and isinstance (result [key ], dict ) and isinstance (value , dict )):
164+ result [key ] = _deep_merge_dict (result [key ], value )
165+ else :
166+ result [key ] = value
167+ return result
168+
169+
170+ def _merge_claims (claims_a , claims_b ):
171+ """Merge two claims JSON strings into a single JSON string.
172+
173+ If either side is empty/None, the other is returned as-is. Mirrors MSAL
174+ .NET's ``ClaimsHelper.MergeClaimsObjects``.
175+ """
176+ if not claims_a :
177+ return claims_b
178+ if not claims_b :
179+ return claims_a
180+ merged = _deep_merge_dict (
181+ _parse_claims_or_raise (claims_a ), _parse_claims_or_raise (claims_b ))
182+ return json .dumps (merged )
183+
184+
116185def is_subdict_of (small , big ):
117186 return dict (big , ** small ) == big
118187
@@ -350,6 +419,11 @@ def make_clean_copy(dictionary, sensitive_fields): # Masks sensitive info
350419 data = make_clean_copy (event .get ("data" , {}), (
351420 "password" , "client_secret" , "refresh_token" , "assertion" ,
352421 "user_federated_identity_credential" ,
422+ # Client-originated claims may carry sensitive values; they are
423+ # kept in data only for ext_cache_key computation, so redact them
424+ # from the debug log (both the cache-key pseudo-param and the
425+ # merged wire parameter).
426+ "client_claims" , "claims" ,
353427 )),
354428 response = make_clean_copy (event .get ("response" , {}), (
355429 "id_token_claims" , # Provided by broker
@@ -392,8 +466,9 @@ def __add(self, event, now=None):
392466 refresh_token = response .get ("refresh_token" )
393467 id_token = response .get ("id_token" )
394468 id_token_claims = response .get ("id_token_claims" ) or ( # Prefer the claims from broker
395- # Only use decode_id_token() when necessary, it contains time-sensitive validation
396- decode_id_token (id_token , client_id = event ["client_id" ]) if id_token else {})
469+ # MSAL does not validate the ID token; it only decodes the claims.
470+ # https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/911
471+ _decode_id_token_claims (id_token ) if id_token else {})
397472 client_info , home_account_id = self .__parse_account (response , id_token_claims )
398473
399474 target = ' ' .join (sorted (event .get ("scope" ) or [])) # Schema should have required sorting
0 commit comments