Skip to content

Commit 9794dcf

Browse files
committed
Merge branch 'dev' into wam
2 parents 9c34873 + 06590ab commit 9794dcf

File tree

5 files changed

+173
-42
lines changed

5 files changed

+173
-42
lines changed

msal/application.py

Lines changed: 69 additions & 23 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 *
@@ -150,7 +150,6 @@ def obtain_token_by_username_password(self, username, password, **kwargs):
150150

151151

152152
class ClientApplication(object):
153-
154153
ACQUIRE_TOKEN_SILENT_ID = "84"
155154
ACQUIRE_TOKEN_BY_REFRESH_TOKEN = "85"
156155
ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID = "301"
@@ -178,6 +177,7 @@ def __init__(
178177
# when we would eventually want to add this feature to PCA in future.
179178
exclude_scopes=None,
180179
http_cache=None,
180+
instance_discovery=None,
181181
allow_broker=None,
182182
):
183183
"""Create an instance of application.
@@ -415,6 +415,34 @@ def __init__(
415415
416416
New in version 1.16.0.
417417
418+
:param boolean instance_discovery:
419+
Historically, MSAL would connect to a central endpoint located at
420+
``https://login.microsoftonline.com`` to acquire some metadata,
421+
especially when using an unfamiliar authority.
422+
This behavior is known as Instance Discovery.
423+
424+
This parameter defaults to None, which enables the Instance Discovery.
425+
426+
If you know some authorities which you allow MSAL to operate with as-is,
427+
without involving any Instance Discovery, the recommended pattern is::
428+
429+
known_authorities = frozenset([ # Treat your known authorities as const
430+
"https://contoso.com/adfs", "https://login.azs/foo"])
431+
...
432+
authority = "https://contoso.com/adfs" # Assuming your app will use this
433+
app1 = PublicClientApplication(
434+
"client_id",
435+
authority=authority,
436+
# Conditionally disable Instance Discovery for known authorities
437+
instance_discovery=authority not in known_authorities,
438+
)
439+
440+
If you do not know some authorities beforehand,
441+
yet still want MSAL to accept any authority that you will provide,
442+
you can use a ``False`` to unconditionally disable Instance Discovery.
443+
444+
New in version 1.19.0.
445+
418446
:param boolean allow_broker:
419447
Brokers provide Single-Sign-On, device identification,
420448
and application identification verification.
@@ -448,6 +476,7 @@ def __init__(
448476
self.client_credential = client_credential
449477
self.client_claims = client_claims
450478
self._client_capabilities = client_capabilities
479+
self._instance_discovery = instance_discovery
451480

452481
if exclude_scopes and not isinstance(exclude_scopes, list):
453482
raise ValueError(
@@ -487,18 +516,24 @@ def __init__(
487516

488517
# Here the self.authority will not be the same type as authority in input
489518
try:
519+
authority_to_use = authority or "https://{}/common/".format(WORLD_WIDE)
490520
self.authority = Authority(
491-
authority or "https://login.microsoftonline.com/common/",
492-
self.http_client, validate_authority=validate_authority)
521+
authority_to_use,
522+
self.http_client,
523+
validate_authority=validate_authority,
524+
instance_discovery=self._instance_discovery,
525+
)
493526
except ValueError: # Those are explicit authority validation errors
494527
raise
495528
except Exception: # The rest are typically connection errors
496529
if validate_authority and azure_region:
497530
# Since caller opts in to use region, here we tolerate connection
498531
# errors happened during authority validation at non-region endpoint
499532
self.authority = Authority(
500-
authority or "https://login.microsoftonline.com/common/",
501-
self.http_client, validate_authority=False)
533+
authority_to_use,
534+
self.http_client,
535+
instance_discovery=False,
536+
)
502537
else:
503538
raise
504539
is_confidential_app = bool(
@@ -584,10 +619,11 @@ def _get_regional_authority(self, central_authority):
584619
"sts.windows.net",
585620
)
586621
else "{}.{}".format(region_to_use, central_authority.instance))
587-
return Authority(
622+
return Authority( # The central_authority has already been validated
588623
"https://{}/{}".format(regional_host, central_authority.tenant),
589624
self.http_client,
590-
validate_authority=False) # The central_authority has already been validated
625+
instance_discovery=False,
626+
)
591627
return None
592628

593629
def _build_client(self, client_credential, authority, skip_regional_client=False):
@@ -839,7 +875,8 @@ def get_authorization_request_url(
839875
# Multi-tenant app can use new authority on demand
840876
the_authority = Authority(
841877
authority,
842-
self.http_client
878+
self.http_client,
879+
instance_discovery=self._instance_discovery,
843880
) if authority else self.authority
844881

845882
client = _ClientWithCcsRoutingInfo(
@@ -1062,14 +1099,23 @@ def _find_msal_accounts(self, environment):
10621099
}
10631100
return list(grouped_accounts.values())
10641101

1102+
def _get_instance_metadata(self): # This exists so it can be mocked in unit test
1103+
resp = self.http_client.get(
1104+
"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
1105+
headers={'Accept': 'application/json'})
1106+
resp.raise_for_status()
1107+
return json.loads(resp.text)['metadata']
1108+
10651109
def _get_authority_aliases(self, instance):
1110+
if self._instance_discovery is False:
1111+
return []
1112+
if self.authority._is_known_to_developer:
1113+
# Then it is an ADFS/B2C/known_authority_hosts situation
1114+
# which may not reach the central endpoint, so we skip it.
1115+
return []
10661116
if not self.authority_groups:
1067-
resp = self.http_client.get(
1068-
"https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize",
1069-
headers={'Accept': 'application/json'})
1070-
resp.raise_for_status()
10711117
self.authority_groups = [
1072-
set(group['aliases']) for group in json.loads(resp.text)['metadata']]
1118+
set(group['aliases']) for group in self._get_instance_metadata()]
10731119
for group in self.authority_groups:
10741120
if instance in group:
10751121
return [alias for alias in group if alias != instance]
@@ -1223,6 +1269,7 @@ def acquire_token_silent_with_error(
12231269
# the_authority = Authority(
12241270
# authority,
12251271
# self.http_client,
1272+
# instance_discovery=self._instance_discovery,
12261273
# ) if authority else self.authority
12271274
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
12281275
scopes, account, self.authority, force_refresh=force_refresh,
@@ -1244,7 +1291,8 @@ def acquire_token_silent_with_error(
12441291
the_authority = Authority(
12451292
"https://" + alias + "/" + self.authority.tenant,
12461293
self.http_client,
1247-
validate_authority=False)
1294+
instance_discovery=False,
1295+
)
12481296
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
12491297
scopes, account, the_authority, force_refresh=force_refresh,
12501298
claims_challenge=claims_challenge,
@@ -1539,10 +1587,9 @@ def acquire_token_by_username_password(
15391587
scopes, # Decorated scopes won't work due to offline_access
15401588
MSALRuntime_Username=username,
15411589
MSALRuntime_Password=password,
1542-
validateAuthority="no"
1543-
if self.authority._validate_authority is False
1544-
or self.authority.is_adfs or self.authority._is_b2c
1545-
else None,
1590+
validateAuthority="no" if (
1591+
self.authority._is_known_to_developer
1592+
or self._instance_discovery is False) else None,
15461593
claims=claims,
15471594
)
15481595
return self._process_broker_response(response, scopes, kwargs.get("data", {}))
@@ -1807,10 +1854,9 @@ def _acquire_token_interactive_via_broker(
18071854
logger.debug(kwargs["welcome_template"]) # Experimental
18081855
authority = "https://{}/{}".format(
18091856
self.authority.instance, self.authority.tenant)
1810-
validate_authority = (
1811-
"no" if self.authority._validate_authority is False
1812-
or self.authority.is_adfs or self.authority._is_b2c
1813-
else None)
1857+
validate_authority = "no" if (
1858+
self.authority._is_known_to_developer
1859+
or self._instance_discovery is False) else None
18141860
# Calls different broker methods to mimic the OIDC behaviors
18151861
if login_hint and prompt != "select_account": # OIDC prompts when the user did not sign in
18161862
accounts = self.get_accounts(username=login_hint)

msal/authority.py

Lines changed: 29 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,20 +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-
self._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-
self._validate_authority = True if validate_authority is None else bool(validate_authority)
78-
if (tenant != "adfs" and (not self._is_b2c) and self._validate_authority
79-
and self.instance not in WELL_KNOWN_AUTHORITY_HOSTS):
80-
payload = instance_discovery(
85+
self._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 self._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(
8198
"https://{}{}/oauth2/v2.0/authorize".format(
8299
self.instance, authority.path),
83-
self._http_client)
100+
self._http_client,
101+
instance_discovery_endpoint)
84102
if payload.get("error") == "invalid_instance":
85103
raise ValueError(
86104
"invalid_instance: "
@@ -114,7 +132,6 @@ def __init__(self, authority_url, http_client, validate_authority=True):
114132
self.token_endpoint = openid_config['token_endpoint']
115133
self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint')
116134
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
117-
self.is_adfs = self.tenant.lower() == 'adfs'
118135

119136
def user_realm_discovery(self, username, correlation_id=None, response=None):
120137
# It will typically return a dict containing "ver", "account_type",
@@ -146,13 +163,9 @@ def canonicalize(authority_url):
146163
% authority_url)
147164
return authority, authority.hostname, parts[1]
148165

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

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
'requests>=2.0.0,<3',
7777
'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
#

tests/test_authority.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import os
2+
try:
3+
from unittest.mock import patch
4+
except:
5+
from mock import patch
26

7+
import msal
38
from msal.authority import *
49
from tests import unittest
510
from tests.http_client import MinimalHttpClient
@@ -123,3 +128,64 @@ class MockResponse(object):
123128
finally: # MUST NOT let the previous test changes affect other test cases
124129
Authority._domains_without_user_realm_discovery = set([])
125130

131+
132+
@patch("msal.authority.tenant_discovery", return_value={
133+
"authorization_endpoint": "https://contoso.com/placeholder",
134+
"token_endpoint": "https://contoso.com/placeholder",
135+
})
136+
@patch("msal.authority._instance_discovery")
137+
@patch.object(msal.ClientApplication, "_get_instance_metadata", return_value=[])
138+
class TestMsalBehaviorsWithoutAndWithInstanceDiscoveryBoolean(unittest.TestCase):
139+
"""Test cases use ClientApplication, which is a base class of both PCA and CCA"""
140+
141+
def test_by_default_a_known_to_microsoft_authority_should_skip_validation_but_still_use_instance_metadata(
142+
self, instance_metadata, known_to_microsoft_validation, _):
143+
app = msal.ClientApplication("id", authority="https://login.microsoftonline.com/common")
144+
known_to_microsoft_validation.assert_not_called()
145+
app.get_accounts() # This could make an instance metadata call for authority aliases
146+
instance_metadata.assert_called_once_with()
147+
148+
def test_validate_authority_boolean_should_skip_validation_and_instance_metadata(
149+
self, instance_metadata, known_to_microsoft_validation, _):
150+
"""Pending deprecation, but kept for backward compatibility, for now"""
151+
app = msal.ClientApplication(
152+
"id", authority="https://contoso.com/common", validate_authority=False)
153+
known_to_microsoft_validation.assert_not_called()
154+
app.get_accounts() # This could make an instance metadata call for authority aliases
155+
instance_metadata.assert_not_called()
156+
157+
def test_by_default_adfs_should_skip_validation_and_instance_metadata(
158+
self, instance_metadata, known_to_microsoft_validation, _):
159+
"""Not strictly required, but when/if we already supported it, we better keep it"""
160+
app = msal.ClientApplication("id", authority="https://contoso.com/adfs")
161+
known_to_microsoft_validation.assert_not_called()
162+
app.get_accounts() # This could make an instance metadata call for authority aliases
163+
instance_metadata.assert_not_called()
164+
165+
def test_by_default_b2c_should_skip_validation_and_instance_metadata(
166+
self, instance_metadata, known_to_microsoft_validation, _):
167+
"""Not strictly required, but when/if we already supported it, we better keep it"""
168+
app = msal.ClientApplication(
169+
"id", authority="https://login.b2clogin.com/contoso/b2c_policy")
170+
known_to_microsoft_validation.assert_not_called()
171+
app.get_accounts() # This could make an instance metadata call for authority aliases
172+
instance_metadata.assert_not_called()
173+
174+
def test_turning_off_instance_discovery_should_work_for_all_kinds_of_clouds(
175+
self, instance_metadata, known_to_microsoft_validation, _):
176+
for authority in [
177+
"https://login.microsoftonline.com/common", # Known to Microsoft
178+
"https://contoso.com/adfs", # ADFS
179+
"https://login.b2clogin.com/contoso/b2c_policy", # B2C
180+
"https://private.cloud/foo", # Private Cloud
181+
]:
182+
self._test_turning_off_instance_discovery_should_skip_authority_validation_and_instance_metadata(
183+
authority, instance_metadata, known_to_microsoft_validation)
184+
185+
def _test_turning_off_instance_discovery_should_skip_authority_validation_and_instance_metadata(
186+
self, authority, instance_metadata, known_to_microsoft_validation):
187+
app = msal.ClientApplication("id", authority=authority, instance_discovery=False)
188+
known_to_microsoft_validation.assert_not_called()
189+
app.get_accounts() # This could make an instance metadata call for authority aliases
190+
instance_metadata.assert_not_called()
191+

0 commit comments

Comments
 (0)