Skip to content

Commit 4549f9b

Browse files
committed
Merge branch 'dev' into release-1.35.0
2 parents 080111b + 1310fac commit 4549f9b

19 files changed

+1839
-512
lines changed

.github/workflows/python-package.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,8 @@ jobs:
1717
# Fake a TRAVIS env so that the pre-existing test cases would behave like before
1818
TRAVIS: true
1919
LAB_APP_CLIENT_ID: ${{ secrets.LAB_APP_CLIENT_ID }}
20-
LAB_APP_CLIENT_SECRET: ${{ secrets.LAB_APP_CLIENT_SECRET }}
2120
LAB_APP_CLIENT_CERT_BASE64: ${{ secrets.LAB_APP_CLIENT_CERT_BASE64 }}
2221
LAB_APP_CLIENT_CERT_PFX_PATH: lab_cert.pfx
23-
LAB_OBO_CLIENT_SECRET: ${{ secrets.LAB_OBO_CLIENT_SECRET }}
24-
LAB_OBO_CONFIDENTIAL_CLIENT_ID: ${{ secrets.LAB_OBO_CONFIDENTIAL_CLIENT_ID }}
25-
LAB_OBO_PUBLIC_CLIENT_ID: ${{ secrets.LAB_OBO_PUBLIC_CLIENT_ID }}
2622

2723
# Derived from https://docs.github.com/en/actions/guides/building-and-testing-python#starting-with-the-python-workflow-template
2824
runs-on: ubuntu-22.04

azure-pipelines.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,23 @@ steps:
3737

3838
- script: |
3939
pip install pytest pytest-azurepipelines
40-
pytest
41-
displayName: 'pytest'
40+
mkdir -p test-results
41+
set -o pipefail
42+
pytest -vv --junitxml=test-results/junit.xml 2>&1 | tee test-results/pytest.log
43+
displayName: 'pytest (verbose + junit + log)'
44+
45+
- task: PublishTestResults@2
46+
displayName: 'Publish test results'
47+
condition: succeededOrFailed()
48+
inputs:
49+
testResultsFormat: 'JUnit'
50+
testResultsFiles: 'test-results/junit.xml'
51+
failTaskOnFailedTests: true
52+
testRunTitle: 'Python $(python.version) pytest'
53+
54+
- task: PublishPipelineArtifact@1
55+
displayName: 'Publish pytest log artifact'
56+
condition: succeededOrFailed()
57+
inputs:
58+
targetPath: 'test-results'
59+
artifact: 'pytest-logs-$(python.version)'

msal/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ def _main():
339339
logging.error("Invalid input: %s", e)
340340
except KeyboardInterrupt: # Useful for bailing out a stuck interactive flow
341341
print("Aborted")
342+
except Exception as e:
343+
logging.error("Error: %s", e)
342344

343345
if __name__ == "__main__":
344346
_main()

