Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 thread
4gust marked this conversation as resolved.
| 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.
Comment thread
4gust marked this conversation as resolved.

## 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 thread
4gust marked this conversation as resolved.

## 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 thread
Avery-Dunn marked this conversation as resolved.
Outdated
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.20.6,<0.21; python_version>='3.8' and platform_system=='Windows'
pymsalruntime>=0.20,<0.21; python_version>='3.9' and platform_system=='Windows'
Comment thread
4gust marked this conversation as resolved.
# On Mac, PyMsalRuntime 0.17+ is expected to support SSH cert and ROPC
pymsalruntime>=0.20.6,<0.21; python_version>='3.8' and platform_system=='Darwin'
pymsalruntime>=0.20,<0.21; python_version>='3.9' and platform_system=='Darwin'
# PyMsalRuntime 0.18+ is expected to support broker on Linux
pymsalruntime>=0.20.6,<0.21; python_version>='3.8' and platform_system=='Linux'
pymsalruntime>=0.20,<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