Skip to content

Commit 607e702

Browse files
authored
AT POP for Public Client based on broker (#511)
* AT POP for Public Client based on broker Pop test case * Use token source during e2e tests * WIP: unsuccessful e2e test for POP SHR
1 parent 460dc66 commit 607e702

File tree

6 files changed

+283
-38
lines changed

6 files changed

+283
-38
lines changed

msal/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@
3333
)
3434
from .oauth2cli.oidc import Prompt
3535
from .token_cache import TokenCache, SerializableTokenCache
36+
from .auth_scheme import PopAuthScheme
3637

msal/__main__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222

2323
_AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
2424
_VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd"
25+
placeholder_auth_scheme = msal.PopAuthScheme(
26+
http_method=msal.PopAuthScheme.HTTP_GET,
27+
url="https://example.com/endpoint",
28+
nonce="placeholder",
29+
)
2530

2631
def print_json(blob):
2732
print(json.dumps(blob, indent=2, sort_keys=True))
@@ -88,6 +93,9 @@ def _acquire_token_silent(app):
8893
_input_scopes(),
8994
account=account,
9095
force_refresh=_input_boolean("Bypass MSAL Python's token cache?"),
96+
auth_scheme=placeholder_auth_scheme
97+
if app.is_pop_supported() and _input_boolean("Acquire AT POP via Broker?")
98+
else None,
9199
))
92100

93101
def _acquire_token_interactive(app, scopes=None, data=None):
@@ -117,6 +125,9 @@ def _acquire_token_interactive(app, scopes=None, data=None):
117125
], # Here this test app mimics the setting for some known MSA-PT apps
118126
port=1234, # Hard coded for testing. Real app typically uses default value.
119127
prompt=prompt, login_hint=login_hint, data=data or {},
128+
auth_scheme=placeholder_auth_scheme
129+
if app.is_pop_supported() and _input_boolean("Acquire AT POP via Broker?")
130+
else None,
120131
)
121132
if login_hint and "id_token_claims" in result:
122133
signed_in_user = result.get("id_token_claims", {}).get("preferred_username")

msal/application.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ class ClientApplication(object):
182182
_TOKEN_SOURCE_BROKER = "broker"
183183

184184
_enable_broker = False
185+
_AUTH_SCHEME_UNSUPPORTED = (
186+
"auth_scheme is currently only available from broker. "
187+
"You can enable broker by following these instructions. "
188+
"https://msal-python.readthedocs.io/en/latest/#publicclientapplication")
185189

186190
def __init__(
187191
self, client_id,
@@ -557,6 +561,10 @@ def _decide_broker(self, allow_broker, enable_pii_log):
557561
"We will fallback to non-broker.")
558562
logger.debug("Broker enabled? %s", self._enable_broker)
559563

564+
def is_pop_supported(self):
565+
"""Returns True if this client supports Proof-of-Possession Access Token."""
566+
return self._enable_broker
567+
560568
def _decorate_scope(
561569
self, scopes,
562570
reserved_scope=frozenset(['openid', 'profile', 'offline_access'])):
@@ -1185,6 +1193,7 @@ def acquire_token_silent(
11851193
authority=None, # See get_authorization_request_url()
11861194
force_refresh=False, # type: Optional[boolean]
11871195
claims_challenge=None,
1196+
auth_scheme=None,
11881197
**kwargs):
11891198
"""Acquire an access token for given account, without user interaction.
11901199
@@ -1205,7 +1214,7 @@ def acquire_token_silent(
12051214
return None # A backward-compatible NO-OP to drop the account=None usage
12061215
result = _clean_up(self._acquire_token_silent_with_error(
12071216
scopes, account, authority=authority, force_refresh=force_refresh,
1208-
claims_challenge=claims_challenge, **kwargs))
1217+
claims_challenge=claims_challenge, auth_scheme=auth_scheme, **kwargs))
12091218
return result if result and "error" not in result else None
12101219

12111220
def acquire_token_silent_with_error(
@@ -1215,6 +1224,7 @@ def acquire_token_silent_with_error(
12151224
authority=None, # See get_authorization_request_url()
12161225
force_refresh=False, # type: Optional[boolean]
12171226
claims_challenge=None,
1227+
auth_scheme=None,
12181228
**kwargs):
12191229
"""Acquire an access token for given account, without user interaction.
12201230
@@ -1241,6 +1251,12 @@ def acquire_token_silent_with_error(
12411251
in the form of a claims_challenge directive in the www-authenticate header to be
12421252
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
12431253
It is a string of a JSON object which contains lists of claims being requested from these locations.
1254+
:param object auth_scheme:
1255+
You can provide an ``msal.auth_scheme.PopAuthScheme`` object
1256+
so that MSAL will get a Proof-of-Possession (POP) token for you.
1257+
1258+
New in version 1.26.0.
1259+
12441260
:return:
12451261
- A dict containing no "error" key,
12461262
and typically contains an "access_token" key,
@@ -1252,7 +1268,7 @@ def acquire_token_silent_with_error(
12521268
return None # A backward-compatible NO-OP to drop the account=None usage
12531269
return _clean_up(self._acquire_token_silent_with_error(
12541270
scopes, account, authority=authority, force_refresh=force_refresh,
1255-
claims_challenge=claims_challenge, **kwargs))
1271+
claims_challenge=claims_challenge, auth_scheme=auth_scheme, **kwargs))
12561272

