Skip to content
Draft
3 changes: 3 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ Notes and requirements:
* The ``authority`` must be **tenanted** (not ``/common`` or ``/organizations``).
* MSAL must own the TLS transport, so a custom ``http_client`` is not supported
together with ``mtls_proof_of_possession=True``.
A per-request ``post`` override is also rejected.
* If the provider returns a different token type, MSAL returns
``token_type_mismatch`` without caching the rejected response.
* ``azure_region`` is **optional**: omit it to use the global mTLS endpoint
(``mtlsauth.microsoft.com``); set it (e.g. ``westus3``) only to pin a region.
* The existing SN/I + Bearer (client-assertion) flow is unchanged; the same
Expand Down
186 changes: 171 additions & 15 deletions msal/application.py

Large diffs are not rendered by default.

64 changes: 31 additions & 33 deletions msal/mtls.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,15 @@
# ─────────────────────────────────────────────────────────────────────────────
# SOVEREIGN GUARDRAIL - single override point for mTLS cloud availability.
#
# mTLS PoP is currently rejected for the deprecated sovereign login hosts below
# and for any non-"login." host. ``mtlsauth.*`` is rolling out across clouds
# (Azure Government / AGC: available; Bleu / Delos: TBD). To enable a cloud,
# remove its entry here (and, if needed, relax the non-"login." host check in
# ``mtls_pop_host``). This is the ONLY place cloud eligibility is enforced, so
# do not scatter equivalent checks elsewhere in the codebase.
# Only public-cloud aliases and Azure Government are supported. Add an explicit
# mapping here when another cloud becomes available.
# ─────────────────────────────────────────────────────────────────────────────
_MTLS_POP_UNSUPPORTED_HOSTS = {
"login.usgovcloudapi.net":
"login.usgovcloudapi.net is not supported for mTLS PoP, "
"please use login.microsoftonline.us",
"login.chinacloudapi.cn":
"login.chinacloudapi.cn is not supported for mTLS PoP, "
"please use login.partner.microsoftonline.cn",
"login.chinacloudapi.cn is not supported for mTLS PoP.",
}


