Skip to content

Commit 6844506

Browse files
committed
Updated the trusted host list and added region check logic
1 parent 2b697fc commit 6844506

File tree

2 files changed

+158
-47
lines changed

2 files changed

+158
-47
lines changed

msal/authority.py

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@
2121
'login-us.microsoftonline.com',
2222
AZURE_US_GOVERNMENT,
2323
])
24+
25+
# Trusted issuer hosts for OIDC issuer validation
26+
# Includes all well-known Microsoft identity provider hosts and national clouds
27+
TRUSTED_ISSUER_HOSTS = frozenset([
28+
# Global/Public cloud
29+
"login.microsoftonline.com",
30+
"login.microsoft.com",
31+
"login.windows.net",
32+
"sts.windows.net",
33+
# China cloud
34+
"login.chinacloudapi.cn",
35+
"login.partner.microsoftonline.cn",
36+
# Germany cloud (legacy)
37+
"login.microsoftonline.de",
38+
# US Government clouds
39+
"login.microsoftonline.us",
40+
"login.usgovcloudapi.net",
41+
"login-us.microsoftonline.com",
42+
])
43+
2444
WELL_KNOWN_B2C_HOSTS = [
2545
"b2clogin.com",
2646
"b2clogin.cn",
@@ -186,47 +206,59 @@ def user_realm_discovery(self, username, correlation_id=None, response=None):
186206
self.__class__._domains_without_user_realm_discovery.add(self.instance)
187207
return {} # This can guide the caller to fall back normal ROPC flow
188208

189-
def has_valid_issuer(self) -> bool:
209+
def has_valid_issuer(self):
190210
"""
191-
Returns True if the issuer from OIDC discovery is valid for this authority.
192-
193-
An issuer is valid if one of the following is true:
194-
- It exactly matches the authority URL
195-
- It has the same scheme and host as the authority (path can be different)
196-
- For CIAM, the issuer follows the pattern of {tenant}.ciamlogin.com (tenant comes from the authority)
197-
"""
198-
if self._oidc_authority_url == self._issuer:
199-
# The issuer matches the authority URL exactly
200-
return True
211+
Returns True if the issuer from OIDC discovery is valid for this authority.
201212
202-
issuer = urlparse(self._issuer) if self._issuer else None
203-
if not issuer:
213+
An issuer is valid if one of the following is true:
214+
- It exactly matches the authority URL (with/without trailing slash)
215+
- It has the same scheme and host as the authority (path can be different)
216+
- The issuer host is a well-known Microsoft authority host
217+
- The issuer host is a regional variant of a well-known host (e.g., westus2.login.microsoft.com)
218+
- For CIAM, the issuer follows the pattern of {tenant}.ciamlogin.com
219+
"""
220+
if not self._issuer or not self._oidc_authority_url:
204221
return False
205222

206-
# Check if issuer has the same scheme and host as the authority
207-
authority = urlparse(self._oidc_authority_url)
208-
if authority.scheme == issuer.scheme and authority.netloc == issuer.netloc:
223+
# Case 1: Exact match (most common case, normalized for trailing slashes)
224+
if self._issuer.rstrip("/") == self._oidc_authority_url.rstrip("/"):
209225
return True
210226

211-
# Check CIAM scenario: issuer follows the pattern {tenant}.ciamlogin.com
212-
# Extract tenant from authority URL - could be in the host or path
213-
tenant = None
214-
if authority.hostname.endswith(_CIAM_DOMAIN_SUFFIX):
215-
# Normal CIAM host: {tenant}.ciamlogin.com
216-
tenant = authority.hostname.rsplit(_CIAM_DOMAIN_SUFFIX, 1)[0]
217-
else:
218-
# Custom CIAM host: extract tenant from path
219-
parts = authority.path.split('/')
220-
if len(parts) >= 2 and parts[1]:
221-
tenant = parts[1] # First path segment after the initial '/'
227+
issuer_parsed = urlparse(self._issuer)
228+
authority_parsed = urlparse(self._oidc_authority_url)
229+
issuer_host = issuer_parsed.hostname.lower() if issuer_parsed.hostname else None
222230

223-
if tenant and issuer.hostname.endswith(_CIAM_DOMAIN_SUFFIX):
224-
# Check if issuer follows the pattern {tenant}.ciamlogin.com
225-
expected_issuer_host = f"{tenant}{_CIAM_DOMAIN_SUFFIX}"
226-
if issuer.hostname == expected_issuer_host:
231+
if not issuer_host:
232+
return False
233+
234+
# Case 2: Issuer is from a trusted Microsoft host - O(1) lookup
235+
if issuer_host in TRUSTED_ISSUER_HOSTS:
236+
return True
237+
238+
# Case 3: Regional variant check - O(1) lookup
239+
# e.g., westus2.login.microsoft.com -> extract "login.microsoft.com"
240+
dot_index = issuer_host.find(".")
241+
if dot_index > 0:
242+
potential_base = issuer_host[dot_index + 1:]
243+
if potential_base in TRUSTED_ISSUER_HOSTS and "." not in issuer_host[:dot_index]:
227244
return True
228245

229-
# None of the conditions matched
246+
# Case 4: Same scheme and host (path can differ)
247+
if (authority_parsed.scheme == issuer_parsed.scheme and
248+
authority_parsed.netloc == issuer_parsed.netloc):
249+
return True
250+
251+
# Case 5: CIAM scenario - issuer follows pattern {tenant}.ciamlogin.com
252+
if issuer_host.endswith(_CIAM_DOMAIN_SUFFIX):
253+
authority_host = authority_parsed.hostname.lower() if authority_parsed.hostname else ""
254+
if authority_host.endswith(_CIAM_DOMAIN_SUFFIX):
255+
tenant = authority_host[:-len(_CIAM_DOMAIN_SUFFIX)]
256+
else:
257+
parts = authority_parsed.path.split('/')
258+
tenant = parts[1] if len(parts) >= 2 and parts[1] else None
259+
260+
if tenant and issuer_host == tenant + _CIAM_DOMAIN_SUFFIX:
261+
return True
230262
return False
231263

232264
def canonicalize(authority_or_auth_endpoint):

tests/test_authority.py

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import msal
88
from msal.authority import *
9-
from msal.authority import _CIAM_DOMAIN_SUFFIX # Explicitly import the private constant
9+
from msal.authority import _CIAM_DOMAIN_SUFFIX, TRUSTED_ISSUER_HOSTS # Explicitly import private/new constants
1010
from tests import unittest
1111
from tests.http_client import MinimalHttpClient
1212

@@ -372,43 +372,122 @@ def test_invalid_issuer(self, tenant_discovery_mock):
372372

373373
@patch("msal.authority.tenant_discovery")
374374
def test_custom_authority_with_microsoft_issuer(self, tenant_discovery_mock):
375-
"""Test when custom authority is used with a known Microsoft issuer (should fail)"""
375+
"""Test when custom authority is used with a known Microsoft issuer (should succeed)"""
376376
authority_url = "https://custom-domain.com/tenant"
377377
issuer = f"https://{WORLD_WIDE}/tenant"
378+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
379+
self.assertTrue(authority.has_valid_issuer(),
380+
"Issuer from trusted Microsoft host should be valid even with custom authority")
381+
382+
@patch("msal.authority.tenant_discovery")
383+
def test_known_authority_with_non_matching_issuer(self, tenant_discovery_mock):
384+
"""Test when known authority is used with an issuer that doesn't match (should fail)"""
385+
# Known Microsoft authority URLs
386+
authority_url = f"https://{WORLD_WIDE}/tenant"
387+
issuer = "https://custom-domain.com/tenant"
378388

379389
tenant_discovery_mock.return_value = {
380390
"authorization_endpoint": "https://example.com/oauth2/authorize",
381391
"token_endpoint": "https://example.com/oauth2/token",
382392
"issuer": issuer,
383393
}
384394

385-
# Since initialization now checks for valid issuer and we removed the check for known hosts,
386-
# we expect it to raise ValueError because the hosts don't match
395+
# We expect it to raise ValueError because the paths don't match
396+
# and we're now checking for exact matches
387397
with self.assertRaises(ValueError) as context:
388398
Authority(None, self.http_client, oidc_authority_url=authority_url)
389399

390400
self.assertIn("issuer", str(context.exception).lower())
391401
self.assertIn(issuer, str(context.exception))
392402
self.assertIn(authority_url, str(context.exception))
393403

404+
# Regional pattern tests
394405
@patch("msal.authority.tenant_discovery")
395-
def test_known_authority_with_non_matching_issuer(self, tenant_discovery_mock):
396-
"""Test when known authority is used with an issuer that doesn't match (should fail)"""
397-
# Known Microsoft authority URLs
398-
authority_url = f"https://{WORLD_WIDE}/tenant"
399-
issuer = "https://custom-domain.com/tenant"
406+
def test_regional_issuer_westus2_login_microsoft(self, tenant_discovery_mock):
407+
"""Test regional variant: westus2.login.microsoft.com"""
408+
authority_url = "https://custom-authority.com/tenant"
409+
issuer = "https://westus2.login.microsoftonline.com/tenant"
410+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
411+
self.assertTrue(authority.has_valid_issuer(),
412+
"Regional issuer westus2.login.microsoftonline.com should be valid")
400413

414+
@patch("msal.authority.tenant_discovery")
415+
def test_regional_issuer_eastus_login_microsoftonline(self, tenant_discovery_mock):
416+
"""Test regional variant: eastus.login.microsoftonline.com"""
417+
authority_url = "https://custom-authority.com/tenant"
418+
issuer = "https://eastus.login.microsoftonline.com/tenant"
419+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
420+
self.assertTrue(authority.has_valid_issuer(),
421+
"Regional issuer eastus.login.microsoftonline.com should be valid")
422+
423+
@patch("msal.authority.tenant_discovery")
424+
def test_regional_issuer_for_china_cloud(self, tenant_discovery_mock):
425+
"""Test regional variant for China cloud: region.login.chinacloudapi.cn"""
426+
authority_url = "https://custom-authority.com/tenant"
427+
issuer = "https://chinanorth.login.chinacloudapi.cn/tenant"
428+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
429+
self.assertTrue(authority.has_valid_issuer(),
430+
"Regional issuer for China cloud should be valid")
431+
432+
@patch("msal.authority.tenant_discovery")
433+
def test_regional_issuer_for_us_government(self, tenant_discovery_mock):
434+
"""Test regional variant for US Government: region.login.microsoftonline.us"""
435+
authority_url = "https://custom-authority.com/tenant"
436+
issuer = "https://usgovvirginia.login.microsoftonline.us/tenant"
437+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
438+
self.assertTrue(authority.has_valid_issuer(),
439+
"Regional issuer for US Government should be valid")
440+
441+
@patch("msal.authority.tenant_discovery")
442+
def test_invalid_regional_pattern_with_dots_in_region(self, tenant_discovery_mock):
443+
"""Test that region with dots is rejected: west.us.2.login.microsoftonline.com"""
444+
authority_url = "https://custom-authority.com/tenant"
445+
issuer = "https://west.us.2.login.microsoftonline.com/tenant"
401446
tenant_discovery_mock.return_value = {
402447
"authorization_endpoint": "https://example.com/oauth2/authorize",
403448
"token_endpoint": "https://example.com/oauth2/token",
404449
"issuer": issuer,
405450
}
451+
with self.assertRaises(ValueError):
452+
Authority(None, self.http_client, oidc_authority_url=authority_url)
406453

407-
# We expect it to raise ValueError because the paths don't match
408-
# and we're now checking for exact matches
409-
with self.assertRaises(ValueError) as context:
454+
@patch("msal.authority.tenant_discovery")
455+
def test_invalid_regional_pattern_untrusted_base(self, tenant_discovery_mock):
456+
"""Test that regional pattern with untrusted base is rejected"""
457+
authority_url = "https://custom-authority.com/tenant"
458+
issuer = "https://westus2.login.evil.com/tenant" # evil.com is not trusted
459+
tenant_discovery_mock.return_value = {
460+
"authorization_endpoint": "https://example.com/oauth2/authorize",
461+
"token_endpoint": "https://example.com/oauth2/token",
462+
"issuer": issuer,
463+
}
464+
with self.assertRaises(ValueError):
410465
Authority(None, self.http_client, oidc_authority_url=authority_url)
411466

412-
self.assertIn("issuer", str(context.exception).lower())
413-
self.assertIn(issuer, str(context.exception))
414-
self.assertIn(authority_url, str(context.exception))
467+
@patch("msal.authority.tenant_discovery")
468+
def test_well_known_host_issuer_directly(self, tenant_discovery_mock):
469+
"""Test issuer from well-known Microsoft host directly (not regional)"""
470+
authority_url = "https://custom-authority.com/tenant"
471+
issuer = f"https://{WORLD_WIDE}/tenant"
472+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
473+
self.assertTrue(authority.has_valid_issuer(),
474+
"Issuer from well-known Microsoft host should be valid")
475+
476+
@patch("msal.authority.tenant_discovery")
477+
def test_issuer_with_trailing_slash_match(self, tenant_discovery_mock):
478+
"""Test issuer validation handles trailing slashes"""
479+
authority_url = "https://example.com/tenant/"
480+
issuer = "https://example.com/tenant" # No trailing slash
481+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
482+
self.assertTrue(authority.has_valid_issuer(),
483+
"Trailing slash difference should not affect exact match")
484+
485+
@patch("msal.authority.tenant_discovery")
486+
def test_issuer_case_sensitivity_host(self, tenant_discovery_mock):
487+
"""Test that host comparison is case-insensitive for regional check"""
488+
authority_url = "https://custom-authority.com/tenant"
489+
issuer = "https://WESTUS2.LOGIN.MICROSOFTONLINE.COM/tenant" # Uppercase
490+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
491+
self.assertTrue(authority.has_valid_issuer(),
492+
"Host comparison should be case-insensitive")
493+

0 commit comments

Comments
 (0)