Skip to content

Commit dd7b538

Browse files
authored
Merge pull request #496 from AzureAD/instance-discovery-endpoint
Implement instance_discovery=False
2 parents d148d55 + de8d4b2 commit dd7b538

File tree

3 files changed

+158
-30
lines changed

3 files changed

+158
-30
lines changed

msal/application.py

Lines changed: 63 additions & 15 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 *
@@ -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
@@ -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

@@ -534,10 +569,11 @@ def _get_regional_authority(self, central_authority):
534569
"sts.windows.net",
535570
)
536571
else "{}.{}".format(region_to_use, central_authority.instance))
537-
return Authority(
572+
return Authority( # The central_authority has already been validated
538573
"https://{}/{}".format(regional_host, central_authority.tenant),
539574
self.http_client,
540-
validate_authority=False) # The central_authority has already been validated
575+
instance_discovery=False,
576+
)
541577
return None
542578

543579
def _build_client(self, client_credential, authority, skip_regional_client=False):
@@ -789,7 +825,8 @@ def get_authorization_request_url(
789825
# Multi-tenant app can use new authority on demand
790826
the_authority = Authority(
791827
authority,
792-
self.http_client
828+
self.http_client,
829+
instance_discovery=self._instance_discovery,
793830
) if authority else self.authority
794831

795832
client = _ClientWithCcsRoutingInfo(
@@ -1012,14 +1049,23 @@ def _find_msal_accounts(self, environment):
10121049
}
10131050
return list(grouped_accounts.values())
10141051

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+
10151059
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 []
10161066
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()
10211067
self.authority_groups = [
1022-
set(group['aliases']) for group in json.loads(resp.text)['metadata']]
1068+
set(group['aliases']) for group in self._get_instance_metadata()]
10231069
for group in self.authority_groups:
10241070
if instance in group:
10251071
return [alias for alias in group if alias != instance]
@@ -1168,6 +1214,7 @@ def acquire_token_silent_with_error(
11681214
# the_authority = Authority(
11691215
# authority,
11701216
# self.http_client,
1217+
# instance_discovery=self._instance_discovery,
11711218
# ) if authority else self.authority
11721219
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
11731220
scopes, account, self.authority, force_refresh=force_refresh,
@@ -1189,7 +1236,8 @@ def acquire_token_silent_with_error(
11891236
the_authority = Authority(
11901237
"https://" + alias + "/" + self.authority.tenant,
11911238
self.http_client,
1192-
validate_authority=False)
1239+
instance_discovery=False,
1240+
)
11931241
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
11941242
scopes, account, the_authority, force_refresh=force_refresh,
11951243
claims_challenge=claims_challenge,

msal/authority.py

Lines changed: 29 additions & 15 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: "
@@ -113,7 +132,6 @@ def __init__(self, authority_url, http_client, validate_authority=True):
113132
self.token_endpoint = openid_config['token_endpoint']
114133
self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint')
115134
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
116-
self.is_adfs = self.tenant.lower() == 'adfs'
117135

118136
def user_realm_discovery(self, username, correlation_id=None, response=None):
119137
# It will typically return a dict containing "ver", "account_type",
@@ -145,13 +163,9 @@ def canonicalize(authority_url):
145163
% authority_url)
146164
return authority, authority.hostname, parts[1]
147165

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

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)