msal/application.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,7 @@ def initiate_auth_code_flow(
961961
962962
:param str response_mode:
963963
OPTIONAL. Specifies the method with which response parameters should be returned.
964-
The default value is equivalent to ``query``, which is still secure enough in MSAL Python
964+
The default value is equivalent to ``query``, which was still secure enough in MSAL Python
965965
(because MSAL Python does not transfer tokens via query parameter in the first place).
966966
For even better security, we recommend using the value ``form_post``.
967967
In "form_post" mode, response parameters
@@ -973,6 +973,11 @@ def initiate_auth_code_flow(
973973
`here <https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes>`
974974
and `here <https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode>`
975975
976+
.. note::
977+
You should configure your web framework to accept form_post responses instead of query responses.
978+
While this parameter still works, it will be removed in a future version.
979+
Using query-based response modes is less secure and should be avoided.
980+
976981
:return:
977982
The auth code flow. It is a dict in this form::
978983
@@ -991,6 +996,9 @@ def initiate_auth_code_flow(
991996
3. and then relay this dict and subsequent auth response to
992997
:func:`~acquire_token_by_auth_code_flow()`.
993998
"""
999+
# Note to maintainers: Do not emit warning for the use of response_mode here,
1000+
# because response_mode=form_post is still the recommended usage for MSAL Python 1.x.
1001+
# App developers making the right call shall not be disturbed by unactionable warnings.
9941002
client = _ClientWithCcsRoutingInfo(
9951003
{"authorization_endpoint": self.authority.authorization_endpoint},
9961004
self.client_id,

msal/authority.py

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
from urlparse import urlparse
66
import logging
77

8-
98
logger = logging.getLogger(__name__)
10-
119
# Endpoints were copied from here
1210
# https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints
1311
AZURE_US_GOVERNMENT = "login.microsoftonline.us"
@@ -21,6 +19,29 @@
2119
'login-us.microsoftonline.com',
2220
AZURE_US_GOVERNMENT,
2321
])
22+
23+
# Trusted issuer hosts for OIDC issuer validation
24+
# Includes all well-known Microsoft identity provider hosts and national clouds
25+
TRUSTED_ISSUER_HOSTS = frozenset([
26+
# Global/Public cloud
27+
"login.microsoftonline.com",
28+
"login.microsoft.com",
29+
"login.windows.net",
30+
"sts.windows.net",
31+
# China cloud
32+
"login.chinacloudapi.cn",
33+
"login.partner.microsoftonline.cn",
34+
# Germany cloud (legacy)
35+
"login.microsoftonline.de",
36+
# US Government clouds
37+
"login.microsoftonline.us",
38+
"login.usgovcloudapi.net",
39+
"login-us.microsoftonline.com",
40+
"https://login.sovcloud-identity.fr", # AzureBleu
41+
"https://login.sovcloud-identity.de", # AzureDelos
42+
"https://login.sovcloud-identity.sg", # AzureGovSG
43+
])
44+
2445
WELL_KNOWN_B2C_HOSTS = [
2546
"b2clogin.com",
2647
"b2clogin.cn",
@@ -67,12 +88,12 @@ def __init__(
6788
performed.
6889
"""
6990
self._http_client = http_client
91+
self._oidc_authority_url = oidc_authority_url
7092
if oidc_authority_url:
7193
logger.debug("Initializing with OIDC authority: %s", oidc_authority_url)
7294
tenant_discovery_endpoint = self._initialize_oidc_authority(
7395
oidc_authority_url)
7496
else:
75-
logger.debug("Initializing with Entra authority: %s", authority_url)
7697
tenant_discovery_endpoint = self._initialize_entra_authority(
7798
authority_url, validate_authority, instance_discovery)
7899
try:
@@ -93,14 +114,22 @@ def __init__(
93114
.format(authority_url)
94115
) + " Also please double check your tenant name or GUID is correct."
95116
raise ValueError(error_message)
96-
openid_config.pop("issuer", None) # Not used in MSAL.py, so remove it therefore no need to validate it
97-
logger.debug(
98-
'openid_config("%s") = %s', tenant_discovery_endpoint, openid_config)
117+
self._issuer = openid_config.get('issuer')
99118
self.authorization_endpoint = openid_config['authorization_endpoint']
100119
self.token_endpoint = openid_config['token_endpoint']
101120
self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint')
102121
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
103122

123+
# Validate the issuer if using OIDC authority
124+
if self._oidc_authority_url and not self.has_valid_issuer():
125+
raise ValueError((
126+
"The issuer '{iss}' does not match the authority '{auth}' or a known pattern. "
127+
"When using the 'oidc_authority' parameter in ClientApplication, the authority "
128+
"will be validated against the issuer from {auth}/.well-known/openid-configuration ."
129+
"If using a known Entra authority (e.g. login.microsoftonline.com) the "
130+
"'authority' parameter should be used instead of 'oidc_authority'. "
131+
""
132+
).format(iss=self._issuer, auth=oidc_authority_url))
104133
def _initialize_oidc_authority(self, oidc_authority_url):
105134
authority, self.instance, tenant = canonicalize(oidc_authority_url)
106135
self.is_adfs = tenant.lower() == 'adfs' # As a convention
@@ -175,6 +204,60 @@ def user_realm_discovery(self, username, correlation_id=None, response=None):
175204
self.__class__._domains_without_user_realm_discovery.add(self.instance)
176205
return {} # This can guide the caller to fall back normal ROPC flow
177206

207+
def has_valid_issuer(self):
208+
"""
209+
Returns True if the issuer from OIDC discovery is valid for this authority.
210+
211+
An issuer is valid if one of the following is true:
212+
- It exactly matches the authority URL (with/without trailing slash)
213+
- It has the same scheme and host as the authority (path can be different)
214+
- The issuer host is a well-known Microsoft authority host
215+
- The issuer host is a regional variant of a well-known host (e.g., westus2.login.microsoft.com)
216+
- For CIAM, hosts that end with well-known B2C hosts (e.g., tenant.b2clogin.com) are accepted as valid issuers
217+
"""
218+
if not self._issuer or not self._oidc_authority_url:
219+
return False
220+
221+
# Case 1: Exact match (most common case, normalized for trailing slashes)
222+
if self._issuer.rstrip("/") == self._oidc_authority_url.rstrip("/"):
223+
return True
224+
225+
issuer_parsed = urlparse(self._issuer)
226+
authority_parsed = urlparse(self._oidc_authority_url)
227+
issuer_host = issuer_parsed.hostname.lower() if issuer_parsed.hostname else None
228+
229+
if not issuer_host:
230+
return False
231+
232+
# Case 2: Issuer is from a trusted Microsoft host - O(1) lookup
233+
if issuer_host in TRUSTED_ISSUER_HOSTS:
234+
return True
235+
236+
# Case 3: Regional variant check - O(1) lookup
237+
# e.g., westus2.login.microsoft.com -> extract "login.microsoft.com"
238+
dot_index = issuer_host.find(".")
239+
if dot_index > 0:
240+
potential_base = issuer_host[dot_index + 1:]
241+
if "." not in issuer_host[:dot_index]:
242+
# 3a: Base host is a trusted Microsoft host
243+
if potential_base in TRUSTED_ISSUER_HOSTS:
244+
return True
245+
# 3b: Issuer has a region prefix on the authority host
246+
# e.g. issuer=us.someweb.com, authority=someweb.com
247+
authority_host = authority_parsed.hostname.lower() if authority_parsed.hostname else ""
248+
if potential_base == authority_host:
249+
return True
250+
251+
# Case 4: Same scheme and host (path can differ)
252+
if (authority_parsed.scheme == issuer_parsed.scheme and
253+
authority_parsed.netloc == issuer_parsed.netloc):
254+
return True
255+
256+
# Case 5: Check if issuer host ends with any well-known B2C host (e.g., tenant.b2clogin.com)
257+
if any(issuer_host.endswith(h) for h in WELL_KNOWN_B2C_HOSTS):
258+
return True
259+
260+
return False
178261

179262
def canonicalize(authority_or_auth_endpoint):
180263
# Returns (url_parsed_result, hostname_in_lowercase, tenant)
@@ -223,4 +306,3 @@ def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs):
223306
resp.raise_for_status()
224307
raise RuntimeError( # A fallback here, in case resp.raise_for_status() is no-op
225308
"Unable to complete OIDC Discovery: %d, %s" % (resp.status_code, resp.text))
226-

0 commit comments

Comments
 (0)