Skip to content

Commit dabc08c

Browse files
authored
Merge pull request #553 from AzureAD/release-1.22.0
MSAL Python 1.22.0
2 parents 5782059 + 1bb5476 commit dabc08c

10 files changed

+150
-67
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Not sure whether this is the SDK you are looking for your app? There are other M
1111

1212
Quick links:
1313

14-
| [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) |
14+
| [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) |
1515
| --- | --- | --- | --- | --- |
1616

1717
## Scenarios supported

docs/index.rst

+4-4
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ You can find high level conceptual documentations in the project
1515
Scenarios
1616
=========
1717

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

2525
.. raw:: html
2626

27-
<!-- Original diagram came from https://docs.microsoft.com/en-us/azure/active-directory/develop/media/scenarios/scenarios-with-users.svg -->
27+
<!-- Original diagram came from https://docs.microsoft.com/azure/active-directory/develop/media/scenarios/scenarios-with-users.svg -->
2828
<!-- Don't know how to include images into Sphinx, so we host it from github repo instead -->
2929
<img src="https://raw.githubusercontent.com/AzureAD/microsoft-authentication-library-for-python/dev/docs/scenarios-with-users.svg"
3030
usemap="#public-map"><!-- Derived from http://www.image-map.net/ but we had to manually add unique map id -->
3131
<map name="public-map">
3232
<area target="_blank" coords="110,150,59,94" shape="rect"
33-
alt="Web app" title="Web app" href="https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-python-webapp">
33+
alt="Web app" title="Web app" href="https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python">
3434
<area target="_blank" coords="58,281,108,338" shape="rect"
35-
alt="Web app" title="Web app" href="https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-python-webapp">
35+
alt="Web app" title="Web app" href="https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python">
3636
<area target="_blank" coords="57,529,127,470" shape="rect"
3737
alt="Desktop App" title="Desktop App" href="https://github.com/AzureAD/microsoft-authentication-library-for-python/blob/dev/sample/interactive_sample.py">
3838
<!-- 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 -->

msal/application.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626

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

3030
logger = logging.getLogger(__name__)
3131
_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL"
@@ -1182,7 +1182,7 @@ def _acquire_token_by_cloud_shell(self, scopes, data=None):
11821182
client_id=self.client_id,
11831183
scope=response["scope"].split() if "scope" in response else scopes,
11841184
token_endpoint=self.authority.token_endpoint,
1185-
response=response.copy(),
1185+
response=response,
11861186
data=data or {},
11871187
authority_type=_AUTHORITY_TYPE_CLOUDSHELL,
11881188
))
@@ -1399,7 +1399,7 @@ def _process_broker_response(self, response, scopes, data):
13991399
client_id=self.client_id,
14001400
scope=response["scope"].split() if "scope" in response else scopes,
14011401
token_endpoint=self.authority.token_endpoint,
1402-
response=response.copy(),
1402+
response=response,
14031403
data=data,
14041404
_account_id=response["_account_id"],
14051405
))

msal/authority.py

+37-34
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
from urlparse import urlparse
66
import logging
77

8-
from .exceptions import MsalServiceError
9-
108

119
logger = logging.getLogger(__name__)
1210

@@ -28,7 +26,9 @@
2826
"b2clogin.cn",
2927
"b2clogin.us",
3028
"b2clogin.de",
29+
"ciamlogin.com",
3130
]
31+
_CIAM_DOMAIN_SUFFIX = ".ciamlogin.com"
3232

3333

