Skip to content

Commit 53b9995

Browse files
authored
Client capabilities (#240)
1 parent f1243f2 commit 53b9995

File tree

2 files changed

+154
-14
lines changed

2 files changed

+154
-14
lines changed

msal/application.py

Lines changed: 111 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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:
@@ -261,6 +279,7 @@ def get_authorization_request_url(
261279
prompt=None,
262280
nonce=None,
263281
domain_hint=None, # type: Optional[str]
282+
claims_challenge=None,
264283
**kwargs):
265284
"""Constructs a URL for you to start a Authorization Code Grant.
266285
@@ -289,6 +308,12 @@ def get_authorization_request_url(
289308
More information on possible values
290309
`here <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code>`_ and
291310
`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+
292317
:return: The authorization url as a string.
293318
"""
294319
""" # TBD: this would only be meaningful in a new acquire_token_interactive()
@@ -321,6 +346,8 @@ def get_authorization_request_url(
321346
scope=decorate_scope(scopes, self.client_id),
322347
nonce=nonce,
323348
domain_hint=domain_hint,
349+
claims=_merge_claims_challenge_and_capabilities(
350+
self._client_capabilities, claims_challenge),
324351
)
325352

326353
def acquire_token_by_authorization_code(
@@ -332,6 +359,7 @@ def acquire_token_by_authorization_code(
332359
# authorization request as described in Section 4.1.1, and their
333360
# values MUST be identical.
334361
nonce=None,
362+
claims_challenge=None,
335363
**kwargs):
336364
"""The second half of the Authorization Code Grant.
337365
@@ -357,6 +385,12 @@ def acquire_token_by_authorization_code(
357385
same nonce should also be provided here, so that we'll validate it.
358386
An exception will be raised if the nonce in id token mismatches.
359387
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+
360394
:return: A dict representing the json response from AAD:
361395
362396
- A successful response would contain "access_token" key,
@@ -377,6 +411,10 @@ def acquire_token_by_authorization_code(
377411
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
378412
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID),
379413
},
414+
data=dict(
415+
kwargs.pop("data", {}),
416+
claims=_merge_claims_challenge_and_capabilities(
417+
self._client_capabilities, claims_challenge)),
380418
nonce=nonce,
381419
**kwargs)
382420

@@ -479,6 +517,7 @@ def acquire_token_silent(
479517
account, # type: Optional[Account]
480518
authority=None, # See get_authorization_request_url()
481519
force_refresh=False, # type: Optional[boolean]
520+
claims_challenge=None,
482521
**kwargs):
483522
"""Acquire an access token for given account, without user interaction.
484523
@@ -493,14 +532,21 @@ def acquire_token_silent(
493532
494533
Internally, this method calls :func:`~acquire_token_silent_with_error`.
495534
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+
496541
:return:
497542
- A dict containing no "error" key,
498543
and typically contains an "access_token" key,
499544
if cache lookup succeeded.
500545
- None when cache lookup does not yield a token.
501546
"""
502547
result = self.acquire_token_silent_with_error(
503-
scopes, account, authority, force_refresh, **kwargs)
548+
scopes, account, authority, force_refresh,
549+
claims_challenge=claims_challenge, **kwargs)
504550
return result if result and "error" not in result else None
505551

