Skip to content

Commit c0375cf

Browse files
authored
Merge pull request #252 from AzureAD/release-1.5.0
MSAL Python 1.5.0
2 parents f285074 + 9ea3a6d commit c0375cf

File tree

5 files changed

+173
-26
lines changed

5 files changed

+173
-26
lines changed

msal/application.py

Lines changed: 113 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222

2323
# The __init__.py will import this. Not the other way around.
24-
__version__ = "1.4.3"
24+
__version__ = "1.5.0"
2525

2626
logger = logging.getLogger(__name__)
2727

@@ -79,6 +79,17 @@ def extract_certs(public_cert_content):
7979
return [public_cert_content.strip()]
8080

8181

82+
def _merge_claims_challenge_and_capabilities(capabilities, claims_challenge):
83+
# Represent capabilities as {"access_token": {"xms_cc": {"values": capabilities}}}
84+
# and then merge/add it into incoming claims
85+
if not capabilities:
86+
return claims_challenge
87+
claims_dict = json.loads(claims_challenge) if claims_challenge else {}
88+
for key in ["access_token"]: # We could add "id_token" if we'd decide to
89+
claims_dict.setdefault(key, {}).update(xms_cc={"values": capabilities})
90+
return json.dumps(claims_dict)
91+
92+
8293
class ClientApplication(object):
8394

8495
ACQUIRE_TOKEN_SILENT_ID = "84"
@@ -97,7 +108,8 @@ def __init__(
97108
token_cache=None,
98109
http_client=None,
99110
verify=True, proxies=None, timeout=None,
100-
client_claims=None, app_name=None, app_version=None):
111+
client_claims=None, app_name=None, app_version=None,
112+
client_capabilities=None):
101113
"""Create an instance of application.
102114
103115
:param str client_id: Your app has a client_id after you register it on AAD.
@@ -179,10 +191,16 @@ def __init__(
179191
:param app_version: (optional)
180192
You can provide your application version for Microsoft telemetry purposes.
181193
Default value is None, means it will not be passed to Microsoft.
194+
:param list[str] client_capabilities: (optional)
195+
Allows configuration of one or more client capabilities, e.g. ["CP1"].
196+
MSAL will combine them into
197+
`claims parameter <https://openid.net/specs/openid-connect-core-1_0-final.html#ClaimsParameter`_
198+
which you will later provide via one of the acquire-token request.
182199
"""
183200
self.client_id = client_id
184201
self.client_credential = client_credential
185202
self.client_claims = client_claims
203+
self._client_capabilities = client_capabilities
186204
if http_client:
187205
self.http_client = http_client
188206
else:
@@ -235,6 +253,7 @@ def _build_client(self, client_credential, authority):
235253
"authorization_endpoint": authority.authorization_endpoint,
236254
"token_endpoint": authority.token_endpoint,
237255
"device_authorization_endpoint":
256+
authority.device_authorization_endpoint or
238257
urljoin(authority.token_endpoint, "devicecode"),
239258
}
240259
return Client(
@@ -260,6 +279,7 @@ def get_authorization_request_url(
260279
prompt=None,
261280
nonce=None,
262281
domain_hint=None, # type: Optional[str]
282+
claims_challenge=None,
263283
**kwargs):
264284
"""Constructs a URL for you to start a Authorization Code Grant.
265285
@@ -288,6 +308,12 @@ def get_authorization_request_url(
288308
More information on possible values
289309
`here <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code>`_ and
290310
`here <https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8>`_.
311+
:param claims_challenge:
312+
The claims_challenge parameter requests specific claims requested by the resource provider
313+
in the form of a claims_challenge directive in the www-authenticate header to be
314+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
315+
It is a string of a JSON object which contains lists of claims being requested from these locations.
316+
291317
:return: The authorization url as a string.
292318
"""
293319
""" # TBD: this would only be meaningful in a new acquire_token_interactive()
@@ -320,6 +346,8 @@ def get_authorization_request_url(
320346
scope=decorate_scope(scopes, self.client_id),
321347
nonce=nonce,
322348
domain_hint=domain_hint,
349+
claims=_merge_claims_challenge_and_capabilities(
350+
self._client_capabilities, claims_challenge),
323351
)
324352

325353
def acquire_token_by_authorization_code(
@@ -331,6 +359,7 @@ def acquire_token_by_authorization_code(
331359
# authorization request as described in Section 4.1.1, and their
332360
# values MUST be identical.
333361
nonce=None,
362+
claims_challenge=None,
334363
**kwargs):
335364
"""The second half of the Authorization Code Grant.
336365
@@ -356,6 +385,12 @@ def acquire_token_by_authorization_code(
356385
same nonce should also be provided here, so that we'll validate it.
357386
An exception will be raised if the nonce in id token mismatches.
358387
388+
:param claims_challenge:
389+
The claims_challenge parameter requests specific claims requested by the resource provider
390+
in the form of a claims_challenge directive in the www-authenticate header to be
391+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
392+
It is a string of a JSON object which contains lists of claims being requested from these locations.
393+
359394
:return: A dict representing the json response from AAD:
360395
361396
- A successful response would contain "access_token" key,
@@ -376,6 +411,10 @@ def acquire_token_by_authorization_code(
376411
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
377412
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID),
378413
},
414+
data=dict(
415+
kwargs.pop("data", {}),
416+
claims=_merge_claims_challenge_and_capabilities(
417+
self._client_capabilities, claims_challenge)),
379418
nonce=nonce,
380419
**kwargs)
381420

