Skip to content

Commit e611479

Browse files
committed
Broker (WAM) integration
Disabled SSH Cert when using broker
1 parent d148d55 commit e611479

9 files changed

+642
-35
lines changed

msal/application.py

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

msal/authority.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ def __init__(self, authority_url, http_client, validate_authority=True):
7272
authority_url = str(authority_url)
7373
authority, self.instance, tenant = canonicalize(authority_url)
7474
parts = authority.path.split('/')
75-
is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS) or (
75+
self._is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS) or (
7676
len(parts) == 3 and parts[2].lower().startswith("b2c_"))
77-
if (tenant != "adfs" and (not is_b2c) and validate_authority
77+
self._validate_authority = True if validate_authority is None else bool(validate_authority)
78+
if (tenant != "adfs" and (not self._is_b2c) and self._validate_authority
7879
and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS):
7980
payload = instance_discovery(
8081
"https://{}{}/oauth2/v2.0/authorize".format(

msal/broker.py

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

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.12;python_version>='3.6' and platform_system=='Windows'",
95+
],
96+
},
8997
)
9098

tests/msaltest.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,14 @@ def _acquire_token_interactive(app, scopes, data=None):
7474
# login_hint is unnecessary when prompt=select_account,
7575
# but we still let tester input login_hint, just for testing purpose.
7676
[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.)",
77+
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.)",
7878
accept_nonempty_string=True,
7979
)
8080
login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint
8181
result = app.acquire_token_interactive(
82-
scopes, prompt=prompt, login_hint=login_hint, data=data or {})
82+
scopes,
83+
parent_window_handle=app.CONSOLE_WINDOW_HANDLE, # This test app is a console app
84+
prompt=prompt, login_hint=login_hint, data=data or {})
8385
if login_hint and "id_token_claims" in result:
8486
signed_in_user = result.get("id_token_claims", {}).get("preferred_username")
8587
if signed_in_user != login_hint:
@@ -127,9 +129,13 @@ def remove_account(app):
127129
app.remove_account(account)
128130
print('Account "{}" and/or its token(s) are signed out from MSAL Python'.format(account["username"]))
129131

130-
def exit(_):
132+
def exit(app):
131133
"""Exit"""
132-
bug_link = "https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new/choose"
134+
bug_link = (
135+
"https://identitydivision.visualstudio.com/Engineering/_queries/query/79b3a352-a775-406f-87cd-a487c382a8ed/"
136+
if app._enable_broker else
137+
"https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new/choose"
138+
)
133139
print("Bye. If you found a bug, please report it here: {}".format(bug_link))
134140
sys.exit()
135141

@@ -155,6 +161,7 @@ def main():
155161
header="Input authority (Note that MSA-PT apps would NOT use the /common authority)",
156162
accept_nonempty_string=True,
157163
),
164+
allow_broker=_input_boolean("Allow broker? (Azure CLI currently only supports @microsoft.com accounts when enabling broker)"),
158165
)
159166
if _input_boolean("Enable MSAL Python's DEBUG log?"):
160167
logging.basicConfig(level=logging.DEBUG)

0 commit comments

Comments
 (0)