Expand All @@ -62,8 +57,7 @@ def mtls_pop_host(instance, region=None):
:param str instance: The authority host, e.g. ``login.microsoftonline.com``.
:param region: Optional region, e.g. ``westus3``. When provided, a regional
mTLS host ``{region}.mtlsauth...`` is returned; otherwise the global one.
:raises ValueError: When mTLS PoP is not supported for the given host
(the sovereign guardrail above, or a non-``login.`` host).
:raises ValueError: When mTLS PoP is not supported for the given host.
"""
instance = instance.lower()
if instance in _MTLS_POP_UNSUPPORTED_HOSTS: # Sovereign guardrail
Expand All @@ -72,12 +66,12 @@ def mtls_pop_host(instance, region=None):
# Known public aliases (incl. legacy login.windows.net / sts.windows.net)
# all normalize to the single global mTLS host.
base = _PUBLIC_MTLS_HOST
elif instance.startswith("login."): # e.g. login.microsoftonline.us
base = "mtlsauth" + instance[len("login"):] # -> mtlsauth.microsoftonline.us
elif instance == "login.microsoftonline.us":
base = "mtlsauth.microsoftonline.us"
else:
raise ValueError(
"mTLS PoP is only supported for hosts that start with 'login.'. "
"The provided authority host ({}) does not meet this requirement. "
"mTLS PoP is only supported for public-cloud and Azure Government "
"authority hosts. The provided authority host ({}) is not supported. "
"See {} for details.".format(instance, _MTLS_POP_DOC_LINK))
return "{}.{}".format(region, base) if region else base

Expand Down Expand Up @@ -121,40 +115,45 @@ def __init__(self, cert_pem, key_pem, *,
self._proxies = proxies
self._timeout = timeout
self._session = None
self._sessions = {}
self._session_lock = threading.Lock()

def _ensure_session(self):
# Double-checked locking: confidential clients are typically
# multi-threaded servers, and a cold-start burst must not build N
# sessions (each writing the private key to a temp file N times) and
# orphan all but one. Mirrors the client-level lock in application.py.
if self._session is None:
with self._session_lock:
if self._session is None:
import requests # Lazy import, same as the rest of MSAL
def _ensure_session(self, url="", verify=None):
import requests
with self._session_lock:
session = self._session or requests.Session()
session.verify = self._verify
if self._proxies:
session.proxies = self._proxies
effective_verify = session.merge_environment_settings(
url, {}, None, verify, None)["verify"]
if effective_verify not in self._sessions:
if self._sessions:
session = requests.Session()
session.verify = self._verify
if self._proxies:
session.proxies = self._proxies
adapter = _make_mtls_adapter(_create_ssl_context(
self._cert_pem, self._key_pem, self._verify))
session.mount("https://", adapter)
self._session = session
return self._session
adapter = _make_mtls_adapter(_create_ssl_context(
self._cert_pem, self._key_pem, effective_verify))
session.mount("https://", adapter)
self._sessions[effective_verify] = session
self._session = self._session or session
return self._sessions[effective_verify]

def post(self, url, **kwargs):
if self._timeout is not None:
kwargs.setdefault("timeout", self._timeout)
return self._ensure_session().post(url, **kwargs)
return self._ensure_session(url, kwargs.get("verify")).post(url, **kwargs)

def get(self, url, **kwargs):
if self._timeout is not None:
kwargs.setdefault("timeout", self._timeout)
return self._ensure_session().get(url, **kwargs)
return self._ensure_session(url, kwargs.get("verify")).get(url, **kwargs)

def close(self):
if self._session is not None:
self._session.close()
with self._session_lock:
for session in self._sessions.values():
session.close()


def _make_mtls_adapter(ssl_context):
Expand Down Expand Up @@ -229,4 +228,3 @@ def _create_ssl_context(cert_pem, key_pem, verify=True):
except OSError: # pragma: no cover
logger.warning("Unable to remove temporary mTLS key file")
return context

87 changes: 53 additions & 34 deletions msal/token_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,32 @@


def _key_id_to_str(key_id):
"""Coerce a key_id (base64url x5t#S256) into a str for the AT cache key.

It is normally an ASCII str, but tolerate bytes (decoded as ASCII) or any
other type so that building the cache key never raises ``TypeError``.
"""
"""Normalize a key_id to text for cache storage and lookup."""
if isinstance(key_id, bytes):
return key_id.decode("ascii")
try:
return key_id.decode("ascii")
except UnicodeDecodeError as exc:
raise ValueError("key_id bytes must contain ASCII text") from exc
return key_id if isinstance(key_id, str) else str(key_id)


def _make_access_token_key(
home_account_id=None, environment=None, client_id=None, realm=None,
target=None, ext_cache_key=None, key_id=None):
components = [
home_account_id or "", environment or "",
"atext" if ext_cache_key else "AccessToken",
client_id or "", realm or "", target or "",
] + ([ext_cache_key] if ext_cache_key else [])
components = [value.lower() for value in components]
key_id = _key_id_to_str(key_id) if key_id is not None else None
if key_id:
# A hyphen-free namespace cannot collide with the legacy joined keys.
payload = json.dumps(
components + [key_id], separators=(",", ":"))
return "atkey:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
return "-".join(components)

# Fields in the request data dict that should NOT be included in the extended
# cache key hash. Everything else in data IS included, because those are extra
# body parameters going on the wire and must differentiate cached tokens.
Expand Down Expand Up @@ -246,22 +263,9 @@ def __init__(self):
# same app+scope). key_id is absent for Bearer, which
# keeps the Bearer cache key byte-for-byte unchanged.
**ignored_payload_from_a_real_token:
"-".join([ # Note: Could use a hash here to shorten key length
home_account_id or "",
environment or "",
# Use "atext" credential type when ext_cache_key is
# present, matching MSAL Go and MSAL .NET behaviour.
"atext" if ext_cache_key else "AccessToken",
client_id or "",
realm or "",
target or "",
] + ([ext_cache_key] if ext_cache_key else [])
).lower()
# key_id is a base64url x5t#S256 and is case-sensitive,
# so it is appended AFTER lower-casing the rest, to keep
# ATs bound to different keys/certs isolated. Coerce it to
# a str first so a non-str key_id never breaks caching.
+ ("-" + _key_id_to_str(key_id) if key_id else ""),
_make_access_token_key(
home_account_id, environment, client_id, realm, target,
ext_cache_key, key_id),
self.CredentialType.ID_TOKEN:
lambda home_account_id=None, environment=None, client_id=None,
realm=None, **ignored_payload_from_a_real_token:
Expand Down Expand Up @@ -345,6 +349,9 @@ def search(self, credential_type, target=None, query=None, *, now=None, for_remo
"""
target = sorted(target or []) # Match the order sorted by add()
assert isinstance(target, list), "Invalid parameter type"
target_set = set(target)
if query and query.get("key_id") is not None:
query = dict(query, key_id=_key_id_to_str(query["key_id"]))

preferred_result = None
if (credential_type == self.CredentialType.ACCESS_TOKEN
Expand All @@ -357,13 +364,14 @@ def search(self, credential_type, target=None, query=None, *, now=None, for_remo
query["client_id"], query["realm"], target,
ext_cache_key=query.get("ext_cache_key"),
key_id=query.get("key_id"))
if preferred_result and self._is_matching(
preferred_result, query,
# Needs no target_set here because it is satisfied by dict key
if (preferred_result
and self._is_matching(preferred_result, query, target_set)
and (for_removal or all(
not preferred_result.get(field) or query.get(field)
for field in ("key_id", "ext_cache_key")))
):
yield preferred_result

target_set = set(target)
with self._lock:
# O(n) search. The key is NOT used in search.
now = int(time.time() if now is None else now)
Expand All @@ -389,8 +397,8 @@ def search(self, credential_type, target=None, query=None, *, now=None, for_remo
# to enumerate these ATs and delete them.
if (credential_type == self.CredentialType.ACCESS_TOKEN
and not for_removal
and "ext_cache_key" in entry
and "ext_cache_key" not in (query or {})
and entry.get("ext_cache_key")
and not (query or {}).get("ext_cache_key")
):
continue
# Cache isolation for key-bound tokens (e.g. mtls_pop, SSH-cert).
Expand All @@ -401,8 +409,8 @@ def search(self, credential_type, target=None, query=None, *, now=None, for_remo
# to enumerate these key-bound ATs and delete them.
if (credential_type == self.CredentialType.ACCESS_TOKEN
and not for_removal
and "key_id" in entry
and "key_id" not in (query or {})
and entry.get("key_id")
and not (query or {}).get("key_id")
):
continue
yield entry
Expand Down Expand Up @@ -594,13 +602,25 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None):
# You can monkeypatch self.key_makers to support more types on-the-fly.
key = self.key_makers[credential_type](**old_entry)
with self._lock:
entries = self._cache.setdefault(credential_type, {})
if new_key_value_pairs: # Update with them
entries = self._cache.setdefault(credential_type, {})
entries[key] = dict(
entry = dict(
old_entry, # Do not use entries[key] b/c it might not exist
**new_key_value_pairs)
if (credential_type == self.CredentialType.ACCESS_TOKEN
and entry.get("key_id") is not None):
entry["key_id"] = _key_id_to_str(entry["key_id"])
entries[key] = entry
elif credential_type == self.CredentialType.ACCESS_TOKEN:
# Persisted caches may still use an older AT key format.
if entries.get(key) == old_entry:
entries.pop(key)
else:
for stored_key in [
k for k, entry in entries.items() if entry == old_entry]:
entries.pop(stored_key)
else: # Remove old_entry
self._cache.setdefault(credential_type, {}).pop(key, None)
entries.pop(key, None)

def remove_rt(self, rt_item):
assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN
Expand Down Expand Up @@ -683,4 +703,3 @@ def serialize(self):
with self._lock:
self.has_state_changed = False
return json.dumps(self._cache, indent=4)

Loading
Loading