From 279f906c44a834043023df5010afb99ec6a30860 Mon Sep 17 00:00:00 2001 From: Nilesh Choudhary Date: Thu, 7 May 2026 13:05:05 +0100 Subject: [PATCH 1/3] Added a soft warning if callback is not callable --- msal/__init__.py | 1 + msal/application.py | 52 ++++++++++++++++++++++ tests/test_application.py | 94 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/msal/__init__.py b/msal/__init__.py index 295e9756..ea681317 100644 --- a/msal/__init__.py +++ b/msal/__init__.py @@ -30,6 +30,7 @@ ConfidentialClientApplication, PublicClientApplication, ) +from .oauth2cli.assertion import AutoRefresher from .oauth2cli.oidc import Prompt, IdTokenError from .sku import __version__ from .token_cache import TokenCache, SerializableTokenCache diff --git a/msal/application.py b/msal/application.py index 084f9bf3..cf7cf904 100644 --- a/msal/application.py +++ b/msal/application.py @@ -343,6 +343,45 @@ def __init__( "client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..." } + .. note:: + + A pre-signed JWT string has a fixed expiration. Long-running + confidential client applications (for example, workloads using + AKS workload identity federation, or any other dynamic + credential source) should instead pass a **callable** which + MSAL will invoke on demand to obtain a fresh assertion:: + + def get_client_assertion(): + # e.g. read the projected service-account token from disk + with open("/var/run/secrets/azure/tokens/azure-identity-token") as f: + return f.read() + + app = ConfidentialClientApplication( + "client_id", + client_credential={"client_assertion": get_client_assertion}, + ..., + ) + + The callable is only invoked when MSAL needs to send a token + request on the wire (the in-memory token cache transparently + avoids unnecessary calls). + + If your callback is itself expensive (for example it calls + out to a key vault), wrap it in :class:`msal.AutoRefresher` + to memoize the assertion for its lifetime:: + + from msal import AutoRefresher + smart_callback = AutoRefresher(get_client_assertion, expires_in=3600) + app = ConfidentialClientApplication( + "client_id", + client_credential={"client_assertion": smart_callback}, + ..., + ) + + Passing a plain ``str`` / ``bytes`` ``client_assertion`` is + still supported for backward compatibility but is discouraged + because the assertion will eventually expire. + .. admonition:: Supporting reading client certificates from PFX files This usage will automatically use SHA-256 thumbprint of the certificate. @@ -807,6 +846,19 @@ def _build_client(self, client_credential, authority, skip_regional_client=False # so that we can ignore an empty string came from an empty ENV VAR. if client_credential.get("client_assertion"): client_assertion = client_credential['client_assertion'] + if not callable(client_assertion): + # Soft-deprecation: a fixed string assertion has a fixed + # expiration. Long-running apps should pass a callable so + # MSAL can fetch a fresh assertion on demand. See + # https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/746 + warnings.warn( + "Passing a static string/bytes 'client_assertion' is " + "discouraged because the JWT will eventually expire. " + "Pass a no-arg callable instead (optionally wrapped in " + "msal.AutoRefresher) so MSAL can obtain a fresh " + "assertion on demand. " + "See https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/746", + DeprecationWarning, stacklevel=2) else: headers = {} sha1_thumbprint = sha256_thumbprint = None diff --git a/tests/test_application.py b/tests/test_application.py index 54da96c0..a854e37b 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -4,6 +4,7 @@ import logging import sys import time +import warnings from unittest.mock import patch, Mock import msal from msal.application import ( @@ -707,6 +708,99 @@ def test_organizations_authority_should_emit_warning(self): authority="https://login.microsoftonline.com/organizations") +@patch(_OIDC_DISCOVERY, new=_OIDC_DISCOVERY_MOCK) +class TestClientAssertionCallback(unittest.TestCase): + """Issue #746: client_credential={'client_assertion': callable} support.""" + + _AUTHORITY = "https://login.microsoftonline.com/my_tenant" + + def _mock_post_capturing(self, captured): + def mock_post(url, headers=None, data=None, *args, **kwargs): + captured.append(dict(data or {})) + return MinimalResponse( + status_code=200, text=json.dumps({ + "access_token": "an AT", "expires_in": 3600})) + return mock_post + + def test_callable_client_assertion_is_invoked_per_request(self): + calls = {"n": 0} + def assertion_cb(): + calls["n"] += 1 + return "assertion-{}".format(calls["n"]) + app = ConfidentialClientApplication( + "client_id", + client_credential={"client_assertion": assertion_cb}, + authority=self._AUTHORITY) + captured = [] + app.acquire_token_for_client( + ["s1"], post=self._mock_post_capturing(captured)) + app.acquire_token_for_client( + ["s2"], post=self._mock_post_capturing(captured)) + self.assertEqual(2, calls["n"], "Callable should be called per request") + self.assertEqual("assertion-1", captured[0]["client_assertion"]) + self.assertEqual("assertion-2", captured[1]["client_assertion"]) + self.assertEqual( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + captured[0]["client_assertion_type"]) + + def test_autorefresher_caches_assertion(self): + from msal import AutoRefresher + calls = {"n": 0} + def assertion_cb(): + calls["n"] += 1 + return "static-assertion" + app = ConfidentialClientApplication( + "client_id", + client_credential={ + "client_assertion": AutoRefresher(assertion_cb, expires_in=3600)}, + authority=self._AUTHORITY) + captured = [] + app.acquire_token_for_client( + ["s1"], post=self._mock_post_capturing(captured)) + app.acquire_token_for_client( + ["s2"], post=self._mock_post_capturing(captured)) + self.assertEqual( + 1, calls["n"], + "AutoRefresher should reuse the assertion within its lifetime") + self.assertEqual("static-assertion", captured[0]["client_assertion"]) + self.assertEqual("static-assertion", captured[1]["client_assertion"]) + + def test_string_client_assertion_still_works_for_backward_compat(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + app = ConfidentialClientApplication( + "client_id", + client_credential={"client_assertion": "static-jwt"}, + authority=self._AUTHORITY) + captured = [] + result = app.acquire_token_for_client( + ["s"], post=self._mock_post_capturing(captured)) + self.assertEqual("an AT", result.get("access_token")) + self.assertEqual("static-jwt", captured[0]["client_assertion"]) + + def test_string_client_assertion_emits_deprecation_warning(self): + with self.assertWarns(DeprecationWarning): + ConfidentialClientApplication( + "client_id", + client_credential={"client_assertion": "static-jwt"}, + authority=self._AUTHORITY) + + def test_callable_client_assertion_does_not_emit_deprecation_warning(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + ConfidentialClientApplication( + "client_id", + client_credential={"client_assertion": lambda: "x"}, + authority=self._AUTHORITY) + offending = [ + w for w in caught + if issubclass(w.category, DeprecationWarning) + and "client_assertion" in str(w.message)] + self.assertEqual( + [], offending, + "Callable client_assertion must not emit a deprecation warning") + + @patch(_OIDC_DISCOVERY, new=_OIDC_DISCOVERY_MOCK) class TestAcquireTokenForClientWithFmiPath(unittest.TestCase): """Test that acquire_token_for_client(fmi_path=...) attaches fmi_path to HTTP body.""" From eba916176bbd307aa9714bc37d36d11849a013cd Mon Sep 17 00:00:00 2001 From: Nilesh Choudhary Date: Thu, 7 May 2026 13:13:53 +0100 Subject: [PATCH 2/3] updated crypto version max to 51 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index b2164fff..b7477e06 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,7 +53,7 @@ install_requires = # And we will use the cryptography (X+3).0.0 as the upper bound, # based on their latest deprecation policy # https://cryptography.io/en/latest/api-stability/#deprecation - cryptography>=2.5,<50 + cryptography>=2.5,<51 [options.extras_require] From 4c0bdafdaa631efb1f308ac37cb9c771204f63a9 Mon Sep 17 00:00:00 2001 From: Nilesh Choudhary Date: Fri, 8 May 2026 10:59:57 +0100 Subject: [PATCH 3/3] removed support for 3.8 and added doc --- .github/workflows/python-package.yml | 2 +- contributing.md | 9 +++ doc/python_version_support_policy.md | 93 ++++++++++++++++++++++++++++ setup.cfg | 12 ++-- 4 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 doc/python_version_support_policy.md diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 881cb237..a956b091 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 diff --git a/contributing.md b/contributing.md index e78c1ce1..5f4254ad 100644 --- a/contributing.md +++ b/contributing.md @@ -3,6 +3,15 @@ Azure Active Directory SDK projects welcomes new contributors. This document will guide you through the process. +### SUPPORTED PYTHON VERSIONS + +The set of Python versions that MSAL Python supports, and the policy for +adding/removing support, is documented in +[doc/python_version_support_policy.md](doc/python_version_support_policy.md). +Any change that adds or removes a Python version must update both that +policy document and the supported-version declarations it lists +(`setup.cfg`, the GitHub Actions matrix, and the cryptography test). + ### CONTRIBUTOR LICENSE AGREEMENT Please visit [https://cla.microsoft.com/](https://cla.microsoft.com/) and sign the Contributor License diff --git a/doc/python_version_support_policy.md b/doc/python_version_support_policy.md new file mode 100644 index 00000000..26fcb68d --- /dev/null +++ b/doc/python_version_support_policy.md @@ -0,0 +1,93 @@ +# MSAL Python Version Support Policy + +This page describes the Python version support policy for the +Microsoft Authentication Library for Python (MSAL Python), including +end-of-support timelines for each Python version. + +This policy is aligned with the +[Azure SDK for Python version support policy](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/python_version_support_policy.md) +so that MSAL Python and the Azure SDK can be consumed together without +version conflicts. + +End of support means, in the MSAL Python context, that **new MSAL Python +releases will no longer install on, be tested against, or accept bug +fixes for those Python versions**. Older MSAL Python releases that did +support those Python versions remain installable from PyPI via pip's +`requires-python` resolution, so existing applications continue to work +without change — they simply stop receiving new features and security +fixes. + +## Policy + +MSAL Python supports a Python version while it is supported upstream by +the Python core team (PSF), plus an additional **6-month grace window** +after the PSF end-of-support date to give applications time to migrate. + +Concretely: + +- MSAL Python adds support for a new Python release as soon as practical + after that Python release ships a stable `.0`. +- MSAL Python drops support for a Python version on the **first MSAL + Python release published on or after the SDK end-of-support date** for + that Python version (PSF end-of-support + ~6 months). +- Dropping a Python version is a **breaking change** and is delivered in + a new minor or major release of MSAL Python, never in a patch. +- The release notes (`RELEASES.md`) call out every Python-version + removal, and `setup.cfg` is updated in the same change to bump + `python_requires`, the trove classifiers, and any environment markers. + +> **Note:** The "MSAL Python End Of Support" date is inclusive — the +> listed day is the last supported day, and the next day is the first +> unsupported day. + +## Currently supported versions + +| Python Version | PSF End of Support | MSAL Python End Of Support | +|----------------|--------------------|----------------------------| +| 3.9 ([PEP 596](https://peps.python.org/pep-0596/#lifespan)) | October 2025 | April 30, 2026 *(see note)* | +| 3.10 ([PEP 619](https://peps.python.org/pep-0619/#lifespan)) | October 2026 | April 30, 2027 | +| 3.11 ([PEP 664](https://peps.python.org/pep-0664/#lifespan)) | October 2027 | April 30, 2028 | +| 3.12 ([PEP 693](https://peps.python.org/pep-0693/#lifespan)) | October 2028 | April 30, 2029 | +| 3.13 ([PEP 719](https://peps.python.org/pep-0719/#lifespan)) | October 2029 | April 30, 2030 | +| 3.14 ([PEP 745](https://peps.python.org/pep-0745/#lifespan)) | October 2030 | April 30, 2031 | + +> **Note on Python 3.9:** Python 3.9 is past its policy end-of-support +> date but is granted a one-time transition grace window in MSAL Python +> while we adopt this policy and complete the removal of Python 3.8. It +> will be removed in a subsequent MSAL Python release; the date will be +> announced in `RELEASES.md` ahead of removal. + +## End-of-life versions (no longer supported) + +| Python Version | PSF End of Support | MSAL Python End Of Support | +|----------------|--------------------|----------------------------| +| 3.8 ([PEP 569](https://peps.python.org/pep-0569/#lifespan)) | October 2024 | April 2026 | +| 3.7 ([PEP 537](https://peps.python.org/pep-0537/#lifespan)) | June 2023 | December 2023 | +| 3.6 ([PEP 494](https://peps.python.org/pep-0494/#lifespan)) | December 2021 | August 2022 | +| 2.7 ([PEP 373](https://peps.python.org/pep-0373/)) | April 2020 | January 2022 | + +## Implementation + +The supported Python versions are encoded in three places, which must be +kept in sync with this policy: + +1. **`setup.cfg`** — `python_requires`, the `Programming Language :: + Python :: 3.x` trove classifiers, and any `python_version` + environment markers on optional dependencies (e.g. `pymsalruntime`). +2. **`.github/workflows/python-package.yml`** — the `python-version` + matrix used by the `pytest` test job. +3. **`tests/test_cryptography.py`** — the N+3 ceiling test that enforces + tracking the latest `cryptography` release. Newer `cryptography` + versions routinely drop EOL Python versions, which is the most common + forcing function for this policy. + +## Rationale + +MSAL Python depends transitively on `cryptography`, `requests`, and +`PyJWT`. These libraries follow a similar policy and drop EOL Python +versions roughly six months after PSF end-of-support. Continuing to +support an EOL Python version in MSAL Python forces us to either pin +those dependencies to old, unmaintained versions — exposing MSAL users +to known CVEs — or to maintain conditional install metadata that breaks +on every dependency bump. Aligning with the Azure SDK and upstream +policies keeps MSAL Python simple, secure, and predictable. diff --git a/setup.cfg b/setup.cfg index b7477e06..d69208a6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,6 @@ classifiers = Programming Language :: Python Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 @@ -38,8 +37,9 @@ project_urls = [options] include_package_data = False # We used to ship LICENSE, but our __init__.py already mentions MIT packages = find: -# Our test pipeline currently still covers Py37 -python_requires = >=3.8 +# Drop Python 3.8 because cryptography 48+ (and other key deps) no longer +# support it; align with the cryptography upper bound policy. +python_requires = >=3.9 install_requires = requests>=2.0.0,<3 @@ -63,11 +63,11 @@ broker = # most existing MSAL Python apps do not have the redirect_uri needed by broker. # # We need pymsalruntime.CallbackData introduced in PyMsalRuntime 0.14 - pymsalruntime>=0.14,<0.21; python_version>='3.8' and platform_system=='Windows' + pymsalruntime>=0.14,<0.21; python_version>='3.9' and platform_system=='Windows' # On Mac, PyMsalRuntime 0.17+ is expected to support SSH cert and ROPC - pymsalruntime>=0.17,<0.21; python_version>='3.8' and platform_system=='Darwin' + pymsalruntime>=0.17,<0.21; python_version>='3.9' and platform_system=='Darwin' # PyMsalRuntime 0.18+ is expected to support broker on Linux - pymsalruntime>=0.18,<0.21; python_version>='3.8' and platform_system=='Linux' + pymsalruntime>=0.18,<0.21; python_version>='3.9' and platform_system=='Linux' [options.packages.find] exclude =