Skip to content

Commit eff8a1b

Browse files
authored
Merge pull request #457 from AzureAD/release-1.17.0
MSAL Python 1.17.0
2 parents 5b135b2 + 0735871 commit eff8a1b

13 files changed

+127
-83
lines changed

msal/application.py

+28-9
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
from threading import Lock
1212
import os
1313

14-
import requests
15-
1614
from .oauth2cli import Client, JwtAssertionCreator
1715
from .oauth2cli.oidc import decode_part
1816
from .authority import Authority
@@ -26,7 +24,7 @@
2624

2725

2826
# The __init__.py will import this. Not the other way around.
29-
__version__ = "1.16.0"
27+
__version__ = "1.17.0" # When releasing, also check and bump our dependencies's versions if needed
3028

3129
logger = logging.getLogger(__name__)
3230

@@ -80,6 +78,10 @@ def _preferred_browser():
8078
if sys.platform != "linux": # On other platforms, we have no browser preference
8179
return None
8280
browser_path = "/usr/bin/microsoft-edge" # Use a full path owned by sys admin
81+
# Note: /usr/bin/microsoft-edge, /usr/bin/microsoft-edge-stable, etc.
82+
# are symlinks that point to the actual binaries which are found under
83+
# /opt/microsoft/msedge/msedge or /opt/microsoft/msedge-beta/msedge.
84+
# Either method can be used to detect an Edge installation.
8385
user_has_no_preference = "BROWSER" not in os.environ
8486
user_wont_mind_edge = "microsoft-edge" in os.environ.get("BROWSER", "") # Note:
8587
# BROWSER could contain "microsoft-edge" or "/path/to/microsoft-edge".
@@ -231,8 +233,23 @@ def __init__(
231233
232234
:param str authority:
233235
A URL that identifies a token authority. It should be of the format
234-
https://login.microsoftonline.com/your_tenant
235-
By default, we will use https://login.microsoftonline.com/common
236+
``https://login.microsoftonline.com/your_tenant``
237+
By default, we will use ``https://login.microsoftonline.com/common``
238+
239+
*Changed in version 1.17*: you can also use predefined constant
240+
and a builder like this::
241+
242+
from msal.authority import (
243+
AuthorityBuilder,
244+
AZURE_US_GOVERNMENT, AZURE_CHINA, AZURE_PUBLIC)
245+
my_authority = AuthorityBuilder(AZURE_PUBLIC, "contoso.onmicrosoft.com")
246+
# Now you get an equivalent of
247+
# "https://login.microsoftonline.com/contoso.onmicrosoft.com"
248+
249+
# You can feed such an authority to msal's ClientApplication
250+
from msal import PublicClientApplication
251+
app = PublicClientApplication("my_client_id", authority=my_authority, ...)
252+
236253
:param bool validate_authority: (optional) Turns authority validation
237254
on or off. This parameter default to true.
238255
:param TokenCache cache:
@@ -362,10 +379,8 @@ def __init__(
362379
with open(http_cache_filename, "rb") as f:
363380
persisted_http_cache = pickle.load(f) # Take a snapshot
364381
except (
365-
IOError, # A non-exist http cache file
382+
FileNotFoundError, # Or IOError in Python 2
366383
pickle.UnpicklingError, # A corrupted http cache file
367-
EOFError, # An empty http cache file
368-
AttributeError, ImportError, IndexError, # Other corruption
369384
):
370385
persisted_http_cache = {} # Recover by starting afresh
371386
atexit.register(lambda: pickle.dump(
@@ -412,6 +427,8 @@ def __init__(
412427
if http_client:
413428
self.http_client = http_client
414429
else:
430+
import requests # Lazy load
431+
415432
self.http_client = requests.Session()
416433
self.http_client.verify = verify
417434
self.http_client.proxies = proxies
@@ -1207,7 +1224,9 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
12071224
if (result and "error" not in result) or (not access_token_from_cache):
12081225
return result
12091226
except: # The exact HTTP exception is transportation-layer dependent
1210-
logger.exception("Refresh token failed") # Potential AAD outage?
1227+
# Typically network error. Potential AAD outage?
1228+
if not access_token_from_cache: # It means there is no fall back option
1229+
raise # We choose to bubble up the exception
12111230
return access_token_from_cache
12121231

12131232
def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(

msal/authority.py

+37-16
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,23 @@
55
from urlparse import urlparse
66
import logging
77

8-
# Historically some customers patched this module-wide requests instance.
9-
# We keep it here for now. They will be removed in next major release.
10-
import requests
11-
import requests as _requests
12-
138
from .exceptions import MsalServiceError
149

1510

1611
logger = logging.getLogger(__name__)
12+
13+
# Endpoints were copied from here
14+
# https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints
15+
AZURE_US_GOVERNMENT = "login.microsoftonline.us"
16+
AZURE_CHINA = "login.chinacloudapi.cn"
17+
AZURE_PUBLIC = "login.microsoftonline.com"
18+
1719
WORLD_WIDE = 'login.microsoftonline.com' # There was an alias login.windows.net
1820
WELL_KNOWN_AUTHORITY_HOSTS = set([
1921
WORLD_WIDE,
20-
'login.chinacloudapi.cn',
22+
AZURE_CHINA,
2123
'login-us.microsoftonline.com',
22-
'login.microsoftonline.us',
23-
'login.microsoftonline.de',
24+
AZURE_US_GOVERNMENT,
2425
])
2526
WELL_KNOWN_B2C_HOSTS = [
2627
"b2clogin.com",
@@ -30,6 +31,19 @@
3031
]
3132

3233

34+
class AuthorityBuilder(object):
35+
def __init__(self, instance, tenant):
36+
"""A helper to save caller from doing string concatenation.
37+
38+
Usage is documented in :func:`application.ClientApplication.__init__`.
39+
"""
40+
self._instance = instance.rstrip("/")
41+
self._tenant = tenant.strip("/")
42+
43+
def __str__(self):
44+
return "https://{}/{}".format(self._instance, self._tenant)
45+
46+
3347
class Authority(object):
3448
"""This class represents an (already-validated) authority.
3549
@@ -39,9 +53,10 @@ class Authority(object):
3953
_domains_without_user_realm_discovery = set([])
4054

4155
@property
42-
def http_client(self): # Obsolete. We will remove this in next major release.
43-
# A workaround: if module-wide requests is patched, we honor it.
44-
return self._http_client if requests is _requests else requests
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
4560

4661
def __init__(self, authority_url, http_client, validate_authority=True):
4762
"""Creates an authority instance, and also validates it.
@@ -53,6 +68,8 @@ def __init__(self, authority_url, http_client, validate_authority=True):
5368
performed.
5469
"""
5570
self._http_client = http_client
71+
if isinstance(authority_url, AuthorityBuilder):
72+
authority_url = str(authority_url)
5673
authority, self.instance, tenant = canonicalize(authority_url)
5774
parts = authority.path.split('/')
5875
is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS) or (
@@ -62,7 +79,7 @@ def __init__(self, authority_url, http_client, validate_authority=True):
6279
payload = instance_discovery(
6380
"https://{}{}/oauth2/v2.0/authorize".format(
6481
self.instance, authority.path),
65-
self.http_client)
82+
self._http_client)
6683
if payload.get("error") == "invalid_instance":
6784
raise ValueError(
6885
"invalid_instance: "
@@ -82,12 +99,13 @@ def __init__(self, authority_url, http_client, validate_authority=True):
8299
try:
83100
openid_config = tenant_discovery(
84101
tenant_discovery_endpoint,
85-
self.http_client)
102+
self._http_client)
86103
except ValueError:
87104
raise ValueError(
88105
"Unable to get authority configuration for {}. "
89106
"Authority would typically be in a format of "
90-
"https://login.microsoftonline.com/your_tenant_name".format(
107+
"https://login.microsoftonline.com/your_tenant "
108+
"Also please double check your tenant name or GUID is correct.".format(
91109
authority_url))
92110
logger.debug("openid_config = %s", openid_config)
93111
self.authorization_endpoint = openid_config['authorization_endpoint']
@@ -101,7 +119,7 @@ def user_realm_discovery(self, username, correlation_id=None, response=None):
101119
# "federation_protocol", "cloud_audience_urn",
102120
# "federation_metadata_url", "federation_active_auth_url", etc.
103121
if self.instance not in self.__class__._domains_without_user_realm_discovery:
104-
resp = response or self.http_client.get(
122+
resp = response or self._http_client.get(
105123
"https://{netloc}/common/userrealm/{username}?api-version=1.0".format(
106124
netloc=self.instance, username=username),
107125
headers={'Accept': 'application/json',
@@ -148,7 +166,10 @@ def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs):
148166
if 400 <= resp.status_code < 500:
149167
# Nonexist tenant would hit this path
150168
# e.g. https://login.microsoftonline.com/nonexist_tenant/v2.0/.well-known/openid-configuration
151-
raise ValueError("OIDC Discovery endpoint rejects our request")
169+
raise ValueError(
170+
"OIDC Discovery endpoint rejects our request. Error: {}".format(
171+
resp.text # Expose it as-is b/c OIDC defines no error response format
172+
))
152173
# Transient network error would hit this path
153174
resp.raise_for_status()
154175
raise RuntimeError( # A fallback here, in case resp.raise_for_status() is no-op

msal/oauth2cli/assertion.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import uuid
55
import logging
66

7-
import jwt
8-
97

108
logger = logging.getLogger(__name__)
119

@@ -99,6 +97,7 @@ def create_normal_assertion(
9997
Parameters are defined in https://tools.ietf.org/html/rfc7523#section-3
10098
Key-value pairs in additional_claims will be added into payload as-is.
10199
"""
100+
import jwt # Lazy loading
102101
now = time.time()
103102
payload = {
104103
'aud': audience,

msal/oauth2cli/oauth2.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
import string
1818
import hashlib
1919

20-
import requests
21-
2220
from .authcode import AuthCodeReceiver as _AuthCodeReceiver
2321

2422
try:
@@ -159,6 +157,8 @@ def __init__(
159157
"when http_client is in use")
160158
self._http_client = http_client
161159
else:
160+
import requests # Lazy loading
161+
162162
self._http_client = requests.Session()
163163
self._http_client.verify = True if verify is None else verify
164164
self._http_client.proxies = proxies

msal/oauth2cli/oidc.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
4444
err = None # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
4545
_now = int(now or time.time())
4646
skew = 120 # 2 minutes
47+
TIME_SUGGESTION = "Make sure your computer's time and time zone are both correct."
4748
if _now + skew < decoded.get("nbf", _now - 1): # nbf is optional per JWT specs
4849
# This is not an ID token validation, but a JWT validation
4950
# https://tools.ietf.org/html/rfc7519#section-4.1.5
50-
err = "0. The ID token is not yet valid."
51+
err = "0. The ID token is not yet valid. " + TIME_SUGGESTION
5152
if issuer and issuer != decoded["iss"]:
5253
# https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse
5354
err = ('2. The Issuer Identifier for the OpenID Provider, "%s", '
@@ -68,7 +69,7 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
6869
# the TLS server validation MAY be used to validate the issuer
6970
# in place of checking the token signature.
7071
if _now - skew > decoded["exp"]:
71-
err = "9. The current time MUST be before the time represented by the exp Claim."
72+
err = "9. The ID token already expires. " + TIME_SUGGESTION
7273
if nonce and nonce != decoded.get("nonce"):
7374
err = ("11. Nonce must be the same value "
7475
"as the one that was sent in the Authentication Request.")

msal/wstrust_request.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ def send_request(
4444
soap_action = Mex.ACTION_2005
4545
elif '/trust/13/usernamemixed' in endpoint_address:
4646
soap_action = Mex.ACTION_13
47-
assert soap_action in (Mex.ACTION_13, Mex.ACTION_2005), ( # A loose check here
48-
"Unsupported soap action: %s" % soap_action)
47+
if soap_action not in (Mex.ACTION_13, Mex.ACTION_2005):
48+
raise ValueError("Unsupported soap action: %s. "
49+
"Contact your administrator to check your ADFS's MEX settings." % soap_action)
4950
data = _build_rst(
5051
username, password, cloud_audience_urn, endpoint_address, soap_action)
5152
resp = http_client.post(endpoint_address, data=data, headers={

sample/interactive_sample.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
if not result:
5454
logging.info("No suitable token exists in cache. Let's get a new one from AAD.")
5555
print("A local browser window will be open for you to sign in. CTRL+C to cancel.")
56-
result = app.acquire_token_interactive(
56+
result = app.acquire_token_interactive( # Only works if your app is registered with redirect_uri as http://localhost
5757
config["scope"],
5858
login_hint=config.get("username"), # Optional.
5959
# If you know the username ahead of time, this parameter can pre-fill

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
'requests>=2.0.0,<3',
7676
'PyJWT[crypto]>=1.0.0,<3',
7777

78-
'cryptography>=0.6,<38',
78+
'cryptography>=0.6,<39',
7979
# load_pem_private_key() is available since 0.6
8080
# https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29
8181
#

tests/http_client.py

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ def get(self, url, params=None, headers=None, **kwargs):
2020
return MinimalResponse(requests_resp=self.session.get(
2121
url, params=params, headers=headers, timeout=self.timeout))
2222

23+
def close(self): # Not required, but we use it to avoid a warning in unit test
24+
self.session.close()
25+
2326

2427
class MinimalResponse(object): # Not for production use
2528
def __init__(self, requests_resp=None, status_code=None, text=None):

tests/test_application.py

+17-8
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,10 @@ class TestApplicationForRefreshInBehaviors(unittest.TestCase):
331331
account = {"home_account_id": "{}.{}".format(uid, utid)}
332332
rt = "this is a rt"
333333
client_id = "my_app"
334-
app = ClientApplication(client_id, authority=authority_url)
334+
335+
@classmethod
336+
def setUpClass(cls): # Initialization at runtime, not interpret-time
337+
cls.app = ClientApplication(cls.client_id, authority=cls.authority_url)
335338

336339
def setUp(self):
337340
self.app.token_cache = self.cache = msal.SerializableTokenCache()
@@ -485,8 +488,10 @@ def mock_post(url, headers=None, *args, **kwargs):
485488

486489

487490
class TestTelemetryOnClientApplication(unittest.TestCase):
488-
app = ClientApplication(
489-
"client_id", authority="https://login.microsoftonline.com/common")
491+
@classmethod
492+
def setUpClass(cls): # Initialization at runtime, not interpret-time
493+
cls.app = ClientApplication(
494+
"client_id", authority="https://login.microsoftonline.com/common")
490495

491496
def test_acquire_token_by_auth_code_flow(self):
492497
at = "this is an access token"
@@ -509,8 +514,10 @@ def mock_post(url, headers=None, *args, **kwargs):
509514

510515

511516
class TestTelemetryOnPublicClientApplication(unittest.TestCase):
512-
app = PublicClientApplication(
513-
"client_id", authority="https://login.microsoftonline.com/common")
517+
@classmethod
518+
def setUpClass(cls): # Initialization at runtime, not interpret-time
519+
cls.app = PublicClientApplication(
520+
"client_id", authority="https://login.microsoftonline.com/common")
514521

515522
# For now, acquire_token_interactive() is verified by code review.
516523

@@ -534,9 +541,11 @@ def mock_post(url, headers=None, *args, **kwargs):
534541

535542

536543
class TestTelemetryOnConfidentialClientApplication(unittest.TestCase):
537-
app = ConfidentialClientApplication(
538-
"client_id", client_credential="secret",
539-
authority="https://login.microsoftonline.com/common")
544+
@classmethod
545+
def setUpClass(cls): # Initialization at runtime, not interpret-time
546+
cls.app = ConfidentialClientApplication(
547+
"client_id", client_credential="secret",
548+
authority="https://login.microsoftonline.com/common")
540549

541550
def test_acquire_token_for_client(self):
542551
at = "this is an access token"

0 commit comments

Comments
 (0)