Skip to content

Commit 02ccacb

Browse files
Fail closed on mTLS PoP token_type downgrade; honor verify; lock session build
Harden the mTLS Proof-of-Possession path with three fixes surfaced by review. Fail closed on a token_type downgrade. A cert-bound request is driven by our request flag, not the response, so if ESTS answers an mtls_pop request with a non-cert-bound token we previously still attached binding_certificate and cached it as bound. Now acquire_token_for_client returns token_type_mismatch when the response token_type is not "mtls_pop", and _MtlsClient only re-injects key_id (the cache binding) when the response is actually mtls_pop. A downgraded token therefore caches as an ordinary token that the cert-bound silent query can never match, so the flow re-fetches and fails closed every time. Mirrors MSAL .NET/Go. Honor verify in the mTLS transport. A custom ssl_context bypasses requests' own verify handling, so verify was silently ignored: verify=False raised a cryptic ValueError and the default used the system store instead of certifi. The context builder now matches requests' semantics (certifi default, CA file/dir path, or disabled) so verify behaves as documented. Serialize first session build. Wrap the lazy session construction in a double-checked lock so a cold-start burst on a multi-threaded confidential client no longer builds N transports (each writing the private key to a temp file) and orphans all but one. Also drop stray blank lines that had crept into the acquire_token_for_client mTLS setup block. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 224a003 commit 02ccacb

3 files changed

Lines changed: 88 additions & 38 deletions

File tree

msal/application.py

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,15 @@ def obtain_token_for_client(self, scope=None, **kwargs):
300300
outer = kwargs.pop("on_obtaining_tokens", None)
301301

302302
def _reinject_key_id(event):
303-
# Re-attach key_id to the event's data so token_cache.add()
304-
# binds the stored mtls_pop AT to the certificate.
305-
event["data"] = dict(event.get("data", {}), key_id=key_id)
303+
# Bind the stored AT to the certificate (via key_id) only when
304+
# ESTS actually returned a cert-bound token. On a token_type
305+
# downgrade (e.g. a Bearer response) leave key_id off, so the
306+
# token is never cached as a bound mtls_pop token - it caches as
307+
# an ordinary Bearer, and the fail-closed check in
308+
# acquire_token_for_client returns token_type_mismatch.
309+
response = event.get("response") or {}
310+
if (response.get("token_type") or "").lower() == "mtls_pop":
311+
event["data"] = dict(event.get("data", {}), key_id=key_id)
306312
(outer or self.on_obtaining_tokens)(event)
307313