506552
def acquire_token_silent_with_error(
@@ -509,6 +555,7 @@ def acquire_token_silent_with_error(
509555
account, # type: Optional[Account]
510556
authority=None, # See get_authorization_request_url()
511557
force_refresh=False, # type: Optional[boolean]
558+
claims_challenge=None,
512559
**kwargs):
513560
"""Acquire an access token for given account, without user interaction.
514561
@@ -529,6 +576,11 @@ def acquire_token_silent_with_error(
529576
:param force_refresh:
530577
If True, it will skip Access Token look-up,
531578
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.
532584
:return:
533585
- A dict containing no "error" key,
534586
and typically contains an "access_token" key,
@@ -547,6 +599,7 @@ def acquire_token_silent_with_error(
547599
# ) if authority else self.authority
548600
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
549601
scopes, account, self.authority, force_refresh=force_refresh,
602+
claims_challenge=claims_challenge,
550603
correlation_id=correlation_id,
551604
**kwargs)
552605
if result and "error" not in result:
@@ -567,6 +620,7 @@ def acquire_token_silent_with_error(
567620
validate_authority=False)
568621
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
569622
scopes, account, the_authority, force_refresh=force_refresh,
623+
claims_challenge=claims_challenge,
570624
correlation_id=correlation_id,
571625
**kwargs)
572626
if result:
@@ -589,8 +643,9 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
589643
account, # type: Optional[Account]
590644
authority, # This can be different than self.authority
591645
force_refresh=False, # type: Optional[boolean]
646+
claims_challenge=None,
592647
**kwargs):
593-
if not force_refresh:
648+
if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims
594649
query={
595650
"client_id": self.client_id,
596651
"environment": authority.instance,
@@ -617,7 +672,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
617672
}
618673
return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
619674
authority, decorate_scope(scopes, self.client_id), account,
620-
force_refresh=force_refresh, **kwargs)
675+
force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs)
621676

622677
def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
623678
self, authority, scopes, account, **kwargs):
@@ -666,7 +721,7 @@ def _get_app_metadata(self, environment):
666721
def _acquire_token_silent_by_finding_specific_refresh_token(
667722
self, authority, scopes, query,
668723
rt_remover=None, break_condition=lambda response: False,
669-
force_refresh=False, correlation_id=None, **kwargs):
724+
force_refresh=False, correlation_id=None, claims_challenge=None, **kwargs):
670725
matches = self.token_cache.find(
671726
self.token_cache.CredentialType.REFRESH_TOKEN,
672727
# target=scopes, # AAD RTs are scope-independent
@@ -686,6 +741,10 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
686741
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
687742
self.ACQUIRE_TOKEN_SILENT_ID, force_refresh=force_refresh),
688743
},
744+
data=dict(
745+
kwargs.pop("data", {}),
746+
claims=_merge_claims_challenge_and_capabilities(
747+
self._client_capabilities, claims_challenge)),
689748
**kwargs)
690749
if "error" not in response:
691750
return response
@@ -780,14 +839,19 @@ def initiate_device_flow(self, scopes=None, **kwargs):
780839
flow[self.DEVICE_FLOW_CORRELATION_ID] = correlation_id
781840
return flow
782841

783-
def acquire_token_by_device_flow(self, flow, **kwargs):
842+
def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
784843
"""Obtain token by a device flow object, with customizable polling effect.
785844
786845
:param dict flow:
787846
A dict previously generated by :func:`~initiate_device_flow`.
788847
By default, this method's polling effect will block current thread.
789848
You can abort the polling loop at any time,
790849
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.
791855
792856
:return: A dict representing the json response from AAD:
793857
@@ -796,10 +860,14 @@ def acquire_token_by_device_flow(self, flow, **kwargs):
796860
"""
797861
return self.client.obtain_token_by_device_flow(
798862
flow,
799-
data=dict(kwargs.pop("data", {}), code=flow["device_code"]),
800-
# 2018-10-4 Hack:
801-
# during transition period,
802-
# 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+
),
803871
headers={
804872
CLIENT_REQUEST_ID:
805873
flow.get(self.DEVICE_FLOW_CORRELATION_ID) or _get_new_correlation_id(),
@@ -809,7 +877,7 @@ def acquire_token_by_device_flow(self, flow, **kwargs):
809877
**kwargs)
810878

811879
def acquire_token_by_username_password(
812-
self, username, password, scopes, **kwargs):
880+
self, username, password, scopes, claims_challenge=None, **kwargs):
813881
"""Gets a token for a given resource via user credentials.
814882
815883
See this page for constraints of Username Password Flow.
@@ -819,6 +887,11 @@ def acquire_token_by_username_password(
819887
:param str password: The password.
820888
:param list[str] scopes:
821889
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.
822895
823896
:return: A dict representing the json response from AAD:
824897
@@ -831,16 +904,22 @@ def acquire_token_by_username_password(
831904
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
832905
self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID),
833906
}
907+
data = dict(
908+
kwargs.pop("data", {}),
909+
claims=_merge_claims_challenge_and_capabilities(
910+
self._client_capabilities, claims_challenge))
834911
if not self.authority.is_adfs:
835912
user_realm_result = self.authority.user_realm_discovery(
836913
username, correlation_id=headers[CLIENT_REQUEST_ID])
837914
if user_realm_result.get("account_type") == "Federated":
838915
return self._acquire_token_by_username_password_federated(
839916
user_realm_result, username, password, scopes=scopes,
917+
data=data,
840918
headers=headers, **kwargs)
841919
return self.client.obtain_token_by_username_password(
842920
username, password, scope=scopes,
843921
headers=headers,
922+
data=data,
844923
**kwargs)
845924

846925
def _acquire_token_by_username_password_federated(
@@ -882,11 +961,16 @@ def _acquire_token_by_username_password_federated(
882961

883962
class ConfidentialClientApplication(ClientApplication): # server-side web app
884963

885-
def acquire_token_for_client(self, scopes, **kwargs):
964+
def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
886965
"""Acquires token for the current confidential client, not for an end user.
887966
888967
:param list[str] scopes: (Required)
889968
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.
890974
891975
:return: A dict representing the json response from AAD:
892976
@@ -901,9 +985,13 @@ def acquire_token_for_client(self, scopes, **kwargs):
901985
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
902986
self.ACQUIRE_TOKEN_FOR_CLIENT_ID),
903987
},
988+
data=dict(
989+
kwargs.pop("data", {}),
990+
claims=_merge_claims_challenge_and_capabilities(
991+
self._client_capabilities, claims_challenge)),
904992
**kwargs)
905993

906-
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):
907995
"""Acquires token using on-behalf-of (OBO) flow.
908996
909997
The current app is a middle-tier service which was called with a token
@@ -918,6 +1006,11 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs):
9181006
9191007
:param str user_assertion: The incoming token already received by this app
9201008
: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..
9211014
9221015
:return: A dict representing the json response from AAD:
9231016
@@ -935,7 +1028,11 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, **kwargs):
9351028
# 2. Requesting an IDT (which would otherwise be unavailable)
9361029
# so that the calling app could use id_token_claims to implement
9371030
# their own cache mapping, which is likely needed in web apps.
938-
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)),
9391036
headers={
9401037
CLIENT_REQUEST_ID: _get_new_correlation_id(),
9411038
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(

0 commit comments

Comments
 (0)