Skip to content

Commit 14cbf59

Browse files
authored
Merge pull request #502 from AzureAD/release-1.20.0
MSAL Python 1.20.0
2 parents a0a59f1 + a044c32 commit 14cbf59

9 files changed

+738
-72
lines changed

msal/application.py

+272-14
Large diffs are not rendered by default.

msal/authority.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ def __init__(
8282
authority, self.instance, tenant = canonicalize(authority_url)
8383
self.is_adfs = tenant.lower() == 'adfs'
8484
parts = authority.path.split('/')
85-
is_b2c = any(
85+
self._is_b2c = any(
8686
self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS
8787
) or (len(parts) == 3 and parts[2].lower().startswith("b2c_"))
88-
self._is_known_to_developer = self.is_adfs or is_b2c or not validate_authority
88+
self._is_known_to_developer = self.is_adfs or self._is_b2c or not validate_authority
8989
is_known_to_microsoft = self.instance in WELL_KNOWN_AUTHORITY_HOSTS
9090
instance_discovery_endpoint = 'https://{}/common/discovery/instance'.format( # Note: This URL seemingly returns V1 endpoint only
9191
WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too

msal/broker.py

+237
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"""This module is an adaptor to the underlying broker.
2+
It relies on PyMsalRuntime which is the package providing broker's functionality.
3+
"""
4+
from threading import Event
5+
import json
6+
import logging
7+
import time
8+
import uuid
9+
10+
11+
logger = logging.getLogger(__name__)
12+
try:
13+
import pymsalruntime # Its API description is available in site-packages/pymsalruntime/PyMsalRuntime.pyi
14+
pymsalruntime.register_logging_callback(lambda message, level: { # New in pymsalruntime 0.7
15+
pymsalruntime.LogLevel.TRACE: logger.debug, # Python has no TRACE level
16+
pymsalruntime.LogLevel.DEBUG: logger.debug,
17+
# Let broker's excess info, warning and error logs map into default DEBUG, for now
18+
#pymsalruntime.LogLevel.INFO: logger.info,
19+
#pymsalruntime.LogLevel.WARNING: logger.warning,
20+
#pymsalruntime.LogLevel.ERROR: logger.error,
21+
pymsalruntime.LogLevel.FATAL: logger.critical,
22+
}.get(level, logger.debug)(message))
23+
except (ImportError, AttributeError): # AttributeError happens when a prior pymsalruntime uninstallation somehow leaved an empty folder behind
24+
# PyMsalRuntime currently supports these Windows versions, listed in this MSFT internal link
25+
# https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/2406/files
26+
raise ImportError( # TODO: Remove or adjust this line right before merging this PR
27+
'You need to install dependency by: pip install "msal[broker]>=1.20,<2"')
28+
# It could throw RuntimeError when running on ancient versions of Windows
29+
30+
31+
class RedirectUriError(ValueError):
32+
pass
33+
34+
35+
class TokenTypeError(ValueError):
36+
pass
37+
38+
39+
class _CallbackData:
40+
def __init__(self):
41+
self.signal = Event()
42+
self.result = None
43+
44+
def complete(self, result):
45+
self.signal.set()
46+
self.result = result
47+
48+
49+
def _convert_error(error, client_id):
50+
context = error.get_context() # Available since pymsalruntime 0.0.4
51+
if (
52+
"AADSTS50011" in context # In WAM, this could happen on both interactive and silent flows
53+
or "AADSTS7000218" in context # This "request body must contain ... client_secret" is just a symptom of current app has no WAM redirect_uri
54+
):
55+
raise RedirectUriError( # This would be seen by either the app developer or end user
56+
"MsalRuntime won't work unless this one more redirect_uri is registered to current app: "
57+
"ms-appx-web://Microsoft.AAD.BrokerPlugin/{}".format(client_id))
58+
# OTOH, AAD would emit other errors when other error handling branch was hit first,
59+
# so, the AADSTS50011/RedirectUriError is not guaranteed to happen.
60+
return {
61+
"error": "broker_error", # Note: Broker implies your device needs to be compliant.
62+
# You may use "dsregcmd /status" to check your device state
63+
# https://docs.microsoft.com/en-us/azure/active-directory/devices/troubleshoot-device-dsregcmd
64+
"error_description": "{}. Status: {}, Error code: {}, Tag: {}".format(
65+
context,
66+
error.get_status(), error.get_error_code(), error.get_tag()),
67+
"_broker_status": error.get_status(),
68+
"_broker_error_code": error.get_error_code(),
69+
"_broker_tag": error.get_tag(),
70+
}
71+
72+
73+
def _read_account_by_id(account_id, correlation_id):
74+
"""Return an instance of MSALRuntimeError or MSALRuntimeAccount, or None"""
75+
callback_data = _CallbackData()
76+
pymsalruntime.read_account_by_id(
77+
account_id,
78+
correlation_id,
79+
lambda result, callback_data=callback_data: callback_data.complete(result)
80+
)
81+
callback_data.signal.wait()
82+
return (callback_data.result.get_error() or callback_data.result.get_account()
83+
or None) # None happens when the account was not created by broker
84+
85+
86+
def _convert_result(result, client_id, expected_token_type=None): # Mimic an on-the-wire response from AAD
87+
error = result.get_error()
88+
if error:
89+
return _convert_error(error, client_id)
90+
id_token_claims = json.loads(result.get_id_token()) if result.get_id_token() else {}
91+
account = result.get_account()
92+
assert account, "Account is expected to be always available"
93+
# Note: There are more account attribute getters available in pymsalruntime 0.13+
94+
return_value = {k: v for k, v in {
95+
"access_token": result.get_access_token(),
96+
"expires_in": result.get_access_token_expiry_time() - int(time.time()), # Convert epoch to count-down
97+
"id_token": result.get_raw_id_token(), # New in pymsalruntime 0.8.1
98+
"id_token_claims": id_token_claims,
99+
"client_info": account.get_client_info(),
100+
"_account_id": account.get_account_id(),
101+
"token_type": expected_token_type or "Bearer", # Workaround its absence from broker
102+
}.items() if v}
103+
likely_a_cert = return_value["access_token"].startswith("AAAA") # Empirical observation
104+
if return_value["token_type"].lower() == "ssh-cert" and not likely_a_cert:
105+
raise TokenTypeError("Broker could not get an SSH Cert: {}...".format(
106+
return_value["access_token"][:8]))
107+
granted_scopes = result.get_granted_scopes() # New in pymsalruntime 0.3.x
108+
if granted_scopes:
109+
return_value["scope"] = " ".join(granted_scopes) # Mimic the on-the-wire data format
110+
return return_value
111+
112+
113+
def _get_new_correlation_id():
114+
return str(uuid.uuid4())
115+
116+
117+
def _enable_msa_pt(params):
118+
params.set_additional_parameter("msal_request_type", "consumer_passthrough") # PyMsalRuntime 0.8+
119+
120+
121+
def _signin_silently(
122+
authority, client_id, scopes, correlation_id=None, claims=None,
123+
enable_msa_pt=False,
124+
**kwargs):
125+
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
126+
params.set_requested_scopes(scopes)
127+
if claims:
128+
params.set_decoded_claims(claims)
129+
callback_data = _CallbackData()
130+
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
131+
if v is not None:
132+
params.set_additional_parameter(k, str(v))
133+
if enable_msa_pt:
134+
_enable_msa_pt(params)
135+
pymsalruntime.signin_silently(
136+
params,
137+
correlation_id or _get_new_correlation_id(),
138+
lambda result, callback_data=callback_data: callback_data.complete(result))
139+
callback_data.signal.wait()
140+
return _convert_result(
141+
callback_data.result, client_id, expected_token_type=kwargs.get("token_type"))
142+
143+
144+
def _signin_interactively(
145+
authority, client_id, scopes,
146+
parent_window_handle, # None means auto-detect for console apps
147+
prompt=None, # Note: This function does not really use this parameter
148+
login_hint=None,
149+
claims=None,
150+
correlation_id=None,
151+
enable_msa_pt=False,
152+
**kwargs):
153+
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
154+
params.set_requested_scopes(scopes)
155+
params.set_redirect_uri("placeholder") # pymsalruntime 0.1 requires non-empty str,
156+
# the actual redirect_uri will be overridden by a value hardcoded by the broker
157+
if prompt:
158+
if prompt == "select_account":
159+
if login_hint:
160+
# FWIW, AAD's browser interactive flow would honor select_account
161+
# and ignore login_hint in such a case.
162+
# But pymsalruntime 0.3.x would pop up a meaningless account picker
163+
# and then force the account_hint user to re-input password. Not what we want.
164+
# https://identitydivision.visualstudio.com/Engineering/_workitems/edit/1744492
165+
login_hint = None # Mimicing the AAD behavior
166+
logger.warning("Using both select_account and login_hint is ambiguous. Ignoring login_hint.")
167+
else:
168+
logger.warning("prompt=%s is not supported by this module", prompt)
169+
if parent_window_handle is None:
170+
# This fixes account picker hanging in IDE debug mode on some machines
171+
params.set_additional_parameter("msal_gui_thread", "true") # Since pymsalruntime 0.8.1
172+
if enable_msa_pt:
173+
_enable_msa_pt(params)
174+
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
175+
if v is not None:
176+
params.set_additional_parameter(k, str(v))
177+
if claims:
178+
params.set_decoded_claims(claims)
179+
callback_data = _CallbackData()
180+
pymsalruntime.signin_interactively(
181+
parent_window_handle or pymsalruntime.get_console_window() or pymsalruntime.get_desktop_window(), # Since pymsalruntime 0.2+
182+
params,
183+
correlation_id or _get_new_correlation_id(),
184+
login_hint, # None value will be accepted since pymsalruntime 0.3+
185+
lambda result, callback_data=callback_data: callback_data.complete(result))
186+
callback_data.signal.wait()
187+
return _convert_result(
188+
callback_data.result, client_id, expected_token_type=kwargs.get("token_type"))
189+
190+
191+
def _acquire_token_silently(
192+
authority, client_id, account_id, scopes, claims=None, correlation_id=None,
193+
**kwargs):
194+
# For MSA PT scenario where you use the /organizations, yes,
195+
# acquireTokenSilently is expected to fail. - Sam Wilson
196+
correlation_id = correlation_id or _get_new_correlation_id()
197+
account = _read_account_by_id(account_id, correlation_id)
198+
if isinstance(account, pymsalruntime.MSALRuntimeError):
199+
return _convert_error(account, client_id)
200+
if account is None:
201+
return
202+
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
203+
params.set_requested_scopes(scopes)
204+
if claims:
205+
params.set_decoded_claims(claims)
206+
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
207+
if v is not None:
208+
params.set_additional_parameter(k, str(v))
209+
callback_data = _CallbackData()
210+
pymsalruntime.acquire_token_silently(
211+
params,
212+
correlation_id,
213+
account,
214+
lambda result, callback_data=callback_data: callback_data.complete(result))
215+
callback_data.signal.wait()
216+
return _convert_result(
217+
callback_data.result, client_id, expected_token_type=kwargs.get("token_type"))
218+
219+
220+
def _signout_silently(client_id, account_id, correlation_id=None):
221+
correlation_id = correlation_id or _get_new_correlation_id()
222+
account = _read_account_by_id(account_id, correlation_id)
223+
if isinstance(account, pymsalruntime.MSALRuntimeError):
224+
return _convert_error(account, client_id)
225+
if account is None:
226+
return
227+
callback_data = _CallbackData()
228+
pymsalruntime.signout_silently( # New in PyMsalRuntime 0.7
229+
client_id,
230+
correlation_id,
231+
account,
232+
lambda result, callback_data=callback_data: callback_data.complete(result))
233+
callback_data.signal.wait()
234+
error = callback_data.result.get_error()
235+
if error:
236+
return _convert_error(error, client_id)
237+

msal/token_cache.py

+12-8
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
def is_subdict_of(small, big):
1313
return dict(big, **small) == big
1414

15+
def _get_username(id_token_claims):
16+
return id_token_claims.get(
17+
"preferred_username", # AAD
18+
id_token_claims.get("upn")) # ADFS 2019
1519

1620
class TokenCache(object):
1721
"""This is considered as a base class containing minimal cache behavior.
@@ -149,10 +153,9 @@ def __add(self, event, now=None):
149153
access_token = response.get("access_token")
150154
refresh_token = response.get("refresh_token")
151155
id_token = response.get("id_token")
152-
id_token_claims = (
153-
decode_id_token(id_token, client_id=event["client_id"])
154-
if id_token
155-
else response.get("id_token_claims", {})) # Broker would provide id_token_claims
156+
id_token_claims = response.get("id_token_claims") or ( # Prefer the claims from broker
157+
# Only use decode_id_token() when necessary, it contains time-sensitive validation
158+
decode_id_token(id_token, client_id=event["client_id"]) if id_token else {})
156159
client_info, home_account_id = self.__parse_account(response, id_token_claims)
157160

158161
target = ' '.join(event.get("scope") or []) # Per schema, we don't sort it
@@ -190,10 +193,11 @@ def __add(self, event, now=None):
190193
"home_account_id": home_account_id,
191194
"environment": environment,
192195
"realm": realm,
193-
"local_account_id": id_token_claims.get(
194-
"oid", id_token_claims.get("sub")),
195-
"username": id_token_claims.get("preferred_username") # AAD
196-
or id_token_claims.get("upn") # ADFS 2019
196+
"local_account_id": event.get(
197+
"_account_id", # Came from mid-tier code path.
198+
# Emperically, it is the oid in AAD or cid in MSA.
199+
id_token_claims.get("oid", id_token_claims.get("sub"))),
200+
"username": _get_username(id_token_claims)
197201
or data.get("username") # Falls back to ROPC username
198202
or event.get("username") # Falls back to Federated ROPC username
199203
or "", # The schema does not like null

sample/interactive_sample.py

+6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
"""
2+
Prerequisite is documented here:
3+
https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.acquire_token_interactive
4+
25
The configuration file would look like this:
36
47
{
@@ -30,6 +33,8 @@
3033
# Create a preferably long-lived app instance which maintains a token cache.
3134
app = msal.PublicClientApplication(
3235
config["client_id"], authority=config["authority"],
36+
#allow_broker=True, # If opted in, you will be guided to meet the prerequisites, when applicable
37+
# See also: https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-wam#wam-value-proposition
3338
# token_cache=... # Default cache is in memory only.
3439
# You can learn how to use SerializableTokenCache from
3540
# https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache
@@ -55,6 +60,7 @@
5560
print("A local browser window will be open for you to sign in. CTRL+C to cancel.")
5661
result = app.acquire_token_interactive( # Only works if your app is registered with redirect_uri as http://localhost
5762
config["scope"],
63+
#parent_window_handle=..., # If broker is enabled, you will be guided to provide a window handle
5864
login_hint=config.get("username"), # Optional.
5965
# If you know the username ahead of time, this parameter can pre-fill
6066
# the username (or email address) field of the sign-in page for the user,

setup.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@
8585
# https://cryptography.io/en/latest/api-stability/#deprecation
8686

8787
"mock;python_version<'3.3'",
88-
]
88+
],
89+
extras_require={ # It does not seem to work if being defined inside setup.cfg
90+
"broker": [
91+
# The broker is defined as optional dependency,
92+
# so that downstream apps can opt in. The opt-in is needed, partially because
93+
# most existing MSAL Python apps do not have the redirect_uri needed by broker.
94+
"pymsalruntime>=0.11.2,<0.14;python_version>='3.6' and platform_system=='Windows'",
95+
],
96+
},
8997
)
9098

tests/msaltest.py

+19-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import getpass, logging, pprint, sys, msal
22

33

4+
AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
5+
VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd"
6+
47
def _input_boolean(message):
58
return input(
69
"{} (N/n/F/f or empty means False, otherwise it is True): ".format(message)
@@ -74,12 +77,17 @@ def _acquire_token_interactive(app, scopes, data=None):
7477
# login_hint is unnecessary when prompt=select_account,
7578
# but we still let tester input login_hint, just for testing purpose.
7679
[None] + [a["username"] for a in app.get_accounts()],
77-
header="login_hint? (If you have multiple signed-in sessions in browser, and you specify a login_hint to match one of them, you will bypass the account picker.)",
80+
header="login_hint? (If you have multiple signed-in sessions in browser/broker, and you specify a login_hint to match one of them, you will bypass the account picker.)",
7881
accept_nonempty_string=True,
7982
)
8083
login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint
8184
result = app.acquire_token_interactive(
82-
scopes, prompt=prompt, login_hint=login_hint, data=data or {})
85+
scopes,
86+
parent_window_handle=app.CONSOLE_WINDOW_HANDLE, # This test app is a console app
87+
enable_msa_passthrough=app.client_id in [ # Apps are expected to set this right
88+
AZURE_CLI, VISUAL_STUDIO,
89+
], # Here this test app mimics the setting for some known MSA-PT apps
90+
prompt=prompt, login_hint=login_hint, data=data or {})
8391
if login_hint and "id_token_claims" in result:
8492
signed_in_user = result.get("id_token_claims", {}).get("preferred_username")
8593
if signed_in_user != login_hint:
@@ -127,17 +135,21 @@ def remove_account(app):
127135
app.remove_account(account)
128136
print('Account "{}" and/or its token(s) are signed out from MSAL Python'.format(account["username"]))
129137

130-
def exit(_):
138+
def exit(app):
131139
"""Exit"""
132-
bug_link = "https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new/choose"
140+
bug_link = (
141+
"https://identitydivision.visualstudio.com/Engineering/_queries/query/79b3a352-a775-406f-87cd-a487c382a8ed/"
142+
if app._enable_broker else
143+
"https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new/choose"
144+
)
133145
print("Bye. If you found a bug, please report it here: {}".format(bug_link))
134146
sys.exit()
135147

136148
def main():
137149
print("Welcome to the Msal Python Console Test App, committed at 2022-5-2\n")
138150
chosen_app = _select_options([
139-
{"client_id": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "name": "Azure CLI (Correctly configured for MSA-PT)"},
140-
{"client_id": "04f0c124-f2bc-4f59-8241-bf6df9866bbd", "name": "Visual Studio (Correctly configured for MSA-PT)"},
151+
{"client_id": AZURE_CLI, "name": "Azure CLI (Correctly configured for MSA-PT)"},
152+
{"client_id": VISUAL_STUDIO, "name": "Visual Studio (Correctly configured for MSA-PT)"},
141153
{"client_id": "95de633a-083e-42f5-b444-a4295d8e9314", "name": "Whiteboard Services (Non MSA-PT app. Accepts AAD & MSA accounts.)"},
142154
],
143155
option_renderer=lambda a: a["name"],
@@ -155,6 +167,7 @@ def main():
155167
header="Input authority (Note that MSA-PT apps would NOT use the /common authority)",
156168
accept_nonempty_string=True,
157169
),
170+
allow_broker=_input_boolean("Allow broker? (Azure CLI currently only supports @microsoft.com accounts when enabling broker)"),
158171
)
159172
if _input_boolean("Enable MSAL Python's DEBUG log?"):
160173
logging.basicConfig(level=logging.DEBUG)

0 commit comments

Comments
 (0)