308314
kwargs["on_obtaining_tokens"] = _reinject_key_id
@@ -2799,56 +2805,47 @@ def acquire_token_for_client(
27992805
kwargs["data"] = kwargs.get("data", {})
28002806
kwargs["data"]["fmi_path"] = fmi_path
28012807
if mtls_proof_of_possession:
2802-
28032808
# An mTLS transport is required to present the certificate for a
2804-
28052809
# cert-bound PoP request.
2806-
28072810
if self._http_client_is_custom:
2808-
28092811
raise ValueError(
2810-
28112812
"mtls_proof_of_possession=True is not supported with a "
2812-
28132813
"custom http_client, because MSAL must own the TLS transport "
2814-
28152814
"to present the client certificate in the mutual-TLS "
2816-
28172815
"handshake. Omit the http_client argument to use MSAL's "
2818-
28192816
"built-in mTLS transport.")
2820-
28212817
if self.authority.tenant.lower() in ("common", "organizations"):
2822-
28232818
raise ValueError(
2824-
28252819
"mtls_proof_of_possession=True requires a tenanted authority. "
2826-
28272820
"Use a specific tenant id or domain instead of /common or "
2828-
28292821
"/organizations.")
2830-
28312822
# Parse/validate the certificate now (fail fast).
2832-
28332823
mtls_cert = self._get_mtls_pop_cert()
2834-
28352824
# Cert-bound PoP: request an mtls_pop token and bind its cache
2836-
28372825
# entry to the cert via key_id (base64url x5t#S256). token_type
2838-
28392826
# also routes _acquire_token_for_client() to the mTLS client.
2840-
28412827
data = dict(kwargs.get("data") or {})
2842-
28432828
data["token_type"] = "mtls_pop"
2844-
28452829
data["key_id"] = mtls_cert["key_id"]
2846-
28472830
kwargs["data"] = data
28482831

28492832
result = _clean_up(self._acquire_token_silent_with_error(
28502833
scopes, None, claims_challenge=claims_challenge, **kwargs))
28512834
if mtls_proof_of_possession and result and "access_token" in result:
2835+
# Fail closed on a token_type downgrade: if ESTS returned a
2836+
# non-cert-bound token (e.g. a Bearer downgrade) for our mtls_pop
2837+
# request, do not surface it as bound. The cache side is handled in
2838+
# _MtlsClient (key_id binds the entry only when the response is
2839+
# mtls_pop), so a downgraded token is never stored as bound either.
2840+
# Mirrors the fail-closed behavior of MSAL .NET and MSAL Go.
2841+
if (result.get("token_type") or "").lower() != "mtls_pop":
2842+
return {
2843+
"error": "token_type_mismatch",
2844+
"error_description": (
2845+
"The identity provider returned token_type {!r} instead "
2846+
"of 'mtls_pop'; the access token is not "
2847+
"certificate-bound.".format(result.get("token_type"))),
2848+
}
28522849
# Surface the PUBLIC binding certificate (never the private key), so
28532850
# callers can correlate the token to its cert. Survives _clean_up
28542851
# (the key has no "_" prefix).

msal/mtls.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
``RegionAndMtlsDiscoveryProvider`` so MSAL Python stays cross-SDK consistent.
1313
"""
1414
import logging
15+
import threading
1516
try:
1617
from urllib.parse import urlparse, urlunparse
1718
except ImportError: # Python 2
@@ -116,18 +117,25 @@ def __init__(self, cert_pem, key_pem, *,
116117
self._proxies = proxies
117118
self._timeout = timeout
118119
self._session = None
120+
self._session_lock = threading.Lock()
119121

120122
def _ensure_session(self):
123+
# Double-checked locking: confidential clients are typically
124+
# multi-threaded servers, and a cold-start burst must not build N
125+
# sessions (each writing the private key to a temp file N times) and
126+
# orphan all but one. Mirrors the client-level lock in application.py.
121127
if self._session is None:
122-
import requests # Lazy import, same as the rest of MSAL
123-
session = requests.Session()
124-
session.verify = self._verify
125-
if self._proxies:
126-
session.proxies = self._proxies
127-
adapter = _make_mtls_adapter(
128-
_create_ssl_context(self._cert_pem, self._key_pem))
129-
session.mount("https://", adapter)
130-
self._session = session
128+
with self._session_lock:
129+
if self._session is None:
130+
import requests # Lazy import, same as the rest of MSAL
131+
session = requests.Session()
132+
session.verify = self._verify
133+
if self._proxies:
134+
session.proxies = self._proxies
135+
adapter = _make_mtls_adapter(_create_ssl_context(
136+
self._cert_pem, self._key_pem, self._verify))
137+
session.mount("https://", adapter)
138+
self._session = session
131139
return self._session
132140

133141
def post(self, url, **kwargs):
@@ -166,18 +174,43 @@ def proxy_manager_for(self, *args, **kwargs):
166174
return _MtlsHTTPAdapter(ssl_context)
167175

168176

169-
def _create_ssl_context(cert_pem, key_pem):
177+
def _new_verifying_context(verify):
178+
"""Build an ssl context whose server verification matches ``verify`` (the
179+
same semantics as ``requests``). A custom ssl_context otherwise bypasses
180+
``requests``' own ``verify`` handling, so we honor it here: ``True`` uses
181+
certifi (as the rest of MSAL does), a filesystem path uses that CA file/dir,
182+
and ``False`` disables verification (instead of the cryptic ValueError that
183+
``requests`` would raise when it tries to set CERT_NONE on this context).
184+
"""
185+
import ssl
186+
import os
187+
if verify is False:
188+
context = ssl.create_default_context()
189+
context.check_hostname = False # Must be cleared before CERT_NONE
190+
context.verify_mode = ssl.CERT_NONE
191+
return context
192+
if isinstance(verify, str):
193+
return (ssl.create_default_context(capath=verify)
194+
if os.path.isdir(verify)
195+
else ssl.create_default_context(cafile=verify))
196+
try: # Default: match requests' trust store (certifi), not the system one
197+
import certifi
198+
return ssl.create_default_context(cafile=certifi.where())
199+
except ImportError: # pragma: no cover
200+
return ssl.create_default_context()
201+
202+
203+
def _create_ssl_context(cert_pem, key_pem, verify=True):
170204
"""Build a client ``ssl.SSLContext`` that presents ``cert_pem``/``key_pem``.
171205
172206
``ssl.SSLContext.load_cert_chain`` requires a file path, but our key is
173207
in memory. We write a ``0600`` temp PEM (mkstemp defaults to owner-only),
174208
load it, then unlink it immediately - the context keeps the material in
175209
memory, so nothing sensitive lingers on disk.
176210
"""
177-
import ssl
178211
import os
179212
import tempfile
180-
context = ssl.create_default_context() # Verifies the server (ESTS) as usual
213+
context = _new_verifying_context(verify) # Verifies the server (ESTS)
181214
fd, path = tempfile.mkstemp(suffix=".pem") # Owner-only (0600) by default
182215
try:
183216
# os.fdopen() takes ownership of fd and guarantees it is closed even if

tests/test_application.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,6 +1670,26 @@ def test_backward_compatible_bearer_is_unchanged(self):
16701670
self.assertEqual("Bearer", result.get("token_type"))
16711671
self.assertNotIn("binding_certificate", result)
16721672

1673+
def test_token_type_downgrade_fails_closed(self):
1674+
# ESTS returns a non-cert-bound token (a Bearer downgrade) for an
1675+
# mtls_pop request. MSAL must fail closed: an error result, no
1676+
# binding_certificate, and nothing cached as a bound (key_id) token.
1677+
app = ConfidentialClientApplication(
1678+
"cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY)
1679+
result = app.acquire_token_for_client(
1680+
["s1"], mtls_proof_of_possession=True,
1681+
post=self._capturing_post([], token_type="Bearer"))
1682+
self.assertEqual("token_type_mismatch", result.get("error"))
1683+
self.assertNotIn("access_token", result)
1684+
self.assertNotIn("binding_certificate", result)
1685+
bound = [
1686+
at for at in app.token_cache.search(
1687+
msal.TokenCache.CredentialType.ACCESS_TOKEN)
1688+
if at.get("key_id")
1689+
or (at.get("token_type") or "").lower() == "mtls_pop"]
1690+
self.assertEqual(
1691+
[], bound, "A downgraded token must never be cached as bound")
1692+
16731693
def test_regional_mtls_endpoint(self):
16741694
app = ConfidentialClientApplication(
16751695
"cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY,

0 commit comments

Comments
 (0)