3434
class AuthorityBuilder(object):
@@ -52,12 +52,6 @@ class Authority(object):
5252
"""
5353
_domains_without_user_realm_discovery = set([])
5454

55-
@property
56-
def http_client(self): # Obsolete. We will remove this eventually
57-
warnings.warn(
58-
"authority.http_client might be removed in MSAL Python 1.21+", DeprecationWarning)
59-
return self._http_client
60-
6155
def __init__(
6256
self, authority_url, http_client,
6357
validate_authority=True,
@@ -80,7 +74,8 @@ def __init__(
8074
if isinstance(authority_url, AuthorityBuilder):
8175
authority_url = str(authority_url)
8276
authority, self.instance, tenant = canonicalize(authority_url)
83-
self.is_adfs = tenant.lower() == 'adfs'
77+
is_ciam = self.instance.endswith(_CIAM_DOMAIN_SUFFIX)
78+
self.is_adfs = tenant.lower() == 'adfs' and not is_ciam
8479
parts = authority.path.split('/')
8580
self._is_b2c = any(
8681
self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS
@@ -109,13 +104,13 @@ def __init__(
109104
% authority_url)
110105
tenant_discovery_endpoint = payload['tenant_discovery_endpoint']
111106
else:
112-
tenant_discovery_endpoint = (
113-
'https://{}:{}{}{}/.well-known/openid-configuration'.format(
114-
self.instance,
115-
443 if authority.port is None else authority.port,
116-
authority.path, # In B2C scenario, it is "/tenant/policy"
117-
"" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint
118-
))
107+
tenant_discovery_endpoint = authority._replace(
108+
path="{prefix}{version}/.well-known/openid-configuration".format(
109+
prefix=tenant if is_ciam and len(authority.path) <= 1 # Path-less CIAM
110+
else authority.path, # In B2C, it is "/tenant/policy"
111+
version="" if self.is_adfs else "/v2.0",
112+
)
113+
).geturl() # Keeping original port and query. Query is useful for test.
119114
try:
120115
openid_config = tenant_discovery(
121116
tenant_discovery_endpoint,
@@ -150,18 +145,28 @@ def user_realm_discovery(self, username, correlation_id=None, response=None):
150145
return {} # This can guide the caller to fall back normal ROPC flow
151146

152147

153-
def canonicalize(authority_url):
148+
def canonicalize(authority_or_auth_endpoint):
154149
# Returns (url_parsed_result, hostname_in_lowercase, tenant)
155-
authority = urlparse(authority_url)
156-
parts = authority.path.split("/")
157-
if authority.scheme != "https" or len(parts) < 2 or not parts[1]:
158-
raise ValueError(
159-
"Your given address (%s) should consist of "
160-
"an https url with a minimum of one segment in a path: e.g. "
161-
"https://login.microsoftonline.com/<tenant> "
162-
"or https://<tenant_name>.b2clogin.com/<tenant_name>.onmicrosoft.com/policy"
163-
% authority_url)
164-
return authority, authority.hostname, parts[1]
150+
authority = urlparse(authority_or_auth_endpoint)
151+
if authority.scheme == "https":
152+
parts = authority.path.split("/")
153+
first_part = parts[1] if len(parts) >= 2 and parts[1] else None
154+
if authority.hostname.endswith(_CIAM_DOMAIN_SUFFIX): # CIAM
155+
# Use path in CIAM authority. It will be validated by OIDC Discovery soon
156+
tenant = first_part if first_part else "{}.onmicrosoft.com".format(
157+
# Fallback to sub domain name. This variation may not be advertised
158+
authority.hostname.rsplit(_CIAM_DOMAIN_SUFFIX, 1)[0])
159+
return authority, authority.hostname, tenant
160+
# AAD
161+
if len(parts) >= 2 and parts[1]:
162+
return authority, authority.hostname, parts[1]
163+
raise ValueError(
164+
"Your given address (%s) should consist of "
165+
"an https url with a minimum of one segment in a path: e.g. "
166+
"https://login.microsoftonline.com/<tenant> "
167+
"or https://<tenant_name>.ciamlogin.com/<tenant> "
168+
"or https://<tenant_name>.b2clogin.com/<tenant_name>.onmicrosoft.com/policy"
169+
% authority_or_auth_endpoint)
165170

166171
def _instance_discovery(url, http_client, instance_discovery_endpoint, **kwargs):
167172
resp = http_client.get(
@@ -174,16 +179,14 @@ def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs):
174179
# Returns Openid Configuration
175180
resp = http_client.get(tenant_discovery_endpoint, **kwargs)
176181
if resp.status_code == 200:
177-
payload = json.loads(resp.text) # It could raise ValueError
178-
if 'authorization_endpoint' in payload and 'token_endpoint' in payload:
179-
return payload # Happy path
180-
raise ValueError("OIDC Discovery does not provide enough information")
182+
return json.loads(resp.text) # It could raise ValueError
181183
if 400 <= resp.status_code < 500:
182184
# Nonexist tenant would hit this path
183185
# e.g. https://login.microsoftonline.com/nonexist_tenant/v2.0/.well-known/openid-configuration
184-
raise ValueError(
185-
"OIDC Discovery endpoint rejects our request. Error: {}".format(
186-
resp.text # Expose it as-is b/c OIDC defines no error response format
186+
raise ValueError("OIDC Discovery failed on {}. HTTP status: {}, Error: {}".format(
187+
tenant_discovery_endpoint,
188+
resp.status_code,
189+
resp.text, # Expose it as-is b/c OIDC defines no error response format
187190
))
188191
# Transient network error would hit this path
189192
resp.raise_for_status()

msal/token_cache.py

+23-22
Original file line numberDiff line numberDiff line change
@@ -103,29 +103,30 @@ def find(self, credential_type, target=None, query=None):
103103

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

130131
def __parse_account(self, response, id_token_claims):
131132
"""Return client_info and home_account_id"""

setup.cfg

+3
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ universal=1
44
[metadata]
55
project_urls =
66
Changelog = https://github.com/AzureAD/microsoft-authentication-library-for-python/releases
7+
Documentation = https://msal-python.readthedocs.io/
8+
Questions = https://stackoverflow.com/questions/tagged/msal+python
9+
Feature/Bug Tracker = https://github.com/AzureAD/microsoft-authentication-library-for-python/issues

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
'requests>=2.0.0,<3',
7878
'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+
7979

80-
'cryptography>=0.6,<41',
80+
'cryptography>=0.6,<43',
8181
# load_pem_private_key() is available since 0.6
8282
# https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29
8383
#

tests/msaltest.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,18 @@ def _select_options(
3434
return raw_data
3535

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

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

tests/test_authority.py

+20
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,26 @@ def test_invalid_host_skipping_validation_can_be_turned_off(self):
7979
pass # Those are expected for this unittest case
8080

8181

82+
@patch("msal.authority.tenant_discovery", return_value={
83+
"authorization_endpoint": "https://contoso.com/placeholder",
84+
"token_endpoint": "https://contoso.com/placeholder",
85+
})
86+
class TestCiamAuthority(unittest.TestCase):
87+
http_client = MinimalHttpClient()
88+
89+
def test_path_less_authority_should_work(self, oidc_discovery):
90+
Authority('https://contoso.ciamlogin.com', self.http_client)
91+
oidc_discovery.assert_called_once_with(
92+
"https://contoso.ciamlogin.com/contoso.onmicrosoft.com/v2.0/.well-known/openid-configuration",
93+
self.http_client)
94+
95+
def test_authority_with_path_should_be_used_as_is(self, oidc_discovery):
96+
Authority('https://contoso.ciamlogin.com/anything', self.http_client)
97+
oidc_discovery.assert_called_once_with(
98+
"https://contoso.ciamlogin.com/anything/v2.0/.well-known/openid-configuration",
99+
self.http_client)
100+
101+
82102
class TestAuthorityInternalHelperCanonicalize(unittest.TestCase):
83103

84104
def test_canonicalize_tenant_followed_by_extra_paths(self):

tests/test_e2e.py

+51
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,57 @@ def test_b2c_allows_using_client_id_as_scope(self):
897897
)
898898

899899

900+
class CiamTestCase(LabBasedTestCase):
901+
# Test cases below show you what scenarios need to be covered for CIAM.
902+
# Detail test behaviors have already been implemented in preexisting helpers.
903+
904+
@classmethod
905+
def setUpClass(cls):
906+
super(CiamTestCase, cls).setUpClass()
907+
cls.user = cls.get_lab_user(
908+
federationProvider="ciam", signinAudience="azureadmyorg", publicClient="No")
909+
# FYI: Only single- or multi-tenant CIAM app can have other-than-OIDC
910+
# delegated permissions on Microsoft Graph.
911+
cls.app_config = cls.get_lab_app_object(cls.user["client_id"])
912+
913+
def test_ciam_acquire_token_interactive(self):
914+
self._test_acquire_token_interactive(
915+
authority=self.app_config["authority"],
916+
client_id=self.app_config["appId"],
917+
scope=self.app_config["scopes"],
918+
username=self.user["username"],
919+
lab_name=self.user["lab_name"],
920+
)
921+
922+
def test_ciam_acquire_token_for_client(self):
923+
self._test_acquire_token_by_client_secret(
924+
client_id=self.app_config["appId"],
925+
client_secret=self.get_lab_user_secret(
926+
self.app_config["clientSecret"].split("=")[-1]),
927+
authority=self.app_config["authority"],
928+
scope=["{}/.default".format(self.app_config["appId"])], # App permission
929+
)
930+
931+
def test_ciam_acquire_token_by_ropc(self):
932+
# Somehow, this would only work after creating a secret for the test app
933+
# and enabling "Allow public client flows".
934+
# Otherwise it would hit AADSTS7000218.
935+
self._test_username_password(
936+
authority=self.app_config["authority"],
937+
client_id=self.app_config["appId"],
938+
username=self.user["username"],
939+
password=self.get_lab_user_secret(self.user["lab_name"]),
940+
scope=self.app_config["scopes"],
941+
)
942+
943+
def test_ciam_device_flow(self):
944+
self._test_device_flow(
945+
authority=self.app_config["authority"],
946+
client_id=self.app_config["appId"],
947+
scope=self.app_config["scopes"],
948+
)
949+
950+
900951
class WorldWideRegionalEndpointTestCase(LabBasedTestCase):
901952
region = "westus"
902953
timeout = 2 # Short timeout makes this test case responsive on non-VM

0 commit comments

Comments
 (0)