Skip to content

Commit b4c46af

Browse files
committed
Implementing known_authorities
Also test behaviors for instance metadata Address proofreading feedback Rename known_authorities to known_authority_hosts Plan to ship this in 1.19 Address feedback from Xiang Yan from Azure SDK team Fine tune some wording known_authority_hosts allows multiple setting but only one modification Refine test case for idempotency More descriptive test case names Use base ClientApplication to test both PCA and CCA
1 parent d148d55 commit b4c46af

File tree

3 files changed

+197
-23
lines changed

3 files changed

+197
-23
lines changed

msal/application.py

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import json
33
import time
44
try: # Python 2
5-
from urlparse import urljoin
5+
from urlparse import urljoin, urlparse
66
except: # Python 3
7-
from urllib.parse import urljoin
7+
from urllib.parse import urljoin, urlparse
88
import logging
99
import sys
1010
import warnings
@@ -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 *
@@ -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"
@@ -160,6 +159,48 @@ class ClientApplication(object):
160159

161160
ATTEMPT_REGION_DISCOVERY = True # "TryAutoDetect"
162161

162+
_known_authority_hosts = None
163+
164+
@classmethod
165+
def set_known_authority_hosts(cls, known_authority_hosts):
166+
"""Declare a list of hosts which you allow MSAL to operate with.
167+
168+
If your app operates with some authorities that you know and own,
169+
such as some ADFS or B2C or private cloud,
170+
it is recommended and sometimes required that you declare them here,
171+
so that MSAL will use your authorities without discovery,
172+
and reject most of the other undefined authorities.
173+
174+
``known_authority_hosts`` is meant to be a static and per-deployment setting.
175+
This classmethod shall be called at most once,
176+
during your entire app's starting-up,
177+
before your initializing any ``PublicClientApplication`` or
178+
``ConfidentialClientApplication`` instance(s).
179+
180+
:param list[str] known_authority_hosts:
181+
Authorities that you known, for example::
182+
183+
[
184+
"contoso.com", # Your own domain
185+
"login.azs", # This can be a private cloud
186+
]
187+
188+
New in version 1.19
189+
"""
190+
new_input = frozenset(known_authority_hosts)
191+
if (cls._known_authority_hosts is not None
192+
and cls._known_authority_hosts != new_input):
193+
raise ValueError(
194+
"The known_authority_hosts are considered static. "
195+
"Once configured, they should not be changed.")
196+
cls._known_authority_hosts = new_input
197+
logger.debug('known_authority_hosts is set to %s', known_authority_hosts)
198+
199+
def _union_known_authority_hosts(cls, url=None, host=None):
200+
host = host if host else urlparse(url).netloc.split(":")[0]
201+
return (cls._known_authority_hosts.union([host])
202+
if cls._known_authority_hosts else frozenset([host]))
203+
163204
def __init__(
164205
self, client_id,
165206
client_credential=None, authority=None, validate_authority=True,
@@ -453,18 +494,24 @@ def __init__(
453494

454495
# Here the self.authority will not be the same type as authority in input
455496
try:
497+
authority_to_use = authority or "https://{}/common/".format(WORLD_WIDE)
456498
self.authority = Authority(
457-
authority or "https://login.microsoftonline.com/common/",
458-
self.http_client, validate_authority=validate_authority)
499+
authority_to_use,
500+
self.http_client, validate_authority=validate_authority,
501+
known_authority_hosts=self.__class__._known_authority_hosts,
502+
)
459503
except ValueError: # Those are explicit authority validation errors
460504
raise
461505
except Exception: # The rest are typically connection errors
462506
if validate_authority and azure_region:
463507
# Since caller opts in to use region, here we tolerate connection
464508
# errors happened during authority validation at non-region endpoint
465509
self.authority = Authority(
466-
authority or "https://login.microsoftonline.com/common/",
467-
self.http_client, validate_authority=False)
510+
authority_to_use,
511+
self.http_client,
512+
known_authority_hosts=self._union_known_authority_hosts(
513+
url=authority_to_use),
514+
)
468515
else:
469516
raise
470517

