Skip to content

Commit dd4f13a

Browse files
Add SNI certificate support over mTLS Proof-of-Possession
Allow a confidential-client app configured with a Subject Name + Issuer (SN/I) certificate to obtain an mTLS-bound PoP access token from Entra ID, using the same certificate as the client TLS certificate in the mutual-TLS handshake to the token endpoint (token_type=mtls_pop, cnf/x5t#S256 binding). - Add mtls_proof_of_possession kwarg to acquire_token_for_client, returning a binding_certificate (public x5c + sha256 thumbprint) on success - Add mTLS client-cert transport (msal/mtls.py) with endpoint transform and sovereign-cloud / tenanted-authority / custom-http-client guardrails - Isolate mtls_pop tokens in cache via key_id (Bearer unchanged) - Add token-type telemetry, docs, and a confidential-client mTLS PoP sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 55dd09f commit dd4f13a

12 files changed

Lines changed: 1105 additions & 25 deletions

docs/index.rst

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,50 @@ New in MSAL Python 1.26
160160
.. automethod:: __init__
161161

162162

163+
mTLS Proof-of-Possession (SN/I certificate)
164+
-------------------------------------------
165+
166+
New in MSAL Python 1.38
167+
168+
MSAL Python supports two different kinds of Proof-of-Possession (PoP) tokens:
169+
170+
* **Signed HTTP Request (SHR) PoP** -- used by public-client apps through the
171+
broker, and configured with the ``auth_scheme`` parameter and
172+
:py:class:`msal.PopAuthScheme` above.
173+
* **mutual-TLS (mTLS) PoP** -- used by *confidential-client* apps. The app's
174+
Subject Name + Issuer (SN/I) certificate is presented as the **client TLS
175+
certificate** in a mutual-TLS handshake to Microsoft Entra, and the returned
176+
access token is cryptographically bound to that certificate
177+
(``token_type == "mtls_pop"``, ``cnf``/``x5t#S256``).
178+
179+
To request an mTLS-PoP token, configure the confidential client with a
180+
certificate credential and pass ``mtls_proof_of_possession=True`` to
181+
:py:meth:`msal.ConfidentialClientApplication.acquire_token_for_client`::
182+
183+
app = msal.ConfidentialClientApplication(
184+
client_id,
185+
authority="https://login.microsoftonline.com/<tenant-id>", # MUST be tenanted
186+
client_credential={"private_key_pfx_path": "sni.pfx", "public_certificate": True},
187+
# azure_region="westus3", # optional; omit for the global mtls endpoint
188+
)
189+
result = app.acquire_token_for_client(
190+
["https://graph.microsoft.com/.default"],
191+
mtls_proof_of_possession=True,
192+
)
193+
# result["token_type"] == "mtls_pop"
194+
# result["binding_certificate"] == {"x5c": [...], "thumbprint_sha256": "..."}
195+
196+
Notes and requirements:
197+
198+
* The ``authority`` must be **tenanted** (not ``/common`` or ``/organizations``).
199+
* MSAL must own the TLS transport, so a custom ``http_client`` is not supported
200+
together with ``mtls_proof_of_possession=True``.
201+
* The existing SN/I + Bearer (client-assertion) flow is unchanged; the same
202+
certificate can be used either as an assertion signer (Bearer) or as the TLS
203+
client certificate (mtls_pop).
204+
* mTLS PoP currently targets the public and Azure Government (Arlington) clouds.
205+
206+
163207
Exceptions
164208
----------
165209
These are exceptions that MSAL Python may raise.

msal/application.py

Lines changed: 343 additions & 13 deletions
Large diffs are not rendered by default.

msal/mtls.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Helpers for mTLS Proof-of-Possession (PoP).
2+
3+
This module owns two concerns for the "SN/I certificate over mTLS PoP" feature:
4+
5+
1. The endpoint transform + sovereign guardrail: mapping a tenanted ``login.*``
6+
authority to its ``mtlsauth.*`` counterpart (global or regional), and
7+
rejecting clouds/hosts where mTLS PoP is not (yet) available.
8+
2. The mTLS transport: a ``requests`` session whose HTTPS connections present a
9+
client certificate for the mutual-TLS handshake to the token endpoint.
10+
11+
The wire contract and host mapping mirror the shipped MSAL.NET
12+
``RegionAndMtlsDiscoveryProvider`` so MSAL Python stays cross-SDK consistent.
13+
"""
14+
import logging
15+
try:
16+
from urllib.parse import urlparse, urlunparse
17+
except ImportError: # Python 2
18+
from urlparse import urlparse, urlunparse
19+
20+
21+
logger = logging.getLogger(__name__)
22+
23+
_MTLS_POP_DOC_LINK = "https://aka.ms/msal-net-pop"
24+
25+
# The global public mTLS host. The four public "login.*" hosts all normalize to
26+
# this single global endpoint (ESTSR provides regional failover), matching
27+
# MSAL.NET's PublicEnvForRegionalMtlsAuth.
28+
_PUBLIC_MTLS_HOST = "mtlsauth.microsoft.com"
29+
30+
# Public-cloud login hosts that normalize to the single global mTLS host above.
31+
_PUBLIC_CLOUD_LOGIN_HOSTS = frozenset([
32+
"login.microsoftonline.com",
33+
"login.microsoft.com",
34+
"login.windows.net",
35+
"sts.windows.net",
36+
])
37+
38+
# ─────────────────────────────────────────────────────────────────────────────
39+
# SOVEREIGN GUARDRAIL - single override point for mTLS cloud availability.
40+
#
41+
# mTLS PoP is currently rejected for the deprecated sovereign login hosts below
42+
# and for any non-"login." host. ``mtlsauth.*`` is rolling out across clouds
43+
# (Azure Government / AGC: available; Bleu / Delos: TBD). To enable a cloud,
44+
# remove its entry here (and, if needed, relax the non-"login." host check in
45+
# ``mtls_pop_host``). This is the ONLY place cloud eligibility is enforced, so
46+
# do not scatter equivalent checks elsewhere in the codebase.
47+
# ─────────────────────────────────────────────────────────────────────────────
48+
_MTLS_POP_UNSUPPORTED_HOSTS = {
49+
"login.usgovcloudapi.net":
50+
"login.usgovcloudapi.net is not supported for mTLS PoP, "
51+
"please use login.microsoftonline.us",
52+
"login.chinacloudapi.cn":
53+
"login.chinacloudapi.cn is not supported for mTLS PoP, "
54+
"please use login.partner.microsoftonline.cn",
55+
}
56+
57+
58+
def mtls_pop_host(instance, region=None):
59+
"""Return the ``mtlsauth.*`` host for a given ``login.*`` authority instance.
60+
61+
:param str instance: The authority host, e.g. ``login.microsoftonline.com``.
62+
:param region: Optional region, e.g. ``westus3``. When provided, a regional
63+
mTLS host ``{region}.mtlsauth...`` is returned; otherwise the global one.
64+
:raises ValueError: When mTLS PoP is not supported for the given host
65+
(the sovereign guardrail above, or a non-``login.`` host).
66+
"""
67+
instance = instance.lower()
68+
if instance in _MTLS_POP_UNSUPPORTED_HOSTS: # Sovereign guardrail
69+
raise ValueError(_MTLS_POP_UNSUPPORTED_HOSTS[instance])
70+
if instance in _PUBLIC_CLOUD_LOGIN_HOSTS:
71+
# Known public aliases (incl. legacy login.windows.net / sts.windows.net)
72+
# all normalize to the single global mTLS host.
73+
base = _PUBLIC_MTLS_HOST
74+
elif instance.startswith("login."): # e.g. login.microsoftonline.us
75+
base = "mtlsauth" + instance[len("login"):] # -> mtlsauth.microsoftonline.us
76+
else:
77+
raise ValueError(
78+
"mTLS PoP is only supported for hosts that start with 'login.'. "
79+
"The provided authority host ({}) does not meet this requirement. "
80+
"See {} for details.".format(instance, _MTLS_POP_DOC_LINK))
81+
return "{}.{}".format(region, base) if region else base
82+
83+
84+
def transform_token_endpoint(token_endpoint, instance, region=None):
85+
"""Return ``token_endpoint`` with its host swapped for the mTLS host.
86+
87+
The path/tenant and everything else are preserved; only the network host is
88+
rewritten (e.g. ``https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token``
89+
-> ``https://mtlsauth.microsoft.com/{tenant}/oauth2/v2.0/token``).
90+
"""
91+
parsed = urlparse(token_endpoint)
92+
host = mtls_pop_host(instance, region)
93+
# Preserve a non-default port if the original endpoint carried one (tests).
94+
netloc = "{}:{}".format(host, parsed.port) if parsed.port else host
95+
return urlunparse(parsed._replace(netloc=netloc))
96+
97+
98+
class _MtlsHttpClient(object):
99+
"""A minimal http client (``post``/``get``/``close``) whose HTTPS
100+
connections present a client certificate for mutual-TLS.
101+
102+
MSAL owns this transport. A caller's plain custom ``http_client`` cannot
103+
perform the mTLS handshake, which is why requesting mTLS PoP with a
104+
non-mTLS-capable custom transport fails fast (see application.py).
105+
106+
The ``ssl.SSLContext`` (and its temp key file) is built lazily on first use,
107+
so Bearer-only certificate apps never pay the cost nor touch the disk.
108+
"""
109+
def __init__(self, cert_pem, key_pem, *,
110+
verify=True, proxies=None, timeout=None):
111+
# cert_pem / key_pem are PEM-encoded bytes. key_pem must be an
112+
# unencrypted private key (the caller normalizes it).
113+
self._cert_pem = cert_pem
114+
self._key_pem = key_pem
115+
self._verify = verify
116+
self._proxies = proxies
117+
self._timeout = timeout
118+
self._session = None
119+
120+
def _ensure_session(self):
121+
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
131+
return self._session
132+
133+
def post(self, url, **kwargs):
134+
if self._timeout is not None:
135+
kwargs.setdefault("timeout", self._timeout)
136+
return self._ensure_session().post(url, **kwargs)
137+
138+
def get(self, url, **kwargs):
139+
if self._timeout is not None:
140+
kwargs.setdefault("timeout", self._timeout)
141+
return self._ensure_session().get(url, **kwargs)
142+
143+
def close(self):
144+
if self._session is not None:
145+
self._session.close()
146+
147+
148+
def _make_mtls_adapter(ssl_context):
149+
"""Return a ``requests`` HTTPAdapter that injects ``ssl_context`` (with the
150+
client certificate loaded) into every connection pool it creates."""
151+
from requests.adapters import HTTPAdapter # Lazy import
152+
153+
class _MtlsHTTPAdapter(HTTPAdapter):
154+
def __init__(self, ssl_context):
155+
self._ssl_context = ssl_context
156+
super(_MtlsHTTPAdapter, self).__init__(max_retries=1)
157+
158+
def init_poolmanager(self, *args, **kwargs):
159+
kwargs["ssl_context"] = self._ssl_context
160+
return super(_MtlsHTTPAdapter, self).init_poolmanager(*args, **kwargs)
161+
162+
def proxy_manager_for(self, *args, **kwargs):
163+
kwargs["ssl_context"] = self._ssl_context
164+
return super(_MtlsHTTPAdapter, self).proxy_manager_for(*args, **kwargs)
165+
166+
return _MtlsHTTPAdapter(ssl_context)
167+
168+
169+
def _create_ssl_context(cert_pem, key_pem):
170+
"""Build a client ``ssl.SSLContext`` that presents ``cert_pem``/``key_pem``.
171+
172+
``ssl.SSLContext.load_cert_chain`` requires a file path, but our key is
173+
in memory. We write a ``0600`` temp PEM (mkstemp defaults to owner-only),
174+
load it, then unlink it immediately - the context keeps the material in
175+
memory, so nothing sensitive lingers on disk.
176+
"""
177+
import ssl
178+
import os
179+
import tempfile
180+
context = ssl.create_default_context() # Verifies the server (ESTS) as usual
181+
fd, path = tempfile.mkstemp(suffix=".pem") # Owner-only (0600) by default
182+
try:
183+
# os.fdopen() takes ownership of fd and guarantees it is closed even if
184+
# writing raises; file.write() writes every byte (os.write() may perform
185+
# a short write). Closing the fd also lets Windows unlink the file below.
186+
with os.fdopen(fd, "wb") as f:
187+
f.write(key_pem + b"\n" + cert_pem)
188+
context.load_cert_chain(path) # Loads our client cert+key into memory
189+
finally:
190+
try:
191+
os.remove(path) # Unlink immediately; minimal disk exposure
192+
except OSError: # pragma: no cover
193+
logger.warning("Unable to remove temporary mTLS key file")
194+
return context
195+

msal/sku.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"""
33

44
# The __init__.py will import this. Not the other way around.
5-
__version__ = "1.37.0"
5+
__version__ = "1.38.0"
66
SKU = "MSAL.Python"

msal/telemetry.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
AT_AGING = 4
1515
RESERVED = 5
1616

17+
# Server-telemetry token-type values (parity with the other MSALs, e.g. .NET's
18+
# TelemetryTokenTypeConstants). Only non-Bearer token types are emitted.
19+
_TELEMETRY_TOKEN_TYPES = {
20+
"mtls_pop": 6, # mTLS-bound Proof-of-Possession
21+
}
22+
1723

1824
def _get_new_correlation_id():
1925
return str(uuid.uuid4())
@@ -28,18 +34,31 @@ class _TelemetryContext(object):
2834
_CURRENT_HEADER_SIZE_LIMIT = 100
2935
_LAST_HEADER_SIZE_LIMIT = 350
3036

31-
def __init__(self, buffer, lock, api_id, correlation_id=None, refresh_reason=None):
37+
def __init__(self, buffer, lock, api_id, correlation_id=None,
38+
refresh_reason=None, token_type=None):
3239
self._buffer = buffer
3340
self._lock = lock
3441
self._api_id = api_id
3542
self._correlation_id = correlation_id or _get_new_correlation_id()
3643
self._refresh_reason = refresh_reason or NON_SILENT_CALL
44+
self._token_type = token_type
3745
logger.debug("Generate or reuse correlation_id: %s", self._correlation_id)
3846

3947
def generate_headers(self):
4048
with self._lock:
41-
current = "4|{api_id},{cache_refresh}|".format(
42-
api_id=self._api_id, cache_refresh=self._refresh_reason)
49+
# MSAL Python's current schema (4) carries an EMPTY platform-config
50+
# section after the second "|". To stay byte-for-byte unchanged for
51+
# every existing flow, we only populate that section for token types
52+
# that need it (currently mtls_pop -> value 6, placed in the 3rd
53+
# platform field). This is a documented Python-schema divergence:
54+
# other MSALs use schema 5, but the token-type *value* matches.
55+
token_type_value = _TELEMETRY_TOKEN_TYPES.get(self._token_type)
56+
platform_config = (
57+
",,{}".format(token_type_value)
58+
if token_type_value is not None else "")
59+
current = "4|{api_id},{cache_refresh}|{platform_config}".format(
60+
api_id=self._api_id, cache_refresh=self._refresh_reason,
61+
platform_config=platform_config)
4362
if len(current) > self._CURRENT_HEADER_SIZE_LIMIT:
4463
logger.warning(
4564
"Telemetry header greater than {} will be truncated by AAD".format(

msal/token_cache.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,10 @@ def __init__(self):
152152
realm=None, target=None,
153153
ext_cache_key=None,
154154
# Note: New field(s) can be added here
155-
#key_id=None,
155+
key_id=None, # So ATs bound to different keys/certs can
156+
# coexist (e.g. an mtls_pop AT vs a Bearer AT for the
157+
# same app+scope). key_id is absent for Bearer, which
158+
# keeps the Bearer cache key byte-for-byte unchanged.
156159
**ignored_payload_from_a_real_token:
157160
"-".join([ # Note: Could use a hash here to shorten key length
158161
home_account_id or "",
@@ -163,9 +166,12 @@ def __init__(self):
163166
client_id or "",
164167
realm or "",
165168
target or "",
166-
#key_id or "", # So ATs of different key_id can coexist
167169
] + ([ext_cache_key] if ext_cache_key else [])
168-
).lower(),
170+
).lower()
171+
# key_id is a base64url x5t#S256 and is case-sensitive,
172+
# so it is appended AFTER lower-casing the rest, to keep
173+
# ATs bound to different keys/certs isolated.
174+
+ ("-" + key_id if key_id else ""),
169175
self.CredentialType.ID_TOKEN:
170176
lambda home_account_id=None, environment=None, client_id=None,
171177
realm=None, **ignored_payload_from_a_real_token:
@@ -194,6 +200,7 @@ def _get_access_token(
194200
self,
195201
home_account_id, environment, client_id, realm, target, # Together they form a compound key
196202
ext_cache_key=None,
203+
key_id=None,
197204
default=None,
198205
): # O(1)
199206
return self._get(
@@ -205,6 +212,7 @@ def _get_access_token(
205212
realm=realm,
206213
target=" ".join(target),
207214
ext_cache_key=ext_cache_key,
215+
key_id=key_id,
208216
),
209217
default=default)
210218

@@ -251,7 +259,8 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n)
251259
preferred_result = self._get_access_token(
252260
query["home_account_id"], query["environment"],
253261
query["client_id"], query["realm"], target,
254-
ext_cache_key=query.get("ext_cache_key"))
262+
ext_cache_key=query.get("ext_cache_key"),
263+
key_id=query.get("key_id"))
255264
if preferred_result and self._is_matching(
256265
preferred_result, query,
257266
# Needs no target_set here because it is satisfied by dict key
@@ -284,6 +293,14 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n)
284293
and "ext_cache_key" not in (query or {})
285294
):
286295
continue
296+
# Cache isolation for key-bound tokens (e.g. mtls_pop, SSH-cert).
297+
# An entry bound to a key_id must not satisfy a query without
298+
# one, so a Bearer lookup never returns a PoP/mtls_pop token.
299+
if (credential_type == self.CredentialType.ACCESS_TOKEN
300+
and "key_id" in entry
301+
and "key_id" not in (query or {})
302+
):
303+
continue
287304
yield entry
288305
for at in expired_access_tokens:
289306
self.remove_at(at)

0 commit comments

Comments
 (0)