Skip to content

Commit 4661fc7

Browse files
committed
AT POP for Public Client based on broker
Pop test case
1 parent 545e856 commit 4661fc7

File tree

5 files changed

+144
-12
lines changed

5 files changed

+144
-12
lines changed

msal/application.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,7 @@ def acquire_token_silent(
12041204
authority=None, # See get_authorization_request_url()
12051205
force_refresh=False, # type: Optional[boolean]
12061206
claims_challenge=None,
1207+
auth_scheme=None,
12071208
**kwargs):
12081209
"""Acquire an access token for given account, without user interaction.
12091210
@@ -1224,6 +1225,12 @@ def acquire_token_silent(
12241225
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
12251226
It is a string of a JSON object which contains lists of claims being requested from these locations.
12261227
1228+
:param object auth_scheme:
1229+
You can provide an ``msal.auth_scheme.PopAuthScheme`` object
1230+
so that MSAL will get a Proof-of-Possession (POP) token for you.
1231+
1232+
New in version 1.21.0.
1233+
12271234
:return:
12281235
- A dict containing no "error" key,
12291236
and typically contains an "access_token" key,
@@ -1232,7 +1239,7 @@ def acquire_token_silent(
12321239
"""
12331240
result = self.acquire_token_silent_with_error(
12341241
scopes, account, authority=authority, force_refresh=force_refresh,
1235-
claims_challenge=claims_challenge, **kwargs)
1242+
claims_challenge=claims_challenge, auth_scheme=auth_scheme, **kwargs)
12361243
return result if result and "error" not in result else None
12371244