@@ -534,10 +581,12 @@ def _get_regional_authority(self, central_authority):
534581
"sts.windows.net",
535582
)
536583
else "{}.{}".format(region_to_use, central_authority.instance))
537-
return Authority(
584+
return Authority( # The central_authority has already been validated
538585
"https://{}/{}".format(regional_host, central_authority.tenant),
539586
self.http_client,
540-
validate_authority=False) # The central_authority has already been validated
587+
known_authority_hosts=self._union_known_authority_hosts(
588+
host=regional_host),
589+
)
541590
return None
542591

543592
def _build_client(self, client_credential, authority, skip_regional_client=False):
@@ -789,7 +838,8 @@ def get_authorization_request_url(
789838
# Multi-tenant app can use new authority on demand
790839
the_authority = Authority(
791840
authority,
792-
self.http_client
841+
self.http_client,
842+
known_authority_hosts=self.__class__._known_authority_hosts,
793843
) if authority else self.authority
794844

795845
client = _ClientWithCcsRoutingInfo(
@@ -1012,14 +1062,21 @@ def _find_msal_accounts(self, environment):
10121062
}
10131063
return list(grouped_accounts.values())
10141064

1065+
def _get_instance_metadata(self): # This exists so it can be mocked in unit test
1066+
resp = self.http_client.get(
1067+
"https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize",
1068+
headers={'Accept': 'application/json'})
1069+
resp.raise_for_status()
1070+
return json.loads(resp.text)['metadata']
1071+
10151072
def _get_authority_aliases(self, instance):
1073+
if self.authority._is_known_to_developer:
1074+
# Then it is an ADFS/B2C/known_authority_hosts situation
1075+
# which may not reach the central endpoint, so we skip it.
1076+
return []
10161077
if not self.authority_groups:
1017-
resp = self.http_client.get(
1018-
"https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize",
1019-
headers={'Accept': 'application/json'})
1020-
resp.raise_for_status()
10211078
self.authority_groups = [
1022-
set(group['aliases']) for group in json.loads(resp.text)['metadata']]
1079+
set(group['aliases']) for group in self._get_instance_metadata()]
10231080
for group in self.authority_groups:
10241081
if instance in group:
10251082
return [alias for alias in group if alias != instance]
@@ -1189,7 +1246,8 @@ def acquire_token_silent_with_error(
11891246
the_authority = Authority(
11901247
"https://" + alias + "/" + self.authority.tenant,
11911248
self.http_client,
1192-
validate_authority=False)
1249+
known_authority_hosts=self._union_known_authority_hosts(host=alias),
1250+
)
11931251
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
11941252
scopes, account, the_authority, force_refresh=force_refresh,
11951253
claims_challenge=claims_challenge,

msal/authority.py

