Skip to content

Commit fa4ec61

Browse files
committed
Merge branch 'dev' into avdunn/issuer-validation
2 parents ceae25b + 58cf073 commit fa4ec61

24 files changed

+1751
-536
lines changed

.github/workflows/python-package.yml

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,14 @@ 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
2925
strategy:
3026
matrix:
31-
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
27+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
3228

3329
steps:
3430
- uses: actions/checkout@v4
@@ -119,15 +115,15 @@ jobs:
119115
- name: |
120116
Publish to TestPyPI when pushing to release-* branch.
121117
You better test with a1, a2, b1, b2 releases first.
122-
uses: pypa/gh-action-pypi-publish@v1.4.2
118+
uses: pypa/gh-action-pypi-publish@v1.13.0
123119
if: startsWith(github.ref, 'refs/heads/release-')
124120
with:
125121
user: __token__
126122
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
127123
repository_url: https://test.pypi.org/legacy/
128124
- name: Publish to PyPI when tagged
129125
if: startsWith(github.ref, 'refs/tags')
130-
uses: pypa/gh-action-pypi-publish@v1.4.2
126+
uses: pypa/gh-action-pypi-publish@v1.13.0
131127
with:
132128
user: __token__
133129
password: ${{ secrets.PYPI_API_TOKEN }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,6 @@ msal_cache.bin
6262

6363
.env
6464
.perf.baseline
65+
66+
*.pfx
67+
.vscode/settings.json

azure-pipelines.yml

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ strategy:
1919
python.version: '3.11'
2020
Python312:
2121
python.version: '3.12'
22+
Python313:
23+
python.version: '3.13'
24+
Python314:
25+
python.version: '3.14'
2226

2327
steps:
2428
- task: UsePythonVersion@0
@@ -33,5 +37,23 @@ steps:
3337

3438
- script: |
3539
pip install pytest pytest-azurepipelines
36-
pytest
37-
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: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -66,24 +66,32 @@ def _str2bytes(raw):
6666
except:
6767
return raw
6868

69+
def _extract_cert_and_thumbprints(cert):
70+
# Cert concepts https://security.stackexchange.com/a/226758/125264
71+
from cryptography.hazmat.primitives import hashes, serialization
72+
cert_pem = cert.public_bytes( # Requires cryptography 1.0+
73+
encoding=serialization.Encoding.PEM).decode()
74+
x5c = [
75+
'\n'.join(
76+
cert_pem.splitlines()
77+
[1:-1] # Strip the "--- header ---" and "--- footer ---"
78+
)
79+
]
80+
# https://cryptography.io/en/latest/x509/reference/#x-509-certificate-object - Requires cryptography 0.7+
81+
sha256_thumbprint = cert.fingerprint(hashes.SHA256()).hex()
82+
sha1_thumbprint = cert.fingerprint(hashes.SHA1()).hex() # CodeQL [SM02167] for legacy support such as ADFS
83+
return sha256_thumbprint, sha1_thumbprint, x5c
6984

7085
def _parse_pfx(pfx_path, passphrase_bytes):
7186
# Cert concepts https://security.stackexchange.com/a/226758/125264
72-
from cryptography.hazmat.primitives import hashes, serialization
7387
from cryptography.hazmat.primitives.serialization import pkcs12
7488
with open(pfx_path, 'rb') as f:
7589
private_key, cert, _ = pkcs12.load_key_and_certificates( # cryptography 2.5+
7690
# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/serialization/#cryptography.hazmat.primitives.serialization.pkcs12.load_key_and_certificates
7791
f.read(), passphrase_bytes)
7892
if not (private_key and cert):
7993
raise ValueError("Your PFX file shall contain both private key and cert")
80-
cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM).decode() # cryptography 1.0+
81-
x5c = [
82-
'\n'.join(cert_pem.splitlines()[1:-1]) # Strip the "--- header ---" and "--- footer ---"
83-
]
84-
sha256_thumbprint = cert.fingerprint(hashes.SHA256()).hex() # cryptography 0.7+
85-
sha1_thumbprint = cert.fingerprint(hashes.SHA1()).hex() # cryptography 0.7+
86-
# https://cryptography.io/en/latest/x509/reference/#x-509-certificate-object
94+
sha256_thumbprint, sha1_thumbprint, x5c = _extract_cert_and_thumbprints(cert)
8795
return private_key, sha256_thumbprint, sha1_thumbprint, x5c
8896

