Skip to content

Commit a0a59f1

Browse files
authored
Merge pull request #500 from AzureAD/release-1.19.0
MSAL Python Release 1.19.0
2 parents eae0e25 + f0f00af commit a0a59f1

File tree

9 files changed

+180
-46
lines changed

9 files changed

+180
-46
lines changed

.github/workflows/python-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
runs-on: ubuntu-latest
2727
strategy:
2828
matrix:
29-
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11.0-alpha.5"]
29+
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11-dev"]
3030

3131
steps:
3232
- uses: actions/checkout@v2

docs/conf.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@
4040
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
4141
# ones.
4242
extensions = [
43-
'sphinx.ext.autodoc',
43+
'sphinx.ext.autodoc', # This seems need to be the first extension to load
4444
'sphinx.ext.githubpages',
45+
'sphinx_paramlinks',
4546
]
4647

4748
# Add any paths that contain templates here, relative to this directory.
@@ -182,4 +183,4 @@
182183
epub_exclude_files = ['search.html']
183184

184185

185-
# -- Extension configuration -------------------------------------------------
186+
# -- Extension configuration -------------------------------------------------

docs/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
furo
2-
-r ../requirements.txt
2+
sphinx-paramlinks
3+
-r ../requirements.txt

msal/application.py

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from .oauth2cli import Client, JwtAssertionCreator
1515
from .oauth2cli.oidc import decode_part
16-
from .authority import Authority
16+
from .authority import Authority, WORLD_WIDE
1717
from .mex import send_request as mex_send_request
1818
from .wstrust_request import send_request as wst_send_request
1919
from .wstrust_response import *
@@ -25,7 +25,7 @@
2525

2626

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

3030
logger = logging.getLogger(__name__)
3131
_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL"
@@ -146,7 +146,6 @@ def obtain_token_by_username_password(self, username, password, **kwargs):
146146

147147

148148
class ClientApplication(object):
149-
150149
ACQUIRE_TOKEN_SILENT_ID = "84"
151150
ACQUIRE_TOKEN_BY_REFRESH_TOKEN = "85"
152151
ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID = "301"
@@ -174,6 +173,7 @@ def __init__(
174173
# when we would eventually want to add this feature to PCA in future.
175174
exclude_scopes=None,
176175
http_cache=None,
176+
instance_discovery=None,
177177
):
178178
"""Create an instance of application.
179179
@@ -300,7 +300,7 @@ def __init__(
300300
Client capability is implemented using "claims" parameter on the wire,
301301
for now.
302302
MSAL will combine them into
303-
`claims parameter <https://openid.net/specs/openid-connect-core-1_0-final.html#ClaimsParameter`_
303+
`claims parameter <https://openid.net/specs/openid-connect-core-1_0-final.html#ClaimsParameter>`_
304304
which you will later provide via one of the acquire-token request.
305305
306306
:param str azure_region:
@@ -409,11 +409,40 @@ def __init__(
409409
Personally Identifiable Information (PII). Encryption is unnecessary.
410410
411411
New in version 1.16.0.
412+
413+
:param boolean instance_discovery:
414+
Historically, MSAL would connect to a central endpoint located at
415+
``https://login.microsoftonline.com`` to acquire some metadata,
416+
especially when using an unfamiliar authority.
417+
This behavior is known as Instance Discovery.
418+
419+
This parameter defaults to None, which enables the Instance Discovery.
420+
421+
If you know some authorities which you allow MSAL to operate with as-is,
422+
without involving any Instance Discovery, the recommended pattern is::
423+
424+
known_authorities = frozenset([ # Treat your known authorities as const
425+
"https://contoso.com/adfs", "https://login.azs/foo"])
426+
...
427+
authority = "https://contoso.com/adfs" # Assuming your app will use this
428+
app1 = PublicClientApplication(
429+
"client_id",
430+
authority=authority,
431+
# Conditionally disable Instance Discovery for known authorities
432+
instance_discovery=authority not in known_authorities,
433+
)
434+
435+
If you do not know some authorities beforehand,
436+
yet still want MSAL to accept any authority that you will provide,
437+
you can use a ``False`` to unconditionally disable Instance Discovery.
438+
439+
New in version 1.19.0.
412440
"""
413441
self.client_id = client_id
414442
self.client_credential = client_credential
415443
self.client_claims = client_claims
416444
self._client_capabilities = client_capabilities
445+
self._instance_discovery = instance_discovery
417446