Lines changed: 18 additions & 6 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+
known_authority_hosts=None, # Known-to-developer authority hosts
65+
):
6266
"""Creates an authority instance, and also validates it.
6367
6468
:param validate_authority:
@@ -67,15 +71,24 @@ 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+
if known_authority_hosts and not bool(validate_authority):
75+
raise ValueError(
76+
"Since you are using the new known_authority_hosts anyway, "
77+
"you might as well avoid using validate_authority=False completely")
7078
self._http_client = http_client
7179
if isinstance(authority_url, AuthorityBuilder):
7280
authority_url = str(authority_url)
7381
authority, self.instance, tenant = canonicalize(authority_url)
82+
self.is_adfs = tenant.lower() == 'adfs'
7483
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):
84+
is_b2c = any(
85+
self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS
86+
) or (len(parts) == 3 and parts[2].lower().startswith("b2c_"))
87+
self._is_known_to_developer = (
88+
self.instance in known_authority_hosts if known_authority_hosts
89+
else (self.is_adfs or is_b2c or not validate_authority))
90+
is_known_to_microsoft = self.instance in WELL_KNOWN_AUTHORITY_HOSTS
91+
if not (is_known_to_microsoft or self._is_known_to_developer):
7992
payload = instance_discovery(
8093
"https://{}{}/oauth2/v2.0/authorize".format(
8194
self.instance, authority.path),
@@ -113,7 +126,6 @@ def __init__(self, authority_url, http_client, validate_authority=True):
113126
self.token_endpoint = openid_config['token_endpoint']
114127
self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint')
115128
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
116-
self.is_adfs = self.tenant.lower() == 'adfs'
117129

118130
def user_realm_discovery(self, username, correlation_id=None, response=None):
119131
# It will typically return a dict containing "ver", "account_type",

tests/test_authority.py

Lines changed: 104 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, ANY
4+
except:
5+
from mock import patch, ANY
26

7+
import msal
38
from msal.authority import *
49
from tests import unittest
510
from tests.http_client import MinimalHttpClient
@@ -123,3 +128,102 @@ 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 TestMsalBehaviorsWithoutAndWithKnownAuthorityHosts(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_known_authorities_should_allow_multiple_idempotent_calls_but_no_changes(self, *mocks):
175+
known_hosts = ["contoso.com", "fabricam.com"]
176+
msal.ClientApplication.set_known_authority_hosts(known_hosts)
177+
msal.ClientApplication.set_known_authority_hosts(known_hosts) # Allows idempotent calls
178+
msal.ClientApplication.set_known_authority_hosts(
179+
# Optional. Here we treat hosts orderless therefore accept equivalent call
180+
reversed(known_hosts))
181+
with self.assertRaises(ValueError):
182+
msal.ClientApplication.set_known_authority_hosts(["hacked.com"])
183+
184+
def test_known_authority_hosts_should_not_coexist_with_validate_authority_boolean(self, *mocks):
185+
msal.ClientApplication.set_known_authority_hosts(["private.cloud"])
186+
with self.assertRaises(ValueError):
187+
msal.ClientApplication(
188+
"id",
189+
authority="https://unknown.com/common", validate_authority=False)
190+
191+
def test_known_to_developer_authority_should_skip_validation_and_instance_metadata(
192+
self, instance_metadata, known_to_microsoft_validation, _):
193+
msal.ClientApplication.set_known_authority_hosts(["private.cloud"])
194+
app = msal.ClientApplication("foo", authority="https://private.cloud/foo")
195+
known_to_microsoft_validation.assert_not_called()
196+
app.get_accounts() # This could make an instance metadata call for authority aliases
197+
instance_metadata.assert_not_called()
198+
199+
def test_known_to_developer_setting_should_still_let_a_known_to_microsoft_authority_skip_validation_and_use_instance_metadata( # i.e. same as the without-known-to-developer behavior
200+
self, instance_metadata, known_to_microsoft_validation, _):
201+
msal.ClientApplication.set_known_authority_hosts(["private.cloud"])
202+
app = msal.ClientApplication(
203+
"id", authority="https://login.microsoftonline.com/common")
204+
known_to_microsoft_validation.assert_not_called()
205+
app.get_accounts() # This could make an instance metadata call for authority aliases
206+
instance_metadata.assert_called_once_with()
207+
208+
def test_known_authorities_should_make_unknown_adfs_perform_validation_and_instance_metadata(
209+
self, instance_metadata, known_to_microsoft_validation, _):
210+
msal.ClientApplication.set_known_authority_hosts(["private.cloud"])
211+
app = msal.ClientApplication("foo", authority="https://unknown.com/adfs")
212+
known_to_microsoft_validation.assert_called_once_with(ANY, ANY) # Python 3.5+
213+
# We effectively mocked a passed validation, so that MSAL will proceed
214+
app.get_accounts() # This could make an instance metadata call for authority aliases
215+
instance_metadata.assert_called_once_with()
216+
217+
def test_known_authorities_should_make_unknown_b2c_perform_validation_and_instance_metadata(
218+
self, instance_metadata, known_to_microsoft_validation, _):
219+
msal.ClientApplication.set_known_authority_hosts(["private.cloud"])
220+
app = msal.ClientApplication("foo", authority="https://b2clogin.com/contoso/b2c_policy")
221+
known_to_microsoft_validation.assert_called_once_with(ANY, ANY) # Python 3.5+
222+
# We effectively mocked a passed validation, so that MSAL will proceed
223+
app.get_accounts() # This could make an instance metadata call for authority aliases
224+
instance_metadata.assert_called_once_with()
225+
226+
def tearDown(self):
227+
# This is not part of public API. We do it here only for unit-testing.
228+
msal.ClientApplication._known_authority_hosts = None # Reset
229+

0 commit comments

Comments
 (0)