Skip to content

Commit

Permalink
Merge pull request #502 from AzureAD/release-1.20.0
Browse files Browse the repository at this point in the history
MSAL Python 1.20.0
  • Loading branch information
rayluo authored Oct 7, 2022
2 parents a0a59f1 + a044c32 commit 14cbf59
Show file tree
Hide file tree
Showing 9 changed files with 738 additions and 72 deletions.
286 changes: 272 additions & 14 deletions msal/application.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions msal/authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ def __init__(
authority, self.instance, tenant = canonicalize(authority_url)
self.is_adfs = tenant.lower() == 'adfs'
parts = authority.path.split('/')
is_b2c = any(
self._is_b2c = any(
self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS
) or (len(parts) == 3 and parts[2].lower().startswith("b2c_"))
self._is_known_to_developer = self.is_adfs or is_b2c or not validate_authority
self._is_known_to_developer = self.is_adfs or self._is_b2c or not validate_authority
is_known_to_microsoft = self.instance in WELL_KNOWN_AUTHORITY_HOSTS
instance_discovery_endpoint = 'https://{}/common/discovery/instance'.format( # Note: This URL seemingly returns V1 endpoint only
WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too
Expand Down
237 changes: 237 additions & 0 deletions msal/broker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
"""This module is an adaptor to the underlying broker.
It relies on PyMsalRuntime which is the package providing broker's functionality.
"""
from threading import Event
import json
import logging
import time
import uuid


logger = logging.getLogger(__name__)
try:
import pymsalruntime # Its API description is available in site-packages/pymsalruntime/PyMsalRuntime.pyi
pymsalruntime.register_logging_callback(lambda message, level: { # New in pymsalruntime 0.7
pymsalruntime.LogLevel.TRACE: logger.debug, # Python has no TRACE level
pymsalruntime.LogLevel.DEBUG: logger.debug,
# Let broker's excess info, warning and error logs map into default DEBUG, for now
#pymsalruntime.LogLevel.INFO: logger.info,
#pymsalruntime.LogLevel.WARNING: logger.warning,
#pymsalruntime.LogLevel.ERROR: logger.error,
pymsalruntime.LogLevel.FATAL: logger.critical,
}.get(level, logger.debug)(message))
except (ImportError, AttributeError): # AttributeError happens when a prior pymsalruntime uninstallation somehow leaved an empty folder behind
# PyMsalRuntime currently supports these Windows versions, listed in this MSFT internal link
# https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/2406/files
raise ImportError( # TODO: Remove or adjust this line right before merging this PR
'You need to install dependency by: pip install "msal[broker]>=1.20,<2"')
# It could throw RuntimeError when running on ancient versions of Windows


class RedirectUriError(ValueError):
pass


class TokenTypeError(ValueError):
pass


class _CallbackData:
def __init__(self):
self.signal = Event()
self.result = None

def complete(self, result):
self.signal.set()
self.result = result


def _convert_error(error, client_id):
context = error.get_context() # Available since pymsalruntime 0.0.4
if (
"AADSTS50011" in context # In WAM, this could happen on both interactive and silent flows
or "AADSTS7000218" in context # This "request body must contain ... client_secret" is just a symptom of current app has no WAM redirect_uri
):
raise RedirectUriError( # This would be seen by either the app developer or end user
"MsalRuntime won't work unless this one more redirect_uri is registered to current app: "
"ms-appx-web://Microsoft.AAD.BrokerPlugin/{}".format(client_id))
# OTOH, AAD would emit other errors when other error handling branch was hit first,
# so, the AADSTS50011/RedirectUriError is not guaranteed to happen.
return {
"error": "broker_error", # Note: Broker implies your device needs to be compliant.
# You may use "dsregcmd /status" to check your device state
# https://docs.microsoft.com/en-us/azure/active-directory/devices/troubleshoot-device-dsregcmd
"error_description": "{}. Status: {}, Error code: {}, Tag: {}".format(
context,
error.get_status(), error.get_error_code(), error.get_tag()),
"_broker_status": error.get_status(),
"_broker_error_code": error.get_error_code(),
"_broker_tag": error.get_tag(),
}


def _read_account_by_id(account_id, correlation_id):
"""Return an instance of MSALRuntimeError or MSALRuntimeAccount, or None"""
callback_data = _CallbackData()
pymsalruntime.read_account_by_id(
account_id,
correlation_id,
lambda result, callback_data=callback_data: callback_data.complete(result)
)
callback_data.signal.wait()
return (callback_data.result.get_error() or callback_data.result.get_account()
or None) # None happens when the account was not created by broker


def _convert_result(result, client_id, expected_token_type=None): # Mimic an on-the-wire response from AAD
error = result.get_error()
if error:
return _convert_error(error, client_id)
id_token_claims = json.loads(result.get_id_token()) if result.get_id_token() else {}
account = result.get_account()
assert account, "Account is expected to be always available"
# Note: There are more account attribute getters available in pymsalruntime 0.13+
return_value = {k: v for k, v in {
"access_token": result.get_access_token(),
"expires_in": result.get_access_token_expiry_time() - int(time.time()), # Convert epoch to count-down
"id_token": result.get_raw_id_token(), # New in pymsalruntime 0.8.1
"id_token_claims": id_token_claims,
"client_info": account.get_client_info(),
"_account_id": account.get_account_id(),
"token_type": expected_token_type or "Bearer", # Workaround its absence from broker
}.items() if v}
likely_a_cert = return_value["access_token"].startswith("AAAA") # Empirical observation
if return_value["token_type"].lower() == "ssh-cert" and not likely_a_cert:
raise TokenTypeError("Broker could not get an SSH Cert: {}...".format(
return_value["access_token"][:8]))
granted_scopes = result.get_granted_scopes() # New in pymsalruntime 0.3.x
if granted_scopes:
return_value["scope"] = " ".join(granted_scopes) # Mimic the on-the-wire data format
return return_value


def _get_new_correlation_id():
return str(uuid.uuid4())


def _enable_msa_pt(params):
params.set_additional_parameter("msal_request_type", "consumer_passthrough") # PyMsalRuntime 0.8+


def _signin_silently(
authority, client_id, scopes, correlation_id=None, claims=None,
enable_msa_pt=False,
**kwargs):
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
params.set_requested_scopes(scopes)
if claims:
params.set_decoded_claims(claims)
callback_data = _CallbackData()
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
if v is not None:
params.set_additional_parameter(k, str(v))
if enable_msa_pt:
_enable_msa_pt(params)
pymsalruntime.signin_silently(
params,
correlation_id or _get_new_correlation_id(),
lambda result, callback_data=callback_data: callback_data.complete(result))
callback_data.signal.wait()
return _convert_result(
callback_data.result, client_id, expected_token_type=kwargs.get("token_type"))


def _signin_interactively(
authority, client_id, scopes,
parent_window_handle, # None means auto-detect for console apps
prompt=None, # Note: This function does not really use this parameter
login_hint=None,
claims=None,
correlation_id=None,
enable_msa_pt=False,
**kwargs):
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
params.set_requested_scopes(scopes)
params.set_redirect_uri("placeholder") # pymsalruntime 0.1 requires non-empty str,
# the actual redirect_uri will be overridden by a value hardcoded by the broker
if prompt:
if prompt == "select_account":
if login_hint:
# FWIW, AAD's browser interactive flow would honor select_account
# and ignore login_hint in such a case.
# But pymsalruntime 0.3.x would pop up a meaningless account picker
# and then force the account_hint user to re-input password. Not what we want.
# https://identitydivision.visualstudio.com/Engineering/_workitems/edit/1744492
login_hint = None # Mimicing the AAD behavior
logger.warning("Using both select_account and login_hint is ambiguous. Ignoring login_hint.")
else:
logger.warning("prompt=%s is not supported by this module", prompt)
if parent_window_handle is None:
# This fixes account picker hanging in IDE debug mode on some machines
params.set_additional_parameter("msal_gui_thread", "true") # Since pymsalruntime 0.8.1
if enable_msa_pt:
_enable_msa_pt(params)
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
if v is not None:
params.set_additional_parameter(k, str(v))
if claims:
params.set_decoded_claims(claims)
callback_data = _CallbackData()
pymsalruntime.signin_interactively(
parent_window_handle or pymsalruntime.get_console_window() or pymsalruntime.get_desktop_window(), # Since pymsalruntime 0.2+
params,
correlation_id or _get_new_correlation_id(),
login_hint, # None value will be accepted since pymsalruntime 0.3+
lambda result, callback_data=callback_data: callback_data.complete(result))
callback_data.signal.wait()
return _convert_result(
callback_data.result, client_id, expected_token_type=kwargs.get("token_type"))


def _acquire_token_silently(
authority, client_id, account_id, scopes, claims=None, correlation_id=None,
**kwargs):
# For MSA PT scenario where you use the /organizations, yes,
# acquireTokenSilently is expected to fail. - Sam Wilson
correlation_id = correlation_id or _get_new_correlation_id()
account = _read_account_by_id(account_id, correlation_id)
if isinstance(account, pymsalruntime.MSALRuntimeError):
return _convert_error(account, client_id)
if account is None:
return
params = pymsalruntime.MSALRuntimeAuthParameters(client_id, authority)
params.set_requested_scopes(scopes)
if claims:
params.set_decoded_claims(claims)
for k, v in kwargs.items(): # This can be used to support domain_hint, max_age, etc.
if v is not None:
params.set_additional_parameter(k, str(v))
callback_data = _CallbackData()
pymsalruntime.acquire_token_silently(
params,
correlation_id,
account,
lambda result, callback_data=callback_data: callback_data.complete(result))
callback_data.signal.wait()
return _convert_result(
callback_data.result, client_id, expected_token_type=kwargs.get("token_type"))


def _signout_silently(client_id, account_id, correlation_id=None):
correlation_id = correlation_id or _get_new_correlation_id()
account = _read_account_by_id(account_id, correlation_id)
if isinstance(account, pymsalruntime.MSALRuntimeError):
return _convert_error(account, client_id)
if account is None:
return
callback_data = _CallbackData()
pymsalruntime.signout_silently( # New in PyMsalRuntime 0.7
client_id,
correlation_id,
account,
lambda result, callback_data=callback_data: callback_data.complete(result))
callback_data.signal.wait()
error = callback_data.result.get_error()
if error:
return _convert_error(error, client_id)

20 changes: 12 additions & 8 deletions msal/token_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
def is_subdict_of(small, big):
return dict(big, **small) == big

def _get_username(id_token_claims):
return id_token_claims.get(
"preferred_username", # AAD
id_token_claims.get("upn")) # ADFS 2019

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

target = ' '.join(event.get("scope") or []) # Per schema, we don't sort it
Expand Down Expand Up @@ -190,10 +193,11 @@ def __add(self, event, now=None):
"home_account_id": home_account_id,
"environment": environment,
"realm": realm,
"local_account_id": id_token_claims.get(
"oid", id_token_claims.get("sub")),
"username": id_token_claims.get("preferred_username") # AAD
or id_token_claims.get("upn") # ADFS 2019
"local_account_id": event.get(
"_account_id", # Came from mid-tier code path.
# Emperically, it is the oid in AAD or cid in MSA.
id_token_claims.get("oid", id_token_claims.get("sub"))),
"username": _get_username(id_token_claims)
or data.get("username") # Falls back to ROPC username
or event.get("username") # Falls back to Federated ROPC username
or "", # The schema does not like null
Expand Down
6 changes: 6 additions & 0 deletions sample/interactive_sample.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""
Prerequisite is documented here:
https://msal-python.readthedocs.io/en/latest/#msal.PublicClientApplication.acquire_token_interactive
The configuration file would look like this:
{
Expand Down Expand Up @@ -30,6 +33,8 @@
# Create a preferably long-lived app instance which maintains a token cache.
app = msal.PublicClientApplication(
config["client_id"], authority=config["authority"],
#allow_broker=True, # If opted in, you will be guided to meet the prerequisites, when applicable
# See also: https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-wam#wam-value-proposition
# token_cache=... # Default cache is in memory only.
# You can learn how to use SerializableTokenCache from
# https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache
Expand All @@ -55,6 +60,7 @@
print("A local browser window will be open for you to sign in. CTRL+C to cancel.")
result = app.acquire_token_interactive( # Only works if your app is registered with redirect_uri as http://localhost
config["scope"],
#parent_window_handle=..., # If broker is enabled, you will be guided to provide a window handle
login_hint=config.get("username"), # Optional.
# If you know the username ahead of time, this parameter can pre-fill
# the username (or email address) field of the sign-in page for the user,
Expand Down
10 changes: 9 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@
# https://cryptography.io/en/latest/api-stability/#deprecation

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

25 changes: 19 additions & 6 deletions tests/msaltest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import getpass, logging, pprint, sys, msal


AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd"

def _input_boolean(message):
return input(
"{} (N/n/F/f or empty means False, otherwise it is True): ".format(message)
Expand Down Expand Up @@ -74,12 +77,17 @@ def _acquire_token_interactive(app, scopes, data=None):
# login_hint is unnecessary when prompt=select_account,
# but we still let tester input login_hint, just for testing purpose.
[None] + [a["username"] for a in app.get_accounts()],
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.)",
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.)",
accept_nonempty_string=True,
)
login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint
result = app.acquire_token_interactive(
scopes, prompt=prompt, login_hint=login_hint, data=data or {})
scopes,
parent_window_handle=app.CONSOLE_WINDOW_HANDLE, # This test app is a console app
enable_msa_passthrough=app.client_id in [ # Apps are expected to set this right
AZURE_CLI, VISUAL_STUDIO,
], # Here this test app mimics the setting for some known MSA-PT apps
prompt=prompt, login_hint=login_hint, data=data or {})
if login_hint and "id_token_claims" in result:
signed_in_user = result.get("id_token_claims", {}).get("preferred_username")
if signed_in_user != login_hint:
Expand Down Expand Up @@ -127,17 +135,21 @@ def remove_account(app):
app.remove_account(account)
print('Account "{}" and/or its token(s) are signed out from MSAL Python'.format(account["username"]))

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

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

0 comments on commit 14cbf59

Please sign in to comment.