Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions doc/python_version_support_policy.md
Original file line number Diff line number Diff line change
@@ -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)* |
Comment on lines +45 to +47
| 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 |
Comment on lines +64 to +67

## 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.
1 change: 1 addition & 0 deletions msal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Comment on lines +854 to +861
else:
headers = {}
sha1_thumbprint = sha256_thumbprint = None
Expand Down
14 changes: 7 additions & 7 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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]
Expand All @@ -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 =
Expand Down
94 changes: 94 additions & 0 deletions tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import sys
import time
import warnings
from unittest.mock import patch, Mock
import msal
from msal.application import (
Expand Down Expand Up @@ -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."""
Expand Down
Loading