12381245
def acquire_token_silent_with_error(
@@ -1242,6 +1249,7 @@ def acquire_token_silent_with_error(
12421249
authority=None, # See get_authorization_request_url()
12431250
force_refresh=False, # type: Optional[boolean]
12441251
claims_challenge=None,
1252+
auth_scheme=None,
12451253
**kwargs):
12461254
"""Acquire an access token for given account, without user interaction.
12471255
@@ -1267,6 +1275,12 @@ def acquire_token_silent_with_error(
12671275
in the form of a claims_challenge directive in the www-authenticate header to be
12681276
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
12691277
It is a string of a JSON object which contains lists of claims being requested from these locations.
1278+
:param object auth_scheme:
1279+
You can provide an ``msal.auth_scheme.PopAuthScheme`` object
1280+
so that MSAL will get a Proof-of-Possession (POP) token for you.
1281+
1282+
New in version 1.21.0.
1283+
12701284
:return:
12711285
- A dict containing no "error" key,
12721286
and typically contains an "access_token" key,
@@ -1288,6 +1302,7 @@ def acquire_token_silent_with_error(
12881302
scopes, account, self.authority, force_refresh=force_refresh,
12891303
claims_challenge=claims_challenge,
12901304
correlation_id=correlation_id,
1305+
auth_scheme=auth_scheme,
12911306
**kwargs)
12921307
if result and "error" not in result:
12931308
return result
@@ -1310,6 +1325,7 @@ def acquire_token_silent_with_error(
13101325
scopes, account, the_authority, force_refresh=force_refresh,
13111326
claims_challenge=claims_challenge,
13121327
correlation_id=correlation_id,
1328+
auth_scheme=auth_scheme,
13131329
**kwargs)
13141330
if result:
13151331
if "error" not in result:
@@ -1333,9 +1349,10 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
13331349
force_refresh=False, # type: Optional[boolean]
13341350
claims_challenge=None,
13351351
correlation_id=None,
1352+
auth_scheme=None,
13361353
**kwargs):
13371354
access_token_from_cache = None
1338-
if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims
1355+
if not (force_refresh or claims_challenge or auth_scheme): # Then attempt AT cache
13391356
query={
13401357
"client_id": self.client_id,
13411358
"environment": authority.instance,
@@ -1373,6 +1390,8 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
13731390
try:
13741391
data = kwargs.get("data", {})
13751392
if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL:
1393+
if auth_scheme:
1394+
raise ValueError("auth_scheme is not supported in Cloud Shell")
13761395
return self._acquire_token_by_cloud_shell(scopes, data=data)
13771396

13781397
if self._enable_broker and account is not None and data.get("token_type") != "ssh-cert":
@@ -1385,10 +1404,13 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
13851404
claims=_merge_claims_challenge_and_capabilities(
13861405
self._client_capabilities, claims_challenge),
13871406
correlation_id=correlation_id,
1407+
auth_scheme=auth_scheme,
13881408
**data)
13891409
if response: # The broker provided a decisive outcome, so we use it
13901410
return self._process_broker_response(response, scopes, data)
13911411

1412+
if auth_scheme:
1413+
raise ValueError("auth_scheme is currently only available from broker")
13921414
result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
13931415
authority, self._decorate_scope(scopes), account,
13941416
refresh_reason=refresh_reason, claims_challenge=claims_challenge,
@@ -1569,7 +1591,11 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
15691591
return response
15701592

15711593
def acquire_token_by_username_password(
1572-
self, username, password, scopes, claims_challenge=None, **kwargs):
1594+
self, username, password, scopes, claims_challenge=None,
1595+
# Note: We shouldn't need to surface enable_msa_passthrough,
1596+
# because this ROPC won't work with MSA account anyway.
1597+
auth_scheme=None,
1598+
**kwargs):
15731599
"""Gets a token for a given resource via user credentials.
15741600
15751601
See this page for constraints of Username Password Flow.
@@ -1585,6 +1611,12 @@ def acquire_token_by_username_password(
15851611
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
15861612
It is a string of a JSON object which contains lists of claims being requested from these locations.
15871613
1614+
:param object auth_scheme:
1615+
You can provide an ``msal.auth_scheme.PopAuthScheme`` object
1616+
so that MSAL will get a Proof-of-Possession (POP) token for you.
1617+
1618+
New in version 1.21.0.
1619+
15881620
:return: A dict representing the json response from AAD:
15891621
15901622
- A successful response would contain "access_token" key,
@@ -1604,9 +1636,12 @@ def acquire_token_by_username_password(
16041636
self.authority._is_known_to_developer
16051637
or self._instance_discovery is False) else None,
16061638
claims=claims,
1639+
auth_scheme=auth_scheme,
16071640
)
16081641
return self._process_broker_response(response, scopes, kwargs.get("data", {}))
16091642

1643+
if auth_scheme:
1644+
raise ValueError("auth_scheme is currently only available from broker")
16101645
scopes = self._decorate_scope(scopes)
16111646
telemetry_context = self._build_telemetry_context(
16121647
self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID)
@@ -1698,6 +1733,7 @@ def acquire_token_interactive(
16981733
max_age=None,
16991734
parent_window_handle=None,
17001735
on_before_launching_ui=None,
1736+
auth_scheme=None,
17011737
**kwargs):
17021738
"""Acquire token interactively i.e. via a local browser.
17031739
@@ -1773,6 +1809,12 @@ def acquire_token_interactive(
17731809
17741810
New in version 1.20.0.
17751811
1812+
:param object auth_scheme:
1813+
You can provide an ``msal.auth_scheme.PopAuthScheme`` object
1814+
so that MSAL will get a Proof-of-Possession (POP) token for you.
1815+
1816+
New in version 1.21.0.
1817+
17761818
:return:
17771819
- A dict containing no "error" key,
17781820
and typically contains an "access_token" key.
@@ -1817,11 +1859,14 @@ def acquire_token_interactive(
18171859
claims,
18181860
data,
18191861
on_before_launching_ui,
1862+
auth_scheme,
18201863
prompt=prompt,
18211864
login_hint=login_hint,
18221865
max_age=max_age,
18231866
)
18241867

1868+
if auth_scheme:
1869+
raise ValueError("auth_scheme is currently only available from broker")
18251870
on_before_launching_ui(ui="browser")
18261871
telemetry_context = self._build_telemetry_context(
18271872
self.ACQUIRE_TOKEN_INTERACTIVE)
@@ -1854,6 +1899,7 @@ def _acquire_token_interactive_via_broker(
18541899
claims, # type: str
18551900
data, # type: dict
18561901
on_before_launching_ui, # type: callable
1902+
auth_scheme, # type: object
18571903
prompt=None,
18581904
login_hint=None, # type: Optional[str]
18591905
max_age=None,
@@ -1877,6 +1923,7 @@ def _acquire_token_interactive_via_broker(
18771923
accounts[0]["local_account_id"],
18781924
scopes,
18791925
claims=claims,
1926+
auth_scheme=auth_scheme,
18801927
**data)
18811928
if response and "error" not in response:
18821929
return self._process_broker_response(response, scopes, data)
@@ -1889,6 +1936,7 @@ def _acquire_token_interactive_via_broker(
18891936
claims=claims,
18901937
max_age=max_age,
18911938
enable_msa_pt=enable_msa_passthrough,
1939+
auth_scheme=auth_scheme,
18921940
**data)
18931941
is_wrong_account = bool(
18941942
# _signin_silently() only gets tokens for default account,
@@ -1931,6 +1979,7 @@ def _acquire_token_interactive_via_broker(
19311979
claims=claims,
19321980
max_age=max_age,
19331981
enable_msa_pt=enable_msa_passthrough,
1982+
auth_scheme=auth_scheme,
19341983
**data)
19351984
return self._process_broker_response(response, scopes, data)
19361985

msal/auth_scheme.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
try:
2+
from urllib.parse import urlparse
3+
except ImportError: # Fall back to Python 2
4+
from urlparse import urlparse
5+
6+
# We may support more auth schemes in the future
7+
class PopAuthScheme(object):
8+
# Internal design: https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PoPTokensProtocol/PopTokensProtocol.md
9+
def __init__(self, http_method=None, url=None, nonce=None):
10+
"""Create an auth scheme which is needed to obtain a Proof-of-Possession token.
11+
12+
:param str http_method:
13+
Its value is an uppercase http verb, such as "GET" and "POST".
14+
:param str url:
15+
The url to be signed.
16+
:param str nonce:
17+
The nonce came from resource's challenge.
18+
"""
19+
if not (http_method and url and nonce):
20+
# In the future, we may also support accepting an http_response as input
21+
raise ValueError("All http_method, url and nonce are required parameters")
22+
if http_method.upper() != http_method:
23+
raise ValueError("http_method must be uppercase, according to "
24+
"https://datatracker.ietf.org/doc/html/draft-ietf-oauth-signed-http-request-03#section-3")
25+
self._http_method = http_method
26+
self._url = urlparse(url)
27+
self._nonce = nonce
28+

msal/broker.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,17 @@ def _convert_result(result, client_id, expected_token_type=None): # Mimic an on
9292
assert account, "Account is expected to be always available"
9393
# Note: There are more account attribute getters available in pymsalruntime 0.13+
9494
return_value = {k: v for k, v in {
95-
"access_token": result.get_access_token(),
95+
"access_token":
96+
result.get_authorization_header() # It returns "pop SignedHttpRequest"
97+
.split()[1]
98+
if result.is_pop_authorization() else result.get_access_token(),
9699
"expires_in": result.get_access_token_expiry_time() - int(time.time()), # Convert epoch to count-down
97100
"id_token": result.get_raw_id_token(), # New in pymsalruntime 0.8.1
98101
"id_token_claims": id_token_claims,
99102
"client_info": account.get_client_info(),
100103
"_account_id": account.get_account_id(),
101-
"token_type": expected_token_type or "Bearer", # Workaround its absence from broker
104+
"token_type": "pop" if result.is_pop_authorization() else (
105+
expected_token_type or "bearer"), # Workaround "ssh-cert"'s absence from broker
102106
}.items() if v}
103107
likely_a_cert = return_value["access_token"].startswith("AAAA") # Empirical observation
104108
if return_value["token_type"].lower() == "ssh-cert" and not likely_a_cert:
@@ -121,11 +125,16 @@ def _enable_msa_pt(params):
121125
def _signin_silently(
122126
authority, client_id, scopes, correlation_id=None, claims=None,
123127
enable_msa_pt=False,
128+
auth_scheme=None,
124129
**kwargs):
125130
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
126131
params.set_requested_scopes(scopes)
127132
if claims:
128133
params.set_decoded_claims(claims)
134+
if auth_scheme:
135+
params.set_pop_params(
136+
auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path,
137+
auth_scheme._nonce)
129138
callback_data = _CallbackData()
130139
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
131140
if v is not None:
@@ -149,6 +158,7 @@ def _signin_interactively(
149158
claims=None,
150159
correlation_id=None,
151160
enable_msa_pt=False,
161+
auth_scheme=None,
152162
**kwargs):
153163
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
154164
params.set_requested_scopes(scopes)
@@ -171,6 +181,10 @@ def _signin_interactively(
171181
params.set_additional_parameter("msal_gui_thread", "true") # Since pymsalruntime 0.8.1
172182
if enable_msa_pt:
173183
_enable_msa_pt(params)
184+
if auth_scheme:
185+
params.set_pop_params(
186+
auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path,
187+
auth_scheme._nonce)
174188
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
175189
if v is not None:
176190
params.set_additional_parameter(k, str(v))
@@ -190,6 +204,7 @@ def _signin_interactively(
190204

191205
def _acquire_token_silently(
192206
authority, client_id, account_id, scopes, claims=None, correlation_id=None,
207+
auth_scheme=None,
193208
**kwargs):
194209
# For MSA PT scenario where you use the /organizations, yes,
195210
# acquireTokenSilently is expected to fail. - Sam Wilson
@@ -203,6 +218,10 @@ def _acquire_token_silently(
203218
params.set_requested_scopes(scopes)
204219
if claims:
205220
params.set_decoded_claims(claims)
221+
if auth_scheme:
222+
params.set_pop_params(
223+
auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path,
224+
auth_scheme._nonce)
206225
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
207226
if v is not None:
208227
params.set_additional_parameter(k, str(v))

tests/msaltest.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
import getpass, logging, pprint, sys, msal
2+
from msal.auth_scheme import PopAuthScheme
3+
4+
5+
placeholder_auth_scheme = PopAuthScheme(
6+
http_method="GET",
7+
url="https://example.com/endpoint",
8+
nonce="placeholder",
9+
)
210

311

412
AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
@@ -63,6 +71,7 @@ def acquire_token_silent(app):
6371
_input_scopes(),
6472
account=account,
6573
force_refresh=_input_boolean("Bypass MSAL Python's token cache?"),
74+
auth_scheme=placeholder_auth_scheme if app._enable_broker else None,
6675
))
6776

6877
def _acquire_token_interactive(app, scopes, data=None):
@@ -87,7 +96,9 @@ def _acquire_token_interactive(app, scopes, data=None):
8796
enable_msa_passthrough=app.client_id in [ # Apps are expected to set this right
8897
AZURE_CLI, VISUAL_STUDIO,
8998
], # Here this test app mimics the setting for some known MSA-PT apps
90-
prompt=prompt, login_hint=login_hint, data=data or {})
99+
prompt=prompt, login_hint=login_hint, data=data or {},
100+
auth_scheme=placeholder_auth_scheme if app._enable_broker else None,
101+
)
91102
if login_hint and "id_token_claims" in result:
92103
signed_in_user = result.get("id_token_claims", {}).get("preferred_username")
93104
if signed_in_user != login_hint:

0 commit comments

Comments
 (0)