418447
if exclude_scopes and not isinstance(exclude_scopes, list):
419448
raise ValueError(
@@ -453,18 +482,24 @@ def __init__(
453482

454483
# Here the self.authority will not be the same type as authority in input
455484
try:
485+
authority_to_use = authority or "https://{}/common/".format(WORLD_WIDE)
456486
self.authority = Authority(
457-
authority or "https://login.microsoftonline.com/common/",
458-
self.http_client, validate_authority=validate_authority)
487+
authority_to_use,
488+
self.http_client,
489+
validate_authority=validate_authority,
490+
instance_discovery=self._instance_discovery,
491+
)
459492
except ValueError: # Those are explicit authority validation errors
460493
raise
461494
except Exception: # The rest are typically connection errors
462495
if validate_authority and azure_region:
463496
# Since caller opts in to use region, here we tolerate connection
464497
# errors happened during authority validation at non-region endpoint
465498
self.authority = Authority(
466-
authority or "https://login.microsoftonline.com/common/",
467-
self.http_client, validate_authority=False)
499+
authority_to_use,
500+
self.http_client,
501+
instance_discovery=False,
502+
)
468503
else:
469504
raise
470505

@@ -526,16 +561,19 @@ def _get_regional_authority(self, central_authority):
526561
if region_to_use:
527562
regional_host = ("{}.r.login.microsoftonline.com".format(region_to_use)
528563
if central_authority.instance in (
529-
# The list came from https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629400328
564+
# The list came from point 3 of the algorithm section in this internal doc
565+
# https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PinAuthToRegion/AAD%20SDK%20Proposal%20to%20Pin%20Auth%20to%20region.md&anchor=algorithm&_a=preview
530566
"login.microsoftonline.com",
567+
"login.microsoft.com",
531568
"login.windows.net",
532569
"sts.windows.net",
533570
)
534571
else "{}.{}".format(region_to_use, central_authority.instance))
535-
return Authority(
572+
return Authority( # The central_authority has already been validated
536573
"https://{}/{}".format(regional_host, central_authority.tenant),
537574
self.http_client,
538-
validate_authority=False) # The central_authority has already been validated
575+
instance_discovery=False,
576+
)
539577
return None
540578

