Skip to content

Commit

Permalink
Merge pull request #553 from AzureAD/release-1.22.0
Browse files Browse the repository at this point in the history
MSAL Python 1.22.0
  • Loading branch information
rayluo authored Apr 17, 2023
2 parents 5782059 + 1bb5476 commit dabc08c
Show file tree
Hide file tree
Showing 10 changed files with 150 additions and 67 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Not sure whether this is the SDK you are looking for your app? There are other M

Quick links:

| [Getting Started](https://docs.microsoft.com/azure/active-directory/develop/quickstart-v2-python-webapp) | [Docs](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) | [Samples](https://aka.ms/aaddevsamplesv2) | [Support](README.md#community-help-and-support) | [Feedback](https://forms.office.com/r/TMjZkDbzjY) |
| [Getting Started](https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python)| [Docs](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) | [Samples](https://aka.ms/aaddevsamplesv2) | [Support](README.md#community-help-and-support) | [Feedback](https://forms.office.com/r/TMjZkDbzjY) |
| --- | --- | --- | --- | --- |

## Scenarios supported
Expand Down
8 changes: 4 additions & 4 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ You can find high level conceptual documentations in the project
Scenarios
=========

There are many `different application scenarios <https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-flows-app-scenarios>`_.
There are many `different application scenarios <https://docs.microsoft.com/azure/active-directory/develop/authentication-flows-app-scenarios>`_.
MSAL Python supports some of them.
**The following diagram serves as a map. Locate your application scenario on the map.**
**If the corresponding icon is clickable, it will bring you to an MSAL Python sample for that scenario.**
Expand All @@ -24,15 +24,15 @@ MSAL Python supports some of them.

.. raw:: html

<!-- Original diagram came from https://docs.microsoft.com/en-us/azure/active-directory/develop/media/scenarios/scenarios-with-users.svg -->
<!-- Original diagram came from https://docs.microsoft.com/azure/active-directory/develop/media/scenarios/scenarios-with-users.svg -->
<!-- Don't know how to include images into Sphinx, so we host it from github repo instead -->
<img src="https://raw.githubusercontent.com/AzureAD/microsoft-authentication-library-for-python/dev/docs/scenarios-with-users.svg"
usemap="#public-map"><!-- Derived from http://www.image-map.net/ but we had to manually add unique map id -->
<map name="public-map">
<area target="_blank" coords="110,150,59,94" shape="rect"
alt="Web app" title="Web app" href="https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-python-webapp">
alt="Web app" title="Web app" href="https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python">
<area target="_blank" coords="58,281,108,338" shape="rect"
alt="Web app" title="Web app" href="https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-python-webapp">
alt="Web app" title="Web app" href="https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python">
<area target="_blank" coords="57,529,127,470" shape="rect"
alt="Desktop App" title="Desktop App" href="https://github.com/AzureAD/microsoft-authentication-library-for-python/blob/dev/sample/interactive_sample.py">
<!-- TODO: Upgrade this sample to use Interactive Flow: https://github.com/Azure-Samples/ms-identity-python-desktop/blob/master/1-Call-MsGraph-WithUsernamePassword/username_password_sample.py -->
Expand Down
6 changes: 3 additions & 3 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@


# The __init__.py will import this. Not the other way around.
__version__ = "1.21.0" # When releasing, also check and bump our dependencies's versions if needed
__version__ = "1.22.0" # When releasing, also check and bump our dependencies's versions if needed

logger = logging.getLogger(__name__)
_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL"
Expand Down Expand Up @@ -1182,7 +1182,7 @@ def _acquire_token_by_cloud_shell(self, scopes, data=None):
client_id=self.client_id,
scope=response["scope"].split() if "scope" in response else scopes,
token_endpoint=self.authority.token_endpoint,
response=response.copy(),
response=response,
data=data or {},
authority_type=_AUTHORITY_TYPE_CLOUDSHELL,
))
Expand Down Expand Up @@ -1399,7 +1399,7 @@ def _process_broker_response(self, response, scopes, data):
client_id=self.client_id,
scope=response["scope"].split() if "scope" in response else scopes,
token_endpoint=self.authority.token_endpoint,
response=response.copy(),
response=response,
data=data,
_account_id=response["_account_id"],
))
Expand Down
71 changes: 37 additions & 34 deletions msal/authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from urlparse import urlparse
import logging