12571273
def _acquire_token_silent_with_error(
12581274
self,
@@ -1261,6 +1277,7 @@ def _acquire_token_silent_with_error(
12611277
authority=None, # See get_authorization_request_url()
12621278
force_refresh=False, # type: Optional[boolean]
12631279
claims_challenge=None,
1280+
auth_scheme=None,
12641281
**kwargs):
12651282
assert isinstance(scopes, list), "Invalid parameter type"
12661283
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
@@ -1276,6 +1293,7 @@ def _acquire_token_silent_with_error(
12761293
scopes, account, self.authority, force_refresh=force_refresh,
12771294
claims_challenge=claims_challenge,
12781295
correlation_id=correlation_id,
1296+
auth_scheme=auth_scheme,
12791297
**kwargs)
12801298
if result and "error" not in result:
12811299
return result
@@ -1298,6 +1316,7 @@ def _acquire_token_silent_with_error(
12981316
scopes, account, the_authority, force_refresh=force_refresh,
12991317
claims_challenge=claims_challenge,
13001318
correlation_id=correlation_id,
1319+
auth_scheme=auth_scheme,
13011320
**kwargs)
13021321
if result:
13031322
if "error" not in result:
@@ -1322,12 +1341,13 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
13221341
claims_challenge=None,
13231342
correlation_id=None,
13241343
http_exceptions=None,
1344+
auth_scheme=None,
13251345
**kwargs):
13261346
# This internal method has two calling patterns:
13271347
# it accepts a non-empty account to find token for a user,
13281348
# and accepts account=None to find a token for the current app.
13291349
access_token_from_cache = None
1330-
if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims
1350+
if not (force_refresh or claims_challenge or auth_scheme): # Then attempt AT cache
13311351
query={
13321352
"client_id": self.client_id,
13331353
"environment": authority.instance,
@@ -1370,6 +1390,8 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
13701390
try:
13711391
data = kwargs.get("data", {})
13721392
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")
13731395
return self._acquire_token_by_cloud_shell(scopes, data=data)
13741396

13751397
if self._enable_broker and account and account.get("account_source") in (
@@ -1385,6 +1407,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
13851407
claims=_merge_claims_challenge_and_capabilities(
13861408
self._client_capabilities, claims_challenge),
13871409
correlation_id=correlation_id,
1410+
auth_scheme=auth_scheme,
13881411
**data)
13891412
if response: # Broker provides a decisive outcome
13901413
account_was_established_by_broker = account.get(
@@ -1393,6 +1416,8 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
13931416
if account_was_established_by_broker or broker_attempt_succeeded_just_now:
13941417
return self._process_broker_response(response, scopes, data)
13951418

1419+
if auth_scheme:
1420+
raise ValueError(self._AUTH_SCHEME_UNSUPPORTED)
13961421
if account:
13971422
result = self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
13981423
authority, self._decorate_scope(scopes), account,
@@ -1588,7 +1613,11 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
15881613
return response
15891614

15901615
def acquire_token_by_username_password(
1591-
self, username, password, scopes, claims_challenge=None, **kwargs):
1616+
self, username, password, scopes, claims_challenge=None,
1617+
# Note: We shouldn't need to surface enable_msa_passthrough,
1618+
# because this ROPC won't work with MSA account anyway.
1619+
auth_scheme=None,
1620+
**kwargs):
15921621
"""Gets a token for a given resource via user credentials.
15931622
15941623
See this page for constraints of Username Password Flow.
@@ -1604,6 +1633,12 @@ def acquire_token_by_username_password(
16041633
returned from the UserInfo Endpoint and/or in the ID Token and/or Access Token.
16051634
It is a string of a JSON object which contains lists of claims being requested from these locations.
16061635
1636+
:param object auth_scheme:
1637+
You can provide an ``msal.auth_scheme.PopAuthScheme`` object
1638+
so that MSAL will get a Proof-of-Possession (POP) token for you.
1639+
1640+
New in version 1.26.0.
1641+
16071642
:return: A dict representing the json response from AAD:
16081643
16091644
- A successful response would contain "access_token" key,
@@ -1623,9 +1658,12 @@ def acquire_token_by_username_password(
16231658
self.authority._is_known_to_developer
16241659
or self._instance_discovery is False) else None,
16251660
claims=claims,
1661+
auth_scheme=auth_scheme,
16261662
)
16271663
return self._process_broker_response(response, scopes, kwargs.get("data", {}))
16281664

1665+
if auth_scheme:
1666+
raise ValueError(self._AUTH_SCHEME_UNSUPPORTED)
16291667
scopes = self._decorate_scope(scopes)
16301668
telemetry_context = self._build_telemetry_context(
16311669
self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID)
@@ -1768,6 +1806,7 @@ def acquire_token_interactive(
17681806
max_age=None,
17691807
parent_window_handle=None,
17701808
on_before_launching_ui=None,
1809+
auth_scheme=None,
17711810
**kwargs):
17721811
"""Acquire token interactively i.e. via a local browser.
17731812
@@ -1843,6 +1882,12 @@ def acquire_token_interactive(
18431882
18441883
New in version 1.20.0.
18451884
1885+
:param object auth_scheme:
1886+
You can provide an ``msal.auth_scheme.PopAuthScheme`` object
1887+
so that MSAL will get a Proof-of-Possession (POP) token for you.
1888+
1889+
New in version 1.26.0.
1890+
18461891
:return:
18471892
- A dict containing no "error" key,
18481893
and typically contains an "access_token" key.
@@ -1887,12 +1932,15 @@ def acquire_token_interactive(
18871932
claims,
18881933
data,
18891934
on_before_launching_ui,
1935+
auth_scheme,
18901936
prompt=prompt,
18911937
login_hint=login_hint,
18921938
max_age=max_age,
18931939
)
18941940
return self._process_broker_response(response, scopes, data)
18951941

1942+
if auth_scheme:
1943+
raise ValueError("auth_scheme is currently only available from broker")
18961944
on_before_launching_ui(ui="browser")
18971945
telemetry_context = self._build_telemetry_context(
18981946
self.ACQUIRE_TOKEN_INTERACTIVE)
@@ -1927,6 +1975,7 @@ def _acquire_token_interactive_via_broker(
19271975
claims, # type: str
19281976
data, # type: dict
19291977
on_before_launching_ui, # type: callable
1978+
auth_scheme, # type: object
19301979
prompt=None,
19311980
login_hint=None, # type: Optional[str]
19321981
max_age=None,
@@ -1950,6 +1999,7 @@ def _acquire_token_interactive_via_broker(
19501999
accounts[0]["local_account_id"],
19512000
scopes,
19522001
claims=claims,
2002+
auth_scheme=auth_scheme,
19532003
**data)
19542004
if response and "error" not in response:
19552005
return response
@@ -1962,6 +2012,7 @@ def _acquire_token_interactive_via_broker(
19622012
claims=claims,
19632013
max_age=max_age,
19642014
enable_msa_pt=enable_msa_passthrough,
2015+
auth_scheme=auth_scheme,
19652016
**data)
19662017
is_wrong_account = bool(
19672018
# _signin_silently() only gets tokens for default account,
@@ -2002,6 +2053,7 @@ def _acquire_token_interactive_via_broker(
20022053
claims=claims,
20032054
max_age=max_age,
20042055
enable_msa_pt=enable_msa_passthrough,
2056+
auth_scheme=auth_scheme,
20052057
**data)
20062058

20072059
def initiate_device_flow(self, scopes=None, **kwargs):

msal/auth_scheme.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
HTTP_GET = "GET"
9+
HTTP_POST = "POST"
10+
HTTP_PUT = "PUT"
11+
HTTP_DELETE = "DELETE"
12+
HTTP_PATCH = "PATCH"
13+
_HTTP_METHODS = (HTTP_GET, HTTP_POST, HTTP_PUT, HTTP_DELETE, HTTP_PATCH)
14+
# Internal design: https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PoPTokensProtocol/PopTokensProtocol.md
15+
def __init__(self, http_method=None, url=None, nonce=None):
16+
"""Create an auth scheme which is needed to obtain a Proof-of-Possession token.
17+
18+
:param str http_method:
19+
Its value is an uppercase http verb, such as "GET" and "POST".
20+
:param str url:
21+
The url to be signed.
22+
:param str nonce:
23+
The nonce came from resource's challenge.
24+
"""
25+
if not (http_method and url and nonce):
26+
# In the future, we may also support accepting an http_response as input
27+
raise ValueError("All http_method, url and nonce are required parameters")
28+
if http_method not in self._HTTP_METHODS:
29+
raise ValueError("http_method must be uppercase, according to "
30+
"https://datatracker.ietf.org/doc/html/draft-ietf-oauth-signed-http-request-03#section-3")
31+
self._http_method = http_method
32+
self._url = urlparse(url)
33+
self._nonce = nonce
34+

msal/broker.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,17 @@ def _convert_result(result, client_id, expected_token_type=None): # Mimic an on
9999
assert account, "Account is expected to be always available"
100100
# Note: There are more account attribute getters available in pymsalruntime 0.13+
101101
return_value = {k: v for k, v in {
102-
"access_token": result.get_access_token(),
102+
"access_token":
103+
result.get_authorization_header() # It returns "pop SignedHttpRequest"
104+
.split()[1]
105+
if result.is_pop_authorization() else result.get_access_token(),
103106
"expires_in": result.get_access_token_expiry_time() - int(time.time()), # Convert epoch to count-down
104107
"id_token": result.get_raw_id_token(), # New in pymsalruntime 0.8.1
105108
"id_token_claims": id_token_claims,
106109
"client_info": account.get_client_info(),
107110
"_account_id": account.get_account_id(),
108-
"token_type": expected_token_type or "Bearer", # Workaround its absence from broker
111+
"token_type": "pop" if result.is_pop_authorization() else (
112+
expected_token_type or "bearer"), # Workaround "ssh-cert"'s absence from broker
109113
}.items() if v}
110114
likely_a_cert = return_value["access_token"].startswith("AAAA") # Empirical observation
111115
if return_value["token_type"].lower() == "ssh-cert" and not likely_a_cert:
@@ -128,11 +132,16 @@ def _enable_msa_pt(params):
128132
def _signin_silently(
129133
authority, client_id, scopes, correlation_id=None, claims=None,
130134
enable_msa_pt=False,
135+
auth_scheme=None,
131136
**kwargs):
132137
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
133138
params.set_requested_scopes(scopes)
134139
if claims:
135140
params.set_decoded_claims(claims)
141+
if auth_scheme:
142+
params.set_pop_params(
143+
auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path,
144+
auth_scheme._nonce)
136145
callback_data = _CallbackData()
137146
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
138147
if v is not None:
@@ -156,6 +165,7 @@ def _signin_interactively(
156165
claims=None,
157166
correlation_id=None,
158167
enable_msa_pt=False,
168+
auth_scheme=None,
159169
**kwargs):
160170
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
161171
params.set_requested_scopes(scopes)
@@ -178,6 +188,10 @@ def _signin_interactively(
178188
params.set_additional_parameter("msal_gui_thread", "true") # Since pymsalruntime 0.8.1
179189
if enable_msa_pt:
180190
_enable_msa_pt(params)
191+
if auth_scheme:
192+
params.set_pop_params(
193+
auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path,
194+
auth_scheme._nonce)
181195
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
182196
if v is not None:
183197
params.set_additional_parameter(k, str(v))
@@ -197,6 +211,7 @@ def _signin_interactively(
197211

198212
def _acquire_token_silently(
199213
authority, client_id, account_id, scopes, claims=None, correlation_id=None,
214+
auth_scheme=None,
200215
**kwargs):
201216
# For MSA PT scenario where you use the /organizations, yes,
202217
# acquireTokenSilently is expected to fail. - Sam Wilson
@@ -208,6 +223,10 @@ def _acquire_token_silently(
208223
params.set_requested_scopes(scopes)
209224
if claims:
210225
params.set_decoded_claims(claims)
226+
if auth_scheme:
227+
params.set_pop_params(
228+
auth_scheme._http_method, auth_scheme._url.netloc, auth_scheme._url.path,
229+
auth_scheme._nonce)
211230
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
212231
if v is not None:
213232
params.set_additional_parameter(k, str(v))

0 commit comments

Comments
 (0)