541579
def _build_client(self, client_credential, authority, skip_regional_client=False):
@@ -787,7 +825,8 @@ def get_authorization_request_url(
787825
# Multi-tenant app can use new authority on demand
788826
the_authority = Authority(
789827
authority,
790-
self.http_client
828+
self.http_client,
829+
instance_discovery=self._instance_discovery,
791830
) if authority else self.authority
792831

793832
client = _ClientWithCcsRoutingInfo(
@@ -1010,14 +1049,23 @@ def _find_msal_accounts(self, environment):
10101049
}
10111050
return list(grouped_accounts.values())
10121051

1052+
def _get_instance_metadata(self): # This exists so it can be mocked in unit test
1053+
resp = self.http_client.get(
1054+
"https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize", # TBD: We may extend this to use self._instance_discovery endpoint
1055+
headers={'Accept': 'application/json'})
1056+
resp.raise_for_status()
1057+
return json.loads(resp.text)['metadata']
1058+
10131059
def _get_authority_aliases(self, instance):
1060+
if self._instance_discovery is False:
1061+
return []
1062+
if self.authority._is_known_to_developer:
1063+
# Then it is an ADFS/B2C/known_authority_hosts situation
1064+
# which may not reach the central endpoint, so we skip it.
1065+
return []
10141066
if not self.authority_groups:
1015-
resp = self.http_client.get(
1016-
"https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize",
1017-
headers={'Accept': 'application/json'})
1018-
resp.raise_for_status()
10191067
self.authority_groups = [
1020-
set(group['aliases']) for group in json.loads(resp.text)['metadata']]
1068+
set(group['aliases']) for group in self._get_instance_metadata()]
10211069
for group in self.authority_groups:
10221070
if instance in group:
10231071
return [alias for alias in group if alias != instance]
@@ -1166,6 +1214,7 @@ def acquire_token_silent_with_error(
11661214
# the_authority = Authority(
11671215
# authority,
11681216
# self.http_client,
1217+
# instance_discovery=self._instance_discovery,
11691218
# ) if authority else self.authority
11701219
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
11711220
scopes, account, self.authority, force_refresh=force_refresh,
@@ -1187,7 +1236,8 @@ def acquire_token_silent_with_error(
11871236
the_authority = Authority(
11881237
"https://" + alias + "/" + self.authority.tenant,
11891238
self.http_client,
1190-
validate_authority=False)
1239+
instance_discovery=False,
1240+
)
11911241
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
11921242
scopes, account, the_authority, force_refresh=force_refresh,
11931243
claims_challenge=claims_challenge,
@@ -1340,7 +1390,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
13401390
reverse=True):
13411391
logger.debug("Cache attempts an RT")
13421392
headers = telemetry_context.generate_headers()
1343-
if "home_account_id" in query: # Then use it as CCS Routing info
1393+
if query.get("home_account_id"): # Then use it as CCS Routing info
13441394
headers["X-AnchorMailbox"] = "Oid:{}".format( # case-insensitive value
13451395
query["home_account_id"].replace(".", "@"))
13461396
response = client.obtain_token_by_refresh_token(

msal/authority.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ def http_client(self): # Obsolete. We will remove this eventually
5858
"authority.http_client might be removed in MSAL Python 1.21+", DeprecationWarning)
5959
return self._http_client
6060