@@ -478,6 +517,7 @@ def acquire_token_silent(
478517
account, # type: Optional[Account]
479518
authority=None, # See get_authorization_request_url()
480519
force_refresh=False, # type: Optional[boolean]
520+
claims_challenge=None,
481521
**kwargs):
482522
"""Acquire an access token for given account, without user interaction.
483523
@@ -492,14 +532,21 @@ def acquire_token_silent(
492532
493533
Internally, this method calls :func:`~acquire_token_silent_with_error`.
494534
535+
:param claims_challenge:
536+
The claims_challenge parameter requests specific claims requested by the resource provider
537+
in the form of a claims_challenge directive in the www-authenticate header to be
538+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
539+
It is a string of a JSON object which contains lists of claims being requested from these locations.
540+
495541
:return:
496542
- A dict containing no "error" key,
497543
and typically contains an "access_token" key,
498544
if cache lookup succeeded.
499545
- None when cache lookup does not yield a token.
500546
"""
501547
result = self.acquire_token_silent_with_error(
502-
scopes, account, authority, force_refresh, **kwargs)
548+
scopes, account, authority, force_refresh,
549+
claims_challenge=claims_challenge, **kwargs)
503550
return result if result and "error" not in result else None
504551

505552
def acquire_token_silent_with_error(
@@ -508,6 +555,7 @@ def acquire_token_silent_with_error(
508555
account, # type: Optional[Account]
509556
authority=None, # See get_authorization_request_url()
510557
force_refresh=False, # type: Optional[boolean]
558+
claims_challenge=None,
511559
**kwargs):
512560
"""Acquire an access token for given account, without user interaction.
513561
@@ -528,6 +576,11 @@ def acquire_token_silent_with_error(
528576
:param force_refresh:
529577
If True, it will skip Access Token look-up,
530578
and try to find a Refresh Token to obtain a new Access Token.
579+
:param claims_challenge:
580+
The claims_challenge parameter requests specific claims requested by the resource provider
581+
in the form of a claims_challenge directive in the www-authenticate header to be
582+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
583+
It is a string of a JSON object which contains lists of claims being requested from these locations.
531584
:return:
532585
- A dict containing no "error" key,
533586
and typically contains an "access_token" key,
@@ -546,6 +599,7 @@ def acquire_token_silent_with_error(
546599
# ) if authority else self.authority
547600
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
548601
scopes, account, self.authority, force_refresh=force_refresh,
602+
claims_challenge=claims_challenge,
549603
correlation_id=correlation_id,
550604
**kwargs)
551605
if result and "error" not in result:
@@ -566,6 +620,7 @@ def acquire_token_silent_with_error(
566620
validate_authority=False)
567621
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
568622
scopes, account, the_authority, force_refresh=force_refresh,
623+
claims_challenge=claims_challenge,
569624
correlation_id=correlation_id,
570625
**kwargs)
571626
if result:
@@ -588,8 +643,9 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
588643
account, # type: Optional[Account]
589644
authority, # This can be different than self.authority
590645
force_refresh=False, # type: Optional[boolean]
646+
claims_challenge=None,
591647
**kwargs):
592-
if not force_refresh:
648+
if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims
593649
query={
594650
"client_id": self.client_id,
595651
"environment": authority.instance,
@@ -616,7 +672,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
616672
}
617673
return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
618674
authority, decorate_scope(scopes, self.client_id), account,
619-
force_refresh=force_refresh, **kwargs)
675+
force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs)
620676

621677
def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
622678
self, authority, scopes, account, **kwargs):
@@ -665,7 +721,7 @@ def _get_app_metadata(self, environment):
665721
def _acquire_token_silent_by_finding_specific_refresh_token(
666722
self, authority, scopes, query,
667723
rt_remover=None, break_condition=lambda response: False,
668-
force_refresh=False, correlation_id=None, **kwargs):
724+
force_refresh=False, correlation_id=None, claims_challenge=None, **kwargs):
669725
matches = self.token_cache.find(
670726
self.token_cache.CredentialType.REFRESH_TOKEN,
671727
# target=scopes, # AAD RTs are scope-independent
@@ -685,6 +741,10 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
685741
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
686742
self.ACQUIRE_TOKEN_SILENT_ID, force_refresh=force_refresh),
687743
},
744+
data=dict(
745+
kwargs.pop("data", {}),
746+
claims=_merge_claims_challenge_and_capabilities(
747+
self._client_capabilities, claims_challenge)),
688748
**kwargs)
689749
if "error" not in response:
690750
return response
@@ -779,14 +839,19 @@ def initiate_device_flow(self, scopes=None, **kwargs):
779839
flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id
780840
return flow
781841

782-
def acquire_token_by_device_flow(self, flow, **kwargs):
842+
def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
783843
"""Obtain token by a device flow object, with customizable polling effect.
784844
785845
:param dict flow:
786846
A dict previously generated by :func:`~initiate_device_flow`.
787847
By default, this method's polling effect will block current thread.
788848
You can abort the polling loop at any time,
789849
by changing the value of the flow's "expires_at" key to 0.
850+
:param claims_challenge:
851+
The claims_challenge parameter requests specific claims requested by the resource provider
852+
in the form of a claims_challenge directive in the www-authenticate header to be
853+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
854+
It is a string of a JSON object which contains lists of claims being requested from these locations.
790855
791856
:return: A dict representing the json response from AAD:
792857
@@ -795,10 +860,14 @@ def acquire_token_by_device_flow(self, flow, **kwargs):
795860
"""
796861
return self.client.obtain_token_by_device_flow(
797862
flow,
798-
data=dict(kwargs.pop("data", {}), code=flow["device_code"]),
799-
# 2018-10-4 Hack:
800-
# during transition period,
801-
# service seemingly need both device_code and code parameter.
863+
data=dict(
864+
kwargs.pop("data", {}),
865+
code=flow["device_code"], # 2018-10-4 Hack:
866+
# during transition period,
867+
# service seemingly need both device_code and code parameter.
868+
claims=_merge_claims_challenge_and_capabilities(
869+
self._client_capabilities, claims_challenge),
870+
),
802871
headers={
803872
CLIENT_REQUEST_ID:
804873
flow.get(self.DEVICE_FLOW_CORRELATION_ID) or _get_new_correlation_id(),
@@ -808,7 +877,7 @@ def acquire_token_by_device_flow(self, flow, **kwargs):
808877
**kwargs)
809878

810879
def acquire_token_by_username_password(
811-
self, username, password, scopes, **kwargs):
880+
self, username, password, scopes, claims_challenge=None, **kwargs):
812881
"""Gets a token for a given resource via user credentials.
813882
814883
See this page for constraints of Username Password Flow.
@@ -818,6 +887,11 @@ def acquire_token_by_username_password(
818887
:param str password: The password.
819888
:param list[str] scopes:
820889
Scopes requested to access a protected API (a resource).
890+
:param claims_challenge:
891+
The claims_challenge parameter requests specific claims requested by the resource provider
892+
in the form of a claims_challenge directive in the www-authenticate header to be
893+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
894+
It is a string of a JSON object which contains lists of claims being requested from these locations.
821895
822896
:return: A dict representing the json response from AAD:
823897
@@ -830,16 +904,22 @@ def acquire_token_by_username_password(
830904
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
831905
self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID),
832906
}
907+
data = dict(
908+
kwargs.pop("data", {}),
909+
claims=_merge_claims_challenge_and_capabilities(
910+
self._client_capabilities, claims_challenge))
833911
if not self.authority.is_adfs:
834912
user_realm_result = self.authority.user_realm_discovery(
835913
username, correlation_id=headers[CLIENT_REQUEST_ID])
836914
if user_realm_result.get("account_type") == "Federated":
837915
return self._acquire_token_by_username_password_federated(
838916
user_realm_result, username, password, scopes=scopes,
917+
data=data,
839918
headers=headers, **kwargs)
840919
return self.client.obtain_token_by_username_password(
841920
username, password, scope=scopes,
842921
headers=headers,
922+
data=data,
843923
**kwargs)
844924

845925
def _acquire_token_by_username_password_federated(
@@ -881,11 +961,16 @@ def _acquire_token_by_username_password_federated(
881961

882962
class ConfidentialClientApplication(ClientApplication): # server-side web app
883963

884-
def acquire_token_for_client(self, scopes, **kwargs):
964+
def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
885965
"""Acquires token for the current confidential client, not for an end user.
886966
887967
:param list[str] scopes: (Required)
888968
Scopes requested to access a protected API (a resource).
969+
:param claims_challenge:
970+
The claims_challenge parameter requests specific claims requested by the resource provider
971+
in the form of a claims_challenge directive in the www-authenticate header to be
972+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
973+
It is a string of a JSON object which contains lists of claims being requested from these locations.
889974
890975
:return: A dict representing the json response from AAD:
891976
@@ -900,9 +985,13 @@ def acquire_token_for_client(self, scopes, **kwargs):
900985
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
901986
self.ACQUIRE_TOKEN_FOR_CLIENT_ID),
902987
},
988+
data=dict(
989+
kwargs.pop("data", {}),
990+
claims=_merge_claims_challenge_and_capabilities(
991+
self._client_capabilities, claims_challenge)),
903992
**kwargs)
904993

905-
def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs):
994+
def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=None, **kwargs):
906995
"""Acquires token using on-behalf-of (OBO) flow.
907996
908997
The current app is a middle-tier service which was called with a token
@@ -917,6 +1006,11 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs):
9171006
9181007
:param str user_assertion: The incoming token already received by this app
9191008
:param list[str] scopes: Scopes required by downstream API (a resource).
1009+
:param claims_challenge:
1010+
The claims_challenge parameter requests specific claims requested by the resource provider
1011+
in the form of a claims_challenge directive in the www-authenticate header to be
1012+
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
1013+
It is a string of a JSON object which contains lists of claims being requested from these locations.
9201014
9211015
:return: A dict representing the json response from AAD:
9221016
@@ -934,7 +1028,11 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs):
9341028
# 2. Requesting an IDT (which would otherwise be unavailable)
9351029
# so that the calling app could use id_token_claims to implement
9361030
# their own cache mapping, which is likely needed in web apps.
937-
data=dict(kwargs.pop("data", {}), requested_token_use="on_behalf_of"),
1031+
data=dict(
1032+
kwargs.pop("data", {}),
1033+
requested_token_use="on_behalf_of",
1034+
claims=_merge_claims_challenge_and_capabilities(
1035+
self._client_capabilities, claims_challenge)),
9381036
headers={
9391037
CLIENT_REQUEST_ID: _get_new_correlation_id(),
9401038
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(

msal/authority.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def __init__(self, authority_url, http_client, validate_authority=True):
9292
logger.debug("openid_config = %s", openid_config)
9393
self.authorization_endpoint = openid_config['authorization_endpoint']
9494
self.token_endpoint = openid_config['token_endpoint']
95+
self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint')
9596
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
9697
self.is_adfs = self.tenant.lower() == 'adfs'
9798

0 commit comments

Comments
 (0)