from .exceptions import MsalServiceError


logger = logging.getLogger(__name__)

Expand All @@ -28,7 +26,9 @@
"b2clogin.cn",
"b2clogin.us",
"b2clogin.de",
"ciamlogin.com",
]
_CIAM_DOMAIN_SUFFIX = ".ciamlogin.com"


class AuthorityBuilder(object):
Expand All @@ -52,12 +52,6 @@ class Authority(object):
"""
_domains_without_user_realm_discovery = set([])

@property
def http_client(self): # Obsolete. We will remove this eventually
warnings.warn(
"authority.http_client might be removed in MSAL Python 1.21+", DeprecationWarning)
return self._http_client

def __init__(
self, authority_url, http_client,
validate_authority=True,
Expand All @@ -80,7 +74,8 @@ def __init__(
if isinstance(authority_url, AuthorityBuilder):
authority_url = str(authority_url)
authority, self.instance, tenant = canonicalize(authority_url)
self.is_adfs = tenant.lower() == 'adfs'
is_ciam = self.instance.endswith(_CIAM_DOMAIN_SUFFIX)
self.is_adfs = tenant.lower() == 'adfs' and not is_ciam
parts = authority.path.split('/')
self._is_b2c = any(
self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS
Expand Down Expand Up @@ -109,13 +104,13 @@ def __init__(
% authority_url)
tenant_discovery_endpoint = payload['tenant_discovery_endpoint']
else:
tenant_discovery_endpoint = (
'https://{}:{}{}{}/.well-known/openid-configuration'.format(
self.instance,
443 if authority.port is None else authority.port,
authority.path, # In B2C scenario, it is "/tenant/policy"
"" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint
))
tenant_discovery_endpoint = authority._replace(
path="{prefix}{version}/.well-known/openid-configuration".format(
prefix=tenant if is_ciam and len(authority.path) <= 1 # Path-less CIAM
else authority.path, # In B2C, it is "/tenant/policy"
version="" if self.is_adfs else "/v2.0",
)
).geturl() # Keeping original port and query. Query is useful for test.
try:
openid_config = tenant_discovery(
tenant_discovery_endpoint,
Expand Down Expand Up @@ -150,18 +145,28 @@ def user_realm_discovery(self, username, correlation_id=None, response=None):
return {} # This can guide the caller to fall back normal ROPC flow


def canonicalize(authority_url):
def canonicalize(authority_or_auth_endpoint):
# Returns (url_parsed_result, hostname_in_lowercase, tenant)
authority = urlparse(authority_url)
parts = authority.path.split("/")
if authority.scheme != "https" or len(parts) < 2 or not parts[1]:
raise ValueError(
"Your given address (%s) should consist of "
"an https url with a minimum of one segment in a path: e.g. "
"https://login.microsoftonline.com/<tenant> "
"or https://<tenant_name>.b2clogin.com/<tenant_name>.onmicrosoft.com/policy"
% authority_url)
return authority, authority.hostname, parts[1]
authority = urlparse(authority_or_auth_endpoint)
if authority.scheme == "https":
parts = authority.path.split("/")
first_part = parts[1] if len(parts) >= 2 and parts[1] else None
if authority.hostname.endswith(_CIAM_DOMAIN_SUFFIX): # CIAM
# Use path in CIAM authority. It will be validated by OIDC Discovery soon
tenant = first_part if first_part else "{}.onmicrosoft.com".format(
# Fallback to sub domain name. This variation may not be advertised
authority.hostname.rsplit(_CIAM_DOMAIN_SUFFIX, 1)[0])
return authority, authority.hostname, tenant
# AAD
if len(parts) >= 2 and parts[1]:
return authority, authority.hostname, parts[1]
raise ValueError(
"Your given address (%s) should consist of "
"an https url with a minimum of one segment in a path: e.g. "
"https://login.microsoftonline.com/<tenant> "
"or https://<tenant_name>.ciamlogin.com/<tenant> "
"or https://<tenant_name>.b2clogin.com/<tenant_name>.onmicrosoft.com/policy"
% authority_or_auth_endpoint)

def _instance_discovery(url, http_client, instance_discovery_endpoint, **kwargs):
resp = http_client.get(
Expand All @@ -174,16 +179,14 @@ def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs):
# Returns Openid Configuration
resp = http_client.get(tenant_discovery_endpoint, **kwargs)
if resp.status_code == 200:
payload = json.loads(resp.text) # It could raise ValueError
if 'authorization_endpoint' in payload and 'token_endpoint' in payload:
return payload # Happy path
raise ValueError("OIDC Discovery does not provide enough information")
return json.loads(resp.text) # It could raise ValueError
if 400 <= resp.status_code < 500:
# Nonexist tenant would hit this path
# e.g. https://login.microsoftonline.com/nonexist_tenant/v2.0/.well-known/openid-configuration
raise ValueError(
"OIDC Discovery endpoint rejects our request. Error: {}".format(
resp.text # Expose it as-is b/c OIDC defines no error response format
raise ValueError("OIDC Discovery failed on {}. HTTP status: {}, Error: {}".format(
tenant_discovery_endpoint,
resp.status_code,
resp.text, # Expose it as-is b/c OIDC defines no error response format
))
# Transient network error would hit this path
resp.raise_for_status()
Expand Down
45 changes: 23 additions & 22 deletions msal/token_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,29 +103,30 @@ def find(self, credential_type, target=None, query=None):

def add(self, event, now=None):
# type: (dict) -> None
"""Handle a token obtaining event, and add tokens into cache.
Known side effects: This function modifies the input event in place.
"""
def wipe(dictionary, sensitive_fields): # Masks sensitive info
for sensitive in sensitive_fields:
if sensitive in dictionary:
dictionary[sensitive] = "********"
wipe(event.get("data", {}),
("password", "client_secret", "refresh_token", "assertion"))
try:
return self.__add(event, now=now)
finally:
wipe(event.get("response", {}), ( # These claims were useful during __add()
"""Handle a token obtaining event, and add tokens into cache."""
def make_clean_copy(dictionary, sensitive_fields): # Masks sensitive info
return {
k: "********" if k in sensitive_fields else v
for k, v in dictionary.items()
}
clean_event = dict(
event,
data=make_clean_copy(event.get("data", {}), (
"password", "client_secret", "refresh_token", "assertion",
)),
response=make_clean_copy(event.get("response", {}), (
"id_token_claims", # Provided by broker
"access_token", "refresh_token", "id_token", "username"))
wipe(event, ["username"]) # Needed for federated ROPC
logger.debug("event=%s", json.dumps(
# We examined and concluded that this log won't have Log Injection risk,
# because the event payload is already in JSON so CR/LF will be escaped.
event, indent=4, sort_keys=True,
default=str, # A workaround when assertion is in bytes in Python 3
))
"access_token", "refresh_token", "id_token", "username",
)),
)
logger.debug("event=%s", json.dumps(
# We examined and concluded that this log won't have Log Injection risk,
# because the event payload is already in JSON so CR/LF will be escaped.
clean_event,
indent=4, sort_keys=True,
default=str, # assertion is in bytes in Python 3
))
return self.__add(event, now=now)

def __parse_account(self, response, id_token_claims):
"""Return client_info and home_account_id"""
Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ universal=1
[metadata]
project_urls =
Changelog = https://github.com/AzureAD/microsoft-authentication-library-for-python/releases
Documentation = https://msal-python.readthedocs.io/
Questions = https://stackoverflow.com/questions/tagged/msal+python
Feature/Bug Tracker = https://github.com/AzureAD/microsoft-authentication-library-for-python/issues
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
'requests>=2.0.0,<3',
'PyJWT[crypto]>=1.0.0,<3', # MSAL does not use jwt.decode(), therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+

'cryptography>=0.6,<41',
'cryptography>=0.6,<43',
# load_pem_private_key() is available since 0.6
# https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29
#
Expand Down
9 changes: 7 additions & 2 deletions tests/msaltest.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,18 @@ def _select_options(
return raw_data

def _input_scopes():
return _select_options([
scopes = _select_options([
"https://graph.microsoft.com/.default",
"https://management.azure.com/.default",
"User.Read",
"User.ReadBasic.All",
],
header="Select a scope (multiple scopes can only be input by manually typing them, delimited by space):",
accept_nonempty_string=True,
).split()
).split() # It also converts the input string(s) into a list
if "https://pas.windows.net/CheckMyAccess/Linux/.default" in scopes:
raise ValueError("SSH Cert scope shall be tested by its dedicated functions")
return scopes

def _select_account(app):
accounts = app.get_accounts()
Expand Down Expand Up @@ -183,6 +186,8 @@ def main():
], option_renderer=lambda f: f.__doc__, header="MSAL Python APIs:")
try:
func(app)
except ValueError as e:
logging.error("Invalid input: %s", e)
except KeyboardInterrupt: # Useful for bailing out a stuck interactive flow
print("Aborted")

Expand Down
20 changes: 20 additions & 0 deletions tests/test_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ def test_invalid_host_skipping_validation_can_be_turned_off(self):
pass # Those are expected for this unittest case


@patch("msal.authority.tenant_discovery", return_value={
"authorization_endpoint": "https://contoso.com/placeholder",
"token_endpoint": "https://contoso.com/placeholder",
})
class TestCiamAuthority(unittest.TestCase):
http_client = MinimalHttpClient()

def test_path_less_authority_should_work(self, oidc_discovery):
Authority('https://contoso.ciamlogin.com', self.http_client)
oidc_discovery.assert_called_once_with(
"https://contoso.ciamlogin.com/contoso.onmicrosoft.com/v2.0/.well-known/openid-configuration",
self.http_client)

def test_authority_with_path_should_be_used_as_is(self, oidc_discovery):
Authority('https://contoso.ciamlogin.com/anything', self.http_client)
oidc_discovery.assert_called_once_with(
"https://contoso.ciamlogin.com/anything/v2.0/.well-known/openid-configuration",
self.http_client)


class TestAuthorityInternalHelperCanonicalize(unittest.TestCase):

def test_canonicalize_tenant_followed_by_extra_paths(self):
Expand Down
51 changes: 51 additions & 0 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,57 @@ def test_b2c_allows_using_client_id_as_scope(self):
)


class CiamTestCase(LabBasedTestCase):
# Test cases below show you what scenarios need to be covered for CIAM.
# Detail test behaviors have already been implemented in preexisting helpers.

@classmethod
def setUpClass(cls):
super(CiamTestCase, cls).setUpClass()
cls.user = cls.get_lab_user(
federationProvider="ciam", signinAudience="azureadmyorg", publicClient="No")
# FYI: Only single- or multi-tenant CIAM app can have other-than-OIDC
# delegated permissions on Microsoft Graph.
cls.app_config = cls.get_lab_app_object(cls.user["client_id"])

def test_ciam_acquire_token_interactive(self):
self._test_acquire_token_interactive(
authority=self.app_config["authority"],
client_id=self.app_config["appId"],
scope=self.app_config["scopes"],
username=self.user["username"],
lab_name=self.user["lab_name"],
)

def test_ciam_acquire_token_for_client(self):
self._test_acquire_token_by_client_secret(
client_id=self.app_config["appId"],
client_secret=self.get_lab_user_secret(
self.app_config["clientSecret"].split("=")[-1]),
authority=self.app_config["authority"],
scope=["{}/.default".format(self.app_config["appId"])], # App permission
)

def test_ciam_acquire_token_by_ropc(self):
# Somehow, this would only work after creating a secret for the test app
# and enabling "Allow public client flows".
# Otherwise it would hit AADSTS7000218.
self._test_username_password(
authority=self.app_config["authority"],
client_id=self.app_config["appId"],
username=self.user["username"],
password=self.get_lab_user_secret(self.user["lab_name"]),
scope=self.app_config["scopes"],
)

def test_ciam_device_flow(self):
self._test_device_flow(
authority=self.app_config["authority"],
client_id=self.app_config["appId"],
scope=self.app_config["scopes"],
)


class WorldWideRegionalEndpointTestCase(LabBasedTestCase):
region = "westus"
timeout = 2 # Short timeout makes this test case responsive on non-VM
Expand Down

0 comments on commit dabc08c

Please sign in to comment.