61-
def __init__(self, authority_url, http_client, validate_authority=True):
61+
def __init__(
62+
self, authority_url, http_client,
63+
validate_authority=True,
64+
instance_discovery=None,
65+
):
6266
"""Creates an authority instance, and also validates it.
6367
6468
:param validate_authority:
@@ -67,19 +71,34 @@ def __init__(self, authority_url, http_client, validate_authority=True):
6771
This parameter only controls whether an instance discovery will be
6872
performed.
6973
"""
74+
# :param instance_discovery:
75+
# By default, the known-to-Microsoft validation will use an
76+
# instance discovery endpoint located at ``login.microsoftonline.com``.
77+
# You can customize the endpoint by providing a url as a string.
78+
# Or you can turn this behavior off by passing in a False here.
7079
self._http_client = http_client
7180
if isinstance(authority_url, AuthorityBuilder):
7281
authority_url = str(authority_url)
7382
authority, self.instance, tenant = canonicalize(authority_url)
83+
self.is_adfs = tenant.lower() == 'adfs'
7484
parts = authority.path.split('/')
75-
is_b2c = any(self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS) or (
76-
len(parts) == 3 and parts[2].lower().startswith("b2c_"))
77-
if (tenant != "adfs" and (not is_b2c) and validate_authority
78-
and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS):
79-
payload = instance_discovery(
85+
is_b2c = any(
86+
self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS
87+
) or (len(parts) == 3 and parts[2].lower().startswith("b2c_"))
88+
self._is_known_to_developer = self.is_adfs or is_b2c or not validate_authority
89+
is_known_to_microsoft = self.instance in WELL_KNOWN_AUTHORITY_HOSTS
90+
instance_discovery_endpoint = 'https://{}/common/discovery/instance'.format( # Note: This URL seemingly returns V1 endpoint only
91+
WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too
92+
# See https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadInstanceDiscovery.cs#L101-L103
93+
# and https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadAuthority.cs#L19-L33
94+
) if instance_discovery in (None, True) else instance_discovery
95+
if instance_discovery_endpoint and not (
96+
is_known_to_microsoft or self._is_known_to_developer):
97+
payload = _instance_discovery(
8098
"https://{}{}/oauth2/v2.0/authorize".format(
8199
self.instance, authority.path),
82-
self._http_client)
100+
self._http_client,
101+
instance_discovery_endpoint)
83102
if payload.get("error") == "invalid_instance":
84103
raise ValueError(
85104
"invalid_instance: "
@@ -91,8 +110,9 @@ def __init__(self, authority_url, http_client, validate_authority=True):
91110
tenant_discovery_endpoint = payload['tenant_discovery_endpoint']
92111
else:
93112
tenant_discovery_endpoint = (
94-
'https://{}{}{}/.well-known/openid-configuration'.format(
113+
'https://{}:{}{}{}/.well-known/openid-configuration'.format(
95114
self.instance,
115+
443 if authority.port is None else authority.port,
96116
authority.path, # In B2C scenario, it is "/tenant/policy"
97117
"" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint
98118
))
@@ -112,7 +132,6 @@ def __init__(self, authority_url, http_client, validate_authority=True):
112132
self.token_endpoint = openid_config['token_endpoint']
113133
self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint')
114134
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
115-
self.is_adfs = self.tenant.lower() == 'adfs'
116135

117136
def user_realm_discovery(self, username, correlation_id=None, response=None):
118137
# It will typically return a dict containing "ver", "account_type",
@@ -144,13 +163,9 @@ def canonicalize(authority_url):
144163
% authority_url)
145164
return authority, authority.hostname, parts[1]
146165

147-
def instance_discovery(url, http_client, **kwargs):
148-
resp = http_client.get( # Note: This URL seemingly returns V1 endpoint only
149-
'https://{}/common/discovery/instance'.format(
150-
WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too
151-
# See https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadInstanceDiscovery.cs#L101-L103
152-
# and https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadAuthority.cs#L19-L33
153-
),
166+
def _instance_discovery(url, http_client, instance_discovery_endpoint, **kwargs):
167+
resp = http_client.get(
168+
instance_discovery_endpoint,
154169
params={'authorization_endpoint': url, 'api-version': '1.0'},
155170
**kwargs)
156171
return json.loads(resp.text)

msal/oauth2cli/assertion.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def create_normal_assertion(
115115
payload, self.key, algorithm=self.algorithm, headers=self.headers)
116116
return _str2bytes(str_or_bytes) # We normalize them into bytes
117117
except:
118-
if self.algorithm.startswith("RS") or self.algorithm.starswith("ES"):
118+
if self.algorithm.startswith("RS") or self.algorithm.startswith("ES"):
119119
logger.exception(
120120
'Some algorithms requires "pip install cryptography". '
121121
'See https://pyjwt.readthedocs.io/en/latest/installation.html#cryptographic-dependencies-optional')

msal/oauth2cli/oauth2.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,7 @@ def __init__(
139139
"""
140140
if not server_configuration:
141141
raise ValueError("Missing input parameter server_configuration")
142-
if not client_id:
143-
raise ValueError("Missing input parameter client_id")
142+
# Generally we should have client_id, but we tolerate its absence
144143
self.configuration = server_configuration
145144
self.client_id = client_id
146145
self.client_secret = client_secret

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@
7474
# See https://stackoverflow.com/a/14211600/728675 for more detail
7575
install_requires=[
7676
'requests>=2.0.0,<3',
77-
'PyJWT[crypto]>=1.0.0,<3',
77+
'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+
7878

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

0 commit comments

Comments
 (0)