Skip to content

Commit 7af3828

Browse files
committed
Add OIDC issuer validation and related tests
1 parent 70fd4d1 commit 7af3828

File tree

3 files changed

+172
-13
lines changed

3 files changed

+172
-13
lines changed

msal/authority.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def __init__(
6767
performed.
6868
"""
6969
self._http_client = http_client
70+
self._oidc_authority_url = oidc_authority_url
7071
if oidc_authority_url:
7172
logger.debug("Initializing with OIDC authority: %s", oidc_authority_url)
7273
tenant_discovery_endpoint = self._initialize_oidc_authority(
@@ -95,11 +96,19 @@ def __init__(
9596
raise ValueError(error_message)
9697
logger.debug(
9798
'openid_config("%s") = %s', tenant_discovery_endpoint, openid_config)
99+
self._issuer = openid_config.get('issuer')
98100
self.authorization_endpoint = openid_config['authorization_endpoint']
99101
self.token_endpoint = openid_config['token_endpoint']
100102
self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint')
101103
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
102104

105+
# Validate the issuer if using OIDC authority
106+
if self._oidc_authority_url and not self.has_valid_issuer():
107+
raise ValueError((
108+
"The issuer '{iss}' does not match the authority '{auth}' or a known pattern. "
109+
"When using the 'oidc_authority' parameter in ClientApplication, the authority "
110+
"will be validated against the issuer from {auth}/.well-known/openid-configuration ."
111+
).format(iss=self._issuer, auth=oidc_authority_url))
103112
def _initialize_oidc_authority(self, oidc_authority_url):
104113
authority, self.instance, tenant = canonicalize(oidc_authority_url)
105114
self.is_adfs = tenant.lower() == 'adfs' # As a convention
@@ -174,6 +183,53 @@ def user_realm_discovery(self, username, correlation_id=None, response=None):
174183
self.__class__._domains_without_user_realm_discovery.add(self.instance)
175184
return {} # This can guide the caller to fall back normal ROPC flow
176185

186+
def has_valid_issuer(self) -> bool:
187+
"""
188+
Returns True if the issuer from OIDC discovery is valid for this authority.
189+
190+
An issuer is valid if one of the following is true:
191+
- It exactly matches the authority URL
192+
- It has a known Microsoft host (e.g., login.microsoftonline.com)
193+
- It has the same scheme and host as the authority (path can be different)
194+
- For CIAM, the issuer follows the pattern of {tenant}.ciamlogin.com (tenant comes from the authority)
195+
"""
196+
if self._oidc_authority_url == self._issuer:
197+
# The issuer matches the authority URL exactly
198+
return True
199+
200+
issuer = urlparse(self._issuer) if self._issuer else None
201+
if not issuer:
202+
return False
203+
204+
# Check if issuer has a known Microsoft host
205+
if issuer.hostname in WELL_KNOWN_AUTHORITY_HOSTS:
206+
return True
207+
208+
# Check if issuer has the same scheme and host as the authority
209+
authority = urlparse(self._oidc_authority_url)
210+
if authority.scheme == issuer.scheme and authority.netloc == issuer.netloc:
211+
return True
212+
213+
# Check CIAM scenario: issuer follows the pattern {tenant}.ciamlogin.com
214+
# Extract tenant from authority URL - could be in the host or path
215+
tenant = None
216+
if authority.hostname.endswith(_CIAM_DOMAIN_SUFFIX):
217+
# Normal CIAM host: {tenant}.ciamlogin.com
218+
tenant = authority.hostname.rsplit(_CIAM_DOMAIN_SUFFIX, 1)[0]
219+
else:
220+
# Custom CIAM host: extract tenant from path
221+
parts = authority.path.split('/')
222+
if len(parts) >= 2 and parts[1]:
223+
tenant = parts[1] # First path segment after the initial '/'
224+
225+
if tenant and issuer.hostname.endswith(_CIAM_DOMAIN_SUFFIX):
226+
# Check if issuer follows the pattern {tenant}.ciamlogin.com
227+
expected_issuer_host = f"{tenant}{_CIAM_DOMAIN_SUFFIX}"
228+
if issuer.hostname == expected_issuer_host:
229+
return True
230+
231+
# None of the conditions matched
232+
return False
177233

178234
def canonicalize(authority_or_auth_endpoint):
179235
# Returns (url_parsed_result, hostname_in_lowercase, tenant)
@@ -222,4 +278,3 @@ def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs):
222278
resp.raise_for_status()
223279
raise RuntimeError( # A fallback here, in case resp.raise_for_status() is no-op
224280
"Unable to complete OIDC Discovery: %d, %s" % (resp.status_code, resp.text))
225-

tests/test_application.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,7 @@ def test_should_fallback_when_pymsalruntime_failed_to_initialize_broker(self):
796796
@patch("msal.authority.tenant_discovery", new=Mock(return_value={
797797
"authorization_endpoint": "https://contoso.com/placeholder",
798798
"token_endpoint": "https://contoso.com/placeholder",
799+
"issuer": "https://contoso.com/placeholder",
799800
}))
800801
@patch("msal.application._init_broker", new=Mock()) # Pretend pymsalruntime installed and working
801802
class TestBrokerFallbackWithDifferentAuthorities(unittest.TestCase):

tests/test_authority.py

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

77
import msal
88
from msal.authority import *
9+
from msal.authority import _CIAM_DOMAIN_SUFFIX # Explicitly import the private constant
910
from tests import unittest
1011
from tests.http_client import MinimalHttpClient
1112

@@ -100,12 +101,12 @@ def test_authority_with_path_should_be_used_as_is(self, oidc_discovery):
100101

101102

102103
@patch("msal.authority._instance_discovery")
103-
@patch("msal.authority.tenant_discovery", return_value={
104-
"authorization_endpoint": "https://contoso.com/authorize",
105-
"token_endpoint": "https://contoso.com/token",
106-
})
104+
@patch("msal.authority.tenant_discovery") # Moved return_value out of the decorator
107105
class OidcAuthorityTestCase(unittest.TestCase):
108106
authority = "https://contoso.com/tenant"
107+
authorization_endpoint = "https://contoso.com/authorize"
108+
token_endpoint = "https://contoso.com/token"
109+
issuer = "https://contoso.com/tenant" # Added as class variable for inheritance
109110

110111
def setUp(self):
111112
# setUp() gives subclass a dynamic setup based on their authority
@@ -115,25 +116,37 @@ def setUp(self):
115116
# Here the test is to confirm the OIDC endpoint contains no "/v2.0"
116117
self.authority + "/.well-known/openid-configuration")
117118

119+
def setup_tenant_discovery(self, tenant_discovery):
120+
"""Configure the tenant_discovery mock with class-specific values"""
121+
tenant_discovery.return_value = {
122+
"authorization_endpoint": self.authorization_endpoint,
123+
"token_endpoint": self.token_endpoint,
124+
"issuer": self.issuer,
125+
}
126+
118127
def test_authority_obj_should_do_oidc_discovery_and_skip_instance_discovery(
119128
self, oidc_discovery, instance_discovery):
129+
self.setup_tenant_discovery(oidc_discovery)
130+
120131
c = MinimalHttpClient()
121132
a = Authority(None, c, oidc_authority_url=self.authority)
122133
instance_discovery.assert_not_called()
123134
oidc_discovery.assert_called_once_with(self.oidc_discovery_endpoint, c)
124-
self.assertEqual(a.authorization_endpoint, 'https://contoso.com/authorize')
125-
self.assertEqual(a.token_endpoint, 'https://contoso.com/token')
135+
self.assertEqual(a.authorization_endpoint, self.authorization_endpoint)
136+
self.assertEqual(a.token_endpoint, self.token_endpoint)
126137

127138
def test_application_obj_should_do_oidc_discovery_and_skip_instance_discovery(
128139
self, oidc_discovery, instance_discovery):
140+
self.setup_tenant_discovery(oidc_discovery)
141+
129142
app = msal.ClientApplication(
130143
"id", authority=None, oidc_authority=self.authority)
131144
instance_discovery.assert_not_called()
132145
oidc_discovery.assert_called_once_with(
133146
self.oidc_discovery_endpoint, app.http_client)
134147
self.assertEqual(
135-
app.authority.authorization_endpoint, 'https://contoso.com/authorize')
136-
self.assertEqual(app.authority.token_endpoint, 'https://contoso.com/token')
148+
app.authority.authorization_endpoint, self.authorization_endpoint)
149+
self.assertEqual(app.authority.token_endpoint, self.token_endpoint)
137150

138151

139152
class DstsAuthorityTestCase(OidcAuthorityTestCase):
@@ -144,14 +157,14 @@ class DstsAuthorityTestCase(OidcAuthorityTestCase):
144157
"https://some.url.dsts.core.azure-test.net/dstsv2/common/oauth2/authorize")
145158
token_endpoint = (
146159
"https://some.url.dsts.core.azure-test.net/dstsv2/common/oauth2/token")
160+
issuer = "https://test-instance1-dsts.dsts.core.azure-test.net/dstsv2/common"
147161

148162
@patch("msal.authority._instance_discovery")
149-
@patch("msal.authority.tenant_discovery", return_value={
150-
"authorization_endpoint": authorization_endpoint,
151-
"token_endpoint": token_endpoint,
152-
}) # We need to create new patches (i.e. mocks) for non-inherited test cases
163+
@patch("msal.authority.tenant_discovery") # Remove the hard-coded return_value
153164
def test_application_obj_should_accept_dsts_url_as_an_authority(
154165
self, oidc_discovery, instance_discovery):
166+
self.setup_tenant_discovery(oidc_discovery)
167+
155168
app = msal.ClientApplication("id", authority=self.authority)
156169
instance_discovery.assert_not_called()
157170
oidc_discovery.assert_called_once_with(
@@ -274,3 +287,93 @@ def _test_turning_off_instance_discovery_should_skip_authority_validation_and_in
274287
app.get_accounts() # This could make an instance metadata call for authority aliases
275288
instance_metadata.assert_not_called()
276289

290+
291+
class TestAuthorityIssuerValidation(unittest.TestCase):
292+
"""Test cases for authority.has_valid_issuer method """
293+
294+
def setUp(self):
295+
self.http_client = MinimalHttpClient()
296+
297+
def _create_authority_with_issuer(self, oidc_authority_url, issuer, tenant_discovery_mock):
298+
tenant_discovery_mock.return_value = {
299+
"authorization_endpoint": "https://example.com/oauth2/authorize",
300+
"token_endpoint": "https://example.com/oauth2/token",
301+
"issuer": issuer,
302+
}
303+
authority = Authority(
304+
None,
305+
self.http_client,
306+
oidc_authority_url=oidc_authority_url
307+
)
308+
return authority
309+
310+
@patch("msal.authority.tenant_discovery")
311+
def test_exact_match_issuer(self, tenant_discovery_mock):
312+
"""Test when issuer exactly matches the OIDC authority URL"""
313+
authority_url = "https://example.com/tenant"
314+
authority = self._create_authority_with_issuer(authority_url, authority_url, tenant_discovery_mock)
315+
self.assertTrue(authority.has_valid_issuer(), "Issuer should be valid when it exactly matches the authority URL")
316+
317+
@patch("msal.authority.tenant_discovery")
318+
def test_no_issuer(self, tenant_discovery_mock):
319+
"""Test when no issuer is returned from OIDC discovery"""
320+
authority_url = "https://example.com/tenant"
321+
tenant_discovery_mock.return_value = {
322+
"authorization_endpoint": "https://example.com/oauth2/authorize",
323+
"token_endpoint": "https://example.com/oauth2/token",
324+
# No issuer key
325+
}
326+
# Since initialization now checks for valid issuer, we expect it to raise ValueError
327+
with self.assertRaises(ValueError) as context:
328+
Authority(None, self.http_client, oidc_authority_url=authority_url)
329+
self.assertIn("issuer", str(context.exception).lower())
330+
331+
@patch("msal.authority.tenant_discovery")
332+
def test_microsoft_host_issuer(self, tenant_discovery_mock):
333+
"""Test when issuer has a known Microsoft host"""
334+
authority_url = "https://custom-domain.com/tenant"
335+
issuer = f"https://{WORLD_WIDE}/tenant"
336+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
337+
self.assertTrue(authority.has_valid_issuer(), "Issuer should be valid when it has a known Microsoft host")
338+
339+
@patch("msal.authority.tenant_discovery")
340+
def test_same_scheme_and_host_different_path(self, tenant_discovery_mock):
341+
"""Test when issuer has same scheme and host but different path"""
342+
authority_url = "https://example.com/tenant"
343+
issuer = "https://example.com/different/path"
344+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
345+
self.assertTrue(authority.has_valid_issuer(), "Issuer should be valid when it has the same scheme and host")
346+
347+
@patch("msal.authority.tenant_discovery")
348+
def test_ciam_authority_with_matching_tenant(self, tenant_discovery_mock):
349+
"""Test CIAM authority with matching tenant in path"""
350+
authority_url = "https://custom-domain.com/tenant_name"
351+
issuer = f"https://tenant_name{_CIAM_DOMAIN_SUFFIX}"
352+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
353+
self.assertTrue(authority.has_valid_issuer(), "Issuer should be valid for CIAM pattern with matching tenant")
354+
355+
@patch("msal.authority.tenant_discovery")
356+
def test_ciam_authority_with_host_tenant(self, tenant_discovery_mock):
357+
"""Test CIAM authority with tenant in hostname"""
358+
tenant_name = "tenant_name"
359+
authority_url = f"https://{tenant_name}{_CIAM_DOMAIN_SUFFIX}/custom/path"
360+
issuer = f"https://{tenant_name}{_CIAM_DOMAIN_SUFFIX}"
361+
authority = self._create_authority_with_issuer(authority_url, issuer, tenant_discovery_mock)
362+
self.assertTrue(authority.has_valid_issuer(), "Issuer should be valid for CIAM pattern with tenant in hostname")
363+
364+
@patch("msal.authority.tenant_discovery")
365+
def test_invalid_issuer(self, tenant_discovery_mock):
366+
"""Test when issuer is completely different from authority"""
367+
authority_url = "https://example.com/tenant"
368+
issuer = "https://malicious-site.com/tenant"
369+
tenant_discovery_mock.return_value = {
370+
"authorization_endpoint": "https://example.com/oauth2/authorize",
371+
"token_endpoint": "https://example.com/oauth2/token",
372+
"issuer": issuer,
373+
}
374+
# Since initialization now checks for valid issuer, we expect it to raise ValueError
375+
with self.assertRaises(ValueError) as context:
376+
Authority(None, self.http_client, oidc_authority_url=authority_url)
377+
self.assertIn("issuer", str(context.exception).lower())
378+
self.assertIn(issuer, str(context.exception))
379+
self.assertIn(authority_url, str(context.exception))

0 commit comments

Comments
 (0)