11from __future__ import annotations
2+ import base64
3+ import datetime
24import functools
35import json
46import time
@@ -166,6 +168,17 @@ def _preferred_browser():
166168 return None
167169
168170
171+ def _build_req_cnf (jwk :dict , remove_padding :bool = False ) -> str :
172+ """req_cnf usually requires base64url encoding.
173+
174+ https://datatracker.ietf.org/doc/html/draft-ietf-oauth-pop-key-distribution-07#section-4.2.1
175+ https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/e967ebeb-9e9f-443e-857a-5208802943c2
176+ """
177+ raw = json .dumps (jwk )
178+ encoded = base64 .urlsafe_b64encode (raw .encode ('utf-8' )).decode ('utf-8' )
179+ return encoded .rstrip ('=' ) if remove_padding else encoded
180+
181+
169182class _ClientWithCcsRoutingInfo (Client ):
170183
171184 def initiate_auth_code_flow (self , ** kwargs ):
@@ -232,6 +245,7 @@ class ClientApplication(object):
232245 _TOKEN_SOURCE_IDP = "identity_provider"
233246 _TOKEN_SOURCE_CACHE = "cache"
234247 _TOKEN_SOURCE_BROKER = "broker"
248+ _XMS_DS_NONCE = "xms_ds_nonce"
235249
236250 _enable_broker = False
237251 _AUTH_SCHEME_UNSUPPORTED = (
@@ -241,8 +255,17 @@ class ClientApplication(object):
241255
242256 _TOKEN_CACHE_DATA : dict [str , str ] = { # field_in_data: field_in_cache
243257 "key_id" : "key_id" , # Some token types (SSH-certs, POP) are bound to a key
258+ "req_ds_cnf" : "req_ds_cnf" , # Used in CDT scenario
244259 }
245260
261+ @functools .lru_cache (maxsize = 2 )
262+ def __get_rsa_key (self , _bucket ): # _bucket is used with lru_cache pattern
263+ from .crypto import _generate_rsa_key
264+ return _generate_rsa_key ()
265+
266+ def _get_rsa_key (self , _bucket = None ): # Return the same RSA key, cached for a day
267+ return self .__get_rsa_key (_bucket or datetime .date .today ())
268+
246269 def __init__ (
247270 self , client_id ,
248271 client_credential = None , authority = None , validate_authority = True ,
@@ -656,7 +679,12 @@ def __init__(
656679
657680 self ._decide_broker (allow_broker , enable_pii_log )
658681 self .token_cache = token_cache or TokenCache ()
659- self .token_cache ._set (data_to_at = self ._TOKEN_CACHE_DATA )
682+ self .token_cache ._set (
683+ data_to_at = self ._TOKEN_CACHE_DATA ,
684+ response_to_at = { # field_in_resp: field_in_cache
685+ "xms_ds_nonce" : "xms_ds_nonce" ,
686+ },
687+ )
660688 self ._region_configured = azure_region
661689 self ._region_detected = None
662690 self .client , self ._regional_client = self ._build_client (
@@ -1559,6 +1587,9 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
15591587 "expires_in" : int (expires_in ), # OAuth2 specs defines it as int
15601588 self ._TOKEN_SOURCE : self ._TOKEN_SOURCE_CACHE ,
15611589 }
1590+ if self ._XMS_DS_NONCE in entry : # CDT needs this
1591+ access_token_from_cache [self ._XMS_DS_NONCE ] = entry [
1592+ self ._XMS_DS_NONCE ]
15621593 if "refresh_on" in entry :
15631594 access_token_from_cache ["refresh_on" ] = int (entry ["refresh_on" ])
15641595 if int (entry ["refresh_on" ]) < now : # aging
@@ -2347,7 +2378,16 @@ class ConfidentialClientApplication(ClientApplication): # server-side web app
23472378 except that ``allow_broker`` parameter shall remain ``None``.
23482379 """
23492380
2350- def acquire_token_for_client (self , scopes , claims_challenge = None , ** kwargs ):
2381+ def acquire_token_for_client (
2382+ self ,
2383+ scopes ,
2384+ claims_challenge = None ,
2385+ * ,
2386+ delegation_constraints : Optional [list ] = None ,
2387+ delegation_confirmation_key = None , # A Cyprtography's RSAPrivateKey-like object
2388+ # TODO: Support ECC key? https://github.com/pyca/cryptography/issues/4093
2389+ ** kwargs
2390+ ):
23512391 """Acquires token for the current confidential client, not for an end user.
23522392
23532393 Since MSAL Python 1.23, it will automatically look for token from cache,
@@ -2370,8 +2410,36 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
23702410 raise ValueError ( # We choose to disallow force_refresh
23712411 "Historically, this method does not support force_refresh behavior. "
23722412 )
2373- return _clean_up (self ._acquire_token_silent_with_error (
2374- scopes , None , claims_challenge = claims_challenge , ** kwargs ))
2413+ if delegation_constraints :
2414+ private_key = delegation_confirmation_key or self ._get_rsa_key ()
2415+ from .crypto import _convert_rsa_keys
2416+ _ , jwk = _convert_rsa_keys (private_key )
2417+ result = _clean_up (self ._acquire_token_silent_with_error (
2418+ scopes , None , claims_challenge = claims_challenge , data = dict (
2419+ kwargs .pop ("data" , {}),
2420+ req_ds_cnf = _build_req_cnf (jwk ) # It is part of token cache key
2421+ if delegation_constraints else None ,
2422+ ),
2423+ ** kwargs ))
2424+ if delegation_constraints and not result .get ("error" ):
2425+ if not result .get (self ._XMS_DS_NONCE ): # Available in cached token, too
2426+ raise ValueError (
2427+ "The resource did not opt in to xms_ds_cnf claim. "
2428+ "After its opt-in, call this function again with "
2429+ "a new app object or a new delegation_confirmation_key"
2430+ # in order to invalidate the token in cache
2431+ )
2432+ import jwt # Lazy loading
2433+ cdt_envelope = jwt .encode ({
2434+ "constraints" : delegation_constraints ,
2435+ self ._XMS_DS_NONCE : result [self ._XMS_DS_NONCE ],
2436+ }, private_key , algorithm = "PS256" )
2437+ result ["access_token" ] = jwt .encode ({
2438+ "t" : result ["access_token" ],
2439+ "c" : cdt_envelope ,
2440+ }, None , algorithm = None , headers = {"typ" : "cdt+jwt" })
2441+ del result [self ._XMS_DS_NONCE ] # Caller shouldn't need to know that
2442+ return result
23752443
23762444 def _acquire_token_for_client (
23772445 self ,
0 commit comments