8997

@@ -280,12 +288,20 @@ def __init__(
280288
281289
.. admonition:: Support using a certificate in X.509 (.pem) format
282290
291+
Deprecated because it uses SHA-1 thumbprint,
292+
unless you are still using ADFS which supports SHA-1 thumbprint only.
293+
Please use the .pfx option documented later in this page.
294+
283295
Feed in a dict in this form::
284296
285297
{
286298
"private_key": "...-----BEGIN PRIVATE KEY-----... in PEM format",
287-
"thumbprint": "A1B2C3D4E5F6...",
288-
"passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)",
299+
"thumbprint": "An SHA-1 thumbprint such as A1B2C3D4E5F6..."
300+
"Changed in version 1.35.0, if thumbprint is absent"
301+
"and a public_certificate is present, MSAL will"
302+
"automatically calculate an SHA-256 thumbprint instead.",
303+
"passphrase": "Needed if the private_key is encrypted (Added in version 1.6.0)",
304+
"public_certificate": "...-----BEGIN CERTIFICATE-----...", # Needed if you use Subject Name/Issuer auth. Added in version 0.5.0.
289305
}
290306
291307
MSAL Python requires a "private_key" in PEM format.
@@ -296,25 +312,11 @@ def __init__(
296312
The thumbprint is available in your app's registration in Azure Portal.
297313
Alternatively, you can `calculate the thumbprint <https://github.com/Azure/azure-sdk-for-python/blob/07d10639d7e47f4852eaeb74aef5d569db499d6e/sdk/identity/azure-identity/azure/identity/_credentials/certificate.py#L94-L97>`_.
298314
299-
.. admonition:: Support Subject Name/Issuer Auth with a cert in .pem
300-
301-
`Subject Name/Issuer Auth
302-
<https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/60>`_
303-
is an approach to allow easier certificate rotation.
304-
305-
*Added in version 0.5.0*::
306-
307-
{
308-
"private_key": "...-----BEGIN PRIVATE KEY-----... in PEM format",
309-
"thumbprint": "A1B2C3D4E5F6...",
310-
"public_certificate": "...-----BEGIN CERTIFICATE-----...",
311-
"passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)",
312-
}
313-
314315
``public_certificate`` (optional) is public key certificate
315-
which will be sent through 'x5c' JWT header only for
316-
subject name and issuer authentication to support cert auto rolls.
317-
316+
which will be sent through 'x5c' JWT header.
317+
This is useful when you use `Subject Name/Issuer Authentication
318+
<https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/60>`_
319+
which is an approach to allow easier certificate rotation.
318320
Per `specs <https://tools.ietf.org/html/rfc7515#section-4.1.6>`_,
319321
"the certificate containing
320322
the public key corresponding to the key used to digitally sign the
@@ -338,29 +340,26 @@ def __init__(
338340
339341
.. admonition:: Supporting reading client certificates from PFX files
340342
343+
This usage will automatically use SHA-256 thumbprint of the certificate.
344+
341345
*Added in version 1.29.0*:
342346
Feed in a dictionary containing the path to a PFX file::
343347
344348
{
345-
"private_key_pfx_path": "/path/to/your.pfx",
349+
"private_key_pfx_path": "/path/to/your.pfx", # Added in version 1.29.0
350+
"public_certificate": True, # Only needed if you use Subject Name/Issuer auth. Added in version 1.30.0
346351
"passphrase": "Passphrase if the private_key is encrypted (Optional)",
347352
}
348353
349354
The following command will generate a .pfx file from your .key and .pem file::
350355
351356
openssl pkcs12 -export -out certificate.pfx -inkey privateKey.key -in certificate.pem
352357
353-
.. admonition:: Support Subject Name/Issuer Auth with a cert in .pfx
354-
355-
*Added in version 1.30.0*:
358+
`Subject Name/Issuer Auth
359+
<https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/60>`_
360+
is an approach to allow easier certificate rotation.
356361
If your .pfx file contains both the private key and public cert,
357-
you can opt in for Subject Name/Issuer Auth like this::
358-
359-
{
360-
"private_key_pfx_path": "/path/to/your.pfx",
361-
"public_certificate": True,
362-
"passphrase": "Passphrase if the private_key is encrypted (Optional)",
363-
}
362+
you can opt in for Subject Name/Issuer Auth by setting "public_certificate" to ``True``.
364363
365364
:type client_credential: Union[dict, str, None]
366365
@@ -815,15 +814,30 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
815814
passphrase_bytes)
816815
if client_credential.get("public_certificate") is True and x5c:
817816
headers["x5c"] = x5c
818-
elif (
819-
client_credential.get("private_key") # PEM blob
820-
and client_credential.get("thumbprint")):
821-
sha1_thumbprint = client_credential["thumbprint"]
822-
if passphrase_bytes:
823-
private_key = _load_private_key_from_pem_str(
817+
elif client_credential.get("private_key"): # PEM blob
818+
private_key = ( # handles both encrypted and unencrypted
819+
_load_private_key_from_pem_str(
824820
client_credential['private_key'], passphrase_bytes)
825-
else: # PEM without passphrase
826-
private_key = client_credential['private_key']
821+
if passphrase_bytes
822+
else client_credential['private_key']
823+
)
824+
825+
# Determine thumbprints based on what's provided
826+
if client_credential.get("thumbprint"):
827+
# User provided a thumbprint - use it as SHA-1 (legacy/manual approach)
828+
sha1_thumbprint = client_credential["thumbprint"]
829+
sha256_thumbprint = None
830+
elif isinstance(client_credential.get('public_certificate'), str):
831+
# No thumbprint provided, but we have a certificate to calculate thumbprints
832+
from cryptography import x509
833+
cert = x509.load_pem_x509_certificate(
834+
_str2bytes(client_credential['public_certificate']))
835+
sha256_thumbprint, sha1_thumbprint, headers["x5c"] = (
836+
_extract_cert_and_thumbprints(cert))
837+
else:
838+
raise ValueError(
839+
"You must provide either 'thumbprint' or 'public_certificate' "
840+
"from which the thumbprint can be calculated.")
827841
else:
828842
raise ValueError(
829843
"client_credential needs to follow this format "
@@ -947,7 +961,7 @@ def initiate_auth_code_flow(
947961
948962
:param str response_mode:
949963
OPTIONAL. Specifies the method with which response parameters should be returned.
950-
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
951965
(because MSAL Python does not transfer tokens via query parameter in the first place).
952966
For even better security, we recommend using the value ``form_post``.
953967
In "form_post" mode, response parameters
@@ -959,6 +973,11 @@ def initiate_auth_code_flow(
959973
`here <https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes>`
960974
and `here <https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode>`
961975
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+
962981
:return:
963982
The auth code flow. It is a dict in this form::
964983
@@ -977,6 +996,9 @@ def initiate_auth_code_flow(
977996
3. and then relay this dict and subsequent auth response to
978997
:func:`~acquire_token_by_auth_code_flow()`.
979998
"""
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.
9801002
client = _ClientWithCcsRoutingInfo(
9811003
{"authorization_endpoint": self.authority.authorization_endpoint},
9821004
self.client_id,
@@ -1840,7 +1862,17 @@ def acquire_token_by_username_password(
18401862
18411863
- A successful response would contain "access_token" key,
18421864
- an error response would contain "error" and usually "error_description".
1865+
1866+
[Deprecated] This API is deprecated for public client flows and will be
1867+
removed in a future release. Use a more secure flow instead.
1868+
Migration guide: https://aka.ms/msal-ropc-migration
1869+
18431870
"""
1871+
is_confidential_app = self.client_credential or isinstance(
1872+
self, ConfidentialClientApplication)
1873+
if not is_confidential_app:
1874+
warnings.warn("""This API has been deprecated for public client flows, please use a more secure flow.
1875+
See https://aka.ms/msal-ropc-migration for migration guidance""", DeprecationWarning)
18441876
claims = _merge_claims_challenge_and_capabilities(
18451877
self._client_capabilities, claims_challenge)
18461878
if self._enable_broker and sys.platform in ("win32", "darwin"):

msal/authority.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def __init__(
114114
.format(authority_url)
115115
) + " Also please double check your tenant name or GUID is correct."
116116
raise ValueError(error_message)
117+
openid_config.pop("issuer", None) # Not used in MSAL.py, so remove it therefore no need to validate it
117118
logger.debug(
118119
'openid_config("%s") = %s', tenant_discovery_endpoint, openid_config)
119120
self._issuer = openid_config.get('issuer')
@@ -264,7 +265,7 @@ def has_valid_issuer(self):
264265
def canonicalize(authority_or_auth_endpoint):
265266
# Returns (url_parsed_result, hostname_in_lowercase, tenant)
266267
authority = urlparse(authority_or_auth_endpoint)
267-
if authority.scheme == "https":
268+
if authority.scheme == "https" and authority.hostname:
268269
parts = authority.path.split("/")
269270
first_part = parts[1] if len(parts) >= 2 and parts[1] else None
270271
if authority.hostname.endswith(_CIAM_DOMAIN_SUFFIX): # CIAM
@@ -278,7 +279,7 @@ def canonicalize(authority_or_auth_endpoint):
278279
return authority, authority.hostname, parts[1]
279280
raise ValueError(
280281
"Your given address (%s) should consist of "
281-
"an https url with a minimum of one segment in a path: e.g. "
282+
"an https url with hostname and a minimum of one segment in a path: e.g. "
282283
"https://login.microsoftonline.com/{tenant} "
283284
"or https://{tenant_name}.ciamlogin.com/{tenant} "
284285
"or https://{tenant_name}.b2clogin.com/{tenant_name}.onmicrosoft.com/policy"

msal/broker.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,20 @@ def _build_msal_runtime_auth_params(client_id, authority):
145145
params.set_additional_parameter("msal_client_ver", __version__)
146146
return params
147147

148+
def _set_redirect_uri_for_linux(params):
149+
if sys.platform == "linux":
150+
# This is required by Linux Java Broker to set a non-empty valid redirect_uri
151+
params.set_redirect_uri(
152+
"https://login.microsoftonline.com/common/oauth2/nativeclient"
153+
)
154+
148155
def _signin_silently(
149156
authority, client_id, scopes, correlation_id=None, claims=None,
150157
enable_msa_pt=False,
151158
auth_scheme=None,
152159
**kwargs):
153160
params = _build_msal_runtime_auth_params(client_id, authority)
161+
_set_redirect_uri_for_linux(params)
154162
params.set_requested_scopes(scopes)
155163
if claims:
156164
params.set_decoded_claims(claims)
@@ -240,6 +248,7 @@ def _acquire_token_silently(
240248
if account is None:
241249
return
242250
params = _build_msal_runtime_auth_params(client_id, authority)
251+
_set_redirect_uri_for_linux(params)
243252
params.set_requested_scopes(scopes)
244253
if claims:
245254
params.set_decoded_claims(claims)

0 commit comments

Comments
 (0)