From dd4f13a15e239adf34f934d5363ed1487fcb20d7 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 1 Jul 2026 15:40:18 -0400 Subject: [PATCH 01/27] 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> --- docs/index.rst | 44 +++ msal/application.py | 356 +++++++++++++++++- msal/mtls.py | 195 ++++++++++ msal/sku.py | 2 +- msal/telemetry.py | 25 +- msal/token_cache.py | 25 +- sample/confidential_client_mtls_pop_sample.py | 100 +++++ tests/test_application.py | 122 +++++- tests/test_e2e.py | 63 ++++ tests/test_mtls_transport.py | 146 +++++++ tests/test_optional_thumbprint.py | 3 +- tests/test_token_cache.py | 49 ++- 12 files changed, 1105 insertions(+), 25 deletions(-) create mode 100644 msal/mtls.py create mode 100644 sample/confidential_client_mtls_pop_sample.py create mode 100644 tests/test_mtls_transport.py diff --git a/docs/index.rst b/docs/index.rst index 1c5d79bc..98659dcd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -160,6 +160,50 @@ New in MSAL Python 1.26 .. automethod:: __init__ +mTLS Proof-of-Possession (SN/I certificate) +------------------------------------------- + +New in MSAL Python 1.38 + +MSAL Python supports two different kinds of Proof-of-Possession (PoP) tokens: + +* **Signed HTTP Request (SHR) PoP** -- used by public-client apps through the + broker, and configured with the ``auth_scheme`` parameter and + :py:class:`msal.PopAuthScheme` above. +* **mutual-TLS (mTLS) PoP** -- used by *confidential-client* apps. The app's + Subject Name + Issuer (SN/I) certificate is presented as the **client TLS + certificate** in a mutual-TLS handshake to Microsoft Entra, and the returned + access token is cryptographically bound to that certificate + (``token_type == "mtls_pop"``, ``cnf``/``x5t#S256``). + +To request an mTLS-PoP token, configure the confidential client with a +certificate credential and pass ``mtls_proof_of_possession=True`` to +:py:meth:`msal.ConfidentialClientApplication.acquire_token_for_client`:: + + app = msal.ConfidentialClientApplication( + client_id, + authority="https://login.microsoftonline.com/", # MUST be tenanted + client_credential={"private_key_pfx_path": "sni.pfx", "public_certificate": True}, + # azure_region="westus3", # optional; omit for the global mtls endpoint + ) + result = app.acquire_token_for_client( + ["https://graph.microsoft.com/.default"], + mtls_proof_of_possession=True, + ) + # result["token_type"] == "mtls_pop" + # result["binding_certificate"] == {"x5c": [...], "thumbprint_sha256": "..."} + +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``. +* The existing SN/I + Bearer (client-assertion) flow is unchanged; the same + certificate can be used either as an assertion signer (Bearer) or as the TLS + client certificate (mtls_pop). +* mTLS PoP currently targets the public and Azure Government (Arlington) clouds. + + Exceptions ---------- These are exceptions that MSAL Python may raise. diff --git a/msal/application.py b/msal/application.py index 3fc69461..4356a700 100644 --- a/msal/application.py +++ b/msal/application.py @@ -4,6 +4,7 @@ import logging import sys import warnings +import base64 from threading import Lock from typing import Optional # Needed in Python 3.7 & 3.8 from urllib.parse import urlparse @@ -24,6 +25,7 @@ import msal.telemetry from .region import _detect_region, _validate_region from .throttled_http_client import ThrottledHttpClient +from . import mtls from .cloudshell import _is_running_in_cloud_shell from .sku import SKU, __version__ from .oauth2cli.authcode import is_wsl @@ -85,7 +87,7 @@ def _extract_cert_and_thumbprints(cert): # https://cryptography.io/en/latest/x509/reference/#x-509-certificate-object - Requires cryptography 0.7+ sha256_thumbprint = cert.fingerprint(hashes.SHA256()).hex() sha1_thumbprint = cert.fingerprint(hashes.SHA1()).hex() # CodeQL [SM02167] for legacy support such as ADFS - return sha256_thumbprint, sha1_thumbprint, x5c + return sha256_thumbprint, sha1_thumbprint, x5c, cert_pem def _parse_pfx(pfx_path, passphrase_bytes): # Cert concepts https://security.stackexchange.com/a/226758/125264 @@ -96,8 +98,8 @@ def _parse_pfx(pfx_path, passphrase_bytes): f.read(), passphrase_bytes) if not (private_key and cert): raise ValueError("Your PFX file shall contain both private key and cert") - sha256_thumbprint, sha1_thumbprint, x5c = _extract_cert_and_thumbprints(cert) - return private_key, sha256_thumbprint, sha1_thumbprint, x5c + sha256_thumbprint, sha1_thumbprint, x5c, cert_pem = _extract_cert_and_thumbprints(cert) + return private_key, sha256_thumbprint, sha1_thumbprint, x5c, cert_pem def _load_private_key_from_pem_str(private_key_pem_str, passphrase_bytes): @@ -110,6 +112,75 @@ def _load_private_key_from_pem_str(private_key_pem_str, passphrase_bytes): ) +def _private_key_to_unencrypted_pem(private_key, passphrase_bytes=None): + """Normalize a private key to unencrypted PKCS8 PEM bytes. + + ``private_key`` may be a ``cryptography`` private-key object (as returned by + ``_parse_pfx``) or a PEM string/bytes (the raw ``private_key`` credential). + The result is suitable for ``ssl.SSLContext.load_cert_chain`` via a temp file. + """ + from cryptography.hazmat.primitives import serialization + if isinstance(private_key, (str, bytes)): + key_obj = serialization.load_pem_private_key( + _str2bytes(private_key) if isinstance(private_key, str) else private_key, + passphrase_bytes) + else: # Already a cryptography private-key object + key_obj = private_key + return key_obj.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption()) + + +def _load_mtls_cert_material(cert_credential): + """Load client-cert material for an mTLS PoP handshake from a cert credential. + + ``cert_credential`` is the app's main ``client_credential`` (vanilla SN/I), + a certificate credential dict. Returns a dict with the unencrypted-PEM private key, + the leaf cert PEM, ``x5c``, the SHA-256 thumbprint (hex), and ``key_id`` + (base64url ``x5t#S256``, used for cache binding). Raises ``ValueError`` when + the credential cannot yield mTLS-capable certificate material. + """ + passphrase_bytes = _str2bytes( + cert_credential["passphrase"] + ) if cert_credential.get("passphrase") else None + if cert_credential.get("private_key_pfx_path"): + private_key, sha256_thumbprint, _, x5c, cert_pem = _parse_pfx( + cert_credential["private_key_pfx_path"], passphrase_bytes) + elif cert_credential.get("private_key"): + public_cert = cert_credential.get("public_certificate") + if not isinstance(public_cert, str): + raise ValueError( + "mTLS Proof-of-Possession requires the certificate's public part. " + "Provide 'public_certificate' (PEM) alongside 'private_key', " + "or use 'private_key_pfx_path'.") + from cryptography import x509 + cert = x509.load_pem_x509_certificate(_str2bytes(public_cert)) + sha256_thumbprint, _, x5c, cert_pem = _extract_cert_and_thumbprints(cert) + private_key = ( + _load_private_key_from_pem_str( + cert_credential['private_key'], passphrase_bytes) + if passphrase_bytes + else cert_credential['private_key']) + else: + raise ValueError( + "mTLS Proof-of-Possession requires a certificate credential " + "(a 'private_key_pfx_path', or a 'private_key' plus 'public_certificate').") + if not sha256_thumbprint: + raise ValueError( + "mTLS Proof-of-Possession requires a SHA-256 certificate thumbprint.") + key_id = base64.urlsafe_b64encode( + bytes.fromhex(sha256_thumbprint)).rstrip(b"=").decode("ascii") + return { + "private_key_pem": _private_key_to_unencrypted_pem( + private_key, passphrase_bytes), + "cert_pem": cert_pem if isinstance(cert_pem, bytes) else _str2bytes(cert_pem), + "x5c": x5c, + "sha256_thumbprint": sha256_thumbprint, + "key_id": key_id, + } + + def _pii_less_home_account_id(home_account_id): parts = home_account_id.split(".") # It could contain one or two parts parts[0] = "********" @@ -210,6 +281,35 @@ def obtain_token_by_username_password(self, username, password, **kwargs): username, password, headers=headers, **kwargs) +class _MtlsClient(_ClientWithCcsRoutingInfo): + """A confidential-client OAuth2 client whose token request presents a + client certificate over mutual-TLS (mTLS Proof-of-Possession). + + An mtls_pop access token is bound to the certificate, so the cache isolates + it via ``key_id`` (the certificate's base64url ``x5t#S256``). That ``key_id`` + must reach the token cache (for both storage and lookup), but it must NOT be + sent to ESTS on the wire - ESTS derives the binding from the TLS-presented + certificate itself. This override therefore pops ``key_id`` out of the HTTP + request body and re-injects it into the token-obtaining event so the cache + still records it against the certificate. + """ + def obtain_token_for_client(self, scope=None, **kwargs): + data = kwargs.pop("data", {}) + key_id = data.pop("key_id", None) # Keep key_id OFF the wire + if key_id: + outer = kwargs.pop("on_obtaining_tokens", None) + + def _reinject_key_id(event): + # Re-attach key_id to the event's data so token_cache.add() + # binds the stored mtls_pop AT to the certificate. + event["data"] = dict(event.get("data", {}), key_id=key_id) + (outer or self.on_obtaining_tokens)(event) + + kwargs["on_obtaining_tokens"] = _reinject_key_id + return super(_MtlsClient, self).obtain_token_for_client( + scope=scope, data=data, **kwargs) + + def _msal_extension_check(): # Can't run this in module or class level otherwise you'll get circular import error try: @@ -648,6 +748,14 @@ def get_client_assertion(): 'Invalid exclude_scopes={}. You can not opt out "openid" scope'.format( repr(exclude_scopes))) + # Retained for the mTLS PoP transport, which needs its own dedicated + # requests.Session carrying the client certificate (see msal/mtls.py). + # A user-supplied http_client cannot perform the mTLS handshake, so + # requesting mtls_proof_of_possession=True with one fails fast. + self._http_client_is_custom = bool(http_client) + self._http_client_config = { + "verify": verify, "proxies": proxies, "timeout": timeout} + self._http_cache = http_cache # Reused by the lazily-built mTLS client if http_client: self.http_client = http_client else: @@ -716,6 +824,24 @@ def get_client_assertion(): self._region_detected = None self.client, self._regional_client = self._build_client( client_credential, self.authority) + + # mTLS Proof-of-Possession state. The certificate material and the + # dedicated mTLS client(s) are parsed/built lazily on the first + # mtls_proof_of_possession request (see _get_mtls_pop_cert / + # _get_mtls_client), so Bearer-only cert apps pay nothing and the + # sovereign/tenanted guardrails fire at request time. + self._mtls_pop_cert_material = None # Cached parse of the cert below + self._mtls_client = None # Lazily built global mTLS client + self._mtls_regional_client = None # Lazily built regional mTLS client + self._mtls_lock = Lock() + if isinstance(client_credential, dict) and not client_credential.get( + "client_assertion") and ( + client_credential.get("private_key_pfx_path") + or client_credential.get("private_key")): # Vanilla SN/I cert + self._mtls_cert_credential = client_credential + else: # No certificate available to present over mTLS + self._mtls_cert_credential = None + # Warn if using a static string/bytes client_assertion (discouraged for long-running apps) if isinstance(client_credential, dict) and isinstance( client_credential.get("client_assertion"), (str, bytes)): @@ -801,12 +927,21 @@ def _decorate_scope( return list(decorated) def _build_telemetry_context( - self, api_id, correlation_id=None, refresh_reason=None): + self, api_id, correlation_id=None, refresh_reason=None, + token_type=None): return msal.telemetry._TelemetryContext( self._telemetry_buffer, self._telemetry_lock, api_id, - correlation_id=correlation_id, refresh_reason=refresh_reason) + correlation_id=correlation_id, refresh_reason=refresh_reason, + token_type=token_type) - def _get_regional_authority(self, central_authority) -> Optional[Authority]: + def _compute_region_to_use(self) -> Optional[str]: + """Resolve the effective Azure region (or None), honoring the + ``azure_region`` config, ``MSAL_FORCE_REGION``, and auto-detection. + + Factored out of :func:`_get_regional_authority` so the mTLS PoP client + (which builds a ``{region}.mtlsauth.*`` endpoint) can reuse the exact + same region resolution. + """ if self._region_configured is False: # User opts out of ESTS-R return None # Short circuit to completely bypass region detection if self._region_configured is None: # User did not make an ESTS-R choice @@ -825,6 +960,10 @@ def _get_regional_authority(self, central_authority) -> Optional[Authority]: region_to_use = _validate_region( region_to_use, source="azure_region parameter") logger.debug('Region to be used: {}'.format(repr(region_to_use))) + return region_to_use + + def _get_regional_authority(self, central_authority) -> Optional[Authority]: + region_to_use = self._compute_region_to_use() if region_to_use: regional_host = ("{}.login.microsoft.com".format(region_to_use) if central_authority.instance in ( @@ -843,6 +982,98 @@ def _get_regional_authority(self, central_authority) -> Optional[Authority]: ) return None + def _default_client_headers(self): + default_headers = { + "x-client-sku": SKU, "x-client-ver": __version__, + "x-client-os": sys.platform, + "x-ms-lib-capability": "retry-after, h429", + } + if self.app_name: + default_headers['x-app-name'] = self.app_name + if self.app_version: + default_headers['x-app-ver'] = self.app_version + return default_headers + + def _get_mtls_pop_cert(self): + """Lazily parse and cache the client certificate material used as the + TLS client certificate for mTLS PoP. Raises ``ValueError`` when no + suitable certificate credential is configured.""" + if self._mtls_cert_credential is None: + raise ValueError( + "mtls_proof_of_possession=True requires this confidential client " + "to be configured with a certificate credential (a " + "'private_key_pfx_path', or a 'private_key' plus " + "'public_certificate').") + with self._mtls_lock: + if self._mtls_pop_cert_material is None: + self._mtls_pop_cert_material = _load_mtls_cert_material( + self._mtls_cert_credential) + return self._mtls_pop_cert_material + + def _get_mtls_client(self, central_authority): + """Lazily build (and cache) the mTLS PoP client for ``central_authority``. + + The token endpoint host is transformed ``login.* -> [region.]mtlsauth.*`` + (region honored when configured, global otherwise), and the client + presents the configured certificate over mutual-TLS. + """ + if self._http_client_is_custom: + raise ValueError( + "mtls_proof_of_possession=True is not supported with a custom " + "http_client, because MSAL must own the TLS transport to present " + "the client certificate in the mutual-TLS handshake. Omit the " + "http_client argument to use MSAL's built-in mTLS transport.") + region = self._compute_region_to_use() + with self._mtls_lock: + cached = self._mtls_regional_client if region else self._mtls_client + if cached is not None: + return cached + cert = self._get_mtls_pop_cert() # May raise ValueError (no cert configured) + token_endpoint = mtls.transform_token_endpoint( # login.* -> mtlsauth.* + central_authority.token_endpoint, central_authority.instance, region) + logger.debug("mTLS PoP token endpoint: %s", token_endpoint) + configuration = { + "authorization_endpoint": central_authority.authorization_endpoint, + "token_endpoint": token_endpoint, + "device_authorization_endpoint": + central_authority.device_authorization_endpoint, + } + http_client = ThrottledHttpClient( + mtls._MtlsHttpClient( + cert["cert_pem"], cert["private_key_pem"], + **self._http_client_config), + http_cache=self._http_cache, + default_throttle_time=5, + ) + # Vanilla SN/I: the TLS certificate alone authenticates the client. + client = _MtlsClient( + configuration, + self.client_id, + http_client=http_client, + default_headers=self._default_client_headers(), + default_body={"client_info": 1}, + # Cache under the ORIGINAL login.* host, never the mtlsauth.* host, + # so mtls_pop ATs share the environment with the rest of the app. + on_obtaining_tokens=lambda event: self.token_cache.add(dict( + event, environment=central_authority.instance)), + on_removing_rt=self.token_cache.remove_rt, + on_updating_rt=self.token_cache.update_rt) + with self._mtls_lock: + # Double-check under the lock: another thread may have built and + # cached a client while we were building ours (we released the lock + # to build). If so, keep the already-cached instance and let ours be + # discarded, so every caller shares one client instead of orphaning + # a redundant build. + if region: + if self._mtls_regional_client is None: + self._mtls_regional_client = client + client = self._mtls_regional_client + else: + if self._mtls_client is None: + self._mtls_client = client + client = self._mtls_client + return client + def _build_client(self, client_credential, authority, skip_regional_client=False): client_assertion = None client_assertion_type = None @@ -869,7 +1100,7 @@ def _build_client(self, client_credential, authority, skip_regional_client=False client_credential["passphrase"] ) if client_credential.get("passphrase") else None if client_credential.get("private_key_pfx_path"): - private_key, sha256_thumbprint, sha1_thumbprint, x5c = _parse_pfx( + private_key, sha256_thumbprint, sha1_thumbprint, x5c, _ = _parse_pfx( client_credential["private_key_pfx_path"], passphrase_bytes) if client_credential.get("public_certificate") is True and x5c: @@ -892,7 +1123,7 @@ def _build_client(self, client_credential, authority, skip_regional_client=False from cryptography import x509 cert = x509.load_pem_x509_certificate( _str2bytes(client_credential['public_certificate'])) - sha256_thumbprint, sha1_thumbprint, headers["x5c"] = ( + sha256_thumbprint, sha1_thumbprint, headers["x5c"], _ = ( _extract_cert_and_thumbprints(cert)) else: raise ValueError( @@ -2494,7 +2725,9 @@ class ConfidentialClientApplication(ClientApplication): # server-side web app except that ``allow_broker`` parameter shall remain ``None``. """ - def acquire_token_for_client(self, scopes, claims_challenge=None, fmi_path=None, **kwargs): + def acquire_token_for_client( + self, scopes, claims_challenge=None, fmi_path=None, + mtls_proof_of_possession=False, **kwargs): """Acquires token for the current confidential client, not for an end user. Since MSAL Python 1.23, it will automatically look for token from cache, @@ -2518,6 +2751,38 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, fmi_path=None, scopes=["api://resource/.default"], fmi_path="SomeFmiPath/FmiCredentialPath", ) + :param bool mtls_proof_of_possession: + Optional. Defaults to ``False``. When ``True``, MSAL presents this + confidential client's certificate as the client certificate in a + mutual-TLS (mTLS) handshake to Microsoft Entra, and requests an + mTLS-bound Proof-of-Possession (PoP) access token + (``token_type == "mtls_pop"``) rather than a Bearer token. The same + Subject Name + Issuer (SN/I) certificate that would otherwise sign a + client assertion is instead used as the TLS client certificate, and + the returned token is cryptographically bound to it + (``cnf``/``x5t#S256``). + + Requirements: the app must be configured with a certificate + credential (``private_key_pfx_path``, or ``private_key`` + + ``public_certificate``); the ``authority`` must be tenanted (not + ``/common`` or ``/organizations``); and MSAL's built-in HTTP + transport must be in use (a custom ``http_client`` cannot perform + the mTLS handshake). Any of these unmet raises ``ValueError``. + + On success the result also contains a ``binding_certificate`` dict + with the **public** binding material only (never the private key):: + + result["binding_certificate"] == { + "x5c": ["", ...], + "thumbprint_sha256": "", + } + + Example usage:: + + result = cca.acquire_token_for_client( + ["https://graph.microsoft.com/.default"], + mtls_proof_of_possession=True, + ) :return: A dict representing the json response from Microsoft Entra: - A successful response would contain "access_token" key, @@ -2533,8 +2798,65 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, fmi_path=None, "fmi_path must be a string, got {}".format(type(fmi_path).__name__)) kwargs["data"] = kwargs.get("data", {}) kwargs["data"]["fmi_path"] = fmi_path - return _clean_up(self._acquire_token_silent_with_error( + if mtls_proof_of_possession: + + # An mTLS transport is required to present the certificate for a + + # cert-bound PoP request. + + if self._http_client_is_custom: + + raise ValueError( + + "mtls_proof_of_possession=True is not supported with a " + + "custom http_client, because MSAL must own the TLS transport " + + "to present the client certificate in the mutual-TLS " + + "handshake. Omit the http_client argument to use MSAL's " + + "built-in mTLS transport.") + + if self.authority.tenant.lower() in ("common", "organizations"): + + raise ValueError( + + "mtls_proof_of_possession=True requires a tenanted authority. " + + "Use a specific tenant id or domain instead of /common or " + + "/organizations.") + + # Parse/validate the certificate now (fail fast). + + mtls_cert = self._get_mtls_pop_cert() + + # Cert-bound PoP: request an mtls_pop token and bind its cache + + # entry to the cert via key_id (base64url x5t#S256). token_type + + # also routes _acquire_token_for_client() to the mTLS client. + + data = dict(kwargs.get("data") or {}) + + data["token_type"] = "mtls_pop" + + data["key_id"] = mtls_cert["key_id"] + + kwargs["data"] = data + + result = _clean_up(self._acquire_token_silent_with_error( scopes, None, claims_challenge=claims_challenge, **kwargs)) + if mtls_proof_of_possession and result and "access_token" in result: + # Surface the PUBLIC binding certificate (never the private key), so + # callers can correlate the token to its cert. Survives _clean_up + # (the key has no "_" prefix). + result["binding_certificate"] = { + "x5c": mtls_cert["x5c"], + "thumbprint_sha256": mtls_cert["key_id"], # base64url x5t#S256 + } + return result def _acquire_token_for_client( self, @@ -2548,10 +2870,18 @@ def _acquire_token_for_client( "Using /common or /organizations authority " "in acquire_token_for_client() is unreliable. " "Please use a specific tenant instead.", DeprecationWarning) - self._validate_ssh_cert_input_data(kwargs.get("data", {})) + data = kwargs.get("data", {}) + self._validate_ssh_cert_input_data(data) + is_mtls_pop = data.get("token_type") == "mtls_pop" telemetry_context = self._build_telemetry_context( - self.ACQUIRE_TOKEN_FOR_CLIENT_ID, refresh_reason=refresh_reason) - client = self._regional_client or self.client + self.ACQUIRE_TOKEN_FOR_CLIENT_ID, refresh_reason=refresh_reason, + token_type=data.get("token_type")) + if is_mtls_pop: + # Present the certificate over mTLS to obtain a cert-bound + # (mtls_pop) token. + client = self._get_mtls_client(self.authority) + else: + client = self._regional_client or self.client response = client.obtain_token_for_client( scope=scopes, # This grant flow requires no scope decoration headers=telemetry_context.generate_headers(), diff --git a/msal/mtls.py b/msal/mtls.py new file mode 100644 index 00000000..9fb4047a --- /dev/null +++ b/msal/mtls.py @@ -0,0 +1,195 @@ +"""Helpers for mTLS Proof-of-Possession (PoP). + +This module owns two concerns for the "SN/I certificate over mTLS PoP" feature: + +1. The endpoint transform + sovereign guardrail: mapping a tenanted ``login.*`` + authority to its ``mtlsauth.*`` counterpart (global or regional), and + rejecting clouds/hosts where mTLS PoP is not (yet) available. +2. The mTLS transport: a ``requests`` session whose HTTPS connections present a + client certificate for the mutual-TLS handshake to the token endpoint. + +The wire contract and host mapping mirror the shipped MSAL.NET +``RegionAndMtlsDiscoveryProvider`` so MSAL Python stays cross-SDK consistent. +""" +import logging +try: + from urllib.parse import urlparse, urlunparse +except ImportError: # Python 2 + from urlparse import urlparse, urlunparse + + +logger = logging.getLogger(__name__) + +_MTLS_POP_DOC_LINK = "https://aka.ms/msal-net-pop" + +# The global public mTLS host. The four public "login.*" hosts all normalize to +# this single global endpoint (ESTSR provides regional failover), matching +# MSAL.NET's PublicEnvForRegionalMtlsAuth. +_PUBLIC_MTLS_HOST = "mtlsauth.microsoft.com" + +# Public-cloud login hosts that normalize to the single global mTLS host above. +_PUBLIC_CLOUD_LOGIN_HOSTS = frozenset([ + "login.microsoftonline.com", + "login.microsoft.com", + "login.windows.net", + "sts.windows.net", + ]) + +# ───────────────────────────────────────────────────────────────────────────── +# 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. +# ───────────────────────────────────────────────────────────────────────────── +_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", + } + + +def mtls_pop_host(instance, region=None): + """Return the ``mtlsauth.*`` host for a given ``login.*`` authority instance. + + :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). + """ + instance = instance.lower() + if instance in _MTLS_POP_UNSUPPORTED_HOSTS: # Sovereign guardrail + raise ValueError(_MTLS_POP_UNSUPPORTED_HOSTS[instance]) + if instance in _PUBLIC_CLOUD_LOGIN_HOSTS: + # 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 + else: + raise ValueError( + "mTLS PoP is only supported for hosts that start with 'login.'. " + "The provided authority host ({}) does not meet this requirement. " + "See {} for details.".format(instance, _MTLS_POP_DOC_LINK)) + return "{}.{}".format(region, base) if region else base + + +def transform_token_endpoint(token_endpoint, instance, region=None): + """Return ``token_endpoint`` with its host swapped for the mTLS host. + + The path/tenant and everything else are preserved; only the network host is + rewritten (e.g. ``https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token`` + -> ``https://mtlsauth.microsoft.com/{tenant}/oauth2/v2.0/token``). + """ + parsed = urlparse(token_endpoint) + host = mtls_pop_host(instance, region) + # Preserve a non-default port if the original endpoint carried one (tests). + netloc = "{}:{}".format(host, parsed.port) if parsed.port else host + return urlunparse(parsed._replace(netloc=netloc)) + + +class _MtlsHttpClient(object): + """A minimal http client (``post``/``get``/``close``) whose HTTPS + connections present a client certificate for mutual-TLS. + + MSAL owns this transport. A caller's plain custom ``http_client`` cannot + perform the mTLS handshake, which is why requesting mTLS PoP with a + non-mTLS-capable custom transport fails fast (see application.py). + + The ``ssl.SSLContext`` (and its temp key file) is built lazily on first use, + so Bearer-only certificate apps never pay the cost nor touch the disk. + """ + def __init__(self, cert_pem, key_pem, *, + verify=True, proxies=None, timeout=None): + # cert_pem / key_pem are PEM-encoded bytes. key_pem must be an + # unencrypted private key (the caller normalizes it). + self._cert_pem = cert_pem + self._key_pem = key_pem + self._verify = verify + self._proxies = proxies + self._timeout = timeout + self._session = None + + def _ensure_session(self): + if self._session is None: + import requests # Lazy import, same as the rest of MSAL + 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)) + session.mount("https://", adapter) + self._session = session + return self._session + + def post(self, url, **kwargs): + if self._timeout is not None: + kwargs.setdefault("timeout", self._timeout) + return self._ensure_session().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) + + def close(self): + if self._session is not None: + self._session.close() + + +def _make_mtls_adapter(ssl_context): + """Return a ``requests`` HTTPAdapter that injects ``ssl_context`` (with the + client certificate loaded) into every connection pool it creates.""" + from requests.adapters import HTTPAdapter # Lazy import + + class _MtlsHTTPAdapter(HTTPAdapter): + def __init__(self, ssl_context): + self._ssl_context = ssl_context + super(_MtlsHTTPAdapter, self).__init__(max_retries=1) + + def init_poolmanager(self, *args, **kwargs): + kwargs["ssl_context"] = self._ssl_context + return super(_MtlsHTTPAdapter, self).init_poolmanager(*args, **kwargs) + + def proxy_manager_for(self, *args, **kwargs): + kwargs["ssl_context"] = self._ssl_context + return super(_MtlsHTTPAdapter, self).proxy_manager_for(*args, **kwargs) + + return _MtlsHTTPAdapter(ssl_context) + + +def _create_ssl_context(cert_pem, key_pem): + """Build a client ``ssl.SSLContext`` that presents ``cert_pem``/``key_pem``. + + ``ssl.SSLContext.load_cert_chain`` requires a file path, but our key is + in memory. We write a ``0600`` temp PEM (mkstemp defaults to owner-only), + load it, then unlink it immediately - the context keeps the material in + memory, so nothing sensitive lingers on disk. + """ + import ssl + import os + import tempfile + context = ssl.create_default_context() # Verifies the server (ESTS) as usual + fd, path = tempfile.mkstemp(suffix=".pem") # Owner-only (0600) by default + try: + # os.fdopen() takes ownership of fd and guarantees it is closed even if + # writing raises; file.write() writes every byte (os.write() may perform + # a short write). Closing the fd also lets Windows unlink the file below. + with os.fdopen(fd, "wb") as f: + f.write(key_pem + b"\n" + cert_pem) + context.load_cert_chain(path) # Loads our client cert+key into memory + finally: + try: + os.remove(path) # Unlink immediately; minimal disk exposure + except OSError: # pragma: no cover + logger.warning("Unable to remove temporary mTLS key file") + return context + diff --git a/msal/sku.py b/msal/sku.py index 5e58eda9..f3c7f97d 100644 --- a/msal/sku.py +++ b/msal/sku.py @@ -2,5 +2,5 @@ """ # The __init__.py will import this. Not the other way around. -__version__ = "1.37.0" +__version__ = "1.38.0" SKU = "MSAL.Python" diff --git a/msal/telemetry.py b/msal/telemetry.py index b07ab3ed..123d1fdd 100644 --- a/msal/telemetry.py +++ b/msal/telemetry.py @@ -14,6 +14,12 @@ AT_AGING = 4 RESERVED = 5 +# Server-telemetry token-type values (parity with the other MSALs, e.g. .NET's +# TelemetryTokenTypeConstants). Only non-Bearer token types are emitted. +_TELEMETRY_TOKEN_TYPES = { + "mtls_pop": 6, # mTLS-bound Proof-of-Possession + } + def _get_new_correlation_id(): return str(uuid.uuid4()) @@ -28,18 +34,31 @@ class _TelemetryContext(object): _CURRENT_HEADER_SIZE_LIMIT = 100 _LAST_HEADER_SIZE_LIMIT = 350 - def __init__(self, buffer, lock, api_id, correlation_id=None, refresh_reason=None): + def __init__(self, buffer, lock, api_id, correlation_id=None, + refresh_reason=None, token_type=None): self._buffer = buffer self._lock = lock self._api_id = api_id self._correlation_id = correlation_id or _get_new_correlation_id() self._refresh_reason = refresh_reason or NON_SILENT_CALL + self._token_type = token_type logger.debug("Generate or reuse correlation_id: %s", self._correlation_id) def generate_headers(self): with self._lock: - current = "4|{api_id},{cache_refresh}|".format( - api_id=self._api_id, cache_refresh=self._refresh_reason) + # MSAL Python's current schema (4) carries an EMPTY platform-config + # section after the second "|". To stay byte-for-byte unchanged for + # every existing flow, we only populate that section for token types + # that need it (currently mtls_pop -> value 6, placed in the 3rd + # platform field). This is a documented Python-schema divergence: + # other MSALs use schema 5, but the token-type *value* matches. + token_type_value = _TELEMETRY_TOKEN_TYPES.get(self._token_type) + platform_config = ( + ",,{}".format(token_type_value) + if token_type_value is not None else "") + current = "4|{api_id},{cache_refresh}|{platform_config}".format( + api_id=self._api_id, cache_refresh=self._refresh_reason, + platform_config=platform_config) if len(current) > self._CURRENT_HEADER_SIZE_LIMIT: logger.warning( "Telemetry header greater than {} will be truncated by AAD".format( diff --git a/msal/token_cache.py b/msal/token_cache.py index 0ca250df..e36f3f67 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -152,7 +152,10 @@ def __init__(self): realm=None, target=None, ext_cache_key=None, # Note: New field(s) can be added here - #key_id=None, + key_id=None, # So ATs bound to different keys/certs can + # coexist (e.g. an mtls_pop AT vs a Bearer AT for the + # 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 "", @@ -163,9 +166,12 @@ def __init__(self): client_id or "", realm or "", target or "", - #key_id or "", # So ATs of different key_id can coexist ] + ([ext_cache_key] if ext_cache_key else []) - ).lower(), + ).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. + + ("-" + key_id if key_id else ""), self.CredentialType.ID_TOKEN: lambda home_account_id=None, environment=None, client_id=None, realm=None, **ignored_payload_from_a_real_token: @@ -194,6 +200,7 @@ def _get_access_token( self, home_account_id, environment, client_id, realm, target, # Together they form a compound key ext_cache_key=None, + key_id=None, default=None, ): # O(1) return self._get( @@ -205,6 +212,7 @@ def _get_access_token( realm=realm, target=" ".join(target), ext_cache_key=ext_cache_key, + key_id=key_id, ), default=default) @@ -251,7 +259,8 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n) preferred_result = self._get_access_token( query["home_account_id"], query["environment"], query["client_id"], query["realm"], target, - ext_cache_key=query.get("ext_cache_key")) + 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 @@ -284,6 +293,14 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n) and "ext_cache_key" not in (query or {}) ): continue + # Cache isolation for key-bound tokens (e.g. mtls_pop, SSH-cert). + # An entry bound to a key_id must not satisfy a query without + # one, so a Bearer lookup never returns a PoP/mtls_pop token. + if (credential_type == self.CredentialType.ACCESS_TOKEN + and "key_id" in entry + and "key_id" not in (query or {}) + ): + continue yield entry for at in expired_access_tokens: self.remove_at(at) diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py new file mode 100644 index 00000000..cf399e37 --- /dev/null +++ b/sample/confidential_client_mtls_pop_sample.py @@ -0,0 +1,100 @@ +""" +This sample demonstrates a confidential-client (daemon) application that uses a +Subject Name + Issuer (SN/I) certificate as the client TLS certificate in a +mutual-TLS (mTLS) handshake to Microsoft Entra, and obtains an mTLS-bound +Proof-of-Possession (PoP) access token (``token_type == "mtls_pop"``). + +Prerequisites +------------- +* A confidential-client app registration configured with a certificate. +* The SAME certificate available locally as a PFX file (SN/I). The certificate + is used as the TLS client certificate -- it is NOT used to sign a client + assertion here. +* A **tenanted** authority (mTLS PoP does not work with ``/common`` or + ``/organizations``). +* The resource you request a token for must be allow-listed by Entra for mTLS + PoP (for example Microsoft Graph or Azure Key Vault). + +Configuration is read from environment variables so no secrets are hard-coded:: + + CLIENT_ID your application (client) id + AUTHORITY e.g. https://login.microsoftonline.com/ + PFX_PATH path to the .pfx file holding the SN/I cert + private key + PFX_PASSPHRASE (optional) passphrase protecting the .pfx + SCOPE e.g. https://graph.microsoft.com/.default + AZURE_REGION (optional) e.g. westus3; omit for the global mtls endpoint + +Then run:: + + python confidential_client_mtls_pop_sample.py +""" + +import os + +import msal + + +def _build_cert_credential(): + credential = { + "private_key_pfx_path": os.environ["PFX_PATH"], + # "public_certificate": True opts in to Subject Name/Issuer (SN/I) auth, + # which is what makes the full cert chain (x5c) available for mTLS PoP. + "public_certificate": True, + } + passphrase = os.getenv("PFX_PASSPHRASE") + if passphrase: + credential["passphrase"] = passphrase + return credential + + +def _print_result_summary(result, header): + """Print a *log-safe* summary of an MSAL token ``result``. + + We deliberately never log the ``result`` dict, nor any value read out of it, + because on success it carries an ``access_token`` (and, for mTLS PoP, binding + material). Logging token responses is exactly the habit a security library + should not model. Instead we surface only booleans derived via comparison, + which reveal the *shape* of the response without leaking its contents. + + On success the interesting (non-sensitive) fields are:: + + result["token_type"] == "mtls_pop" + result["binding_certificate"] == {"x5c": [...], "thumbprint_sha256": ...} + result["token_source"] in ("identity_provider", "cache") + + Inspect those directly (e.g. in a debugger) when you need their values. + """ + print(header) + if "access_token" in result: + print(" acquired: yes") + print(" token_type is mtls_pop: ", result.get("token_type") == "mtls_pop") + print(" carries binding_certificate: ", "binding_certificate" in result) + print(" served from cache: ", result.get("token_source") == "cache") + else: + print(" acquired: no") + print(" (inspect result['error'] / result['error_description'] to diagnose)") + + +def vanilla_sni_mtls_pop(): + """Acquire an mTLS-bound PoP token using the SN/I cert as the TLS client cert.""" + app = msal.ConfidentialClientApplication( + os.environ["CLIENT_ID"], + authority=os.environ["AUTHORITY"], # MUST be tenanted + client_credential=_build_cert_credential(), + # Note: do NOT pass a custom http_client here. MSAL must own the + # transport to present the client certificate during the TLS handshake. + azure_region=os.getenv("AZURE_REGION"), # optional; None -> global endpoint + ) + + result = app.acquire_token_for_client( + os.environ["SCOPE"].split(), + mtls_proof_of_possession=True, # <-- request an mTLS PoP token + ) + + _print_result_summary(result, "Vanilla SN/I -> mTLS PoP result:") + return result + + +if __name__ == "__main__": + print("=== Vanilla SN/I -> mTLS PoP ===") + vanilla_sni_mtls_pop() diff --git a/tests/test_application.py b/tests/test_application.py index 31f77a71..da429195 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1586,4 +1586,124 @@ def mock_post(url, headers=None, data=None, *args, **kwargs): self.assertEqual( captured_value, captured_data.get("client_assertion"), "Lambda with defaulted params should return its default value, " - "not receive a context dict") \ No newline at end of file + "not receive a context dict") + + +import os as _os +_MTLS_PFX = _os.path.join(_os.path.dirname(__file__), "certificate-with-password.pfx") +_MTLS_CERT_CRED = { + "private_key_pfx_path": _MTLS_PFX, + "passphrase": "password", + "public_certificate": True, +} +_MTLS_OIDC_MOCK = Mock(return_value={ + "authorization_endpoint": "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/authorize", + "token_endpoint": "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/token", + "issuer": "https://login.microsoftonline.com/my_tenant/v2.0", +}) +_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + +@patch(_OIDC_DISCOVERY, new=_MTLS_OIDC_MOCK) +class TestMtlsProofOfPossession(unittest.TestCase): + """SN/I certificate as the client TLS cert over mTLS Proof-of-Possession.""" + + _AUTHORITY = "https://login.microsoftonline.com/my_tenant" + + def _capturing_post(self, captured, token_type="mtls_pop"): + def mock_post(url, headers=None, data=None, *a, **k): + captured.append({ + "url": url, "data": dict(data or {}), "headers": dict(headers or {})}) + return MinimalResponse(status_code=200, text=json.dumps({ + "access_token": "an_at", "expires_in": 3600, "token_type": token_type})) + return mock_post + + def test_vanilla_sni_mtls_pop_request_and_result(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + captured = [] + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, post=self._capturing_post(captured)) + req = captured[0] + # Endpoint transformed to the global mtls host + self.assertEqual( + req["url"], + "https://mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + # Wire body: token_type=mtls_pop, and NO client_assertion / req_cnf / key_id + self.assertEqual("mtls_pop", req["data"].get("token_type")) + self.assertNotIn("client_assertion", req["data"]) + self.assertNotIn("req_cnf", req["data"]) + self.assertNotIn("key_id", req["data"], "key_id must never travel on the wire") + # Telemetry marks token-type 6 (parity with .NET MtlsPop) + self.assertEqual("4|730,2|,,6", req["headers"].get(CLIENT_CURRENT_TELEMETRY)) + # Result carries token_type and the PUBLIC binding cert (no private key) + self.assertEqual("mtls_pop", result.get("token_type")) + binding = result["binding_certificate"] + self.assertEqual({"x5c", "thumbprint_sha256"}, set(binding)) + self.assertNotIn("private", json.dumps(binding).lower()) + + def test_repeat_call_is_cache_hit(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, post=self._capturing_post([])) + second = [] + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, post=self._capturing_post(second)) + self.assertEqual([], second, "Second call must be served from cache") + self.assertEqual(app._TOKEN_SOURCE_CACHE, result[app._TOKEN_SOURCE]) + self.assertTrue(result.get("binding_certificate")) + + def test_backward_compatible_bearer_is_unchanged(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + captured = [] + result = app.acquire_token_for_client( + ["s1"], post=self._capturing_post(captured, token_type="Bearer")) + req = captured[0] + # Original login.* endpoint, classic SN/I private_key_jwt (Bearer) + self.assertEqual( + req["url"], "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/token") + self.assertTrue(req["data"].get("client_assertion")) + self.assertEqual(_JWT_BEARER, req["data"].get("client_assertion_type")) + self.assertEqual("4|730,2|", req["headers"].get(CLIENT_CURRENT_TELEMETRY)) + self.assertEqual("Bearer", result.get("token_type")) + self.assertNotIn("binding_certificate", result) + + def test_regional_mtls_endpoint(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY, + azure_region="westus3") + captured = [] + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, post=self._capturing_post(captured)) + self.assertEqual( + captured[0]["url"], + "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + + def test_secret_credential_with_flag_raises(self): + app = ConfidentialClientApplication( + "cid", client_credential="a_secret", authority=self._AUTHORITY) + with self.assertRaises(ValueError): + app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) + + def test_custom_http_client_with_flag_fails_fast(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY, + http_client=MinimalHttpClient()) + with self.assertRaises(ValueError): + app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) + + +@patch(_OIDC_DISCOVERY, new=Mock(return_value={ + "authorization_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "token_endpoint": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0", +})) +class TestMtlsProofOfPossessionUntenantedAuthority(unittest.TestCase): + def test_common_authority_with_flag_raises(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority="https://login.microsoftonline.com/common") + with self.assertRaises(ValueError): + app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 37632ee7..92af3642 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -441,6 +441,69 @@ def test_subject_name_issuer_authentication(self): self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) + # ── SN/I certificate over mTLS Proof-of-Possession ───────────────────────── + # These reuse the same lab SN/I cert as test_subject_name_issuer_authentication + # (it is not CNG-backed, so it works as a TLS client cert on the CI agents). + # NOTE: MSAL must own the TLS transport for mTLS, so unlike the tests above we + # do NOT pass http_client=MinimalHttpClient() here. + # ESTS gates mTLS PoP on the final resource audience (must be allow-listed, + # e.g. MS Graph), not on the client app, so we target Microsoft Graph. + _MTLS_POP_SCOPE = ["https://graph.microsoft.com/.default"] + + def _assert_pop_cache_hit(self, app, scope): + # A second mTLS-PoP call for the same scope must be served from cache. + # (assertCacheWorksForApp cannot be reused: its follow-up call omits the + # flag and would look up a Bearer entry, missing the mtls_pop token.) + again = app.acquire_token_for_client(scope, mtls_proof_of_possession=True) + self.assertIsNotNone(again) + self.assertEqual(app._TOKEN_SOURCE_CACHE, again[app._TOKEN_SOURCE]) + + @staticmethod + def _cnf_x5t_s256(access_token): + # Best-effort decode of an mtls_pop JWT's cnf.x5t#S256 binding claim. + try: + payload = json.loads(decode_part(access_token.split(".")[1])) + return payload.get("cnf", {}).get("x5t#S256") + except Exception: # Opaque/non-JWT token - skip the offline binding check + return None + + def test_sni_mtls_pop_for_client(self): + from tests.lab_config import get_client_certificate + if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): + self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") + self.app = msal.ConfidentialClientApplication( + LAB_APP_CLIENT_ID, + authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", + client_credential=get_client_certificate()) + result = self.app.acquire_token_for_client( + self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) + self.assertIn("access_token", result, "mTLS PoP request failed: %s" % result) + self.assertEqual("mtls_pop", result.get("token_type")) + binding = result.get("binding_certificate") + self.assertTrue(binding and binding.get("x5c") and binding.get("thumbprint_sha256")) + self.assertNotIn("private", json.dumps(binding).lower()) + cnf = self._cnf_x5t_s256(result["access_token"]) + if cnf: # When the AT is a decodable JWT, its cnf must match our cert + self.assertEqual(binding["thumbprint_sha256"], cnf) + self._assert_pop_cache_hit(self.app, self._MTLS_POP_SCOPE) + + def test_sni_mtls_pop_for_client_regional(self): + from tests.lab_config import get_client_certificate + if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): + self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") + self.app = msal.ConfidentialClientApplication( + LAB_APP_CLIENT_ID, + authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", + client_credential=get_client_certificate(), + azure_region="westus3") + result = self.app.acquire_token_for_client( + self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) + if result.get("error") in ("invalid_request", "temporarily_unavailable"): + self.skipTest("Regional mTLS endpoint not reachable: %s" % result) + self.assertIn("access_token", result, "Regional mTLS PoP failed: %s" % result) + self.assertEqual("mtls_pop", result.get("token_type")) + + class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once @classmethod def setUpClass(cls): diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py new file mode 100644 index 00000000..bd0ea35d --- /dev/null +++ b/tests/test_mtls_transport.py @@ -0,0 +1,146 @@ +import os +import ssl +import unittest + +from msal import mtls +from msal.application import _load_mtls_cert_material + + +class TestMtlsEndpointTransform(unittest.TestCase): + """The login.* -> [region.]mtlsauth.* transform and the sovereign guardrail. + + These values mirror MSAL.NET's RegionAndMtlsDiscoveryProvider so MSAL Python + stays wire-compatible across SDKs. + """ + + def test_public_hosts_normalize_to_global_mtls_host(self): + for host in [ + "login.microsoftonline.com", "login.microsoft.com", + "login.windows.net", "sts.windows.net", + ]: + self.assertEqual(mtls.mtls_pop_host(host), "mtlsauth.microsoft.com") + + def test_case_insensitive_host(self): + self.assertEqual( + mtls.mtls_pop_host("Login.MicrosoftOnline.Com"), + "mtlsauth.microsoft.com") + + def test_region_prefixes_global_host(self): + self.assertEqual( + mtls.mtls_pop_host("login.microsoftonline.com", region="westus3"), + "westus3.mtlsauth.microsoft.com") + + def test_non_public_login_host_keeps_its_suffix(self): + # e.g. a national cloud that IS supported keeps its own suffix + self.assertEqual( + mtls.mtls_pop_host("login.microsoftonline.us"), + "mtlsauth.microsoftonline.us") + + def test_transform_token_endpoint_swaps_only_the_host(self): + self.assertEqual( + mtls.transform_token_endpoint( + "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/token", + "login.microsoftonline.com"), + "https://mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + + def test_transform_token_endpoint_regional(self): + self.assertEqual( + mtls.transform_token_endpoint( + "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/token", + "login.microsoftonline.com", region="westus3"), + "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + + def test_transform_preserves_non_default_port(self): + self.assertEqual( + mtls.transform_token_endpoint( + "https://login.microsoftonline.com:8443/t/token", + "login.microsoftonline.com"), + "https://mtlsauth.microsoft.com:8443/t/token") + + def test_sovereign_us_gov_fails_fast(self): + with self.assertRaises(ValueError) as cm: + mtls.mtls_pop_host("login.usgovcloudapi.net") + self.assertIn("login.microsoftonline.us", str(cm.exception)) + + def test_sovereign_china_fails_fast(self): + with self.assertRaises(ValueError) as cm: + mtls.mtls_pop_host("login.chinacloudapi.cn") + self.assertIn("login.partner.microsoftonline.cn", str(cm.exception)) + + def test_non_login_host_fails_fast(self): + with self.assertRaises(ValueError): + mtls.mtls_pop_host("contoso.example.com") + + +class TestMtlsSslContext(unittest.TestCase): + """The mTLS SSLContext is built from in-memory PEM and leaves no key on disk.""" + + _PFX = os.path.join(os.path.dirname(__file__), "certificate-with-password.pfx") + _PASSPHRASE = "password" + + def _material(self): + return _load_mtls_cert_material({ + "private_key_pfx_path": self._PFX, + "passphrase": self._PASSPHRASE, + "public_certificate": True, + }) + + def test_cert_material_shape(self): + material = self._material() + self.assertTrue(material["private_key_pem"].startswith(b"-----BEGIN ")) + self.assertIn(b"PRIVATE KEY", material["private_key_pem"]) + self.assertIn(b"BEGIN CERTIFICATE", material["cert_pem"]) + self.assertTrue(material["x5c"]) + self.assertEqual(len(material["sha256_thumbprint"]), 64) # hex sha256 + # key_id is the base64url (unpadded) x5t#S256 used for cache binding + self.assertNotIn("=", material["key_id"]) + + def test_create_ssl_context_loads_cert_and_unlinks_tempfile(self): + material = self._material() + created = {} + # Capture the temp path that _create_ssl_context uses, then assert it is + # gone after the call (the key must not linger on disk). + import tempfile as _tempfile + orig_mkstemp = _tempfile.mkstemp + + def spy_mkstemp(*a, **k): + fd, path = orig_mkstemp(*a, **k) + created["path"] = path + return fd, path + + _tempfile.mkstemp = spy_mkstemp + try: + context = mtls._create_ssl_context( + material["cert_pem"], material["private_key_pem"]) + finally: + _tempfile.mkstemp = orig_mkstemp + self.assertIsInstance(context, ssl.SSLContext) + self.assertIn("path", created) + self.assertFalse( + os.path.exists(created["path"]), + "temp key file must be unlinked immediately after load_cert_chain") + + def test_adapter_injects_ssl_context(self): + material = self._material() + context = mtls._create_ssl_context( + material["cert_pem"], material["private_key_pem"]) + adapter = mtls._make_mtls_adapter(context) + # The adapter builds its default poolmanager on construction; our + # ssl_context must have been injected into the pool's connection kwargs. + self.assertIs( + adapter.poolmanager.connection_pool_kw.get("ssl_context"), context) + + def test_http_client_builds_session_lazily(self): + material = self._material() + client = mtls._MtlsHttpClient( + material["cert_pem"], material["private_key_pem"]) + self.assertIsNone(client._session, "session must not be built until first use") + session = client._ensure_session() + self.assertIsNotNone(session) + # The https adapter is our mTLS adapter (cert-bearing), not the default + self.assertIn("https://", session.adapters) + client.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_optional_thumbprint.py b/tests/test_optional_thumbprint.py index 56ec4013..1932bf6a 100644 --- a/tests/test_optional_thumbprint.py +++ b/tests/test_optional_thumbprint.py @@ -54,7 +54,8 @@ def _setup_certificate_mocks(self, mock_extract, mock_load_cert): mock_extract.return_value = ( "mock_sha256_thumbprint", # sha256_thumbprint "mock_sha1_thumbprint", # sha1_thumbprint - ["mock_x5c_value"] # x5c + ["mock_x5c_value"], # x5c + "-----BEGIN CERTIFICATE-----\nmock\n-----END CERTIFICATE-----\n", # cert_pem ) def _verify_assertion_params(self, mock_jwt_creator_class, expected_algorithm, diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index d7dfe8de..663c3119 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -246,8 +246,53 @@ def test_access_tokens_with_different_key_id(self): self._test_data_should_be_saved_and_searchable_in_access_token({"key_id": "2"}) self.assertEqual( len(self.cache._cache["AccessToken"]), - 1, """Historically, tokens are not keyed by key_id, -so a new token overwrites the old one, and we would end up with 1 token in cache""") + 2, """Access tokens are now isolated by key_id, so that a key-bound +token (SSH-cert or mTLS PoP) never overwrites another one bound to a different +key, nor a plain Bearer token for the same scope. We therefore keep 2 tokens.""") + # Each key-bound token remains independently searchable by its own key_id + for key_id in ("1", "2"): + self.assertFoundAccessToken( + scopes=["s1", "s2", "s3"], now=1000, query=dict( + key_id=key_id, + client_id="my_client_id", + environment="login.example.com", + realm="contoso", + home_account_id="uid.utid", + )) + + def test_bearer_and_mtls_pop_tokens_coexist_and_isolate(self): + # The crux of mTLS PoP cache isolation (plan C6): a Bearer token and an + # mtls_pop token for the SAME app/scope/tenant must coexist, and an + # unkeyed (Bearer) lookup must never return the key-bound mtls_pop token. + scopes = ["s2", "s1", "s3"] + now = 1000 + common = dict( + client_id="my_client_id", scope=scopes, + token_endpoint="https://login.example.com/contoso/v2/token") + # 1) Store a plain Bearer AT (no key_id) + self.cache.add(dict(common, data={}, response=build_response( + uid="uid", utid="utid", expires_in=3600, + access_token="bearer_at", token_type="Bearer")), now=now) + # 2) Store an mtls_pop AT bound to a cert key_id, same scope + self.cache.add(dict(common, data={"key_id": "THUMB"}, response=build_response( + uid="uid", utid="utid", expires_in=3600, + access_token="mtls_at", token_type="mtls_pop")), now=now) + self.assertEqual( + 2, len(self.cache._cache["AccessToken"]), + "Bearer and mtls_pop ATs for the same scope must be separate entries") + base_query = dict( + client_id="my_client_id", environment="login.example.com", + realm="contoso", home_account_id="uid.utid") + # A Bearer lookup (no key_id) must return ONLY the Bearer token + bearer = self.assertFoundAccessToken( + scopes=scopes, now=now, query=dict(base_query)) + self.assertEqual("bearer_at", bearer["secret"]) + self.assertEqual("Bearer", bearer["token_type"]) + # A keyed lookup must return the mtls_pop token + pop = self.assertFoundAccessToken( + scopes=scopes, now=now, query=dict(base_query, key_id="THUMB")) + self.assertEqual("mtls_at", pop["secret"]) + self.assertEqual("mtls_pop", pop["token_type"]) def test_refresh_in_should_be_recorded_as_refresh_on(self): # Sounds weird. Yep. scopes = ["s2", "s1", "s3"] # Not in particular order From 81c3eb774abb4e3f0476ff02e7516dab563f45eb Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 7 Jul 2026 20:03:52 -0400 Subject: [PATCH 02/27] Use SN/I-allow-listed app for mTLS PoP e2e tests The two SN/I-over-mTLS-PoP e2e tests used the generic lab app (LAB_APP_CLIENT_ID) with the microsoft.onmicrosoft.com authority. That combination works for Bearer SN/I but is not ESTS allow-listed for mTLS PoP, so the token request fails with AADSTS700025. Point both tests at the SN/I-allow-listed app 163ffef9-a313-45b4-ab2f-c7e2f5e0e23e in the MSI-team tenant, mirroring the MSAL .NET/Java e2e config. The lab SN/I cert and the MS Graph resource are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 92af3642..9d80a9b6 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -449,6 +449,11 @@ def test_subject_name_issuer_authentication(self): # ESTS gates mTLS PoP on the final resource audience (must be allow-listed, # e.g. MS Graph), not on the client app, so we target Microsoft Graph. _MTLS_POP_SCOPE = ["https://graph.microsoft.com/.default"] + # SN/I app + tenant that ESTS allow-lists for mTLS PoP. The generic + # LAB_APP_CLIENT_ID + microsoft.onmicrosoft.com works for Bearer SN/I but is + # rejected for mTLS PoP with AADSTS700025. Mirrors the MSAL .NET/Java e2e config. + _SNI_ALLOWLISTED_CLIENT_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e" + _SNI_ALLOWLISTED_AUTHORITY = "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c" def _assert_pop_cache_hit(self, app, scope): # A second mTLS-PoP call for the same scope must be served from cache. @@ -472,8 +477,8 @@ def test_sni_mtls_pop_for_client(self): if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") self.app = msal.ConfidentialClientApplication( - LAB_APP_CLIENT_ID, - authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", + self._SNI_ALLOWLISTED_CLIENT_ID, + authority=self._SNI_ALLOWLISTED_AUTHORITY, client_credential=get_client_certificate()) result = self.app.acquire_token_for_client( self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) @@ -492,8 +497,8 @@ def test_sni_mtls_pop_for_client_regional(self): if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") self.app = msal.ConfidentialClientApplication( - LAB_APP_CLIENT_ID, - authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", + self._SNI_ALLOWLISTED_CLIENT_ID, + authority=self._SNI_ALLOWLISTED_AUTHORITY, client_credential=get_client_certificate(), azure_region="westus3") result = self.app.acquire_token_for_client( From 224a003ff94b9b67b76c973c73d0beb08c450714 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 7 Jul 2026 22:26:16 -0400 Subject: [PATCH 03/27] Enumerate key-bound ATs on sign-out and account removal Gate the ext_cache_key and key_id cache-isolation filters in TokenCache.search() on a non-empty target. Scoped token retrieval keeps the mtls_pop / FMI isolation intact, but broad target-less searches (used by _sign_out and remove_account) now enumerate key-bound access tokens so they are removed from the cache instead of being left behind. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/token_cache.py | 6 ++++++ tests/test_token_cache.py | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/msal/token_cache.py b/msal/token_cache.py index e36f3f67..8b09db35 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -288,7 +288,10 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n) ): # Cache isolation for extended cache keys (e.g., FMI path). # Entries with ext_cache_key must not match queries without one. + # Gated on target so broad target-less searches (sign-out / + # account removal) still enumerate these ATs and can delete them. if (credential_type == self.CredentialType.ACCESS_TOKEN + and target and "ext_cache_key" in entry and "ext_cache_key" not in (query or {}) ): @@ -296,7 +299,10 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n) # Cache isolation for key-bound tokens (e.g. mtls_pop, SSH-cert). # An entry bound to a key_id must not satisfy a query without # one, so a Bearer lookup never returns a PoP/mtls_pop token. + # Gated on target so broad target-less searches (sign-out / + # account removal) still enumerate key-bound ATs and can delete them. if (credential_type == self.CredentialType.ACCESS_TOKEN + and target and "key_id" in entry and "key_id" not in (query or {}) ): diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 663c3119..6ecd67ae 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -294,6 +294,33 @@ def test_bearer_and_mtls_pop_tokens_coexist_and_isolate(self): self.assertEqual("mtls_at", pop["secret"]) self.assertEqual("mtls_pop", pop["token_type"]) + def test_broad_search_without_target_enumerates_key_bound_tokens(self): + # Removal paths (e.g. ClientApplication._sign_out) enumerate ATs by + # account with no target and no key_id. Such a broad search must still + # surface key-bound (mtls_pop) ATs; otherwise sign-out / remove_account + # would leave them behind. Regression guard for the key_id isolation gate. + scopes = ["s2", "s1", "s3"] + now = 1000 + common = dict( + client_id="my_client_id", scope=scopes, + token_endpoint="https://login.example.com/contoso/v2/token") + self.cache.add(dict(common, data={}, response=build_response( + uid="uid", utid="utid", expires_in=3600, + access_token="bearer_at", token_type="Bearer")), now=now) + self.cache.add(dict(common, data={"key_id": "THUMB"}, response=build_response( + uid="uid", utid="utid", expires_in=3600, + access_token="mtls_at", token_type="mtls_pop")), now=now) + owned_by_account = dict( + client_id="my_client_id", environment="login.example.com", + realm="contoso", home_account_id="uid.utid") + # Broad search: no target/scopes and no key_id -> BOTH ATs, incl. mtls_pop + found = list(self.cache.search( + TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_account, now=now)) + secrets = {at["secret"] for at in found} + self.assertIn("mtls_at", secrets, + "A target-less search must enumerate key-bound ATs so they can be removed") + self.assertIn("bearer_at", secrets) + def test_refresh_in_should_be_recorded_as_refresh_on(self): # Sounds weird. Yep. scopes = ["s2", "s1", "s3"] # Not in particular order self.cache.add({ From 02ccacb3165be856043db4bf8d1536fd952c1885 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 16:39:18 -0400 Subject: [PATCH 04/27] 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> --- msal/application.py | 49 ++++++++++++++++----------------- msal/mtls.py | 57 ++++++++++++++++++++++++++++++--------- tests/test_application.py | 20 ++++++++++++++ 3 files changed, 88 insertions(+), 38 deletions(-) diff --git a/msal/application.py b/msal/application.py index 4356a700..f91feb55 100644 --- a/msal/application.py +++ b/msal/application.py @@ -300,9 +300,15 @@ def obtain_token_for_client(self, scope=None, **kwargs): outer = kwargs.pop("on_obtaining_tokens", None) def _reinject_key_id(event): - # Re-attach key_id to the event's data so token_cache.add() - # binds the stored mtls_pop AT to the certificate. - event["data"] = dict(event.get("data", {}), key_id=key_id) + # Bind the stored AT to the certificate (via key_id) only when + # ESTS actually returned a cert-bound token. On a token_type + # downgrade (e.g. a Bearer response) leave key_id off, so the + # token is never cached as a bound mtls_pop token - it caches as + # an ordinary Bearer, and the fail-closed check in + # acquire_token_for_client returns token_type_mismatch. + response = event.get("response") or {} + if (response.get("token_type") or "").lower() == "mtls_pop": + event["data"] = dict(event.get("data", {}), key_id=key_id) (outer or self.on_obtaining_tokens)(event) kwargs["on_obtaining_tokens"] = _reinject_key_id @@ -2799,56 +2805,47 @@ def acquire_token_for_client( kwargs["data"] = kwargs.get("data", {}) kwargs["data"]["fmi_path"] = fmi_path if mtls_proof_of_possession: - # An mTLS transport is required to present the certificate for a - # cert-bound PoP request. - if self._http_client_is_custom: - raise ValueError( - "mtls_proof_of_possession=True is not supported with a " - "custom http_client, because MSAL must own the TLS transport " - "to present the client certificate in the mutual-TLS " - "handshake. Omit the http_client argument to use MSAL's " - "built-in mTLS transport.") - if self.authority.tenant.lower() in ("common", "organizations"): - raise ValueError( - "mtls_proof_of_possession=True requires a tenanted authority. " - "Use a specific tenant id or domain instead of /common or " - "/organizations.") - # Parse/validate the certificate now (fail fast). - mtls_cert = self._get_mtls_pop_cert() - # Cert-bound PoP: request an mtls_pop token and bind its cache - # entry to the cert via key_id (base64url x5t#S256). token_type - # also routes _acquire_token_for_client() to the mTLS client. - data = dict(kwargs.get("data") or {}) - data["token_type"] = "mtls_pop" - data["key_id"] = mtls_cert["key_id"] - kwargs["data"] = data result = _clean_up(self._acquire_token_silent_with_error( scopes, None, claims_challenge=claims_challenge, **kwargs)) if mtls_proof_of_possession and result and "access_token" in result: + # Fail closed on a token_type downgrade: if ESTS returned a + # non-cert-bound token (e.g. a Bearer downgrade) for our mtls_pop + # request, do not surface it as bound. The cache side is handled in + # _MtlsClient (key_id binds the entry only when the response is + # mtls_pop), so a downgraded token is never stored as bound either. + # Mirrors the fail-closed behavior of MSAL .NET and MSAL Go. + if (result.get("token_type") or "").lower() != "mtls_pop": + return { + "error": "token_type_mismatch", + "error_description": ( + "The identity provider returned token_type {!r} instead " + "of 'mtls_pop'; the access token is not " + "certificate-bound.".format(result.get("token_type"))), + } # Surface the PUBLIC binding certificate (never the private key), so # callers can correlate the token to its cert. Survives _clean_up # (the key has no "_" prefix). diff --git a/msal/mtls.py b/msal/mtls.py index 9fb4047a..fcf442e6 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -12,6 +12,7 @@ ``RegionAndMtlsDiscoveryProvider`` so MSAL Python stays cross-SDK consistent. """ import logging +import threading try: from urllib.parse import urlparse, urlunparse except ImportError: # Python 2 @@ -116,18 +117,25 @@ def __init__(self, cert_pem, key_pem, *, self._proxies = proxies self._timeout = timeout self._session = None + 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: - import requests # Lazy import, same as the rest of MSAL - 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)) - session.mount("https://", adapter) - self._session = session + with self._session_lock: + if self._session is None: + import requests # Lazy import, same as the rest of MSAL + 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 def post(self, url, **kwargs): @@ -166,7 +174,33 @@ def proxy_manager_for(self, *args, **kwargs): return _MtlsHTTPAdapter(ssl_context) -def _create_ssl_context(cert_pem, key_pem): +def _new_verifying_context(verify): + """Build an ssl context whose server verification matches ``verify`` (the + same semantics as ``requests``). A custom ssl_context otherwise bypasses + ``requests``' own ``verify`` handling, so we honor it here: ``True`` uses + certifi (as the rest of MSAL does), a filesystem path uses that CA file/dir, + and ``False`` disables verification (instead of the cryptic ValueError that + ``requests`` would raise when it tries to set CERT_NONE on this context). + """ + import ssl + import os + if verify is False: + context = ssl.create_default_context() + context.check_hostname = False # Must be cleared before CERT_NONE + context.verify_mode = ssl.CERT_NONE + return context + if isinstance(verify, str): + return (ssl.create_default_context(capath=verify) + if os.path.isdir(verify) + else ssl.create_default_context(cafile=verify)) + try: # Default: match requests' trust store (certifi), not the system one + import certifi + return ssl.create_default_context(cafile=certifi.where()) + except ImportError: # pragma: no cover + return ssl.create_default_context() + + +def _create_ssl_context(cert_pem, key_pem, verify=True): """Build a client ``ssl.SSLContext`` that presents ``cert_pem``/``key_pem``. ``ssl.SSLContext.load_cert_chain`` requires a file path, but our key is @@ -174,10 +208,9 @@ def _create_ssl_context(cert_pem, key_pem): load it, then unlink it immediately - the context keeps the material in memory, so nothing sensitive lingers on disk. """ - import ssl import os import tempfile - context = ssl.create_default_context() # Verifies the server (ESTS) as usual + context = _new_verifying_context(verify) # Verifies the server (ESTS) fd, path = tempfile.mkstemp(suffix=".pem") # Owner-only (0600) by default try: # os.fdopen() takes ownership of fd and guarantees it is closed even if diff --git a/tests/test_application.py b/tests/test_application.py index da429195..29c244fd 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1670,6 +1670,26 @@ def test_backward_compatible_bearer_is_unchanged(self): self.assertEqual("Bearer", result.get("token_type")) self.assertNotIn("binding_certificate", result) + def test_token_type_downgrade_fails_closed(self): + # ESTS returns a non-cert-bound token (a Bearer downgrade) for an + # mtls_pop request. MSAL must fail closed: an error result, no + # binding_certificate, and nothing cached as a bound (key_id) token. + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, + post=self._capturing_post([], token_type="Bearer")) + self.assertEqual("token_type_mismatch", result.get("error")) + self.assertNotIn("access_token", result) + self.assertNotIn("binding_certificate", result) + bound = [ + at for at in app.token_cache.search( + msal.TokenCache.CredentialType.ACCESS_TOKEN) + if at.get("key_id") + or (at.get("token_type") or "").lower() == "mtls_pop"] + self.assertEqual( + [], bound, "A downgraded token must never be cached as bound") + def test_regional_mtls_endpoint(self): app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY, From 51adcec4924846ff654f1d04400104e24fda0495 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 17:50:53 -0400 Subject: [PATCH 05/27] Isolate key/ext-bound ATs unconditionally; normalize verify=None Two review fixes for the mTLS PoP work. Cache isolation (empty-scopes leak): the key_id and ext_cache_key isolation filters in TokenCache.search() were gated on `target`, so an empty-scopes for-use lookup (e.g. acquire_token_for_client([])) was target-less and bypassed isolation -- returning a key-bound (mtls_pop) or FMI-bound AT the caller never asked for. Decouple "removal intent" from "target-less" via a keyword-only for_removal flag: isolation is now unconditional for for-use lookups and only the two removal callers (_sign_out, remove_tokens_for_client) opt out with for_removal=True to enumerate and delete those ATs. Mirrors MSAL .NET, where token-type isolation is never gated on scope state. mTLS transport verify=None: _MtlsHttpClient forced CERT_NONE onto a verifying ssl_context when verify was None, raising ValueError. Normalize None to True (the safe default for a security library, verifying via certifi) so session.verify and the ssl_context agree. Adds regression tests for both empty-scope leak paths (key-bound and ext-bound) and updates the removal-enumeration test to opt in via for_removal=True. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 3 +- msal/mtls.py | 6 +++- msal/token_cache.py | 22 +++++++++----- tests/test_token_cache.py | 64 +++++++++++++++++++++++++++++++++++---- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/msal/application.py b/msal/application.py index f91feb55..7d2e0ff6 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1662,6 +1662,7 @@ def _sign_out(self, home_account): # to avoid changing self.token_cache while it is being iterated TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_home_account, # Regardless of realm, b/c we've removed realm-independent RTs anyway + for_removal=True, # Enumerate key-bound (mtls_pop) / FMI ATs so sign-out removes them )): # To avoid the complexity of locating sibling family app's AT, # we skip AT's app ownership check. @@ -2900,7 +2901,7 @@ def remove_tokens_for_client(self): "client_id": self.client_id, "environment": env, "home_account_id": None, # These are mostly app-only tokens - })): + }, for_removal=True)): # Enumerate key-bound (mtls_pop) ATs so they are removed self.token_cache.remove_at(at) # acquire_token_for_client() obtains no RTs, so we have no RT to remove diff --git a/msal/mtls.py b/msal/mtls.py index fcf442e6..f55684aa 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -113,7 +113,11 @@ def __init__(self, cert_pem, key_pem, *, # unencrypted private key (the caller normalizes it). self._cert_pem = cert_pem self._key_pem = key_pem - self._verify = verify + # Normalize None to True. Our custom ssl_context fully owns server + # verification, but requests treats session.verify=None as "no verify" + # and would force CERT_NONE onto a verifying context (ValueError). None + # means "default", which for a security library is verify (via certifi). + self._verify = True if verify is None else verify self._proxies = proxies self._timeout = timeout self._session = None diff --git a/msal/token_cache.py b/msal/token_cache.py index 8b09db35..6808daf9 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -241,11 +241,17 @@ def _is_matching(entry: dict, query: dict, target_set: set = None) -> bool: target_set <= set(entry.get("target", "").split()) if target_set else True) - def search(self, credential_type, target=None, query=None, *, now=None): # O(n) generator + def search(self, credential_type, target=None, query=None, *, now=None, for_removal=False): # O(n) generator """Returns a generator of matching entries. It is O(1) for AT hits, and O(n) for other types. Note that it holds a lock during the entire search. + + ``for_removal=True`` (used only by sign-out / remove_tokens_for_client) + disables the key_id/ext_cache_key isolation filters so those broad + cleanups can enumerate and delete key-bound and FMI-bound ATs. For-use + lookups must leave it False so a Bearer request never receives a + key-bound (e.g. mtls_pop) or ext-bound token, even with empty scopes. """ target = sorted(target or []) # Match the order sorted by add() assert isinstance(target, list), "Invalid parameter type" @@ -288,10 +294,11 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n) ): # Cache isolation for extended cache keys (e.g., FMI path). # Entries with ext_cache_key must not match queries without one. - # Gated on target so broad target-less searches (sign-out / - # account removal) still enumerate these ATs and can delete them. + # Unconditional for for-use lookups; only removal callers + # (sign-out / remove_tokens_for_client) pass for_removal=True + # to enumerate these ATs and delete them. if (credential_type == self.CredentialType.ACCESS_TOKEN - and target + and not for_removal and "ext_cache_key" in entry and "ext_cache_key" not in (query or {}) ): @@ -299,10 +306,11 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n) # Cache isolation for key-bound tokens (e.g. mtls_pop, SSH-cert). # An entry bound to a key_id must not satisfy a query without # one, so a Bearer lookup never returns a PoP/mtls_pop token. - # Gated on target so broad target-less searches (sign-out / - # account removal) still enumerate key-bound ATs and can delete them. + # Unconditional for for-use lookups; only removal callers + # (sign-out / remove_tokens_for_client) pass for_removal=True + # to enumerate these key-bound ATs and delete them. if (credential_type == self.CredentialType.ACCESS_TOKEN - and target + and not for_removal and "key_id" in entry and "key_id" not in (query or {}) ): diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 6ecd67ae..cce2317a 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -296,9 +296,10 @@ def test_bearer_and_mtls_pop_tokens_coexist_and_isolate(self): def test_broad_search_without_target_enumerates_key_bound_tokens(self): # Removal paths (e.g. ClientApplication._sign_out) enumerate ATs by - # account with no target and no key_id. Such a broad search must still - # surface key-bound (mtls_pop) ATs; otherwise sign-out / remove_account - # would leave them behind. Regression guard for the key_id isolation gate. + # account with no target and no key_id, opting in via for_removal=True. + # Such a removal search must still surface key-bound (mtls_pop) ATs; + # otherwise sign-out / remove_account would leave them behind. Regression + # guard for the key_id isolation gate. scopes = ["s2", "s1", "s3"] now = 1000 common = dict( @@ -313,12 +314,42 @@ def test_broad_search_without_target_enumerates_key_bound_tokens(self): owned_by_account = dict( client_id="my_client_id", environment="login.example.com", realm="contoso", home_account_id="uid.utid") - # Broad search: no target/scopes and no key_id -> BOTH ATs, incl. mtls_pop + # Removal search opts in with for_removal=True -> BOTH ATs, incl. mtls_pop found = list(self.cache.search( - TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_account, now=now)) + TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_account, now=now, + for_removal=True)) secrets = {at["secret"] for at in found} self.assertIn("mtls_at", secrets, - "A target-less search must enumerate key-bound ATs so they can be removed") + "A for_removal search must enumerate key-bound ATs so they can be removed") + self.assertIn("bearer_at", secrets) + + def test_empty_scopes_for_use_lookup_does_not_leak_key_bound_token(self): + # A for-use lookup with EMPTY scopes is also target-less, but unlike a + # removal enumeration (for_removal=True) it must NOT receive a key-bound + # (mtls_pop) token it did not ask for. Isolation is unconditional for + # for-use lookups, mirroring MSAL .NET (token-type isolation is never + # gated on scope state). Regression for the empty-scopes bypass. + scopes = ["s2", "s1", "s3"] + now = 1000 + common = dict( + client_id="my_client_id", scope=scopes, + token_endpoint="https://login.example.com/contoso/v2/token") + self.cache.add(dict(common, data={}, response=build_response( + uid="uid", utid="utid", expires_in=3600, + access_token="bearer_at", token_type="Bearer")), now=now) + self.cache.add(dict(common, data={"key_id": "THUMB"}, response=build_response( + uid="uid", utid="utid", expires_in=3600, + access_token="mtls_at", token_type="mtls_pop")), now=now) + base_query = dict( + client_id="my_client_id", environment="login.example.com", + realm="contoso", home_account_id="uid.utid") + # Empty scopes (target=[]) + no key_id + no for_removal -> only the unkeyed AT + found = list(self.cache.search( + TokenCache.CredentialType.ACCESS_TOKEN, target=[], + query=base_query, now=now)) + secrets = {at["secret"] for at in found} + self.assertNotIn("mtls_at", secrets, + "Empty-scope for-use lookup must not leak the key-bound mtls_pop token") self.assertIn("bearer_at", secrets) def test_refresh_in_should_be_recorded_as_refresh_on(self): # Sounds weird. Yep. @@ -514,6 +545,27 @@ def test_non_fmi_tokens_not_affected_by_fmi_cache(self): self.assertEqual(1, len(at_entries)) self.assertEqual("regular_token", at_entries[0]["secret"]) + def test_empty_scopes_lookup_does_not_leak_ext_bound_token(self): + # A for-use lookup with EMPTY scopes is target-less, but must NOT receive + # an ext_cache_key-bound (FMI) token it did not ask for. Isolation is + # unconditional for for-use lookups (for_removal defaults False). + cache = TokenCache() + cache.add(self._build_event( + "cid", ["s1"], "https://login.example.com/tenant/v2/token", + "fmi_token", data={"fmi_path": "some/path"})) + cache.add(self._build_event( + "cid", ["s1"], "https://login.example.com/tenant/v2/token", + "regular_token")) + # Empty scopes + no ext_cache_key + no for_removal -> only the regular token + at_entries = list(cache.search( + TokenCache.CredentialType.ACCESS_TOKEN, target=[], + query={"client_id": "cid", "environment": "login.example.com", + "realm": "tenant", "home_account_id": None})) + secrets = {at["secret"] for at in at_entries} + self.assertNotIn("fmi_token", secrets, + "Empty-scope for-use lookup must not leak the ext-bound FMI token") + self.assertIn("regular_token", secrets) + class TestCrossMsalCacheKeyCompatibility(unittest.TestCase): """Verify that _compute_ext_cache_key produces hashes identical to MSAL Go From ba697e89be58ab27124cc1854578e25b92aa7976 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 18:50:54 -0400 Subject: [PATCH 06/27] Address review comments on mTLS PoP cert/key handling Four small, low-risk fixes from the code review: - application.py `_private_key_to_unencrypted_pem`: pass an explicit `backend=default_backend()` to `load_pem_private_key` (required on the supported `cryptography` floor, 2.5) and wrap the call so a bad or encrypted-without-passphrase key surfaces a clear, actionable ValueError instead of a cryptic low-level error. Mirrors the sibling `_load_private_key_from_pem_str`. - token_cache.py: coerce `key_id` to a str via a new `_key_id_to_str` helper before building the AccessToken cache key, so a non-str key_id never raises TypeError at the `"-" + key_id` concatenation. The normal ASCII-str path is byte-identical, so existing mtls_pop cache entries are unaffected. Defensive only: current callers already pass an ASCII str. - test_application.py: hoist `import os` to the top of the module and drop the mid-file `import os as _os` alias. Adds regression tests: bytes/other key_id coercion (test_token_cache.py) and the clearer private-key load errors (test_mtls_transport.py). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 14 +++++++++++--- msal/token_cache.py | 16 ++++++++++++++-- tests/test_application.py | 4 ++-- tests/test_mtls_transport.py | 36 +++++++++++++++++++++++++++++++++++- tests/test_token_cache.py | 16 +++++++++++++++- 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/msal/application.py b/msal/application.py index 7d2e0ff6..08b9c810 100644 --- a/msal/application.py +++ b/msal/application.py @@ -120,10 +120,18 @@ def _private_key_to_unencrypted_pem(private_key, passphrase_bytes=None): The result is suitable for ``ssl.SSLContext.load_cert_chain`` via a temp file. """ from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.backends import default_backend if isinstance(private_key, (str, bytes)): - key_obj = serialization.load_pem_private_key( - _str2bytes(private_key) if isinstance(private_key, str) else private_key, - passphrase_bytes) + try: + key_obj = serialization.load_pem_private_key( + _str2bytes(private_key) if isinstance(private_key, str) else private_key, + passphrase_bytes, + backend=default_backend(), # Required param until cryptography 3.1 + ) + except (TypeError, ValueError) as exc: + raise ValueError( + "Could not load the private key for mTLS Proof-of-Possession. " + "If the key is encrypted, provide its 'passphrase'.") from exc else: # Already a cryptography private-key object key_obj = private_key return key_obj.private_bytes( diff --git a/msal/token_cache.py b/msal/token_cache.py index 6808daf9..41bc3252 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -14,6 +14,17 @@ logger = logging.getLogger(__name__) _GRANT_TYPE_BROKER = "broker" + +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``. + """ + if isinstance(key_id, bytes): + return key_id.decode("ascii") + return key_id if isinstance(key_id, str) else str(key_id) + # 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. @@ -170,8 +181,9 @@ def __init__(self): ).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. - + ("-" + key_id if key_id else ""), + # 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 ""), self.CredentialType.ID_TOKEN: lambda home_account_id=None, environment=None, client_id=None, realm=None, **ignored_payload_from_a_real_token: diff --git a/tests/test_application.py b/tests/test_application.py index 29c244fd..cc54e1ff 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -3,6 +3,7 @@ import base64 import json import logging +import os import sys import time import warnings @@ -1589,8 +1590,7 @@ def mock_post(url, headers=None, data=None, *args, **kwargs): "not receive a context dict") -import os as _os -_MTLS_PFX = _os.path.join(_os.path.dirname(__file__), "certificate-with-password.pfx") +_MTLS_PFX = os.path.join(os.path.dirname(__file__), "certificate-with-password.pfx") _MTLS_CERT_CRED = { "private_key_pfx_path": _MTLS_PFX, "passphrase": "password", diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index bd0ea35d..6baa69cd 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -3,7 +3,8 @@ import unittest from msal import mtls -from msal.application import _load_mtls_cert_material +from msal.application import ( + _load_mtls_cert_material, _private_key_to_unencrypted_pem) class TestMtlsEndpointTransform(unittest.TestCase): @@ -142,5 +143,38 @@ def test_http_client_builds_session_lazily(self): client.close() +class TestUnencryptedPemLoading(unittest.TestCase): + """_private_key_to_unencrypted_pem pins the cryptography backend and turns + low-level load failures into a clear, actionable ValueError.""" + + @staticmethod + def _encrypted_pem(passphrase=b"secret"): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.backends import default_backend + key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend()) + return key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(passphrase)) + + def test_encrypted_key_without_passphrase_raises_clear_error(self): + with self.assertRaises(ValueError) as cm: + _private_key_to_unencrypted_pem(self._encrypted_pem(), None) + self.assertIn("passphrase", str(cm.exception)) + + def test_garbage_key_raises_clear_error(self): + with self.assertRaises(ValueError) as cm: + _private_key_to_unencrypted_pem(b"not a real pem", None) + self.assertIn("private key for mTLS", str(cm.exception)) + + def test_encrypted_key_with_passphrase_round_trips_to_unencrypted_pem(self): + pem = _private_key_to_unencrypted_pem( + self._encrypted_pem(b"secret"), b"secret") + self.assertIn(b"PRIVATE KEY", pem) + self.assertNotIn(b"ENCRYPTED", pem) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index cce2317a..f840987a 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -4,7 +4,8 @@ import time import warnings -from msal.token_cache import TokenCache, SerializableTokenCache, _compute_ext_cache_key +from msal.token_cache import ( + TokenCache, SerializableTokenCache, _compute_ext_cache_key, _key_id_to_str) from tests import unittest @@ -260,6 +261,19 @@ def test_access_tokens_with_different_key_id(self): home_account_id="uid.utid", )) + def test_key_id_is_coerced_to_str_for_cache_key(self): + # A bytes (or otherwise non-str) key_id must not raise a TypeError while + # building the AT cache key (the "-" + key_id concatenation once assumed + # str). This is defensive: current callers already pass an ASCII str. + self.assertEqual("THUMB", _key_id_to_str(b"THUMB")) + self.assertEqual("THUMB", _key_id_to_str("THUMB")) + self.assertEqual("123", _key_id_to_str(123)) # Any other type -> str() + # Adding + searching an AT with a bytes key_id no longer raises; the + # helper below stores and then finds it by that same key_id. + self._test_data_should_be_saved_and_searchable_in_access_token( + {"key_id": b"THUMB"}) + self.assertEqual(1, len(self.cache._cache["AccessToken"])) + def test_bearer_and_mtls_pop_tokens_coexist_and_isolate(self): # The crux of mTLS PoP cache isolation (plan C6): a Bearer token and an # mtls_pop token for the SAME app/scope/tenant must coexist, and an From b79166dd1ffea1d09cad9c616a962e67338bc14e Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 30 Jul 2026 15:41:35 -0400 Subject: [PATCH 07/27] Harden mTLS PoP e2e: prove token at resource + naming matrix Port the msal-dotnet PR #6100 rigor to the SN/I-over-mTLS-PoP e2e tests: - Add a reusable _call_graph_with_mtls_pop_token(token, cert) helper that presents the acquired token to the mTLS Graph host (mtlstb.graph.microsoft.com) over a TLS channel bound by the SAME certificate and asserts HTTP 200. Acquiring a token is not proof it works; a 401/403 (binding lost / wrong scheme) is a regression. The helper reuses msal.mtls._create_ssl_context/_make_mtls_adapter and is shared with the FIC e2e (follow-up PR). - Rename the two PoP tests to the cross-SDK Credential__Output_ matrix (snake_case): test_credential_x509_output_pop (+_regional), both now call the resource-call helper. - Add test_credential_x509_output_bearer: the same SN/I cert WITHOUT the flag must still authenticate via a signed client_assertion and yield an ordinary token (no binding_certificate) - the client_assertion is dropped only on the mTLS path. - Regional test: skip only on temporarily_unavailable. invalid_request is a 400 config error (a real regression), so it now fails loudly instead of being masked as "endpoint not reachable". - Docs/sample/code comments: azure_region is optional (global mtlsauth fallback); clarify the classic cert path still signs a private_key_jwt client_assertion while only mTLS PoP omits it; tighten the sample's log-safety note (binding_certificate x5c is public, not a secret). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 6 ++ msal/application.py | 5 +- sample/confidential_client_mtls_pop_sample.py | 11 +-- tests/test_e2e.py | 77 +++++++++++++++++-- 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 98659dcd..bfb71ada 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -198,10 +198,16 @@ 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``. +* ``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 certificate can be used either as an assertion signer (Bearer) or as the TLS client certificate (mtls_pop). * mTLS PoP currently targets the public and Azure Government (Arlington) clouds. +* Only the mTLS-PoP path drops the signed ``client_assertion``: the certificate + authenticates the client through the mutual-TLS handshake instead. The classic + (non-mTLS) certificate path is unchanged and still sends a signed + ``client_assertion`` (``private_key_jwt``) to obtain an ordinary token. Exceptions diff --git a/msal/application.py b/msal/application.py index 08b9c810..ac8f4890 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1059,7 +1059,10 @@ def _get_mtls_client(self, central_authority): http_cache=self._http_cache, default_throttle_time=5, ) - # Vanilla SN/I: the TLS certificate alone authenticates the client. + # Vanilla SN/I: the TLS certificate alone authenticates the client, so + # this mTLS path sends NO signed client_assertion. (The classic, non-mTLS + # certificate path still signs a private_key_jwt client_assertion; only + # mTLS PoP replaces that assertion with the mutual-TLS handshake.) client = _MtlsClient( configuration, self.client_id, diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index cf399e37..fa15c7ed 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -50,11 +50,12 @@ def _build_cert_credential(): def _print_result_summary(result, header): """Print a *log-safe* summary of an MSAL token ``result``. - We deliberately never log the ``result`` dict, nor any value read out of it, - because on success it carries an ``access_token`` (and, for mTLS PoP, binding - material). Logging token responses is exactly the habit a security library - should not model. Instead we surface only booleans derived via comparison, - which reveal the *shape* of the response without leaking its contents. + The sensitive part of ``result`` is the ``access_token`` (a PoP/bearer + secret) - that is what we must never log. The ``binding_certificate`` is the + certificate's PUBLIC material (x5c + thumbprint), not a secret, so it is safe + to inspect. We still avoid dumping the whole ``result`` dict and instead + surface only a few derived booleans/fields, to model the habit of not + routinely logging token responses. On success the interesting (non-sensitive) fields are:: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9d80a9b6..afa67acb 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -454,6 +454,11 @@ def test_subject_name_issuer_authentication(self): # rejected for mTLS PoP with AADSTS700025. Mirrors the MSAL .NET/Java e2e config. _SNI_ALLOWLISTED_CLIENT_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e" _SNI_ALLOWLISTED_AUTHORITY = "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c" + # The mTLS Graph host (NOT plain graph.microsoft.com): only this host performs + # the client-certificate TLS handshake that an mtls_pop token's binding is + # validated against. Presenting the token here, with the binding cert on the + # handshake, must return 200; a 401/403 means the binding was lost. + _MTLS_GRAPH_RESOURCE = "https://mtlstb.graph.microsoft.com/v1.0/applications?$top=1" def _assert_pop_cache_hit(self, app, scope): # A second mTLS-PoP call for the same scope must be served from cache. @@ -472,14 +477,48 @@ def _cnf_x5t_s256(access_token): except Exception: # Opaque/non-JWT token - skip the offline binding check return None - def test_sni_mtls_pop_for_client(self): + def _call_graph_with_mtls_pop_token(self, token, cert_credential): + """Prove an ``mtls_pop`` token is genuinely usable: present it to the + mTLS Graph endpoint over a TLS channel bound by the SAME certificate and + require HTTP 200. Acquiring the token is not enough - a resource only + honors it when the binding certificate is on the TLS handshake and the + auth scheme is ``mtls_pop``; a 401/403 means the binding was lost, i.e. a + real regression rather than a transient failure. Reused by the FIC e2e. + """ + from msal.application import _load_mtls_cert_material + from msal.mtls import _create_ssl_context, _make_mtls_adapter + material = _load_mtls_cert_material(cert_credential) + session = requests.Session() + session.mount("https://", _make_mtls_adapter(_create_ssl_context( + material["cert_pem"], material["private_key_pem"]))) + try: + resp = session.get( + self._MTLS_GRAPH_RESOURCE, + headers={"Authorization": "mtls_pop " + token}, + timeout=30) + finally: + session.close() + self.assertEqual( + 200, resp.status_code, + "mtls_pop token rejected by the resource (HTTP %s). A 401/403 means " + "the binding certificate was not presented on the TLS handshake, or " + "the 'mtls_pop' auth scheme was wrong. Body[:500]=%s" % ( + resp.status_code, (resp.text or "")[:500])) + return resp + + # ── Credential x Output matrix (mirrors the MSAL .NET e2e naming) ────────── + # Credential__Output_. Task 1 covers the two X509 cells + # (this file); the Fic cells live in the FIC follow-up PR. snake_case per + # unittest, but the matrix names line up 1:1 with the sibling SDKs. + def test_credential_x509_output_pop(self): from tests.lab_config import get_client_certificate if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") + cert = get_client_certificate() self.app = msal.ConfidentialClientApplication( self._SNI_ALLOWLISTED_CLIENT_ID, authority=self._SNI_ALLOWLISTED_AUTHORITY, - client_credential=get_client_certificate()) + client_credential=cert) result = self.app.acquire_token_for_client( self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) self.assertIn("access_token", result, "mTLS PoP request failed: %s" % result) @@ -490,23 +529,49 @@ def test_sni_mtls_pop_for_client(self): cnf = self._cnf_x5t_s256(result["access_token"]) if cnf: # When the AT is a decodable JWT, its cnf must match our cert self.assertEqual(binding["thumbprint_sha256"], cnf) + # The token must actually work at the resource, bound to our cert. + self._call_graph_with_mtls_pop_token(result["access_token"], cert) self._assert_pop_cache_hit(self.app, self._MTLS_POP_SCOPE) - def test_sni_mtls_pop_for_client_regional(self): + def test_credential_x509_output_pop_regional(self): from tests.lab_config import get_client_certificate if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") + cert = get_client_certificate() self.app = msal.ConfidentialClientApplication( self._SNI_ALLOWLISTED_CLIENT_ID, authority=self._SNI_ALLOWLISTED_AUTHORITY, - client_credential=get_client_certificate(), + client_credential=cert, azure_region="westus3") result = self.app.acquire_token_for_client( self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) - if result.get("error") in ("invalid_request", "temporarily_unavailable"): - self.skipTest("Regional mTLS endpoint not reachable: %s" % result) + # Only a genuinely transient condition warrants a skip. invalid_request is + # a 400 config error (a real regression), so let it fail loudly instead of + # masking it as "endpoint not reachable". + if result.get("error") == "temporarily_unavailable": + self.skipTest( + "Regional mTLS endpoint temporarily unavailable: %s" % result) self.assertIn("access_token", result, "Regional mTLS PoP failed: %s" % result) self.assertEqual("mtls_pop", result.get("token_type")) + self._call_graph_with_mtls_pop_token(result["access_token"], cert) + + def test_credential_x509_output_bearer(self): + # The SAME SN/I cert, but WITHOUT mtls_proof_of_possession, must still + # authenticate via a signed client_assertion (private_key_jwt) and yield + # an ordinary Bearer token. This proves the client_assertion is dropped + # only on the mTLS-PoP path, never on the classic certificate path. + from tests.lab_config import get_client_certificate + if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): + self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") + self.app = msal.ConfidentialClientApplication( + self._SNI_ALLOWLISTED_CLIENT_ID, + authority=self._SNI_ALLOWLISTED_AUTHORITY, + client_credential=get_client_certificate()) + result = self.app.acquire_token_for_client(self._MTLS_POP_SCOPE) + self.assertIn("access_token", result, "SN/I Bearer request failed: %s" % result) + self.assertEqual("Bearer", result.get("token_type")) + self.assertNotIn("binding_certificate", result) + self.assertCacheWorksForApp(result, self._MTLS_POP_SCOPE) class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once From aaf62c156daa5aaba5e68a9daa2cee09698834bc Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 30 Jul 2026 15:45:36 -0400 Subject: [PATCH 08/27] Strengthen X509->Bearer e2e: assert cert binding is absent Per cross-SDK review feedback: make the Bearer contrast explicit. On the non-mTLS path MSAL Python omits binding_certificate entirely, so assert both that the key is absent and that a lookup yields None - proving the cert binding is genuinely gone, not merely that the mtls_proof_of_possession flag was skipped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index afa67acb..d606c49e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -570,7 +570,12 @@ def test_credential_x509_output_bearer(self): result = self.app.acquire_token_for_client(self._MTLS_POP_SCOPE) self.assertIn("access_token", result, "SN/I Bearer request failed: %s" % result) self.assertEqual("Bearer", result.get("token_type")) + # Prove the cert binding is genuinely ABSENT, not merely that we skipped + # the flag. MSAL Python adds binding_certificate ONLY on the mtls_pop + # path, so assert both the SDK-internal contract (key omitted entirely) + # and the caller-visible one (a lookup yields None) - no cert is bound. self.assertNotIn("binding_certificate", result) + self.assertIsNone(result.get("binding_certificate")) self.assertCacheWorksForApp(result, self._MTLS_POP_SCOPE) From fec7d7f3138c48af385982423970e86687591651 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 30 Jul 2026 15:46:51 -0400 Subject: [PATCH 09/27] Note _call_graph_with_mtls_pop_token is shared with FIC #939 Per cross-SDK review: document that the FIC two-leg e2e (PR #939) reuses this helper as-is, so it is not inlined later. Explains why it takes an explicit cert_credential instead of reading self.app's cert. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index d606c49e..1506403d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -483,7 +483,11 @@ def _call_graph_with_mtls_pop_token(self, token, cert_credential): require HTTP 200. Acquiring the token is not enough - a resource only honors it when the binding certificate is on the TLS handshake and the auth scheme is ``mtls_pop``; a 401/403 means the binding was lost, i.e. a - real regression rather than a transient failure. Reused by the FIC e2e. + real regression rather than a transient failure. + + Shared test infrastructure: the FIC two-leg e2e in PR #939 reuses this + as-is (please do not inline it), which is why it takes an explicit + ``cert_credential`` rather than reading ``self.app``'s certificate. """ from msal.application import _load_mtls_cert_material from msal.mtls import _create_ssl_context, _make_mtls_adapter From b9233c60313f308bc5c3475f890fdcff8abd58be Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 30 Jul 2026 16:38:45 -0400 Subject: [PATCH 10/27] Split mtls_pop/Bearer e2e by region (drop regional PoP cell) Align with the region-split rule shared across the sibling SDKs (merged in MSAL .NET, matched by Java/JS/Go): the regional slice intermittently downgrades an mtls_pop request to Bearer by returning a Bearer *token* (not a temporarily_unavailable error), so a regional PoP cell either fails its token_type assert or forces us to mask a real downgrade - both wrong. - DELETE test_credential_x509_output_pop_regional. The {region}.mtlsauth host rewrite stays covered by the mtls transport/endpoint unit tests, so no unit coverage is lost. - Make test_credential_x509_output_bearer REGIONAL (azure_region="westus3"). A Bearer token_type is deterministic, so this safely RETAINS live regional-endpoint E2E coverage. Comment explains region is correct here but forbidden on the PoP cell. - test_credential_x509_output_pop stays GLOBAL (Graph scope, resource-call 200, cache-hit) - unchanged. Result mirrors MSAL .NET: pop (global) / bearer (regional westus3). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 1506403d..200c5cdb 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -537,28 +537,6 @@ def test_credential_x509_output_pop(self): self._call_graph_with_mtls_pop_token(result["access_token"], cert) self._assert_pop_cache_hit(self.app, self._MTLS_POP_SCOPE) - def test_credential_x509_output_pop_regional(self): - from tests.lab_config import get_client_certificate - if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): - self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") - cert = get_client_certificate() - self.app = msal.ConfidentialClientApplication( - self._SNI_ALLOWLISTED_CLIENT_ID, - authority=self._SNI_ALLOWLISTED_AUTHORITY, - client_credential=cert, - azure_region="westus3") - result = self.app.acquire_token_for_client( - self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) - # Only a genuinely transient condition warrants a skip. invalid_request is - # a 400 config error (a real regression), so let it fail loudly instead of - # masking it as "endpoint not reachable". - if result.get("error") == "temporarily_unavailable": - self.skipTest( - "Regional mTLS endpoint temporarily unavailable: %s" % result) - self.assertIn("access_token", result, "Regional mTLS PoP failed: %s" % result) - self.assertEqual("mtls_pop", result.get("token_type")) - self._call_graph_with_mtls_pop_token(result["access_token"], cert) - def test_credential_x509_output_bearer(self): # The SAME SN/I cert, but WITHOUT mtls_proof_of_possession, must still # authenticate via a signed client_assertion (private_key_jwt) and yield @@ -570,7 +548,14 @@ def test_credential_x509_output_bearer(self): self.app = msal.ConfidentialClientApplication( self._SNI_ALLOWLISTED_CLIENT_ID, authority=self._SNI_ALLOWLISTED_AUTHORITY, - client_credential=get_client_certificate()) + client_credential=get_client_certificate(), + # Regional is correct/desirable HERE and must stay: a Bearer + # token_type is deterministic, so this retains live regional-endpoint + # E2E coverage. Region is FORBIDDEN on the mtls_pop cell, where the + # regional slice intermittently downgrades PoP -> Bearer (a Bearer + # *token*, not a temporarily_unavailable error) - which is why all + # sibling SDKs (.NET/Java/JS/Go) have no regional PoP cell either. + azure_region="westus3") result = self.app.acquire_token_for_client(self._MTLS_POP_SCOPE) self.assertIn("access_token", result, "SN/I Bearer request failed: %s" % result) self.assertEqual("Bearer", result.get("token_type")) From 0424c5397735c78002e6ac7bf4881bdba9b7dc82 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 22 Oct 2026 09:58:29 -0400 Subject: [PATCH 11/27] Address mTLS PoP review comments Preserve positional API compatibility, constrain certificate-bearing transport and authorities, isolate bound cache entries, honor effective CA bundles, and preserve mTLS certificate chains without changing ordinary certificate authentication. Reject persistence of downgraded responses and cover cache upgrades and serialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 3 + msal/application.py | 82 ++++++++++-- msal/mtls.py | 69 +++++----- msal/token_cache.py | 87 ++++++++----- tests/test_application.py | 99 +++++++++++++-- tests/test_mtls_transport.py | 238 ++++++++++++++++++++++++++++++++++- tests/test_token_cache.py | 107 +++++++++++++++- 7 files changed, 583 insertions(+), 102 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index bfb71ada..ec0efffe 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/msal/application.py b/msal/application.py index 0ceb2953..e72c4ca2 100644 --- a/msal/application.py +++ b/msal/application.py @@ -126,16 +126,70 @@ def _extract_cert_and_thumbprints(cert): sha1_thumbprint = cert.fingerprint(hashes.SHA1()).hex() # CodeQL [SM02167] for legacy support such as ADFS return sha256_thumbprint, sha1_thumbprint, x5c, cert_pem -def _parse_pfx(pfx_path, passphrase_bytes): +def _is_certificate_issuer(cert, issuer): + from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm + from cryptography.hazmat.primitives.asymmetric import rsa, ec, dsa, padding + from cryptography.x509.oid import SignatureAlgorithmOID + + if cert.issuer != issuer.subject: + return False + try: + if hasattr(cert, "verify_directly_issued_by"): + cert.verify_directly_issued_by(issuer) + else: # Support cryptography versions before 40. + key = issuer.public_key() + if isinstance(key, rsa.RSAPublicKey): + parameters = getattr(cert, "signature_algorithm_parameters", None) + if (cert.signature_algorithm_oid == SignatureAlgorithmOID.RSASSA_PSS + and parameters is None): + raise UnsupportedAlgorithm( + "Upgrade cryptography to verify RSA-PSS certificate chains") + key.verify( + cert.signature, cert.tbs_certificate_bytes, + parameters or padding.PKCS1v15(), cert.signature_hash_algorithm) + elif isinstance(key, ec.EllipticCurvePublicKey): + key.verify( + cert.signature, cert.tbs_certificate_bytes, + ec.ECDSA(cert.signature_hash_algorithm)) + elif isinstance(key, dsa.DSAPublicKey): + key.verify( + cert.signature, cert.tbs_certificate_bytes, + cert.signature_hash_algorithm) + else: + from cryptography.hazmat.primitives.asymmetric import ed25519, ed448 + if isinstance(key, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)): + key.verify(cert.signature, cert.tbs_certificate_bytes) + else: + raise UnsupportedAlgorithm("Unsupported certificate issuer key") + except (InvalidSignature, ValueError): + return False + return True + + +def _parse_pfx(pfx_path, passphrase_bytes, *, include_chain=False): # Cert concepts https://security.stackexchange.com/a/226758/125264 from cryptography.hazmat.primitives.serialization import pkcs12 with open(pfx_path, 'rb') as f: - private_key, cert, _ = pkcs12.load_key_and_certificates( # cryptography 2.5+ + private_key, cert, additional_certs = pkcs12.load_key_and_certificates( # cryptography 2.5+ # https://cryptography.io/en/latest/hazmat/primitives/asymmetric/serialization/#cryptography.hazmat.primitives.serialization.pkcs12.load_key_and_certificates f.read(), passphrase_bytes) if not (private_key and cert): raise ValueError("Your PFX file shall contain both private key and cert") sha256_thumbprint, sha1_thumbprint, x5c, cert_pem = _extract_cert_and_thumbprints(cert) + if include_chain: + remaining = [candidate for candidate in additional_certs or [] if candidate != cert] + current = cert + while remaining: + issuer = next( + (candidate for candidate in remaining + if _is_certificate_issuer(current, candidate)), None) + if issuer is None: + break # A PFX may contain only a partial chain. + remaining = [candidate for candidate in remaining if candidate != issuer] + _, _, issuer_x5c, issuer_pem = _extract_cert_and_thumbprints(issuer) + x5c.extend(issuer_x5c) + cert_pem += issuer_pem + current = issuer return private_key, sha256_thumbprint, sha1_thumbprint, x5c, cert_pem @@ -182,7 +236,7 @@ def _load_mtls_cert_material(cert_credential): ``cert_credential`` is the app's main ``client_credential`` (vanilla SN/I), a certificate credential dict. Returns a dict with the unencrypted-PEM private key, - the leaf cert PEM, ``x5c``, the SHA-256 thumbprint (hex), and ``key_id`` + the certificate chain PEM, ``x5c``, the SHA-256 thumbprint (hex), and ``key_id`` (base64url ``x5t#S256``, used for cache binding). Raises ``ValueError`` when the credential cannot yield mTLS-capable certificate material. """ @@ -191,7 +245,8 @@ def _load_mtls_cert_material(cert_credential): ) if cert_credential.get("passphrase") else None if cert_credential.get("private_key_pfx_path"): private_key, sha256_thumbprint, _, x5c, cert_pem = _parse_pfx( - cert_credential["private_key_pfx_path"], passphrase_bytes) + cert_credential["private_key_pfx_path"], passphrase_bytes, + include_chain=True) elif cert_credential.get("private_key"): public_cert = cert_credential.get("public_certificate") if not isinstance(public_cert, str): @@ -202,6 +257,8 @@ def _load_mtls_cert_material(cert_credential): from cryptography import x509 cert = x509.load_pem_x509_certificate(_str2bytes(public_cert)) sha256_thumbprint, _, x5c, cert_pem = _extract_cert_and_thumbprints(cert) + x5c = extract_certs(public_cert) + cert_pem = public_cert private_key = ( _load_private_key_from_pem_str( cert_credential['private_key'], passphrase_bytes) @@ -348,13 +405,12 @@ def _reinject_key_id(event): # Bind the stored AT to the certificate (via key_id) only when # ESTS actually returned a cert-bound token. On a token_type # downgrade (e.g. a Bearer response) leave key_id off, so the - # token is never cached as a bound mtls_pop token - it caches as - # an ordinary Bearer, and the fail-closed check in + # response is never cached, and the fail-closed check in # acquire_token_for_client returns token_type_mismatch. response = event.get("response") or {} if (response.get("token_type") or "").lower() == "mtls_pop": event["data"] = dict(event.get("data", {}), key_id=key_id) - (outer or self.on_obtaining_tokens)(event) + (outer or self.on_obtaining_tokens)(event) kwargs["on_obtaining_tokens"] = _reinject_key_id return super(_MtlsClient, self).obtain_token_for_client( @@ -2860,7 +2916,7 @@ class ConfidentialClientApplication(ClientApplication): # server-side web app def acquire_token_for_client( self, scopes, claims_challenge=None, fmi_path=None, - mtls_proof_of_possession=False, forwarded_client_claims=None, + forwarded_client_claims=None, *, mtls_proof_of_possession=False, **kwargs): """Acquires token for the current confidential client, not for an end user. @@ -2902,6 +2958,9 @@ def acquire_token_for_client( ``/common`` or ``/organizations``); and MSAL's built-in HTTP transport must be in use (a custom ``http_client`` cannot perform the mTLS handshake). Any of these unmet raises ``ValueError``. + A ``post`` transport override is also rejected. If the provider + returns another token type, the result is ``token_type_mismatch`` + and the rejected response is not cached. On success the result also contains a ``binding_certificate`` dict with the **public** binding material only (never the private key):: @@ -2953,6 +3012,10 @@ def acquire_token_for_client( kwargs["data"] = kwargs.get("data", {}) _stash_client_claims(forwarded_client_claims, kwargs["data"]) if mtls_proof_of_possession: + if "post" in kwargs: + raise ValueError( + "mtls_proof_of_possession=True does not support a post " + "override. Omit post to use MSAL's built-in mTLS transport.") # An mTLS transport is required to present the certificate for a # cert-bound PoP request. if self._http_client_is_custom: @@ -2983,8 +3046,7 @@ def acquire_token_for_client( # Fail closed on a token_type downgrade: if ESTS returned a # non-cert-bound token (e.g. a Bearer downgrade) for our mtls_pop # request, do not surface it as bound. The cache side is handled in - # _MtlsClient (key_id binds the entry only when the response is - # mtls_pop), so a downgraded token is never stored as bound either. + # _MtlsClient, which does not cache downgraded responses. # Mirrors the fail-closed behavior of MSAL .NET and MSAL Go. if (result.get("token_type") or "").lower() != "mtls_pop": return { diff --git a/msal/mtls.py b/msal/mtls.py index f55684aa..c5bf8f3c 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -23,7 +23,7 @@ _MTLS_POP_DOC_LINK = "https://aka.ms/msal-net-pop" -# The global public mTLS host. The four public "login.*" hosts all normalize to +# The global public mTLS host. The three public "login.*" hosts all normalize to # this single global endpoint (ESTSR provides regional failover), matching # MSAL.NET's PublicEnvForRegionalMtlsAuth. _PUBLIC_MTLS_HOST = "mtlsauth.microsoft.com" @@ -33,26 +33,20 @@ "login.microsoftonline.com", "login.microsoft.com", "login.windows.net", - "sts.windows.net", ]) # ───────────────────────────────────────────────────────────────────────────── # 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.", } @@ -62,22 +56,21 @@ 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 raise ValueError(_MTLS_POP_UNSUPPORTED_HOSTS[instance]) if instance in _PUBLIC_CLOUD_LOGIN_HOSTS: - # Known public aliases (incl. legacy login.windows.net / sts.windows.net) + # Known public aliases (including legacy login.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 @@ -121,40 +114,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): @@ -229,4 +227,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 - diff --git a/msal/token_cache.py b/msal/token_cache.py index 54bf35e5..be450a40 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -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. @@ -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: @@ -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 @@ -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) @@ -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). @@ -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 @@ -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 @@ -683,4 +703,3 @@ def serialize(self): with self._lock: self.has_state_changed = False return json.dumps(self._cache, indent=4) - diff --git a/tests/test_application.py b/tests/test_application.py index 007f9da9..7f47967c 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1575,6 +1575,34 @@ def test_both_silent_entry_points_validate_client_claims(self): @patch(_OIDC_DISCOVERY, new=_OIDC_DISCOVERY_MOCK) class TestRemoveTokensForClient(unittest.TestCase): + def test_legacy_bound_tokens_are_removed_after_deserialization(self): + for home_account_id in (None, "uid.utid"): + with self.subTest(home_account_id=home_account_id): + cache = msal.SerializableTokenCache() + entry = { + "credential_type": "AccessToken", + "environment": "login.microsoftonline.com", + "client_id": "client_id", "realm": "tenant", + "home_account_id": home_account_id, "target": "scope", + "key_id": "THUMB", "token_type": "ssh-cert", + "secret": "legacy_at", "expires_on": str(int(time.time()) + 3600), + } + legacy_key = cache.key_makers["AccessToken"]( + **dict(entry, key_id=None)) + cache.deserialize(json.dumps({"AccessToken": {legacy_key: entry}})) + app = ConfidentialClientApplication( + "client_id", client_credential="secret", token_cache=cache, + authority="https://login.microsoftonline.com/tenant") + if home_account_id is None: + with patch.object(app, "_get_authority_aliases", return_value=[]): + app.remove_tokens_for_client() + else: + app.remove_account({ + "environment": entry["environment"], + "home_account_id": home_account_id}) + self.assertEqual({}, cache._cache["AccessToken"]) + self.assertTrue(cache.has_state_changed) + def test_remove_tokens_for_client_should_remove_client_tokens_only(self): at_for_user = "AT for user" cca = msal.ConfidentialClientApplication( @@ -2278,8 +2306,10 @@ def test_vanilla_sni_mtls_pop_request_and_result(self): app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) captured = [] - result = app.acquire_token_for_client( - ["s1"], mtls_proof_of_possession=True, post=self._capturing_post(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) req = captured[0] # Endpoint transformed to the global mtls host self.assertEqual( @@ -2301,15 +2331,40 @@ def test_vanilla_sni_mtls_pop_request_and_result(self): def test_repeat_call_is_cache_hit(self): app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) - app.acquire_token_for_client( - ["s1"], mtls_proof_of_possession=True, post=self._capturing_post([])) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([])): + app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) second = [] - result = app.acquire_token_for_client( - ["s1"], mtls_proof_of_possession=True, post=self._capturing_post(second)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(second)): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) self.assertEqual([], second, "Second call must be served from cache") self.assertEqual(app._TOKEN_SOURCE_CACHE, result[app._TOKEN_SOURCE]) + self.assertEqual("mtls_pop", result["token_type"]) self.assertTrue(result.get("binding_certificate")) + def test_released_positional_claims_argument_is_preserved(self): + app = ConfidentialClientApplication( + "cid", client_credential="a_secret", authority=self._AUTHORITY) + captured = [] + claims = '{"access_token": {"example": {"value": "value"}}}' + result = app.acquire_token_for_client( + ["s1"], None, None, claims, + post=self._capturing_post(captured, token_type="Bearer")) + self.assertEqual(json.loads(claims), json.loads(captured[0]["data"]["claims"])) + self.assertEqual("Bearer", result["token_type"]) + self.assertNotIn("binding_certificate", result) + + def test_post_override_is_rejected_for_pop(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + post = Mock() + with self.assertRaisesRegex(ValueError, "post"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, post=post) + post.assert_not_called() + def test_backward_compatible_bearer_is_unchanged(self): app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) @@ -2329,30 +2384,48 @@ def test_backward_compatible_bearer_is_unchanged(self): def test_token_type_downgrade_fails_closed(self): # ESTS returns a non-cert-bound token (a Bearer downgrade) for an # mtls_pop request. MSAL must fail closed: an error result, no - # binding_certificate, and nothing cached as a bound (key_id) token. + # binding_certificate, and no persistence of the rejected response. app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) - result = app.acquire_token_for_client( - ["s1"], mtls_proof_of_possession=True, - post=self._capturing_post([], token_type="Bearer")) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([], token_type="Bearer")): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) self.assertEqual("token_type_mismatch", result.get("error")) self.assertNotIn("access_token", result) self.assertNotIn("binding_certificate", result) bound = [ at for at in app.token_cache.search( - msal.TokenCache.CredentialType.ACCESS_TOKEN) + msal.TokenCache.CredentialType.ACCESS_TOKEN, for_removal=True) if at.get("key_id") or (at.get("token_type") or "").lower() == "mtls_pop"] self.assertEqual( [], bound, "A downgraded token must never be cached as bound") + self.assertEqual({}, app.token_cache._cache.get("AccessToken", {})) + captured = [] + ordinary = app.acquire_token_for_client( + ["s1"], post=self._capturing_post(captured, token_type="Bearer")) + self.assertEqual(1, len(captured)) + self.assertEqual(app._TOKEN_SOURCE_IDP, ordinary[app._TOKEN_SOURCE]) + + def test_missing_token_type_is_not_cached(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([], token_type=None)): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual("token_type_mismatch", result["error"]) + self.assertEqual({}, app.token_cache._cache.get("AccessToken", {})) def test_regional_mtls_endpoint(self): app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY, azure_region="westus3") captured = [] - app.acquire_token_for_client( - ["s1"], mtls_proof_of_possession=True, post=self._capturing_post(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) self.assertEqual( captured[0]["url"], "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index 6baa69cd..37719ebb 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -1,10 +1,16 @@ +import base64 +import json import os import ssl +import tempfile import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import patch from msal import mtls from msal.application import ( - _load_mtls_cert_material, _private_key_to_unencrypted_pem) + _is_certificate_issuer, _load_mtls_cert_material, + _private_key_to_unencrypted_pem, extract_certs) class TestMtlsEndpointTransform(unittest.TestCase): @@ -17,7 +23,7 @@ class TestMtlsEndpointTransform(unittest.TestCase): def test_public_hosts_normalize_to_global_mtls_host(self): for host in [ "login.microsoftonline.com", "login.microsoft.com", - "login.windows.net", "sts.windows.net", + "login.windows.net", ]: self.assertEqual(mtls.mtls_pop_host(host), "mtlsauth.microsoft.com") @@ -66,11 +72,20 @@ def test_sovereign_us_gov_fails_fast(self): def test_sovereign_china_fails_fast(self): with self.assertRaises(ValueError) as cm: mtls.mtls_pop_host("login.chinacloudapi.cn") - self.assertIn("login.partner.microsoftonline.cn", str(cm.exception)) + self.assertIn("not supported", str(cm.exception)) + + def test_unsupported_login_hosts_fail_fast(self): + for host in ("login.example.com", "login.partner.microsoftonline.cn", + "login.microsoftonline.de"): + with self.subTest(host=host), self.assertRaisesRegex( + ValueError, "not supported"): + mtls.transform_token_endpoint( + "https://{}/tenant/token".format(host), host) def test_non_login_host_fails_fast(self): - with self.assertRaises(ValueError): - mtls.mtls_pop_host("contoso.example.com") + for host in ("contoso.example.com", "sts.windows.net", "STS.Windows.Net"): + with self.subTest(host=host), self.assertRaises(ValueError): + mtls.mtls_pop_host(host) class TestMtlsSslContext(unittest.TestCase): @@ -143,6 +158,219 @@ def test_http_client_builds_session_lazily(self): client.close() +class TestMtlsCertificateChain(unittest.TestCase): + @classmethod + def setUpClass(cls): + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + def make_cert(name, key, issuer, issuer_key, ca): + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)]) + now = datetime.now(timezone.utc) + return (x509.CertificateBuilder() + .subject_name(subject).issuer_name(issuer or subject) + .public_key(key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=ca, path_length=None), True) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key( + issuer_key.public_key()), False) + .add_extension(x509.KeyUsage( + digital_signature=True, content_commitment=False, + key_encipherment=False, data_encipherment=False, + key_agreement=False, key_cert_sign=ca, crl_sign=ca, + encipher_only=False, decipher_only=False), True) + .sign(issuer_key, hashes.SHA256())) + + root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + root = make_cert("root", root_key, None, root_key, True) + cls.root = root + upper_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cls.upper_intermediate = make_cert( + "upper", upper_key, root.subject, root_key, True) + intermediate_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cls.intermediate = make_cert( + "intermediate", intermediate_key, cls.upper_intermediate.subject, upper_key, True) + unrelated_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cls.unrelated = make_cert("unrelated", unrelated_key, None, unrelated_key, True) + cls.impostor = make_cert("intermediate", unrelated_key, None, unrelated_key, True) + cls.leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cls.leaf = make_cert( + "client", cls.leaf_key, cls.intermediate.subject, intermediate_key, False) + cls.chain_pem = b"".join(cert.public_bytes(serialization.Encoding.PEM) + for cert in (cls.leaf, cls.intermediate, cls.upper_intermediate)) + cls.key_pem = _private_key_to_unencrypted_pem(cls.leaf_key) + cls.root_pem = root.public_bytes(serialization.Encoding.PEM) + cls.server_pem = cls.root_pem + _private_key_to_unencrypted_pem(root_key) + cls.key_id = base64.urlsafe_b64encode( + cls.leaf.fingerprint(hashes.SHA256())).rstrip(b"=").decode("ascii") + + def _assert_chain_and_handshake(self, material): + self.assertEqual(self.chain_pem, material["cert_pem"]) + self.assertEqual(extract_certs(self.chain_pem.decode("ascii")), material["x5c"]) + self.assertEqual(self.key_id, material["key_id"]) + client = mtls._create_ssl_context( + material["cert_pem"], material["private_key_pem"], verify=False) + server = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server.verify_mode = ssl.CERT_REQUIRED + server.load_verify_locations(cadata=self.root_pem.decode("ascii")) + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "server.pem") + with open(path, "wb") as stream: + stream.write(self.server_pem) + server.load_cert_chain(path) + client_in, client_out, server_in, server_out = [ + ssl.MemoryBIO() for _ in range(4)] + client_tls = client.wrap_bio(client_in, client_out, server_side=False) + server_tls = server.wrap_bio(server_in, server_out, server_side=True) + client_done = server_done = False + for _ in range(10): + if not client_done: + try: + client_tls.do_handshake() + client_done = True + except ssl.SSLWantReadError: + pass + if client_out.pending: + server_in.write(client_out.read()) + if not server_done: + try: + server_tls.do_handshake() + server_done = True + except ssl.SSLWantReadError: + pass + if server_out.pending: + client_in.write(server_out.read()) + if client_done and server_done: + break + self.assertTrue(client_done and server_done, "mTLS handshake did not complete") + + def test_pem_intermediates_reach_tls_handshake(self): + self._assert_chain_and_handshake(_load_mtls_cert_material({ + "private_key": self.key_pem.decode("ascii"), + "public_certificate": self.chain_pem.decode("ascii"), + })) + + def test_non_mtls_assertion_preserves_pem_chain(self): + from cryptography.hazmat.primitives import hashes + from msal.application import ConfidentialClientApplication + from tests.http_client import MinimalResponse + + endpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token" + expected_chain = extract_certs(self.chain_pem.decode("ascii")) + with patch("msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": endpoint.replace("/token", "/authorize"), + "token_endpoint": endpoint, + "issuer": "https://login.microsoftonline.com/tenant/v2.0"}): + app = ConfidentialClientApplication( + "cid", authority="https://login.microsoftonline.com/tenant", + azure_region=False, client_credential={ + "private_key": self.key_pem.decode("ascii"), + "thumbprint": self.leaf.fingerprint(hashes.SHA1()).hex(), + "public_certificate": self.chain_pem.decode("ascii"), + }) + + def post(url, data=None, **kwargs): + header = data["client_assertion"].split(b".")[0] + decoded = json.loads(base64.urlsafe_b64decode( + header + b"=" * (-len(header) % 4))) + self.assertEqual(expected_chain, decoded["x5c"]) + return MinimalResponse( + status_code=200, text='{"access_token": "at", "token_type": "Bearer"}') + + app.acquire_token_for_client(["s1"], post=post) + + def test_pfx_intermediates_reach_tls_handshake(self): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.serialization import pkcs12 + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "chain.pfx") + with open(path, "wb") as stream: + stream.write(pkcs12.serialize_key_and_certificates( + b"client", self.leaf_key, self.leaf, + [self.intermediate, self.upper_intermediate], + serialization.NoEncryption())) + material = _load_mtls_cert_material({"private_key_pfx_path": path}) + self._assert_chain_and_handshake(material) + + def test_unordered_pfx_chain_preserves_non_mtls_leaf_only_assertion(self): + from cryptography.hazmat.primitives import serialization + from msal.application import ConfidentialClientApplication + from tests.http_client import MinimalResponse + + endpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token" + path = os.path.join(os.path.dirname(__file__), "certificate-with-password.pfx") + expected_pem = (self.chain_pem + self.root_pem).decode("ascii") + additional = [ + self.root, self.impostor, self.upper_intermediate, + self.unrelated, self.intermediate, self.leaf, self.intermediate] + with patch("cryptography.hazmat.primitives.serialization.pkcs12." + "load_key_and_certificates", + return_value=(self.leaf_key, self.leaf, additional)): + material = _load_mtls_cert_material({"private_key_pfx_path": path}) + self.assertEqual(expected_pem.encode("ascii"), material["cert_pem"]) + self.assertEqual(extract_certs(expected_pem), material["x5c"]) + self.assertEqual(self.key_id, material["key_id"]) + with patch("msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": endpoint.replace("/token", "/authorize"), + "token_endpoint": endpoint, + "issuer": "https://login.microsoftonline.com/tenant/v2.0"}): + app = ConfidentialClientApplication( + "cid", authority="https://login.microsoftonline.com/tenant", + azure_region=False, client_credential={ + "private_key_pfx_path": path, "public_certificate": True}) + + def post(url, data=None, **kwargs): + self.assertEqual(endpoint, url) + header = data["client_assertion"].split(b".")[0] + decoded = json.loads(base64.urlsafe_b64decode( + header + b"=" * (-len(header) % 4))) + leaf_pem = self.leaf.public_bytes( + serialization.Encoding.PEM).decode("ascii") + self.assertEqual(extract_certs(leaf_pem), decoded["x5c"]) + return MinimalResponse( + status_code=200, + text='{"access_token": "at", "token_type": "Bearer"}') + + app.acquire_token_for_client(["s1"], post=post) + + def test_issuer_signature_verification_on_older_cryptography(self): + class LegacyCertificate: + issuer = self.leaf.issuer + signature = self.leaf.signature + tbs_certificate_bytes = self.leaf.tbs_certificate_bytes + signature_algorithm_oid = self.leaf.signature_algorithm_oid + signature_hash_algorithm = self.leaf.signature_hash_algorithm + + self.assertFalse(_is_certificate_issuer(LegacyCertificate(), self.impostor)) + self.assertTrue(_is_certificate_issuer(LegacyCertificate(), self.intermediate)) + + def test_environment_ca_bundles_replace_default_roots(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "private-ca.pem") + with open(path, "wb") as stream: + stream.write(self.root_pem) + for variable in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): + with self.subTest(variable=variable), patch.dict( + os.environ, {"REQUESTS_CA_BUNDLE": "", "CURL_CA_BUNDLE": "", + variable: path}): + client = mtls._MtlsHttpClient(self.chain_pem, self.key_pem) + self.addCleanup(client.close) + session = client._ensure_session("https://mtlsauth.microsoft.com") + context = session.get_adapter("https://")._ssl_context + self.assertEqual(1, len(context.get_ca_certs())) + self.assertEqual( + session, client._ensure_session("https://mtlsauth.microsoft.com")) + unverified = client._ensure_session( + "https://mtlsauth.microsoft.com", verify=False) + self.assertIsNot(session, unverified) + self.assertEqual(ssl.CERT_NONE, + unverified.get_adapter("https://")._ssl_context.verify_mode) + + class TestUnencryptedPemLoading(unittest.TestCase): """_private_key_to_unencrypted_pem pins the cryptography backend and turns low-level load failures into a clear, actionable ValueError.""" diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index f967f543..e8e1b9c9 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -405,12 +405,111 @@ def test_key_id_is_coerced_to_str_for_cache_key(self): self.assertEqual("THUMB", _key_id_to_str(b"THUMB")) self.assertEqual("THUMB", _key_id_to_str("THUMB")) self.assertEqual("123", _key_id_to_str(123)) # Any other type -> str() - # Adding + searching an AT with a bytes key_id no longer raises; the - # helper below stores and then finds it by that same key_id. - self._test_data_should_be_saved_and_searchable_in_access_token( - {"key_id": b"THUMB"}) + # Bytes IDs are normalized before persistence and during lookup. + self.cache = SerializableTokenCache() + self.cache.add({ + "client_id": "my_client_id", "scope": ["s1"], + "token_endpoint": "https://login.example.com/contoso/v2/token", + "data": {"key_id": b"THUMB"}, + "response": build_response(access_token="bound_at"), + }, now=1000) + self.cache.deserialize(self.cache.serialize()) + for key_id in (b"THUMB", "THUMB"): + entry = self.assertFoundAccessToken( + scopes=["s1"], now=1000, query=dict( + client_id="my_client_id", environment="login.example.com", + realm="contoso", home_account_id=None, key_id=key_id)) + self.assertEqual("THUMB", entry["key_id"]) self.assertEqual(1, len(self.cache._cache["AccessToken"])) + def test_non_ascii_key_id_bytes_fail_explicitly(self): + with self.assertRaisesRegex(ValueError, "ASCII"): + self.cache.add({ + "data": {"key_id": b"\xff"}, + "response": build_response(access_token="bound_at"), + }) + + def test_bound_scope_suffix_cannot_collide_with_unbound_scope(self): + common = dict( + client_id="my_client_id", + token_endpoint="https://login.example.com/contoso/v2/token") + events = [ + dict(common, scope=["scope"], data={"key_id": "abc"}, + response=build_response(access_token="bound_at", token_type="mtls_pop")), + dict(common, scope=["scope-abc"], + response=build_response(access_token="bearer_at", token_type="Bearer")), + ] + query = dict( + client_id="my_client_id", environment="login.example.com", + realm="contoso", home_account_id=None) + for insertion_order in (events, list(reversed(events))): + with self.subTest(order=[e["scope"] for e in insertion_order]): + self.cache = TokenCache() + for event in insertion_order: + self.cache.add(event, now=1000) + self.assertEqual(2, len(self.cache._cache["AccessToken"])) + self.assertEqual("bearer_at", self.assertFoundAccessToken( + scopes=["scope-abc"], query=query, now=1000)["secret"]) + self.assertEqual("bound_at", self.assertFoundAccessToken( + scopes=["scope"], query=dict(query, key_id="abc"), + now=1000)["secret"]) + + def test_legacy_bound_entries_are_isolated_and_removable(self): + self.cache.add({ + "client_id": "my_client_id", "scope": ["s1"], + "token_endpoint": "https://login.example.com/contoso/v2/token", + "data": {"key_id": "THUMB"}, + "response": build_response(access_token="legacy_at", token_type="ssh-cert"), + }, now=1000) + entry = next(iter(self.cache._cache["AccessToken"].values())) + legacy_key = self.at_key_maker(**dict(entry, key_id=None)) + for stored_key in (legacy_key, legacy_key + "-THUMB"): + for expired in (False, True): + with self.subTest(key=stored_key, expired=expired): + self.cache = SerializableTokenCache() + self.cache.deserialize(json.dumps({ + "AccessToken": {stored_key: entry}})) + query = dict( + client_id="my_client_id", environment="login.example.com", + realm="contoso", home_account_id=None) + self.assertEqual([], list(self.cache.search( + "AccessToken", target=["s1"], query=query, now=1000))) + if expired: + list(self.cache.search("AccessToken", now=5000)) + else: + self.assertFoundAccessToken( + scopes=["s1"], query=dict(query, key_id="THUMB"), + now=1000) + self.cache.remove_at(entry) + self.assertEqual({}, self.cache._cache["AccessToken"]) + self.assertTrue(self.cache.has_state_changed) + + def test_fast_lookup_checks_stored_scope(self): + entry = { + "credential_type": "AccessToken", "home_account_id": None, + "environment": "env", "client_id": "cid", "realm": "tenant", + "target": "different", "secret": "wrong_at", "expires_on": "4600", + } + query = dict( + home_account_id=None, environment="env", client_id="cid", realm="tenant") + key = self.at_key_maker(**dict(query, target="requested")) + self.cache._cache["AccessToken"] = {key: entry} + self.assertEqual([], list(self.cache.search( + "AccessToken", target=["requested"], query=query, now=1000))) + + def test_case_distinct_key_ids_remain_distinct_after_serialization(self): + self.cache = SerializableTokenCache() + for key_id in ("Thumb", "thumb"): + self.cache.add({ + "scope": ["s1"], "data": {"key_id": key_id}, + "response": build_response(access_token=key_id), + }, now=1000) + self.cache.deserialize(self.cache.serialize()) + for key_id in ("Thumb", "thumb"): + entries = list(self.cache.search( + "AccessToken", target=["s1"], query={"key_id": key_id}, now=1000)) + self.assertEqual([key_id], [entry["secret"] for entry in entries]) + def test_bearer_and_mtls_pop_tokens_coexist_and_isolate(self): # The crux of mTLS PoP cache isolation (plan C6): a Bearer token and an # mtls_pop token for the SAME app/scope/tenant must coexist, and an From d3a120c02336a026c1a5b20a846c36f04e9288df Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 22 Oct 2026 17:10:23 -0400 Subject: [PATCH 12/27] Add downstream mTLS binding context Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 25 +++++- msal/__init__.py | 2 +- msal/application.py | 35 +++++--- msal/mtls.py | 85 +++++++++++++++++++ sample/confidential_client_mtls_pop_sample.py | 26 ++++-- tests/test_application.py | 14 ++- tests/test_e2e.py | 20 ++--- tests/test_mtls_transport.py | 51 ++++++++++- 8 files changed, 217 insertions(+), 41 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index ec0efffe..e450c78f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -191,7 +191,22 @@ certificate credential and pass ``mtls_proof_of_possession=True`` to mtls_proof_of_possession=True, ) # result["token_type"] == "mtls_pop" - # result["binding_certificate"] == {"x5c": [...], "thumbprint_sha256": "..."} + binding = result["binding_certificate"] + # binding.x5c + # binding.thumbprint_sha256 + + with binding.create_requests_session() as session: + response = session.get( + resource_url, + headers={ + "Authorization": + "{} {}".format( + result["token_type"], + result["access_token"], + ), + }, + timeout=30, + ) Notes and requirements: @@ -211,6 +226,14 @@ Notes and requirements: authenticates the client through the mutual-TLS handshake instead. The classic (non-mTLS) certificate path is unchanged and still sends a signed ``client_assertion`` (``private_key_jwt``) to obtain an ordinary token. +* ``result["binding_certificate"]`` is a process-local + :py:class:`msal.MtlsBindingContext`. It exposes the public certificate chain + and SHA-256 thumbprint and can create an ``ssl.SSLContext`` or + ``requests.Session`` that presents the same certificate to the resource. It + is not serializable and must not be persisted. + +.. autoclass:: msal.MtlsBindingContext + :members: Exceptions diff --git a/msal/__init__.py b/msal/__init__.py index ea681317..f830db39 100644 --- a/msal/__init__.py +++ b/msal/__init__.py @@ -35,6 +35,7 @@ from .sku import __version__ from .token_cache import TokenCache, SerializableTokenCache from .auth_scheme import PopAuthScheme +from .mtls import MtlsBindingContext from .managed_identity import ( SystemAssignedManagedIdentity, UserAssignedManagedIdentity, ManagedIdentityClient, @@ -46,4 +47,3 @@ # 1. officially part of the MSAL public API, and # 2. can still be caught by the user code even if we change the module structure. from .oauth2cli.oauth2 import BrowserInteractionTimeoutError - diff --git a/msal/application.py b/msal/application.py index e72c4ca2..ae1c5b97 100644 --- a/msal/application.py +++ b/msal/application.py @@ -2962,13 +2962,19 @@ def acquire_token_for_client( returns another token type, the result is ``token_type_mismatch`` and the rejected response is not cached. - On success the result also contains a ``binding_certificate`` dict - with the **public** binding material only (never the private key):: - - result["binding_certificate"] == { - "x5c": ["", ...], - "thumbprint_sha256": "", - } + On success the result also contains an + :py:class:`msal.MtlsBindingContext` as ``binding_certificate``. It + exposes public binding metadata and creates a TLS context or + ``requests`` session that presents the same certificate:: + + binding = result["binding_certificate"] + binding.x5c + binding.thumbprint_sha256 + with binding.create_requests_session() as session: + response = session.get( + resource_url, + headers={"Authorization": "mtls_pop " + result["access_token"]}, + ) Example usage:: @@ -3056,13 +3062,14 @@ def acquire_token_for_client( "of 'mtls_pop'; the access token is not " "certificate-bound.".format(result.get("token_type"))), } - # Surface the PUBLIC binding certificate (never the private key), so - # callers can correlate the token to its cert. Survives _clean_up - # (the key has no "_" prefix). - result["binding_certificate"] = { - "x5c": mtls_cert["x5c"], - "thumbprint_sha256": mtls_cert["key_id"], # base64url x5t#S256 - } + # Attach process-local TLS capability after cache cleanup. The + # private key is never added to the token result or token cache. + result["binding_certificate"] = mtls.MtlsBindingContext( + mtls_cert["cert_pem"], + mtls_cert["private_key_pem"], + mtls_cert["x5c"], + mtls_cert["key_id"], # base64url x5t#S256 + ) return result def _acquire_token_for_client( diff --git a/msal/mtls.py b/msal/mtls.py index c5bf8f3c..699db0ff 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -13,6 +13,10 @@ """ import logging import threading +try: + from collections.abc import Mapping +except ImportError: # Python 2 + from collections import Mapping try: from urllib.parse import urlparse, urlunparse except ImportError: # Python 2 @@ -89,6 +93,87 @@ def transform_token_endpoint(token_endpoint, instance, region=None): return urlunparse(parsed._replace(netloc=netloc)) +class MtlsBindingContext(Mapping): + """The certificate binding and TLS helpers for an mTLS-bound access token. + + Instances are created by MSAL and returned as ``binding_certificate`` in a + successful token result. The certificate metadata remains available through + mapping access for compatibility, while the private key is exposed only + through TLS contexts and sessions created by this object. + + This object is process-local and must not be serialized or persisted. + """ + + __slots__ = ( + "__cert_pem", "__key_pem", "__x5c", "__thumbprint_sha256") + _PUBLIC_KEYS = ("x5c", "thumbprint_sha256") + + def __init__(self, cert_pem, key_pem, x5c, thumbprint_sha256): + self.__cert_pem = cert_pem + self.__key_pem = key_pem + self.__x5c = tuple(x5c) + self.__thumbprint_sha256 = thumbprint_sha256 + + @property + def x5c(self): + """The public certificate chain as base64-encoded DER certificates.""" + return list(self.__x5c) + + @property + def thumbprint_sha256(self): + """The base64url-encoded SHA-256 thumbprint (``x5t#S256``).""" + return self.__thumbprint_sha256 + + def create_ssl_context(self, verify=True): + """Create an ``ssl.SSLContext`` that presents the binding certificate. + + ``verify`` follows ``requests`` semantics: ``True`` uses its default CA + bundle, a path selects a custom CA file/directory, and ``False`` disables + server verification. + """ + return _create_ssl_context(self.__cert_pem, self.__key_pem, verify) + + def create_requests_session(self, verify=True, proxies=None): + """Create a ``requests.Session`` that presents the binding certificate. + + The caller owns the returned session and should close it, preferably by + using it as a context manager. + """ + import requests + effective_verify = True if verify is None else verify + session = requests.Session() + session.verify = effective_verify + if proxies: + session.proxies.update(proxies) + session.mount( + "https://", + _make_mtls_adapter(self.create_ssl_context(effective_verify))) + return session + + def __getitem__(self, key): + if key == "x5c": + return self.x5c + if key == "thumbprint_sha256": + return self.thumbprint_sha256 + raise KeyError(key) + + def __iter__(self): + return iter(self._PUBLIC_KEYS) + + def __len__(self): + return len(self._PUBLIC_KEYS) + + def __repr__(self): + return ( + "MtlsBindingContext(" + "thumbprint_sha256={!r}, certificate_chain_length={})".format( + self.thumbprint_sha256, len(self.__x5c))) + + def __reduce__(self): + raise TypeError( + "MtlsBindingContext is process-local and cannot be serialized") + + class _MtlsHttpClient(object): """A minimal http client (``post``/``get``/``close``) whose HTTPS connections present a client certificate for mutual-TLS. diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index fa15c7ed..4918559c 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -22,6 +22,7 @@ PFX_PATH path to the .pfx file holding the SN/I cert + private key PFX_PASSPHRASE (optional) passphrase protecting the .pfx SCOPE e.g. https://graph.microsoft.com/.default + RESOURCE_URL optional mTLS resource URL to call with the acquired token AZURE_REGION (optional) e.g. westus3; omit for the global mtls endpoint Then run:: @@ -51,16 +52,16 @@ def _print_result_summary(result, header): """Print a *log-safe* summary of an MSAL token ``result``. The sensitive part of ``result`` is the ``access_token`` (a PoP/bearer - secret) - that is what we must never log. The ``binding_certificate`` is the - certificate's PUBLIC material (x5c + thumbprint), not a secret, so it is safe - to inspect. We still avoid dumping the whole ``result`` dict and instead - surface only a few derived booleans/fields, to model the habit of not - routinely logging token responses. + secret) - that is what we must never log. The ``binding_certificate`` is a + process-local TLS context containing private-key capability, so it must not + be serialized or logged. Its ``x5c`` and ``thumbprint_sha256`` properties + contain public metadata. On success the interesting (non-sensitive) fields are:: result["token_type"] == "mtls_pop" - result["binding_certificate"] == {"x5c": [...], "thumbprint_sha256": ...} + result["binding_certificate"].x5c + result["binding_certificate"].thumbprint_sha256 result["token_source"] in ("identity_provider", "cache") Inspect those directly (e.g. in a debugger) when you need their values. @@ -93,6 +94,19 @@ def vanilla_sni_mtls_pop(): ) _print_result_summary(result, "Vanilla SN/I -> mTLS PoP result:") + resource_url = os.getenv("RESOURCE_URL") + if resource_url and "access_token" in result: + binding = result["binding_certificate"] + with binding.create_requests_session() as session: + response = session.get( + resource_url, + headers={ + "Authorization": "mtls_pop " + result["access_token"], + }, + timeout=30, + ) + response.raise_for_status() + print(" resource call status: ", response.status_code) return result diff --git a/tests/test_application.py b/tests/test_application.py index 7f47967c..9ad5a575 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -4,6 +4,7 @@ import json import logging import os +import pickle import sys import time import warnings @@ -2322,11 +2323,17 @@ def test_vanilla_sni_mtls_pop_request_and_result(self): self.assertNotIn("key_id", req["data"], "key_id must never travel on the wire") # Telemetry marks token-type 6 (parity with .NET MtlsPop) self.assertEqual("4|730,2|,,6", req["headers"].get(CLIENT_CURRENT_TELEMETRY)) - # Result carries token_type and the PUBLIC binding cert (no private key) + # Result carries token_type and a TLS-capable binding context. self.assertEqual("mtls_pop", result.get("token_type")) binding = result["binding_certificate"] + self.assertIsInstance(binding, msal.MtlsBindingContext) self.assertEqual({"x5c", "thumbprint_sha256"}, set(binding)) - self.assertNotIn("private", json.dumps(binding).lower()) + self.assertEqual(binding.x5c, binding["x5c"]) + self.assertEqual( + binding.thumbprint_sha256, binding["thumbprint_sha256"]) + self.assertNotIn("private", repr(binding).lower()) + with self.assertRaises(TypeError): + pickle.dumps(binding) def test_repeat_call_is_cache_hit(self): app = ConfidentialClientApplication( @@ -2342,7 +2349,8 @@ def test_repeat_call_is_cache_hit(self): self.assertEqual([], second, "Second call must be served from cache") self.assertEqual(app._TOKEN_SOURCE_CACHE, result[app._TOKEN_SOURCE]) self.assertEqual("mtls_pop", result["token_type"]) - self.assertTrue(result.get("binding_certificate")) + self.assertIsInstance( + result.get("binding_certificate"), msal.MtlsBindingContext) def test_released_positional_claims_argument_is_preserved(self): app = ConfidentialClientApplication( diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 173e5e77..6c4f38fb 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -477,7 +477,7 @@ def _cnf_x5t_s256(access_token): except Exception: # Opaque/non-JWT token - skip the offline binding check return None - def _call_graph_with_mtls_pop_token(self, token, cert_credential): + def _call_graph_with_mtls_pop_token(self, token, binding_certificate): """Prove an ``mtls_pop`` token is genuinely usable: present it to the mTLS Graph endpoint over a TLS channel bound by the SAME certificate and require HTTP 200. Acquiring the token is not enough - a resource only @@ -486,22 +486,13 @@ def _call_graph_with_mtls_pop_token(self, token, cert_credential): real regression rather than a transient failure. Shared test infrastructure: the FIC two-leg e2e in PR #939 reuses this - as-is (please do not inline it), which is why it takes an explicit - ``cert_credential`` rather than reading ``self.app``'s certificate. + as-is (please do not inline it). """ - from msal.application import _load_mtls_cert_material - from msal.mtls import _create_ssl_context, _make_mtls_adapter - material = _load_mtls_cert_material(cert_credential) - session = requests.Session() - session.mount("https://", _make_mtls_adapter(_create_ssl_context( - material["cert_pem"], material["private_key_pem"]))) - try: + with binding_certificate.create_requests_session() as session: resp = session.get( self._MTLS_GRAPH_RESOURCE, headers={"Authorization": "mtls_pop " + token}, timeout=30) - finally: - session.close() self.assertEqual( 200, resp.status_code, "mtls_pop token rejected by the resource (HTTP %s). A 401/403 means " @@ -529,12 +520,13 @@ def test_credential_x509_output_pop(self): self.assertEqual("mtls_pop", result.get("token_type")) binding = result.get("binding_certificate") self.assertTrue(binding and binding.get("x5c") and binding.get("thumbprint_sha256")) - self.assertNotIn("private", json.dumps(binding).lower()) + self.assertIsInstance(binding, msal.MtlsBindingContext) + self.assertNotIn("private", repr(binding).lower()) cnf = self._cnf_x5t_s256(result["access_token"]) if cnf: # When the AT is a decodable JWT, its cnf must match our cert self.assertEqual(binding["thumbprint_sha256"], cnf) # The token must actually work at the resource, bound to our cert. - self._call_graph_with_mtls_pop_token(result["access_token"], cert) + self._call_graph_with_mtls_pop_token(result["access_token"], binding) self._assert_pop_cache_hit(self.app, self._MTLS_POP_SCOPE) def test_credential_x509_output_bearer(self): diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index 37719ebb..d9eeb7dd 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -5,9 +5,9 @@ import tempfile import unittest from datetime import datetime, timedelta, timezone -from unittest.mock import patch +from unittest.mock import patch, Mock -from msal import mtls +from msal import mtls, MtlsBindingContext from msal.application import ( _is_certificate_issuer, _load_mtls_cert_material, _private_key_to_unencrypted_pem, extract_certs) @@ -157,6 +157,53 @@ def test_http_client_builds_session_lazily(self): self.assertIn("https://", session.adapters) client.close() + def test_binding_context_exposes_metadata_without_key_material(self): + material = self._material() + binding = MtlsBindingContext( + material["cert_pem"], + material["private_key_pem"], + material["x5c"], + material["key_id"]) + self.assertEqual(material["x5c"], binding.x5c) + self.assertEqual(material["x5c"], binding["x5c"]) + self.assertEqual(material["key_id"], binding.thumbprint_sha256) + self.assertEqual(material["key_id"], binding["thumbprint_sha256"]) + self.assertEqual( + {"x5c", "thumbprint_sha256"}, set(binding)) + self.assertNotIn("PRIVATE KEY", repr(binding)) + + def test_binding_context_creates_ssl_context(self): + material = self._material() + binding = MtlsBindingContext( + material["cert_pem"], + material["private_key_pem"], + material["x5c"], + material["key_id"]) + self.assertIsInstance(binding.create_ssl_context(), ssl.SSLContext) + + def test_binding_context_creates_requests_session(self): + material = self._material() + binding = MtlsBindingContext( + material["cert_pem"], + material["private_key_pem"], + material["x5c"], + material["key_id"]) + context = Mock(spec=ssl.SSLContext) + adapter = Mock() + with patch.object( + MtlsBindingContext, "create_ssl_context", + return_value=context), \ + patch("msal.mtls._make_mtls_adapter", return_value=adapter): + session = binding.create_requests_session( + verify=False, proxies={"https": "https://proxy.example"}) + try: + self.assertFalse(session.verify) + self.assertEqual( + "https://proxy.example", session.proxies["https"]) + self.assertIs(adapter, session.adapters["https://"]) + finally: + session.close() + class TestMtlsCertificateChain(unittest.TestCase): @classmethod From 9e35a334cf79cf493409bfa5db53da2b9245690c Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 10:15:35 -0400 Subject: [PATCH 13/27] Fix mTLS cache isolation and transport compatibility Isolate certificate authentication failures, reject unvalidated mTLS request data, preserve legacy cryptography support, and honor effective TLS verification settings across Requests versions. Add regression coverage for all five review findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 12 ++++- msal/mtls.py | 43 ++++++++++++++++- tests/test_application.py | 68 +++++++++++++++++++++++++++ tests/test_mtls_transport.py | 89 ++++++++++++++++++++++++++++++++++-- 4 files changed, 203 insertions(+), 9 deletions(-) diff --git a/msal/application.py b/msal/application.py index ae1c5b97..13400341 100644 --- a/msal/application.py +++ b/msal/application.py @@ -255,7 +255,9 @@ def _load_mtls_cert_material(cert_credential): "Provide 'public_certificate' (PEM) alongside 'private_key', " "or use 'private_key_pfx_path'.") from cryptography import x509 - cert = x509.load_pem_x509_certificate(_str2bytes(public_cert)) + from cryptography.hazmat.backends import default_backend + cert = x509.load_pem_x509_certificate( + _str2bytes(public_cert), backend=default_backend()) sha256_thumbprint, _, x5c, cert_pem = _extract_cert_and_thumbprints(cert) x5c = extract_certs(public_cert) cert_pem = public_cert @@ -1183,7 +1185,8 @@ def _get_mtls_client(self, central_authority): mtls._MtlsHttpClient( cert["cert_pem"], cert["private_key_pem"], **self._http_client_config), - http_cache=self._http_cache, + # The TLS credential is absent from HTTP cache keys. + http_cache={}, default_throttle_time=5, ) # Vanilla SN/I: the TLS certificate alone authenticates the client, so @@ -3004,6 +3007,11 @@ def acquire_token_for_client( raise ValueError( # We choose to disallow force_refresh "Historically, this method does not support force_refresh behavior. " ) + if (not mtls_proof_of_possession + and (kwargs.get("data") or {}).get("token_type") == "mtls_pop"): + raise ValueError( + "Request mTLS PoP using mtls_proof_of_possession=True, " + "not data['token_type'].") if fmi_path is not None: if not isinstance(fmi_path, str): raise ValueError( diff --git a/msal/mtls.py b/msal/mtls.py index 699db0ff..6710ae0e 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -147,7 +147,7 @@ def create_requests_session(self, verify=True, proxies=None): session.proxies.update(proxies) session.mount( "https://", - _make_mtls_adapter(self.create_ssl_context(effective_verify))) + _make_mtls_adapter(ssl_context_factory=self.create_ssl_context)) return session def __getitem__(self, key): @@ -240,7 +240,7 @@ def close(self): session.close() -def _make_mtls_adapter(ssl_context): +def _make_mtls_adapter(ssl_context=None, ssl_context_factory=None): """Return a ``requests`` HTTPAdapter that injects ``ssl_context`` (with the client certificate loaded) into every connection pool it creates.""" from requests.adapters import HTTPAdapter # Lazy import @@ -248,8 +248,26 @@ def _make_mtls_adapter(ssl_context): class _MtlsHTTPAdapter(HTTPAdapter): def __init__(self, ssl_context): self._ssl_context = ssl_context + self._adapters = {} + self._adapter_lock = threading.Lock() super(_MtlsHTTPAdapter, self).__init__(max_retries=1) + def send(self, request, **kwargs): + if ssl_context_factory is None: + return super(_MtlsHTTPAdapter, self).send(request, **kwargs) + verify = kwargs.get("verify", True) + with self._adapter_lock: + if verify not in self._adapters: + self._adapters[verify] = _make_mtls_adapter( + ssl_context_factory(verify)) + adapter = self._adapters[verify] + return adapter.send(request, **kwargs) + + def close(self): + for adapter in self._adapters.values(): + adapter.close() + super(_MtlsHTTPAdapter, self).close() + def init_poolmanager(self, *args, **kwargs): kwargs["ssl_context"] = self._ssl_context return super(_MtlsHTTPAdapter, self).init_poolmanager(*args, **kwargs) @@ -258,6 +276,27 @@ def proxy_manager_for(self, *args, **kwargs): kwargs["ssl_context"] = self._ssl_context return super(_MtlsHTTPAdapter, self).proxy_manager_for(*args, **kwargs) + def get_connection_with_tls_context( + self, request, verify, proxies=None, cert=None): + # Requests 2.32.0-2.32.2 otherwise replace our certificate-bearing + # context with their preloaded, certificate-less context. + from requests.utils import select_proxy, prepend_scheme_if_needed + from requests.exceptions import InvalidProxyURL + from urllib3.util import parse_url + + proxy = select_proxy(request.url, proxies) + manager = self.poolmanager + if proxy: + proxy = prepend_scheme_if_needed(proxy, "http") + if not parse_url(proxy).host: + raise InvalidProxyURL( + "Proxy URL is malformed or missing a host.") + manager = self.proxy_manager_for(proxy) + return manager.connection_from_url( + request.url, pool_kwargs={"ssl_context": self._ssl_context}) + + _get_connection = get_connection_with_tls_context # Requests 2.32.0/1 + return _MtlsHTTPAdapter(ssl_context) diff --git a/tests/test_application.py b/tests/test_application.py index 9ad5a575..6e508b79 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2352,6 +2352,74 @@ def test_repeat_call_is_cache_hit(self): self.assertIsInstance( result.get("binding_certificate"), msal.MtlsBindingContext) + def test_certificate_failure_does_not_throttle_replacement(self): + from datetime import datetime, timedelta, timezone + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from msal.application import _load_mtls_cert_material + + material = _load_mtls_cert_material(_MTLS_CERT_CRED) + key = serialization.load_pem_private_key( + material["private_key_pem"], password=None) + original = x509.load_pem_x509_certificate(material["cert_pem"]) + now = datetime.now(timezone.utc) + replacement = (x509.CertificateBuilder() + .subject_name(original.subject).issuer_name(original.subject) + .public_key(key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=1)) + .sign(key, hashes.SHA256())) + credential = { + "private_key": material["private_key_pem"].decode("ascii"), + "public_certificate": replacement.public_bytes( + serialization.Encoding.PEM).decode("ascii"), + "thumbprint": replacement.fingerprint(hashes.SHA1()).hex(), + } + shared_cache = {} + first = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY, + http_cache=shared_cache, instance_discovery=False) + second = ConfidentialClientApplication( + "cid", client_credential=credential, authority=self._AUTHORITY, + http_cache=shared_cache, instance_discovery=False) + failure = MinimalResponse( + status_code=400, text='{"error": "invalid_client"}') + with patch("msal.mtls._MtlsHttpClient.post", return_value=failure) as post: + for _ in range(2): + result = first.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual("invalid_client", result["error"]) + self.assertEqual(1, post.call_count, "Local throttling must remain active") + captured = [] + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + result = second.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual(1, len(captured)) + self.assertEqual("mtls_pop", result["token_type"]) + self.assertNotEqual(first._get_mtls_pop_cert()["key_id"], + second._get_mtls_pop_cert()["key_id"]) + + def test_raw_mtls_token_type_is_rejected_before_cache_lookup(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + for cached in (False, True): + with self.subTest(cached=cached): + if cached: + app.acquire_token_for_client( + ["s1"], post=self._capturing_post([], token_type="Bearer")) + with patch.object(app, "_acquire_token_silent_with_error") as lookup: + with self.assertRaisesRegex( + ValueError, "mtls_proof_of_possession=True"): + app.acquire_token_for_client( + ["s1"], data={"token_type": "mtls_pop"}) + lookup.assert_not_called() + captured = [] + ordinary = app.acquire_token_for_client( + ["s1"], post=self._capturing_post(captured, token_type="Bearer")) + self.assertEqual("Bearer", ordinary["token_type"]) + self.assertNotIn("binding_certificate", ordinary) + def test_released_positional_claims_argument_is_preserved(self): app = ConfidentialClientApplication( "cid", client_credential="a_secret", authority=self._AUTHORITY) diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index d9eeb7dd..00fafd98 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -1,4 +1,5 @@ import base64 +import io import json import os import ssl @@ -111,6 +112,23 @@ def test_cert_material_shape(self): # key_id is the base64url (unpadded) x5t#S256 used for cache binding self.assertNotIn("=", material["key_id"]) + def test_pem_certificate_loader_supports_required_backend(self): + from cryptography import x509 + + material = self._material() + load = x509.load_pem_x509_certificate + + def legacy_load(data, backend): + return load(data, backend=backend) + + with patch("cryptography.x509.load_pem_x509_certificate", + side_effect=legacy_load): + loaded = _load_mtls_cert_material({ + "private_key": material["private_key_pem"].decode("ascii"), + "public_certificate": material["cert_pem"].decode("ascii"), + }) + self.assertEqual(material["key_id"], loaded["key_id"]) + def test_create_ssl_context_loads_cert_and_unlinks_tempfile(self): material = self._material() created = {} @@ -146,6 +164,30 @@ def test_adapter_injects_ssl_context(self): self.assertIs( adapter.poolmanager.connection_pool_kw.get("ssl_context"), context) + def test_selected_pool_preserves_certificate_context_on_requests_2322(self): + import requests + from requests.adapters import HTTPAdapter + + material = self._material() + context = mtls._create_ssl_context( + material["cert_pem"], material["private_key_pem"]) + adapter = mtls._make_mtls_adapter(context) + self.addCleanup(adapter.close) + request = requests.Request("GET", "https://resource.example/path").prepare() + preloaded = ssl.create_default_context() + + def legacy_connection(adapter, request, verify, proxies=None, cert=None): + return adapter.poolmanager.connection_from_url( + request.url, pool_kwargs={"ssl_context": preloaded}) + + with patch.object(HTTPAdapter, "get_connection_with_tls_context", + legacy_connection, create=True): + for proxies in (None, {"https": "http://proxy.example:8080"}): + with self.subTest(proxies=proxies): + pool = adapter.get_connection_with_tls_context( + request, verify=True, proxies=proxies) + self.assertIs(context, pool.conn_kw["ssl_context"]) + def test_http_client_builds_session_lazily(self): material = self._material() client = mtls._MtlsHttpClient( @@ -188,12 +230,8 @@ def test_binding_context_creates_requests_session(self): material["private_key_pem"], material["x5c"], material["key_id"]) - context = Mock(spec=ssl.SSLContext) adapter = Mock() - with patch.object( - MtlsBindingContext, "create_ssl_context", - return_value=context), \ - patch("msal.mtls._make_mtls_adapter", return_value=adapter): + with patch("msal.mtls._make_mtls_adapter", return_value=adapter): session = binding.create_requests_session( verify=False, proxies={"https": "https://proxy.example"}) try: @@ -418,6 +456,47 @@ def test_environment_ca_bundles_replace_default_roots(self): unverified.get_adapter("https://")._ssl_context.verify_mode) + def test_binding_session_uses_only_effective_ca_bundle(self): + from cryptography.hazmat.primitives import serialization + from urllib3.response import HTTPResponse + + binding = MtlsBindingContext( + self.chain_pem, self.key_pem, + extract_certs(self.chain_pem.decode("ascii")), self.key_id) + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "private-ca.pem") + with open(path, "wb") as stream: + stream.write(self.root_pem) + for setting in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", + "session", "request"): + environment = {"REQUESTS_CA_BUNDLE": "", "CURL_CA_BUNDLE": ""} + if setting in environment: + environment[setting] = path + with self.subTest(setting=setting), patch.dict( + os.environ, environment): + contexts = [] + + def urlopen(pool, *args, **kwargs): + contexts.append(pool.conn_kw["ssl_context"]) + return HTTPResponse( + body=io.BytesIO(b"ok"), status=200, preload_content=False) + + with binding.create_requests_session( + verify=path if setting == "session" else True) as session: + with patch("urllib3.connectionpool.HTTPSConnectionPool.urlopen", + autospec=True, side_effect=urlopen): + options = {"verify": path} if setting == "request" else {} + session.get("https://resource.example", **options) + session.get("https://resource.example", verify=False) + session.get("https://resource.example", **options) + self.assertEqual( + [self.root.public_bytes(serialization.Encoding.DER)], + contexts[0].get_ca_certs(binary_form=True)) + self.assertEqual(ssl.CERT_REQUIRED, contexts[0].verify_mode) + self.assertEqual(ssl.CERT_NONE, contexts[1].verify_mode) + self.assertIs(contexts[0], contexts[2]) + + class TestUnencryptedPemLoading(unittest.TestCase): """_private_key_to_unencrypted_pem pins the cryptography backend and turns low-level load failures into a clear, actionable ValueError.""" From e574edd663080a7f074faa48b1f2b97d986651d1 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 11:51:31 -0400 Subject: [PATCH 14/27] Fix mTLS cache compatibility and transport review findings Isolate mTLS persisted state from released readers, preserve public enumeration, and replace legacy bound-token entries. Validate certificate overrides and token schemes before use, memoize region discovery, enforce mTLS transport capabilities, and preserve custom CA paths. Document rollback and cover the regression cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 22 +- msal/application.py | 37 ++- msal/mtls.py | 52 ++- msal/token_cache.py | 133 +++++--- sample/confidential_client_mtls_pop_sample.py | 4 +- tests/test_application.py | 57 ++++ tests/test_e2e.py | 3 +- tests/test_mtls_transport.py | 55 +++- tests/test_token_cache.py | 300 ++++++++++++++++-- 9 files changed, 573 insertions(+), 90 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index e450c78f..83a837db 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -213,11 +213,17 @@ 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. + Per-request ``post`` and ``cert`` overrides are also rejected. +* mTLS transport and ``binding.create_requests_session()`` require + Requests >= 2.25.0 with a compatible urllib3 that supports ``SSLContext``. + Missing transport capabilities raise ``ValueError`` with upgrade instructions. + This requirement does not change support for ordinary Bearer authentication. * 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. +* ``azure_region`` is **optional**: omission honors ``MSAL_FORCE_REGION`` and + otherwise uses the global mTLS endpoint (``mtlsauth.microsoft.com``). + Set ``azure_region=False`` to guarantee global routing, or provide a region + (e.g. ``westus3``) to pin one. * The existing SN/I + Bearer (client-assertion) flow is unchanged; the same certificate can be used either as an assertion signer (Bearer) or as the TLS client certificate (mtls_pop). @@ -231,6 +237,16 @@ Notes and requirements: and SHA-256 thumbprint and can create an ``ssl.SSLContext`` or ``requests.Session`` that presents the same certificate to the resource. It is not serializable and must not be persisted. + The TLS helpers accept custom CA file/directory paths as strings or + ``os.PathLike`` objects (including ``pathlib.Path``); these replace the default + trust roots. ``True`` or ``None`` selects the Requests CA bundle, while + ``False`` disables server verification. + +mTLS-bound access tokens are persisted in a separate ``AccessToken_mTLS`` cache +bucket. Older SDK versions ignore that bucket and retain their normal Bearer +and SSH behavior. Before rolling back, remove bound tokens using the newer +MSAL version or clear the entire persisted cache. An older version's +``remove_tokens_for_client()`` does not purge an unknown mTLS bucket. .. autoclass:: msal.MtlsBindingContext :members: diff --git a/msal/application.py b/msal/application.py index 13400341..2613979f 100644 --- a/msal/application.py +++ b/msal/application.py @@ -385,6 +385,19 @@ def obtain_token_by_username_password(self, username, password, **kwargs): username, password, headers=headers, **kwargs) +def _validate_mtls_pop_response(response): + if (response and "access_token" in response + and (response.get("token_type") or "").lower() != "mtls_pop"): + return { + "error": "token_type_mismatch", + "error_description": ( + "The identity provider returned token_type {!r} instead " + "of 'mtls_pop'; the access token is not " + "certificate-bound.".format(response.get("token_type"))), + } + return response + + class _MtlsClient(_ClientWithCcsRoutingInfo): """A confidential-client OAuth2 client whose token request presents a client certificate over mutual-TLS (mTLS Proof-of-Possession). @@ -415,8 +428,9 @@ def _reinject_key_id(event): (outer or self.on_obtaining_tokens)(event) kwargs["on_obtaining_tokens"] = _reinject_key_id - return super(_MtlsClient, self).obtain_token_for_client( - scope=scope, data=data, **kwargs) + return _validate_mtls_pop_response( + super(_MtlsClient, self).obtain_token_for_client( + scope=scope, data=data, **kwargs)) def _msal_extension_check(): @@ -947,6 +961,8 @@ def get_client_assertion(): self.token_cache = token_cache or TokenCache() self._region_configured = azure_region self._region_detected = None + self._region_resolved = False + self._region_to_use = None self.client, self._regional_client = self._build_client( client_credential, self.authority) @@ -1082,6 +1098,8 @@ def _compute_region_to_use(self) -> Optional[str]: (which builds a ``{region}.mtlsauth.*`` endpoint) can reuse the exact same region resolution. """ + if self._region_resolved: + return self._region_to_use if self._region_configured is False: # User opts out of ESTS-R return None # Short circuit to completely bypass region detection if self._region_configured is None: # User did not make an ESTS-R choice @@ -1103,6 +1121,8 @@ def _compute_region_to_use(self) -> Optional[str]: region_to_use = _validate_region( region_to_use, source="azure_region parameter") logger.debug('Region to be used: {}'.format(repr(region_to_use))) + self._region_to_use = region_to_use + self._region_resolved = True return region_to_use def _get_regional_authority(self, central_authority) -> Optional[Authority]: @@ -2049,6 +2069,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it( "environment": authority.instance, "realm": authority.tenant, "home_account_id": (account or {}).get("home_account_id"), + "token_type": kwargs.get("data", {}).get("token_type") or "Bearer", } key_id = kwargs.get("data", {}).get("key_id") if key_id: # Some token types (SSH-certs, POP) are bound to a key @@ -3026,6 +3047,10 @@ def acquire_token_for_client( kwargs["data"] = kwargs.get("data", {}) _stash_client_claims(forwarded_client_claims, kwargs["data"]) if mtls_proof_of_possession: + if kwargs.get("cert") is not None: + raise ValueError( + "mtls_proof_of_possession=True does not support a cert " + "override. MSAL must present the configured binding certificate.") if "post" in kwargs: raise ValueError( "mtls_proof_of_possession=True does not support a post " @@ -3063,13 +3088,7 @@ def acquire_token_for_client( # _MtlsClient, which does not cache downgraded responses. # Mirrors the fail-closed behavior of MSAL .NET and MSAL Go. if (result.get("token_type") or "").lower() != "mtls_pop": - return { - "error": "token_type_mismatch", - "error_description": ( - "The identity provider returned token_type {!r} instead " - "of 'mtls_pop'; the access token is not " - "certificate-bound.".format(result.get("token_type"))), - } + return _validate_mtls_pop_response(result) # Attach process-local TLS capability after cache cleanup. The # private key is never added to the token result or token cache. result["binding_certificate"] = mtls.MtlsBindingContext( diff --git a/msal/mtls.py b/msal/mtls.py index 6710ae0e..a9c8d9c8 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -12,6 +12,7 @@ ``RegionAndMtlsDiscoveryProvider`` so MSAL Python stays cross-SDK consistent. """ import logging +import os import threading try: from collections.abc import Mapping @@ -140,7 +141,8 @@ def create_requests_session(self, verify=True, proxies=None): using it as a context manager. """ import requests - effective_verify = True if verify is None else verify + _require_mtls_dependencies() + effective_verify = _normalize_verify(verify) session = requests.Session() session.verify = effective_verify if proxies: @@ -187,6 +189,7 @@ class _MtlsHttpClient(object): """ def __init__(self, cert_pem, key_pem, *, verify=True, proxies=None, timeout=None): + _require_mtls_dependencies() # cert_pem / key_pem are PEM-encoded bytes. key_pem must be an # unencrypted private key (the caller normalizes it). self._cert_pem = cert_pem @@ -195,7 +198,7 @@ def __init__(self, cert_pem, key_pem, *, # verification, but requests treats session.verify=None as "no verify" # and would force CERT_NONE onto a verifying context (ValueError). None # means "default", which for a security library is verify (via certifi). - self._verify = True if verify is None else verify + self._verify = _normalize_verify(verify) self._proxies = proxies self._timeout = timeout self._session = None @@ -209,8 +212,8 @@ def _ensure_session(self, url="", verify=None): session.verify = self._verify if self._proxies: session.proxies = self._proxies - effective_verify = session.merge_environment_settings( - url, {}, None, verify, None)["verify"] + effective_verify = _normalize_verify(session.merge_environment_settings( + url, {}, None, verify, None)["verify"]) if effective_verify not in self._sessions: if self._sessions: session = requests.Session() @@ -255,7 +258,8 @@ def __init__(self, ssl_context): def send(self, request, **kwargs): if ssl_context_factory is None: return super(_MtlsHTTPAdapter, self).send(request, **kwargs) - verify = kwargs.get("verify", True) + verify = _normalize_verify(kwargs.get("verify", True)) + kwargs["verify"] = verify with self._adapter_lock: if verify not in self._adapters: self._adapters[verify] = _make_mtls_adapter( @@ -300,6 +304,42 @@ def get_connection_with_tls_context( return _MtlsHTTPAdapter(ssl_context) +def _require_mtls_dependencies(): + """Reject unsupported mTLS transports without restricting Bearer users.""" + import inspect + import re + import requests + + message = ( + "mTLS requires Requests >= 2.25.0 and a compatible urllib3 with " + "SSLContext support and Session.merge_environment_settings. " + "Upgrade with: python -m pip install --upgrade 'requests>=2.25.0,<3'") + version = re.match(r"^(\d+)\.(\d+)", requests.__version__) + if (not version or tuple(map(int, version.groups())) < (2, 25) + or not callable(getattr( + requests.Session, "merge_environment_settings", None))): + raise ValueError(message) + try: + from urllib3.connection import HTTPSConnection + from requests.adapters import PoolManager + supported = ( + "ssl_context" in inspect.signature(HTTPSConnection).parameters + and "pool_kwargs" in inspect.signature( + PoolManager.connection_from_url).parameters) + except (ImportError, AttributeError, TypeError, ValueError): + supported = False + if not supported: + raise ValueError(message) + + +def _normalize_verify(verify): + if verify is None: + return True + if isinstance(verify, (str, bytes, os.PathLike)): + return os.fsdecode(os.fspath(verify)) + return verify + + def _new_verifying_context(verify): """Build an ssl context whose server verification matches ``verify`` (the same semantics as ``requests``). A custom ssl_context otherwise bypasses @@ -309,7 +349,7 @@ def _new_verifying_context(verify): ``requests`` would raise when it tries to set CERT_NONE on this context). """ import ssl - import os + verify = _normalize_verify(verify) if verify is False: context = ssl.create_default_context() context.check_hostname = False # Must be cleared before CERT_NONE diff --git a/msal/token_cache.py b/msal/token_cache.py index be450a40..466472d6 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -13,6 +13,25 @@ logger = logging.getLogger(__name__) _GRANT_TYPE_BROKER = "broker" +_ACCESS_TOKEN_MTLS = "AccessToken_mTLS" + + +def _token_type(entry): + return (entry.get("token_type") or "Bearer").lower() + + +def _access_token_bucket(entry): + # Released readers must never enumerate an mTLS token as a bearer token. + return _ACCESS_TOKEN_MTLS if _token_type(entry) == "mtls_pop" else "AccessToken" + + +def _access_token_identity(entry): + return tuple(entry.get(field) or "" for field in ( + "home_account_id", "environment", "client_id", "realm", "ext_cache_key") + ) + ( + tuple(sorted((entry.get("target") or "").split())), + _key_id_to_str(entry["key_id"]) if entry.get("key_id") is not None else "", + _token_type(entry)) def _key_id_to_str(key_id): @@ -27,7 +46,7 @@ def _key_id_to_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): + target=None, ext_cache_key=None, key_id=None, token_type=None): components = [ home_account_id or "", environment or "", "atext" if ext_cache_key else "AccessToken", @@ -35,11 +54,11 @@ def _make_access_token_key( ] + ([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. + if (token_type or "").lower() == "mtls_pop": payload = json.dumps( components + [key_id], separators=(",", ":")) return "atkey:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + # SSH keys must remain removable by released MSAL versions. return "-".join(components) # Fields in the request data dict that should NOT be included in the extended @@ -256,16 +275,13 @@ def __init__(self): self.CredentialType.ACCESS_TOKEN: lambda home_account_id=None, environment=None, client_id=None, realm=None, target=None, - ext_cache_key=None, + ext_cache_key=None, token_type=None, # Note: New field(s) can be added here - key_id=None, # So ATs bound to different keys/certs can - # coexist (e.g. an mtls_pop AT vs a Bearer AT for the - # same app+scope). key_id is absent for Bearer, which - # keeps the Bearer cache key byte-for-byte unchanged. + key_id=None, # Separates mTLS certificate identities **ignored_payload_from_a_real_token: _make_access_token_key( home_account_id, environment, client_id, realm, target, - ext_cache_key, key_id), + ext_cache_key, key_id, token_type), self.CredentialType.ID_TOKEN: lambda home_account_id=None, environment=None, client_id=None, realm=None, **ignored_payload_from_a_real_token: @@ -296,9 +312,10 @@ def _get_access_token( ext_cache_key=None, key_id=None, default=None, + token_type=None, ): # O(1) return self._get( - self.CredentialType.ACCESS_TOKEN, + _access_token_bucket({"token_type": token_type}), self.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]( home_account_id=home_account_id, environment=environment, @@ -307,6 +324,7 @@ def _get_access_token( target=" ".join(target), ext_cache_key=ext_cache_key, key_id=key_id, + token_type=token_type, ), default=default) @@ -328,9 +346,11 @@ def _is_matching(entry: dict, query: dict, target_set: set = None) -> bool: query_with_lowercase_environment = { # __add() canonicalized entry's environment value to lower case, # so we do the same here. - k: v.lower() if k == "environment" and isinstance(v, str) else v + k: v.lower() if k in ("environment", "token_type") and isinstance(v, str) else v for k, v in query.items() } if query else {} + if "token_type" in query_with_lowercase_environment: + entry = dict(entry, token_type=_token_type(entry)) return is_subdict_of(query_with_lowercase_environment, entry) and ( target_set <= set(entry.get("target", "").split()) if target_set else True) @@ -341,18 +361,17 @@ def search(self, credential_type, target=None, query=None, *, now=None, for_remo It is O(1) for AT hits, and O(n) for other types. Note that it holds a lock during the entire search. - ``for_removal=True`` (used only by sign-out / remove_tokens_for_client) - disables the key_id/ext_cache_key isolation filters so those broad - cleanups can enumerate and delete key-bound and FMI-bound ATs. For-use - lookups must leave it False so a Bearer request never receives a - key-bound (e.g. mtls_pop) or ext-bound token, even with empty scopes. + Acquisition queries must specify ``token_type`` and, for mTLS, ``key_id``. + Queries without a token type retain public enumeration semantics, + including SSH certificates. The logical access-token view also includes + mTLS records stored separately for compatibility with released readers. + ``for_removal=True`` disables extended-key filtering. """ 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 and isinstance(query, dict) @@ -363,12 +382,12 @@ def search(self, credential_type, target=None, query=None, *, now=None, for_remo query["home_account_id"], query["environment"], query["client_id"], query["realm"], target, ext_cache_key=query.get("ext_cache_key"), - key_id=query.get("key_id")) + key_id=query.get("key_id"), + token_type=query.get("token_type")) 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"))) + and (for_removal or not preferred_result.get("ext_cache_key") + or query.get("ext_cache_key")) ): yield preferred_result @@ -380,7 +399,13 @@ def search(self, credential_type, target=None, query=None, *, now=None, for_remo # stale ATs keyed by an old key_id would stay forever. # Here we collect them for their removal. ] - for entry in self._cache.get(credential_type, {}).values(): + buckets = ( + (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS) + if credential_type == self.CredentialType.ACCESS_TOKEN + else (credential_type,)) + for entry in ( + entry for bucket in buckets + for entry in self._cache.get(bucket, {}).values()): if ( # Automatically delete expired access tokens credential_type == self.CredentialType.ACCESS_TOKEN and int(entry["expires_on"]) < now @@ -401,18 +426,6 @@ def search(self, credential_type, target=None, query=None, *, now=None, for_remo and not (query or {}).get("ext_cache_key") ): continue - # Cache isolation for key-bound tokens (e.g. mtls_pop, SSH-cert). - # An entry bound to a key_id must not satisfy a query without - # one, so a Bearer lookup never returns a PoP/mtls_pop token. - # Unconditional for for-use lookups; only removal callers - # (sign-out / remove_tokens_for_client) pass for_removal=True - # to enumerate these key-bound ATs and delete them. - if (credential_type == self.CredentialType.ACCESS_TOKEN - and not for_removal - and entry.get("key_id") - and not (query or {}).get("key_id") - ): - continue yield entry for at in expired_access_tokens: self.remove_at(at) @@ -602,20 +615,33 @@ 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, {}) + is_at = credential_type == self.CredentialType.ACCESS_TOKEN + entries = self._cache.setdefault( + _access_token_bucket(old_entry) if is_at else credential_type, {}) if new_key_value_pairs: # Update with them 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"]) + if is_at: + if entry.get("key_id") is not None: + entry["key_id"] = _key_id_to_str(entry["key_id"]) + if entry.get("key_id") or _token_type(entry) == "mtls_pop": + # Replace older bound-token keys, not other certificates + # or authentication schemes. + identity = _access_token_identity(entry) + for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS): + stored = self._cache.get(bucket, {}) + for stored_key in [ + k for k, value in stored.items() + if _access_token_identity(value) == identity]: + stored.pop(stored_key) + entries = self._cache.setdefault(_access_token_bucket(entry), {}) + key = self.key_makers[credential_type](**entry) entries[key] = entry - elif credential_type == self.CredentialType.ACCESS_TOKEN: + elif is_at: # Persisted caches may still use an older AT key format. - if entries.get(key) == old_entry: - entries.pop(key) - else: + for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS): + entries = self._cache.get(bucket, {}) for stored_key in [ k for k, entry in entries.items() if entry == old_entry]: entries.pop(stored_key) @@ -692,10 +718,29 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None): def deserialize(self, state): # type: (Optional[str]) -> None - """Deserialize the cache from a state previously obtained by serialize()""" + """Deserialize cache state, marking migrated bound-token layouts as changed.""" with self._lock: self._cache = json.loads(state) if state else {} - self.has_state_changed = False # reset + original_access_tokens = { + bucket: dict(self._cache.get(bucket, {})) + for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS)} + # Repair pre-release bound-token layouts before this state can be + # persisted again and read by a released client. + entries = self._cache.get(self.CredentialType.ACCESS_TOKEN, {}) + for stored_key, entry in sorted( + list(entries.items()) + + list(self._cache.get(_ACCESS_TOKEN_MTLS, {}).items()), + key=lambda item: int(item[1].get("cached_at", 0))): + if _token_type(entry) == "mtls_pop": + self.modify(self.CredentialType.ACCESS_TOKEN, entry, entry) + elif stored_key.startswith("atkey:") and entry.get("key_id"): + key = self.key_makers[self.CredentialType.ACCESS_TOKEN](**entry) + if (key not in entries or _access_token_identity(entries[key]) + == _access_token_identity(entry)): + self.modify(self.CredentialType.ACCESS_TOKEN, entry, entry) + self.has_state_changed = any( + original_access_tokens[bucket] != self._cache.get(bucket, {}) + for bucket in original_access_tokens) def serialize(self): # type: () -> str diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index 4918559c..17c1afc7 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -23,7 +23,7 @@ PFX_PASSPHRASE (optional) passphrase protecting the .pfx SCOPE e.g. https://graph.microsoft.com/.default RESOURCE_URL optional mTLS resource URL to call with the acquired token - AZURE_REGION (optional) e.g. westus3; omit for the global mtls endpoint + AZURE_REGION (optional) e.g. westus3; omission honors MSAL_FORCE_REGION Then run:: @@ -85,7 +85,7 @@ def vanilla_sni_mtls_pop(): client_credential=_build_cert_credential(), # Note: do NOT pass a custom http_client here. MSAL must own the # transport to present the client certificate during the TLS handshake. - azure_region=os.getenv("AZURE_REGION"), # optional; None -> global endpoint + azure_region=os.getenv("AZURE_REGION"), # None honors MSAL_FORCE_REGION ) result = app.acquire_token_for_client( diff --git a/tests/test_application.py b/tests/test_application.py index 6e508b79..eb1ce257 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2303,6 +2303,57 @@ def mock_post(url, headers=None, data=None, *a, **k): "access_token": "an_at", "expires_in": 3600, "token_type": token_type})) return mock_post + def test_certificate_override_is_rejected_before_cache_lookup(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority=self._AUTHORITY, azure_region=False) + for cached in (False, True): + with self.subTest(cached=cached): + if cached: + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([])): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + with patch.object(app, "_acquire_token_silent_with_error") as lookup: + with self.assertRaisesRegex(ValueError, "cert override"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, + cert=("other-cert.pem", "other-key.pem")) + lookup.assert_not_called() + + def test_failed_region_discovery_is_not_repeated(self): + for region in ("westus3", True): + with self.subTest(region=region), patch( + "msal.application._detect_region", return_value=None) as detect: + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority=self._AUTHORITY, azure_region=region) + first = app._get_mtls_client(app.authority) + self.assertIs(first, app._get_mtls_client(app.authority)) + self.assertEqual(1, detect.call_count) + + def test_ssh_and_mtls_with_same_key_are_isolated(self): + for first_kind in ("ssh-cert", "mtls_pop"): + with self.subTest(first_kind=first_kind): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority=self._AUTHORITY, azure_region=False) + key_id = app._get_mtls_pop_cert()["key_id"] + ssh = {"token_type": "ssh-cert", "req_cnf": "{}", "key_id": key_id} + second_kind = "mtls_pop" if first_kind == "ssh-cert" else "ssh-cert" + for kind in (first_kind, second_kind): + captured = [] + post = self._capturing_post(captured, token_type=kind) + if kind == "mtls_pop": + with patch("msal.mtls._MtlsHttpClient.post", side_effect=post): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + else: + result = app.acquire_token_for_client( + ["s1"], data=dict(ssh), post=post) + self.assertEqual(1, len(captured)) + self.assertEqual(kind, result["token_type"]) + def test_vanilla_sni_mtls_pop_request_and_result(self): app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) @@ -2468,6 +2519,10 @@ def test_token_type_downgrade_fails_closed(self): result = app.acquire_token_for_client( ["s1"], mtls_proof_of_possession=True) self.assertEqual("token_type_mismatch", result.get("error")) + self.assertIn( + "token_type_mismatch", + app._build_telemetry_context( + app.ACQUIRE_TOKEN_FOR_CLIENT_ID).generate_headers()[CLIENT_LAST_TELEMETRY]) self.assertNotIn("access_token", result) self.assertNotIn("binding_certificate", result) bound = [ @@ -2483,6 +2538,8 @@ def test_token_type_downgrade_fails_closed(self): ["s1"], post=self._capturing_post(captured, token_type="Bearer")) self.assertEqual(1, len(captured)) self.assertEqual(app._TOKEN_SOURCE_IDP, ordinary[app._TOKEN_SOURCE]) + self.assertIn( + "token_type_mismatch", captured[0]["headers"][CLIENT_LAST_TELEMETRY]) def test_missing_token_type_is_not_cached(self): app = ConfidentialClientApplication( diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 6c4f38fb..65093477 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -513,7 +513,8 @@ def test_credential_x509_output_pop(self): self.app = msal.ConfidentialClientApplication( self._SNI_ALLOWLISTED_CLIENT_ID, authority=self._SNI_ALLOWLISTED_AUTHORITY, - client_credential=cert) + client_credential=cert, + azure_region=False) result = self.app.acquire_token_for_client( self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) self.assertIn("access_token", result, "mTLS PoP request failed: %s" % result) diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index 00fafd98..00cb1af8 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -6,6 +6,7 @@ import tempfile import unittest from datetime import datetime, timedelta, timezone +from pathlib import Path from unittest.mock import patch, Mock from msal import mtls, MtlsBindingContext @@ -199,6 +200,24 @@ def test_http_client_builds_session_lazily(self): self.assertIn("https://", session.adapters) client.close() + def test_unsupported_mtls_dependencies_fail_with_upgrade_instructions(self): + binding = MtlsBindingContext(b"cert", b"key", [], "thumbprint") + unsupported = ( + patch("requests.__version__", "2.0.0"), + patch("requests.Session.merge_environment_settings", None), + patch("urllib3.connection.HTTPSConnection.__init__", + lambda self, host: None), + ) + for dependency in unsupported: + with dependency: + for factory in ( + lambda: mtls._MtlsHttpClient(b"cert", b"key"), + binding.create_requests_session): + with self.subTest(factory=factory), self.assertRaisesRegex( + ValueError, r"mTLS requires Requests >= 2\.25\.0.*" + r"python -m pip install --upgrade"): + factory() + def test_binding_context_exposes_metadata_without_key_material(self): material = self._material() binding = MtlsBindingContext( @@ -455,6 +474,30 @@ def test_environment_ca_bundles_replace_default_roots(self): self.assertEqual(ssl.CERT_NONE, unverified.get_adapter("https://")._ssl_context.verify_mode) + def test_pathlike_ca_bundle_replaces_roots_and_reuses_transport_session(self): + from cryptography.hazmat.primitives import serialization + + binding = MtlsBindingContext( + self.chain_pem, self.key_pem, + extract_certs(self.chain_pem.decode("ascii")), self.key_id) + expected = [self.root.public_bytes(serialization.Encoding.DER)] + with tempfile.TemporaryDirectory() as directory, patch.dict( + os.environ, {"REQUESTS_CA_BUNDLE": "", "CURL_CA_BUNDLE": ""}): + path = Path(directory) / "private-ca.pem" + path.write_bytes(self.root_pem) + for verify in (path, os.fsencode(path)): + with self.subTest(verify=verify): + context = binding.create_ssl_context(verify=verify) + self.assertEqual(expected, context.get_ca_certs(binary_form=True)) + client = mtls._MtlsHttpClient( + self.chain_pem, self.key_pem, verify=verify) + self.addCleanup(client.close) + session = client._ensure_session("https://mtlsauth.microsoft.com") + self.assertEqual( + expected, session.get_adapter("https://")._ssl_context + .get_ca_certs(binary_form=True)) + self.assertIs(session, client._ensure_session( + "https://mtlsauth.microsoft.com", verify=str(path))) def test_binding_session_uses_only_effective_ca_bundle(self): from cryptography.hazmat.primitives import serialization @@ -468,7 +511,7 @@ def test_binding_session_uses_only_effective_ca_bundle(self): with open(path, "wb") as stream: stream.write(self.root_pem) for setting in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", - "session", "request"): + "session", "request", "session_path", "request_path"): environment = {"REQUESTS_CA_BUNDLE": "", "CURL_CA_BUNDLE": ""} if setting in environment: environment[setting] = path @@ -482,12 +525,18 @@ def urlopen(pool, *args, **kwargs): body=io.BytesIO(b"ok"), status=200, preload_content=False) with binding.create_requests_session( - verify=path if setting == "session" else True) as session: + verify=(Path(path) if setting == "session_path" + else path if setting == "session" + else True)) as session: with patch("urllib3.connectionpool.HTTPSConnectionPool.urlopen", autospec=True, side_effect=urlopen): - options = {"verify": path} if setting == "request" else {} + options = ( + {"verify": Path(path)} if setting == "request_path" + else {"verify": path} if setting == "request" else {}) session.get("https://resource.example", **options) session.get("https://resource.example", verify=False) + if setting in ("session_path", "request_path"): + options = {"verify": path} session.get("https://resource.example", **options) self.assertEqual( [self.root.public_bytes(serialization.Encoding.DER)], diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index e8e1b9c9..70b08dc5 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -2,8 +2,11 @@ import base64 import copy import json +from pathlib import Path +import subprocess import time import warnings +from unittest.mock import Mock, patch from msal.token_cache import ( TokenCache, SerializableTokenCache, _compute_ext_cache_key, _key_id_to_str, @@ -34,7 +37,7 @@ def build_id_token( def build_response( # simulate a response from AAD uid=None, utid=None, # If present, they will form client_info - access_token=None, expires_in=3600, token_type="some type", + access_token=None, expires_in=3600, token_type="Bearer", **kwargs # Pass-through: refresh_token, foci, id_token, error, refresh_in, ... ): response = {} @@ -218,7 +221,7 @@ def testAddByAad(self): 'realm': 'contoso', 'secret': 'an access token', 'target': 's1 s2 s3', # Sorted - 'token_type': 'some type', + 'token_type': 'Bearer', } self.assertEqual(access_token_entry, self.cache._cache["AccessToken"].get( self.at_key_maker(**access_token_entry))) @@ -295,7 +298,7 @@ def testAddByAdfs(self): 'realm': 'adfs', 'secret': 'an access token', 'target': 's1 s2 s3', # Sorted - 'token_type': 'some type', + 'token_type': 'Bearer', } self.assertEqual(access_token_entry, self.cache._cache["AccessToken"].get( self.at_key_maker(**access_token_entry))) @@ -355,7 +358,8 @@ def assertFoundAccessToken(self, *, scopes, query, data=None, now=None): self.assertTrue(cached_at, "AT should be cached and searchable") return cached_at - def _test_data_should_be_saved_and_searchable_in_access_token(self, data): + def _test_data_should_be_saved_and_searchable_in_access_token( + self, data, token_type="some type"): scopes = ["s2", "s1", "s3"] # Not in particular order now = 1000 self.cache.add({ @@ -365,7 +369,7 @@ def _test_data_should_be_saved_and_searchable_in_access_token(self, data): "token_endpoint": "https://login.example.com/contoso/v2/token", "response": build_response( uid="uid", utid="utid", # client_info - expires_in=3600, access_token="an access token", + expires_in=3600, access_token="an access token", token_type=token_type, refresh_token="a refresh token"), }, now=now) self.assertFoundAccessToken(scopes=scopes, data=data, now=now, query=dict( @@ -380,12 +384,14 @@ def test_extra_data_should_also_be_recorded_and_searchable_in_access_token(self) self._test_data_should_be_saved_and_searchable_in_access_token({"key_id": "1"}) def test_access_tokens_with_different_key_id(self): - self._test_data_should_be_saved_and_searchable_in_access_token({"key_id": "1"}) - self._test_data_should_be_saved_and_searchable_in_access_token({"key_id": "2"}) + self._test_data_should_be_saved_and_searchable_in_access_token( + {"key_id": "1"}, token_type="mtls_pop") + self._test_data_should_be_saved_and_searchable_in_access_token( + {"key_id": "2"}, token_type="mtls_pop") self.assertEqual( - len(self.cache._cache["AccessToken"]), + len(list(self.cache.search("AccessToken", now=1000))), 2, """Access tokens are now isolated by key_id, so that a key-bound -token (SSH-cert or mTLS PoP) never overwrites another one bound to a different +mTLS token never overwrites another one bound to a different key, nor a plain Bearer token for the same scope. We therefore keep 2 tokens.""") # Each key-bound token remains independently searchable by its own key_id for key_id in ("1", "2"): @@ -447,7 +453,7 @@ def test_bound_scope_suffix_cannot_collide_with_unbound_scope(self): self.cache = TokenCache() for event in insertion_order: self.cache.add(event, now=1000) - self.assertEqual(2, len(self.cache._cache["AccessToken"])) + self.assertEqual(2, len(list(self.cache.search("AccessToken", now=1000)))) self.assertEqual("bearer_at", self.assertFoundAccessToken( scopes=["scope-abc"], query=query, now=1000)["secret"]) self.assertEqual("bound_at", self.assertFoundAccessToken( @@ -473,7 +479,8 @@ def test_legacy_bound_entries_are_isolated_and_removable(self): client_id="my_client_id", environment="login.example.com", realm="contoso", home_account_id=None) self.assertEqual([], list(self.cache.search( - "AccessToken", target=["s1"], query=query, now=1000))) + "AccessToken", target=["s1"], + query=dict(query, token_type="Bearer"), now=1000))) if expired: list(self.cache.search("AccessToken", now=5000)) else: @@ -502,7 +509,7 @@ def test_case_distinct_key_ids_remain_distinct_after_serialization(self): for key_id in ("Thumb", "thumb"): self.cache.add({ "scope": ["s1"], "data": {"key_id": key_id}, - "response": build_response(access_token=key_id), + "response": build_response(access_token=key_id, token_type="mtls_pop"), }, now=1000) self.cache.deserialize(self.cache.serialize()) for key_id in ("Thumb", "thumb"): @@ -528,25 +535,25 @@ def test_bearer_and_mtls_pop_tokens_coexist_and_isolate(self): uid="uid", utid="utid", expires_in=3600, access_token="mtls_at", token_type="mtls_pop")), now=now) self.assertEqual( - 2, len(self.cache._cache["AccessToken"]), + 2, len(list(self.cache.search("AccessToken", now=now))), "Bearer and mtls_pop ATs for the same scope must be separate entries") base_query = dict( client_id="my_client_id", environment="login.example.com", realm="contoso", home_account_id="uid.utid") # A Bearer lookup (no key_id) must return ONLY the Bearer token bearer = self.assertFoundAccessToken( - scopes=scopes, now=now, query=dict(base_query)) + scopes=scopes, now=now, query=dict(base_query, token_type="Bearer")) self.assertEqual("bearer_at", bearer["secret"]) self.assertEqual("Bearer", bearer["token_type"]) # A keyed lookup must return the mtls_pop token pop = self.assertFoundAccessToken( - scopes=scopes, now=now, query=dict(base_query, key_id="THUMB")) + scopes=scopes, now=now, + query=dict(base_query, key_id="THUMB", token_type="mtls_pop")) self.assertEqual("mtls_at", pop["secret"]) self.assertEqual("mtls_pop", pop["token_type"]) def test_broad_search_without_target_enumerates_key_bound_tokens(self): - # Removal paths (e.g. ClientApplication._sign_out) enumerate ATs by - # account with no target and no key_id, opting in via for_removal=True. + # Removal paths enumerate ATs by account with no target and no key_id. # Such a removal search must still surface key-bound (mtls_pop) ATs; # otherwise sign-out / remove_account would leave them behind. Regression # guard for the key_id isolation gate. @@ -564,13 +571,12 @@ def test_broad_search_without_target_enumerates_key_bound_tokens(self): owned_by_account = dict( client_id="my_client_id", environment="login.example.com", realm="contoso", home_account_id="uid.utid") - # Removal search opts in with for_removal=True -> BOTH ATs, incl. mtls_pop + # Public enumeration requires no opt-in to include both token types. found = list(self.cache.search( - TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_account, now=now, - for_removal=True)) + TokenCache.CredentialType.ACCESS_TOKEN, query=owned_by_account, now=now)) secrets = {at["secret"] for at in found} self.assertIn("mtls_at", secrets, - "A for_removal search must enumerate key-bound ATs so they can be removed") + "A public search must enumerate key-bound ATs so they can be removed") self.assertIn("bearer_at", secrets) def test_empty_scopes_for_use_lookup_does_not_leak_key_bound_token(self): @@ -592,7 +598,7 @@ def test_empty_scopes_for_use_lookup_does_not_leak_key_bound_token(self): access_token="mtls_at", token_type="mtls_pop")), now=now) base_query = dict( client_id="my_client_id", environment="login.example.com", - realm="contoso", home_account_id="uid.utid") + realm="contoso", home_account_id="uid.utid", token_type="Bearer") # Empty scopes (target=[]) + no key_id + no for_removal -> only the unkeyed AT found = list(self.cache.search( TokenCache.CredentialType.ACCESS_TOKEN, target=[], @@ -641,6 +647,256 @@ def test_old_rt_data_with_wrong_key_should_still_be_salvaged_into_new_rt(self): ) +class ReleasedCacheCompatibilityTestCase(unittest.TestCase): + + def released_cache(self): + cls = type(self) + if hasattr(cls, "ReleasedCache"): + return cls.ReleasedCache() + try: + source = subprocess.check_output( + ["git", "show", "1.39.0:msal/token_cache.py"], + cwd=str(Path(__file__).resolve().parents[1]), + stderr=subprocess.DEVNULL).decode("utf-8-sig") + application_source = subprocess.check_output( + ["git", "show", "1.39.0:msal/application.py"], + cwd=str(Path(__file__).resolve().parents[1]), + stderr=subprocess.DEVNULL).decode("utf-8-sig") + except (OSError, subprocess.CalledProcessError): + raise unittest.SkipTest("Compatibility tests require the local 1.39.0 tag") + namespace = {"__name__": "msal._released_token_cache", "__package__": "msal"} + exec(compile(source, "1.39.0:msal/token_cache.py", "exec"), namespace) + cls.ReleasedCache = namespace["SerializableTokenCache"] + application = {"__name__": "msal._released_application", "__package__": "msal"} + exec(compile(application_source, "1.39.0:msal/application.py", "exec"), application) + application["TokenCache"] = namespace["TokenCache"] + cls.ReleasedApplication = application["ConfidentialClientApplication"] + return cls.ReleasedCache() + + def setUp(self): + self.cache = SerializableTokenCache() + self.query = dict( + home_account_id=None, environment="login.example.com", + client_id="client", realm="tenant") + + def event(self, scheme, secret, key_id="Thumb", scopes=None): + return { + "client_id": "client", + "token_endpoint": "https://login.example.com/tenant/v2/token", + "scope": scopes or ["s1", "s2"], + "data": {"key_id": key_id} if key_id is not None else {}, + "response": build_response(access_token=secret, token_type=scheme), + } + + def search(self, scheme=None, key_id=None, target=None, **kwargs): + query = dict(self.query) + if scheme is not None: + query["token_type"] = scheme + if key_id is not None: + query["key_id"] = key_id + return list(self.cache.search( + "AccessToken", target=target, query=query, now=1000, **kwargs)) + + def released_application(self, cache): + app = self.ReleasedApplication.__new__(self.ReleasedApplication) + app.client_id = "client" + app.token_cache = cache + app.authority = Mock(instance="login.example.com", tenant="tenant") + app._enable_broker = False + app._build_telemetry_context = Mock() + app._get_authority_aliases = Mock(return_value=[]) + app._acquire_token_for_client = Mock(return_value={"access_token": "from-network"}) + return app + + def test_released_writer_and_reader_keep_legacy_keys_and_cleanup(self): + for scheme, key_id in (("Bearer", None), ("ssh-cert", "Thumb")): + with self.subTest(scheme=scheme): + released = self.released_cache() + event = self.event(scheme, "old", key_id=key_id) + released.add(event, now=1000) + released_keys = set(json.loads(released.serialize())["AccessToken"]) + self.cache.deserialize(released.serialize()) + self.assertFalse(self.cache.has_state_changed) + self.cache.add(self.event(scheme, "renewed", key_id=key_id), now=1000) + self.cache.add(self.event("mtls_pop", "mtls"), now=1000) + self.assertEqual( + released_keys, set(json.loads(self.cache.serialize())["AccessToken"])) + released.deserialize(self.cache.serialize()) + found = list(released.search( + "AccessToken", target=["s1"], query=self.query, now=1000)) + self.assertEqual(["renewed"], [entry["secret"] for entry in found]) + with patch("time.time", return_value=1000): + self.released_application(released).remove_tokens_for_client() + self.assertEqual([], list(released.search( + "AccessToken", query=self.query, now=1000))) + self.cache.deserialize(released.serialize()) + self.assertEqual(["mtls"], [ + entry["secret"] for entry in self.search("mtls_pop", "Thumb")]) + + def test_released_ordinary_lookups_cannot_return_mtls(self): + self.cache.add(self.event("mTlS_pOp", "mtls"), now=1000) + released = self.released_cache() + for with_bearer in (False, True): + if with_bearer: + self.cache.add(self.event("Bearer", "bearer", key_id=None), now=1000) + released.deserialize(self.cache.serialize()) + for target in (["s1", "s2"], ["s1"], []): + for query in (self.query, {"client_id": "client"}): + with self.subTest(bearer=with_bearer, target=target, query=query): + self.assertEqual( + ["bearer"] if with_bearer else [], + [entry["secret"] for entry in released.search( + "AccessToken", target=target, query=query, now=1000)]) + + def test_actual_released_ordinary_acquisition_cannot_use_mtls(self): + self.cache.add(self.event("mtls_pop", "mtls"), now=1000) + for with_bearer in (False, True): + if with_bearer: + self.cache.add(self.event("Bearer", "bearer", key_id=None), now=1000) + released = self.released_cache() + released.deserialize(self.cache.serialize()) + for target in (["s1", "s2"], ["s1"], []): + app = self.released_application(released) + with patch("time.time", return_value=1000): + result = app._acquire_token_silent_from_cache_and_possibly_refresh_it( + target, None, app.authority) + self.assertEqual( + "bearer" if with_bearer else "from-network", result["access_token"]) + self.assertEqual(not with_bearer, app._acquire_token_for_client.called) + + def test_released_ssh_renewal_and_subset_lookup_have_no_stale_duplicate(self): + released = self.released_cache() + released.add(self.event("ssh-cert", "old"), now=1000) + self.cache.deserialize(released.serialize()) + self.cache.add(self.event("SSH-CERT", "renewed"), now=1001) + for target in (["s1", "s2"], ["s1"], []): + self.assertEqual(["renewed"], [ + entry["secret"] for entry in self.search( + "sSh-CeRt", "Thumb", target=target)]) + self.assertEqual(1, len(json.loads(self.cache.serialize())["AccessToken"])) + + def test_public_ssh_enumeration_and_cleanup_need_no_key_id_or_flag(self): + self.cache.add(self.event("SSH-CERT", "ssh"), now=1000) + self.cache.add(self.event("mtls_pop", "mtls"), now=1000) + for target in (["s1", "s2"], ["s1"], []): + self.assertEqual(["ssh"], [ + entry["secret"] for entry in self.search("ssh-cert", target=target)]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + self.assertEqual(["ssh"], [entry["secret"] for entry in self.cache.find( + "AccessToken", target=target, + query=dict(self.query, token_type="ssh-cert"), now=1000)]) + found = self.search() + self.assertEqual({"ssh", "mtls"}, {entry["secret"] for entry in found}) + for entry in found: + self.cache.remove_at(entry) + self.assertEqual([], self.search()) + self.assertEqual([], self.search(for_removal=True)) + + def test_same_key_id_isolated_by_scheme_in_both_insertion_orders(self): + events = [self.event("sSh-CeRt", "ssh"), self.event("MtLs_PoP", "mtls")] + for ordered in (events, list(reversed(events))): + self.cache = SerializableTokenCache() + for event in ordered: + self.cache.add(event, now=1000) + self.cache.deserialize(self.cache.serialize()) + for target in (["s1", "s2"], ["s1"], []): + for scheme, secret in (("SSH-CERT", "ssh"), ("MTLS_POP", "mtls")): + self.assertEqual([secret], [ + entry["secret"] for entry in self.search( + scheme, "Thumb", target=target)]) + self.assertEqual([], self.search("Bearer", target=target)) + self.assertEqual(["mtls"], [ + entry["secret"] for entry in self.search("mtls_pop", target=target)]) + self.cache.remove_at(self.search("ssh-cert")[0]) + self.assertEqual(["mtls"], [ + entry["secret"] for entry in self.search("mtls_pop", "Thumb")]) + + def test_missing_legacy_token_type_matches_bearer_case_insensitively(self): + released = self.released_cache() + released.add(self.event("Bearer", "bearer", key_id=None), now=1000) + state = json.loads(released.serialize()) + for entry in state["AccessToken"].values(): + entry.pop("token_type") + self.cache.deserialize(json.dumps(state)) + for target in (["s1", "s2"], ["s1"], []): + self.assertEqual(["bearer"], [ + entry["secret"] for entry in self.search("bEaReR", target=target)]) + self.assertEqual([], self.search("ssh-cert", target=target)) + + def test_pre_fix_serialized_state_migrates_and_renews_without_losing_certs(self): + released = self.released_cache() + released.add(self.event("ssh-cert", "ssh-old"), now=1000) + ssh = next(iter(json.loads(released.serialize())["AccessToken"].values())) + mtls = dict(ssh, token_type="mtls_pop", secret="mtls-old") + other_cert = dict(mtls, key_id="thumb", secret="other-cert") + newer_mtls = dict(mtls, cached_at="1002", secret="mtls-newer") + self.cache.deserialize(json.dumps({ + "AccessToken": { + "atkey:ssh": ssh, "atkey:mtls": mtls, "atkey:other": other_cert}, + "AccessToken_mTLS": {"atkey:newer": newer_mtls}, + })) + state = json.loads(self.cache.serialize()) + self.assertEqual( + {released.key_makers["AccessToken"](**ssh)}, set(state["AccessToken"])) + reader = self.released_cache() + reader.deserialize(self.cache.serialize()) + self.assertEqual(["ssh-old"], [entry["secret"] for entry in reader.search( + "AccessToken", target=["s1"], query=self.query, now=1000)]) + self.assertEqual(["mtls-newer"], [ + entry["secret"] for entry in self.search("mtls_pop", "Thumb")]) + self.cache.add(self.event("SSH-CERT", "ssh-renewed"), now=1003) + self.cache.add(self.event("MTLS_POP", "mtls-renewed"), now=1003) + for target in (["s1", "s2"], ["s1"], []): + self.assertEqual(["ssh-renewed"], [ + entry["secret"] for entry in self.search( + "ssh-cert", "Thumb", target=target)]) + self.assertEqual(["mtls-renewed"], [ + entry["secret"] for entry in self.search( + "mtls_pop", "Thumb", target=target)]) + self.assertEqual(["other-cert"], [ + entry["secret"] for entry in self.search( + "mtls_pop", "thumb", target=target)]) + self.assertEqual(3, len(self.search())) + self.assertEqual([], list(self.cache.search("AccessToken", now=5000))) + self.assertEqual([], self.search()) + + def test_native_cache_renewal_replaces_noncanonical_bound_keys(self): + for scheme in ("ssh-cert", "mtls_pop"): + with self.subTest(scheme=scheme): + self.cache = TokenCache() + self.cache.add(self.event(scheme, "old"), now=1000) + old = self.search(scheme, "Thumb")[0] + self.cache._cache["AccessToken"] = {"atkey:old": old} + self.cache.add(self.event("mtls_pop", "other", key_id="thumb"), now=1000) + self.cache.add(self.event(scheme, "renewed"), now=1001) + self.assertEqual(["renewed"], [ + entry["secret"] for entry in self.search( + scheme, "Thumb", target=["s1"])]) + self.assertEqual(["other"], [ + entry["secret"] for entry in self.search( + "mtls_pop", "thumb", target=["s1"])]) + self.assertEqual(2, len(self.search())) + + def test_deserialize_marks_migration_dirty_until_persisted(self): + for scheme in ("ssh-cert", "mtls_pop"): + with self.subTest(scheme=scheme): + self.cache = SerializableTokenCache() + self.cache.add(self.event(scheme, "cached"), now=1000) + entry = self.search(scheme, "Thumb")[0] + self.cache.deserialize(json.dumps({ + "AccessToken": {"atkey:pre-fix": entry}})) + self.assertTrue(self.cache.has_state_changed) + self.assertEqual(["cached"], [ + item["secret"] for item in self.search( + scheme, "Thumb", target=["s1", "s2"])]) + self.assertTrue(self.cache.has_state_changed) + persisted = self.cache.serialize() + self.assertFalse(self.cache.has_state_changed) + self.cache.deserialize(persisted) + self.assertFalse(self.cache.has_state_changed) + + class SerializableTokenCacheTestCase(unittest.TestCase): # Run all inherited test methods, and have extra check in tearDown() From 6f34f2e26fd18df52e85a7ee81b973f21c9819c8 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 12:13:42 -0400 Subject: [PATCH 15/27] Avoid cache persistence reentry and quadratic migration cleanup Migrate bound-token records in one in-memory pass without invoking persistence hooks. Restore checked canonical deletion with legacy fallback, and normalize CA paths before fixed-context sends. Cover MSAL Extensions reload, bounded cache work, duplicate cleanup, and actual token-transport sends. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- msal/mtls.py | 4 +- msal/token_cache.py | 49 ++++++++------ tests/test_mtls_transport.py | 37 +++++++++++ tests/test_token_cache.py | 125 +++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 22 deletions(-) diff --git a/msal/mtls.py b/msal/mtls.py index a9c8d9c8..44fda7ff 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -256,10 +256,10 @@ def __init__(self, ssl_context): super(_MtlsHTTPAdapter, self).__init__(max_retries=1) def send(self, request, **kwargs): - if ssl_context_factory is None: - return super(_MtlsHTTPAdapter, self).send(request, **kwargs) verify = _normalize_verify(kwargs.get("verify", True)) kwargs["verify"] = verify + if ssl_context_factory is None: + return super(_MtlsHTTPAdapter, self).send(request, **kwargs) with self._adapter_lock: if verify not in self._adapters: self._adapters[verify] = _make_mtls_adapter( diff --git a/msal/token_cache.py b/msal/token_cache.py index 466472d6..f38d5713 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -639,12 +639,15 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None): key = self.key_makers[credential_type](**entry) entries[key] = entry elif is_at: - # Persisted caches may still use an older AT key format. - for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS): - entries = self._cache.get(bucket, {}) - for stored_key in [ - k for k, entry in entries.items() if entry == old_entry]: - entries.pop(stored_key) + if entries.get(key) == old_entry: + entries.pop(key) + else: + # Persisted caches may still use an older AT key format. + for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS): + entries = self._cache.get(bucket, {}) + for stored_key in [ + k for k, entry in entries.items() if entry == old_entry]: + entries.pop(stored_key) else: # Remove old_entry entries.pop(key, None) @@ -724,20 +727,26 @@ def deserialize(self, state): original_access_tokens = { bucket: dict(self._cache.get(bucket, {})) for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS)} - # Repair pre-release bound-token layouts before this state can be - # persisted again and read by a released client. - entries = self._cache.get(self.CredentialType.ACCESS_TOKEN, {}) - for stored_key, entry in sorted( - list(entries.items()) - + list(self._cache.get(_ACCESS_TOKEN_MTLS, {}).items()), - key=lambda item: int(item[1].get("cached_at", 0))): - if _token_type(entry) == "mtls_pop": - self.modify(self.CredentialType.ACCESS_TOKEN, entry, entry) - elif stored_key.startswith("atkey:") and entry.get("key_id"): - key = self.key_makers[self.CredentialType.ACCESS_TOKEN](**entry) - if (key not in entries or _access_token_identity(entries[key]) - == _access_token_identity(entry)): - self.modify(self.CredentialType.ACCESS_TOKEN, entry, entry) + # Migrate in memory: overridable modify() hooks may reload persistence. + bound_entries = {} + for bucket, entries in original_access_tokens.items(): + for stored_key, entry in entries.items(): + if entry.get("key_id") or _token_type(entry) == "mtls_pop": + bound_entries.setdefault(_access_token_identity(entry), []).append( + (bucket, stored_key, entry)) + for identity, records in bound_entries.items(): + entry = max( + reversed(records), # Prefer the later entry when timestamps tie. + key=lambda record: int(record[2].get("cached_at", 0)))[2] + bucket = _access_token_bucket(entry) + entries = self._cache.setdefault(bucket, {}) + key = self.key_makers[self.CredentialType.ACCESS_TOKEN](**entry) + if (bucket != _ACCESS_TOKEN_MTLS and key in entries + and _access_token_identity(entries[key]) != identity): + continue # Preserve a different SSH key already using its legacy slot. + for old_bucket, stored_key, _ in records: + self._cache[old_bucket].pop(stored_key, None) + entries[key] = entry self.has_state_changed = any( original_access_tokens[bucket] != self._cache.get(bucket, {}) for bucket in original_access_tokens) diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index 00cb1af8..d30dfc4d 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -499,6 +499,43 @@ def test_pathlike_ca_bundle_replaces_roots_and_reuses_transport_session(self): self.assertIs(session, client._ensure_session( "https://mtlsauth.microsoft.com", verify=str(path))) + def test_token_transport_normalizes_per_request_ca_paths_before_send(self): + from cryptography.hazmat.primitives import serialization + from urllib3.response import HTTPResponse + + expected = [self.root.public_bytes(serialization.Encoding.DER)] + with tempfile.TemporaryDirectory() as directory, patch.dict( + os.environ, {"REQUESTS_CA_BUNDLE": "", "CURL_CA_BUNDLE": ""}): + path = Path(directory) / "private-ca.pem" + path.write_bytes(self.root_pem) + for method in ("get", "post"): + for verify in (path, os.fsencode(path)): + with self.subTest(method=method, verify=verify): + observed = [] + + def urlopen(pool, *args, **kwargs): + self.assertIsInstance(pool.ca_certs, str) + self.assertEqual(str(path), pool.ca_certs) + context = pool.conn_kw["ssl_context"] + context.load_verify_locations(cafile=pool.ca_certs) + self.assertEqual( + expected, context.get_ca_certs(binary_form=True)) + observed.append(context) + return HTTPResponse( + body=io.BytesIO(b"ok"), status=200, preload_content=False) + + client = mtls._MtlsHttpClient(self.chain_pem, self.key_pem) + try: + with patch( + "urllib3.connectionpool.HTTPSConnectionPool.urlopen", + autospec=True, side_effect=urlopen): + response = getattr(client, method)( + "https://mtlsauth.microsoft.com/token", verify=verify) + self.assertEqual(200, response.status_code) + self.assertEqual(1, len(observed)) + finally: + client.close() + def test_binding_session_uses_only_effective_ca_bundle(self): from cryptography.hazmat.primitives import serialization from urllib3.response import HTTPResponse diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 70b08dc5..587dc09f 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -4,6 +4,7 @@ import json from pathlib import Path import subprocess +import tempfile import time import warnings from unittest.mock import Mock, patch @@ -896,6 +897,130 @@ def test_deserialize_marks_migration_dirty_until_persisted(self): self.cache.deserialize(persisted) self.assertFalse(self.cache.has_state_changed) + def test_deserialize_does_not_invoke_persistence_hooks(self): + class PersistenceHookCache(SerializableTokenCache): + def modify(self, *args, **kwargs): + raise AssertionError("deserialize reentered a persistence hook") + + for scheme in ("ssh-cert", "mtls_pop"): + self.cache = SerializableTokenCache() + self.cache.add(self.event(scheme, "cached"), now=1000) + canonical = self.cache.serialize() + entry = self.search(scheme, "Thumb")[0] + for state in (canonical, json.dumps({ + "AccessToken": {"atkey:old": entry}})): + with self.subTest(scheme=scheme, state=state): + cache = PersistenceHookCache() + cache.deserialize(state) + self.assertEqual(["cached"], [ + item["secret"] for item in cache.search( + "AccessToken", query={"token_type": scheme}, now=1000)]) + + def test_persisted_cache_reloads_canonical_and_legacy_bound_tokens(self): + try: + from msal_extensions import FilePersistence, PersistedTokenCache + except ImportError: + self.skipTest("MSAL Extensions is not installed") + + for scheme in ("ssh-cert", "mtls_pop"): + self.cache = SerializableTokenCache() + self.cache.add(self.event(scheme, "cached"), now=1000) + canonical = self.cache.serialize() + entry = self.search(scheme, "Thumb")[0] + for state in (canonical, json.dumps({ + "AccessToken": {"atkey:old": entry}})): + with self.subTest(scheme=scheme, state=state): + with tempfile.TemporaryDirectory() as directory: + persistence = FilePersistence(str(Path(directory) / "cache")) + persistence.save(state) + cache = PersistedTokenCache(persistence) + with patch.object(cache, "modify", side_effect=AssertionError( + "reload must not acquire a persistence lock")): + found = list(cache.search( + "AccessToken", query={"token_type": scheme}, now=1000)) + self.assertEqual(["cached"], [item["secret"] for item in found]) + cache.remove_at(found[0]) + reloaded = PersistedTokenCache(persistence) + self.assertEqual([], list(reloaded.search( + "AccessToken", now=1000))) + + def test_mtls_reload_identity_work_is_linear(self): + from msal import token_cache + + self.cache.add(self.event("mtls_pop", "cached"), now=1000) + sample = self.search("mtls_pop", "Thumb")[0] + for legacy in (False, True): + for count in (50, 100): + entries = {} + for i in range(count): + entry = dict(sample, target="scope{}".format(i)) + key = ("atkey:old{}".format(i) if legacy else + self.cache.key_makers["AccessToken"](**entry)) + entries[key] = entry + state = json.dumps({ + "AccessToken" if legacy else "AccessToken_mTLS": entries}) + with self.subTest(legacy=legacy, count=count), patch.object( + token_cache, "_access_token_identity", + wraps=token_cache._access_token_identity) as identity: + cache = SerializableTokenCache() + cache.deserialize(state) + self.assertLessEqual(identity.call_count, 4 * count) + self.assertEqual(legacy, cache.has_state_changed) + self.assertEqual(count, len(list(cache.search( + "AccessToken", now=1000)))) + + def test_canonical_expiration_and_bulk_removal_do_not_scan_per_token(self): + class CountingEntries(dict): + scanned = 0 + + def items(self): + for item in super().items(): + type(self).scanned += 1 + yield item + + for scheme in ("Bearer", "mtls_pop"): + self.cache = SerializableTokenCache() + self.cache.add(self.event(scheme, "cached"), now=1000) + sample = self.search(scheme, "Thumb")[0] + for expired in (False, True): + cache = TokenCache() + entries = CountingEntries() + for i in range(100): + entry = dict(sample, target="scope{}".format(i)) + entries[cache.key_makers["AccessToken"](**entry)] = entry + bucket = "AccessToken_mTLS" if scheme == "mtls_pop" else "AccessToken" + cache._cache[bucket] = entries + CountingEntries.scanned = 0 + with self.subTest(scheme=scheme, expired=expired): + found = list(cache.search( + "AccessToken", now=5000 if expired else 1000)) + for entry in found: + cache.remove_at(entry) + self.assertEqual({}, entries) + self.assertLessEqual(CountingEntries.scanned, 300) + + def test_removal_preserves_replacements_and_cleans_legacy_duplicates(self): + for scheme in ("ssh-cert", "mtls_pop"): + self.cache = SerializableTokenCache() + self.cache.add(self.event(scheme, "old"), now=1000) + old = self.search(scheme, "Thumb")[0] + canonical = json.loads(self.cache.serialize()) + canonical.setdefault("AccessToken", {})["atkey:duplicate"] = old + self.cache.deserialize(json.dumps(canonical)) + self.assertEqual(1, len(self.search(scheme, "Thumb"))) + self.cache.remove_at(old) + self.assertEqual([], self.search()) + + cache = TokenCache() + cache._cache["AccessToken"] = {"old1": old, "old2": dict(old)} + cache.remove_at(old) + self.assertEqual([], list(cache.search("AccessToken", now=1000))) + + self.cache.add(self.event(scheme, "renewed"), now=1001) + self.cache.remove_at(old) + self.assertEqual(["renewed"], [ + entry["secret"] for entry in self.search(scheme, "Thumb")]) + class SerializableTokenCacheTestCase(unittest.TestCase): # Run all inherited test methods, and have extra check in tearDown() From 2de965fe22fcf5ed1419441d05bf6d8137b3929e Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 12:47:53 -0400 Subject: [PATCH 16/27] Reject mixed-case raw mTLS token types before cache lookup Normalize the raw token scheme before applying the mTLS opt-in guard. Cover lowercase, uppercase, and mixed-case inputs for certificate and secret-only clients using cold, Bearer, and mTLS-populated caches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 3 ++- tests/test_application.py | 45 ++++++++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/msal/application.py b/msal/application.py index 2613979f..4b6d9ff5 100644 --- a/msal/application.py +++ b/msal/application.py @@ -3028,8 +3028,9 @@ def acquire_token_for_client( raise ValueError( # We choose to disallow force_refresh "Historically, this method does not support force_refresh behavior. " ) + requested_token_type = (kwargs.get("data") or {}).get("token_type") if (not mtls_proof_of_possession - and (kwargs.get("data") or {}).get("token_type") == "mtls_pop"): + and (requested_token_type or "").lower() == "mtls_pop"): raise ValueError( "Request mTLS PoP using mtls_proof_of_possession=True, " "not data['token_type'].") diff --git a/tests/test_application.py b/tests/test_application.py index eb1ce257..cf9d4dad 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2452,22 +2452,37 @@ def test_certificate_failure_does_not_throttle_replacement(self): second._get_mtls_pop_cert()["key_id"]) def test_raw_mtls_token_type_is_rejected_before_cache_lookup(self): - app = ConfidentialClientApplication( - "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) - for cached in (False, True): - with self.subTest(cached=cached): - if cached: - app.acquire_token_for_client( - ["s1"], post=self._capturing_post([], token_type="Bearer")) - with patch.object(app, "_acquire_token_silent_with_error") as lookup: - with self.assertRaisesRegex( - ValueError, "mtls_proof_of_possession=True"): - app.acquire_token_for_client( - ["s1"], data={"token_type": "mtls_pop"}) - lookup.assert_not_called() - captured = [] + for credential in (_MTLS_CERT_CRED, "a_secret"): + for cached_type in (None, "Bearer", "mtls_pop"): + producer = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority=self._AUTHORITY, azure_region=False) + if cached_type == "mtls_pop": + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([])): + producer.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + elif cached_type: + producer.acquire_token_for_client( + ["s1"], post=self._capturing_post([], token_type=cached_type)) + app = ConfidentialClientApplication( + "cid", client_credential=credential, authority=self._AUTHORITY, + azure_region=False, token_cache=producer.token_cache) + for token_type in ("mtls_pop", "MTLS_POP", "mTlS_pOp"): + with self.subTest( + certificate=isinstance(credential, dict), + cached_type=cached_type, token_type=token_type): + with patch.object( + app, "_acquire_token_silent_with_error", + wraps=app._acquire_token_silent_with_error) as lookup: + with self.assertRaisesRegex( + ValueError, "mtls_proof_of_possession=True"): + app.acquire_token_for_client( + ["s1"], data={"token_type": token_type}, + post=self._capturing_post([], token_type="Bearer")) + lookup.assert_not_called() ordinary = app.acquire_token_for_client( - ["s1"], post=self._capturing_post(captured, token_type="Bearer")) + ["s1"], post=self._capturing_post([], token_type="Bearer")) self.assertEqual("Bearer", ordinary["token_type"]) self.assertNotIn("binding_certificate", ordinary) From dd0739350333be41f18d9c40385d38a1c8bdbde2 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 13:04:19 -0400 Subject: [PATCH 17/27] Reject redirects in the owned mTLS token transport Disable redirects even when callers request them and reject all HTTP 3xx responses before token parsing or caching. Keep resource-session behavior unchanged and cover no-follow/no-cache handling through the acquisition API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 2 ++ msal/mtls.py | 21 +++++++++++++++++---- tests/test_application.py | 32 ++++++++++++++++++++++++++++++++ tests/test_mtls_transport.py | 14 ++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 83a837db..bc844db4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -214,6 +214,8 @@ Notes and requirements: * MSAL must own the TLS transport, so a custom ``http_client`` is not supported together with ``mtls_proof_of_possession=True``. Per-request ``post`` and ``cert`` overrides are also rejected. + The owned mTLS token transport never follows redirects; HTTP 3xx responses + raise ``requests.exceptions.HTTPError`` and are not cached as tokens. * mTLS transport and ``binding.create_requests_session()`` require Requests >= 2.25.0 with a compatible urllib3 that supports ``SSLContext``. Missing transport capabilities raise ``ValueError`` with upgrade instructions. diff --git a/msal/mtls.py b/msal/mtls.py index 44fda7ff..67b2e31b 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -228,14 +228,27 @@ def _ensure_session(self, url="", verify=None): 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(url, kwargs.get("verify")).post(url, **kwargs) + return self._request("POST", url, **kwargs) def get(self, url, **kwargs): + return self._request("GET", url, **kwargs) + + def _request(self, method, url, **kwargs): + from requests.exceptions import HTTPError + if self._timeout is not None: kwargs.setdefault("timeout", self._timeout) - return self._ensure_session(url, kwargs.get("verify")).get(url, **kwargs) + # The certificate and token request must stay at the selected authority. + kwargs["allow_redirects"] = False + response = self._ensure_session(url, kwargs.get("verify")).request( + method, url, **kwargs) + if 300 <= response.status_code < 400: + response.close() + raise HTTPError( + "mTLS token transport does not support HTTP redirects " + "(status {}).".format(response.status_code), + response=response) + return response def close(self): with self._session_lock: diff --git a/tests/test_application.py b/tests/test_application.py index cf9d4dad..03c1607a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2566,6 +2566,38 @@ def test_missing_token_type_is_not_cached(self): self.assertEqual("token_type_mismatch", result["error"]) self.assertEqual({}, app.token_cache._cache.get("AccessToken", {})) + def test_mtls_redirect_is_not_followed_or_cached(self): + import io + from requests.exceptions import HTTPError + from urllib3.response import HTTPResponse + + for status in (300, 301, 302, 303, 304, 307, 308, 399): + with self.subTest(status=status): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority=self._AUTHORITY, azure_region=False) + self.addCleanup(app._get_mtls_client(app.authority)._http_client.close) + response = HTTPResponse( + body=io.BytesIO(b"" if status == 304 else json.dumps({ + "access_token": "must_not_be_cached", + "token_type": "mtls_pop", "expires_in": 3600, + }).encode("utf-8")), + status=status, + headers={"Location": "https://other.example/token"}, + preload_content=False) + with patch( + "urllib3.connectionpool.HTTPSConnectionPool.urlopen", + autospec=True, return_value=response) as send: + with self.assertRaisesRegex( + HTTPError, "does not support HTTP redirects") as error: + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, + allow_redirects=True) + self.assertEqual(status, error.exception.response.status_code) + self.assertEqual(1, send.call_count) + self.assertEqual([], list(app.token_cache.search( + msal.TokenCache.CredentialType.ACCESS_TOKEN, for_removal=True))) + def test_regional_mtls_endpoint(self): app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY, diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index d30dfc4d..e77aa4dc 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -200,6 +200,20 @@ def test_http_client_builds_session_lazily(self): self.assertIn("https://", session.adapters) client.close() + def test_owned_transport_disables_redirects_even_with_override(self): + client = mtls._MtlsHttpClient(b"cert", b"key", timeout=30) + session = Mock() + session.request.return_value = Mock(status_code=200) + with patch.object(client, "_ensure_session", return_value=session): + for method in ("get", "post"): + with self.subTest(method=method): + response = getattr(client, method)( + "https://mtlsauth.microsoft.com/token", allow_redirects=True) + self.assertIs(session.request.return_value, response) + session.request.assert_called_with( + method.upper(), "https://mtlsauth.microsoft.com/token", + allow_redirects=False, timeout=30) + def test_unsupported_mtls_dependencies_fail_with_upgrade_instructions(self): binding = MtlsBindingContext(b"cert", b"key", [], "thumbprint") unsupported = ( From 1fceb2fa4b9d6dbd72d3e5e8d7281fc4b4b34b56 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 15:12:16 -0400 Subject: [PATCH 18/27] Fail required certificate E2E scenarios on missing lab configuration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .Pipelines/CI-AND-RELEASE-PIPELINES.md | 2 +- .Pipelines/template-pipeline-stages.yml | 8 +-- azure-pipelines.yml | 3 +- tests/README.md | 17 ++++++ tests/lab_config.py | 12 +++- tests/test_e2e.py | 17 +++--- tests/test_lab_config.py | 74 +++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 tests/test_lab_config.py diff --git a/.Pipelines/CI-AND-RELEASE-PIPELINES.md b/.Pipelines/CI-AND-RELEASE-PIPELINES.md index d19ec5fb..59df0da9 100644 --- a/.Pipelines/CI-AND-RELEASE-PIPELINES.md +++ b/.Pipelines/CI-AND-RELEASE-PIPELINES.md @@ -41,7 +41,7 @@ PreBuildCheck ─► UnitTests ─► E2ETests ─► Benchmark (post-merge to d |-------|-------------|-------------| | **PreBuildCheck** | Runs SDL security scans: PoliCheck (policy/offensive content), CredScan (leaked credentials), and PostAnalysis (breaks the build on findings) | Always | | **UnitTests** | Runs the unit test suite on Python 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14 (no Key Vault required) | After PreBuildCheck | -| **E2ETests** | Fetches the MSID Lab certificate from Key Vault and runs `tests/test_e2e.py` + `tests/test_fmi_e2e.py` on the same Python matrix. On forked PRs the stage still runs, but the Key Vault tasks are skipped and the E2E tests self-skip (because `LAB_APP_CLIENT_CERT_PFX_PATH` is unset), so the stage reports green with all E2E tests marked Skipped in the Tests tab. | After UnitTests | +| **E2ETests** | Fetches the MSID Lab certificate from Key Vault and runs `tests/test_e2e.py` + `tests/test_fmi_e2e.py` on the same Python matrix. On forked PRs the Key Vault tasks remain skipped to protect secrets. Required certificate scenarios fail when `LAB_APP_CLIENT_CERT_PFX_PATH` is missing or invalid; missing credentials cannot produce green skipped coverage. Legacy optional skips are unchanged. | After UnitTests | | **Benchmark** | Runs performance benchmarks on Python 3.9 and publishes `benchmark-results` artifact | Post-merge pushes to `dev` and manual runs only | The `Validate` stage is **skipped** on PR/CI runs (it only applies to release builds). diff --git a/.Pipelines/template-pipeline-stages.yml b/.Pipelines/template-pipeline-stages.yml index dcc2870a..1f2077fa 100644 --- a/.Pipelines/template-pipeline-stages.yml +++ b/.Pipelines/template-pipeline-stages.yml @@ -184,10 +184,10 @@ stages: # certificate from Key Vault. # Fork behaviour: the stage still runs on forked PRs, but the # Key Vault retrieval and certificate decoding steps are skipped -# via `ne(System.PullRequest.IsFork, 'True')`. The pytest step then -# self-skips each test because LAB_APP_CLIENT_CERT_PFX_PATH is unset -# (see tests/test_e2e.py). Result: green stage on forks with all -# E2E tests reported as Skipped in the Tests tab. +# via `ne(System.PullRequest.IsFork, 'True')`. Required certificate +# scenarios then fail because LAB_APP_CLIENT_CERT_PFX_PATH is unset; +# missing credentials must not produce green skipped E2E coverage. +# Legacy optional scenarios retain their existing skips. # ═══════════════════════════════════════════════════════════════════════════ - stage: E2ETests displayName: 'E2E tests' diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 58b821f2..39afd64c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,6 +1,6 @@ ################################################################################# # OneBranch Pipelines - PR Gate and CI # -# Runs on pushes to dev, on all PRs to dev, and on a daily schedule. # +# Runs on dev pushes, PRs to dev or the mTLS PoP base, and a daily schedule. # # Documentation: https://aka.ms/obpipelines # # Yaml Schema: https://aka.ms/obpipelines/yaml/schema # ################################################################################# @@ -14,6 +14,7 @@ pr: branches: include: - dev + - rginsburg/sni-mtls-pop drafts: false schedules: diff --git a/tests/README.md b/tests/README.md index 0b044a60..293073dc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,6 +33,9 @@ LAB_APP_CLIENT_CERT_PFX_PATH=C:/path/to/your/cert.pfx Notes: - `tests/test_e2e.py` loads `.env` automatically when `python-dotenv` is installed. - For certificate auth, `LAB_APP_CLIENT_CERT_PFX_PATH` should be an absolute path. +- Required certificate scenarios use the exportable `LabAuth.MSIDLab.com` PFX + from the MSID Lab `LabAuth` secret, without a PFX password. Missing, unreadable, + or invalid PFX files fail these scenarios; they never count as skipped coverage. ## 4) Run unit/integration tests @@ -48,6 +51,20 @@ Run full E2E unattended suite: python -m pytest -q tests/test_e2e.py ``` +Run only the required certificate scenarios: + +```powershell +python -m pytest -q tests/test_e2e.py::CertificateScenariosTestCase +``` + +These cover ordinary SN/I certificate Bearer authentication, global X509 mTLS +PoP acquisition with a certificate-bound Graph resource call and cache reuse, +and ordinary certificate Bearer authentication in `westus3`. The X509 matrix +uses client `163ffef9-a313-45b4-ab2f-c7e2f5e0e23e` in tenant +`bea21ebe-8b64-4d06-9f6d-6a889b120a7c`, matching the .NET and Go lab setup. +They do not require fetching unrelated user configuration from Key Vault. +Legacy interactive and other optional E2E skips are unchanged. + ## 5) Manual-intervention E2E tests Manual tests (interactive browser/device-flow/POP manual scenarios) are separated into: diff --git a/tests/lab_config.py b/tests/lab_config.py index 8b692e3f..25138bd6 100644 --- a/tests/lab_config.py +++ b/tests/lab_config.py @@ -31,6 +31,7 @@ from azure.identity import CertificateCredential from azure.keyvault.secrets import SecretClient +from cryptography.hazmat.primitives.serialization import pkcs12 logger = logging.getLogger(__name__) @@ -411,7 +412,8 @@ def get_client_certificate() -> Dict[str, object]: } Raises: - EnvironmentError: If LAB_APP_CLIENT_CERT_PFX_PATH is not set. + EnvironmentError: If LAB_APP_CLIENT_CERT_PFX_PATH is not set or unreadable. + ValueError: If the PFX is invalid or lacks a private key or certificate. """ cert_path = clean_env("LAB_APP_CLIENT_CERT_PFX_PATH") if not cert_path: @@ -419,7 +421,13 @@ def get_client_certificate() -> Dict[str, object]: "LAB_APP_CLIENT_CERT_PFX_PATH environment variable is required " "for certificate authentication" ) - + with open(cert_path, "rb") as cert_file: + private_key, certificate, _ = pkcs12.load_key_and_certificates( + cert_file.read(), None) + if private_key is None or certificate is None: + raise ValueError( + "LAB_APP_CLIENT_CERT_PFX_PATH must contain a private key and certificate") + logger.debug("Using client certificate from: %s", cert_path) return { "private_key_pfx_path": cert_path, diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 65093477..d4df481d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,5 +1,7 @@ -"""If the following ENV VAR were available, many end-to-end test cases would run. -LAB_APP_CLIENT_CERT_PFX_PATH=... +"""Unattended certificate E2E scenarios require LAB_APP_CLIENT_CERT_PFX_PATH. + +Missing or invalid lab credentials fail the required certificate scenarios; +legacy interactive and other optional scenarios retain their existing gates. """ try: from dotenv import load_dotenv # Use this only in local dev machine @@ -426,10 +428,13 @@ def test_client_secret(self): self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) + +class CertificateScenariosTestCase(E2eTestCase): + # Required unattended scenarios must also run during tagged releases. + __unittest_skip__ = False + def test_subject_name_issuer_authentication(self): from tests.lab_config import get_client_certificate - if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): - self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") self.app = msal.ConfidentialClientApplication( LAB_APP_CLIENT_ID, @@ -507,8 +512,6 @@ def _call_graph_with_mtls_pop_token(self, token, binding_certificate): # unittest, but the matrix names line up 1:1 with the sibling SDKs. def test_credential_x509_output_pop(self): from tests.lab_config import get_client_certificate - if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): - self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") cert = get_client_certificate() self.app = msal.ConfidentialClientApplication( self._SNI_ALLOWLISTED_CLIENT_ID, @@ -536,8 +539,6 @@ def test_credential_x509_output_bearer(self): # an ordinary Bearer token. This proves the client_assertion is dropped # only on the mTLS-PoP path, never on the classic certificate path. from tests.lab_config import get_client_certificate - if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"): - self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set") self.app = msal.ConfidentialClientApplication( self._SNI_ALLOWLISTED_CLIENT_ID, authority=self._SNI_ALLOWLISTED_AUTHORITY, diff --git a/tests/test_lab_config.py b/tests/test_lab_config.py new file mode 100644 index 00000000..09501273 --- /dev/null +++ b/tests/test_lab_config.py @@ -0,0 +1,74 @@ +import io +import os +import tempfile +import unittest +from unittest.mock import patch + +from cryptography.hazmat.primitives.serialization import NoEncryption, pkcs12 + +from tests.lab_config import get_client_certificate +from tests import test_e2e + + +class TestRequiredCertificateConfiguration(unittest.TestCase): + def test_missing_certificate_configuration_fails(self): + for value in (None, "", "$(LAB_APP_CLIENT_CERT_PFX_PATH)"): + with self.subTest(value=value), patch.dict(os.environ, {}, clear=True): + if value is not None: + os.environ["LAB_APP_CLIENT_CERT_PFX_PATH"] = value + with self.assertRaisesRegex( + EnvironmentError, "LAB_APP_CLIENT_CERT_PFX_PATH"): + get_client_certificate() + + def test_missing_certificate_file_fails(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "missing.pfx") + with patch.dict(os.environ, {"LAB_APP_CLIENT_CERT_PFX_PATH": path}): + with self.assertRaises(FileNotFoundError): + get_client_certificate() + + def test_invalid_certificate_file_fails(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "invalid.pfx") + with open(path, "wb") as cert_file: + cert_file.write(b"not a PFX") + with patch.dict(os.environ, {"LAB_APP_CLIENT_CERT_PFX_PATH": path}): + with self.assertRaises(ValueError): + get_client_certificate() + + def test_valid_certificate_configuration_is_preserved(self): + fixture = os.path.join( + os.path.dirname(__file__), "certificate-with-password.pfx") + with open(fixture, "rb") as cert_file: + key, certificate, chain = pkcs12.load_key_and_certificates( + cert_file.read(), b"password") + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "valid.pfx") + with open(path, "wb") as cert_file: + cert_file.write(pkcs12.serialize_key_and_certificates( + b"test", key, certificate, chain, NoEncryption())) + with patch.dict(os.environ, {"LAB_APP_CLIENT_CERT_PFX_PATH": path}): + self.assertEqual({ + "private_key_pfx_path": path, + "public_certificate": True, + }, get_client_certificate()) + + def test_required_scenarios_fail_without_credentials_instead_of_skipping(self): + with patch.dict(os.environ, {"TRAVIS_TAG": "test-release"}, clear=True), patch.object( + test_e2e.E2eTestCase, "__unittest_skip__", True), patch( + "tests.test_e2e.msal.ConfidentialClientApplication") as application: + suite = unittest.defaultTestLoader.loadTestsFromTestCase( + test_e2e.CertificateScenariosTestCase) + result = unittest.TextTestRunner(stream=io.StringIO()).run(suite) + self.assertEqual(3, result.testsRun) + self.assertEqual(3, len(result.errors)) + self.assertEqual([], result.skipped) + self.assertEqual([], result.failures) + for _, traceback in result.errors: + self.assertIn("LAB_APP_CLIENT_CERT_PFX_PATH", traceback) + application.assert_not_called() + + def test_required_scenarios_override_legacy_release_skip(self): + self.assertIn( + "__unittest_skip__", test_e2e.CertificateScenariosTestCase.__dict__) + self.assertFalse(test_e2e.CertificateScenariosTestCase.__unittest_skip__) From f67c6808fa9969952d3caf0e8ef32aea91cd16d3 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 15:15:44 -0400 Subject: [PATCH 19/27] Fix certificate fixture regression when release tag is unset Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_lab_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_lab_config.py b/tests/test_lab_config.py index 09501273..43108d4b 100644 --- a/tests/test_lab_config.py +++ b/tests/test_lab_config.py @@ -55,7 +55,7 @@ def test_valid_certificate_configuration_is_preserved(self): def test_required_scenarios_fail_without_credentials_instead_of_skipping(self): with patch.dict(os.environ, {"TRAVIS_TAG": "test-release"}, clear=True), patch.object( - test_e2e.E2eTestCase, "__unittest_skip__", True), patch( + test_e2e.E2eTestCase, "__unittest_skip__", True, create=True), patch( "tests.test_e2e.msal.ConfidentialClientApplication") as application: suite = unittest.defaultTestLoader.loadTestsFromTestCase( test_e2e.CertificateScenariosTestCase) From a447ee50baf04e63d9e105785a1605326695e461 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 27 Oct 2026 10:44:02 -0400 Subject: [PATCH 20/27] Relocate core mTLS parity fixtures and cache scaling regressions Extract the generic certificate, resource, recorder, and privacy coverage from validated source 0cd290d14ed194a493bf2f3653cf795880bcdc10. Keep sibling feature leaves out of the core inventory and carry the validated access-token identity index fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/token_cache.py | 68 +++-- tests/README.md | 78 +++++- tests/mtls_e2e_helpers.py | 168 +++++++++++++ tests/test_e2e.py | 436 ++++++++++++++++++++++++--------- tests/test_lab_config.py | 34 ++- tests/test_mtls_e2e_helpers.py | 319 ++++++++++++++++++++++++ tests/test_token_cache.py | 165 +++++++++++++ 7 files changed, 1118 insertions(+), 150 deletions(-) create mode 100644 tests/mtls_e2e_helpers.py create mode 100644 tests/test_mtls_e2e_helpers.py diff --git a/msal/token_cache.py b/msal/token_cache.py index f38d5713..d3f8609e 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -258,6 +258,8 @@ class AuthorityType: def __init__(self): self._lock = threading.RLock() self._cache = {} + self._access_token_index = {} + self._access_token_index_state = None self.key_makers = { # Note: We have changed token key format before when ordering scopes; # changing token key won't result in cache miss. @@ -605,6 +607,32 @@ def __add(self, event, now=None): app_metadata["family_id"] = response.get("foci") self.modify(self.CredentialType.APP_METADATA, app_metadata, app_metadata) + def _get_access_token_index(self): + state = ( + self._cache, self._cache.get(self.CredentialType.ACCESS_TOKEN), + self._cache.get(_ACCESS_TOKEN_MTLS)) + # Persistence can replace the cache or either bucket, including legacy keys. + if self._access_token_index_state is None or any( + previous is not current for previous, current in zip( + self._access_token_index_state, state)): + index = {} + for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS): + for key, entry in self._cache.get(bucket, {}).items(): + index.setdefault(_access_token_identity(entry), set()).add( + (bucket, key)) + self._access_token_index = index + self._access_token_index_state = state + return self._access_token_index + + def _remove_access_token_entry(self, bucket, key): + entry = self._cache[bucket][key] + identity = _access_token_identity(entry) + locations = self._access_token_index[identity] + locations.remove((bucket, key)) + self._cache[bucket].pop(key) + if not locations: + del self._access_token_index[identity] + def modify(self, credential_type, old_entry, new_key_value_pairs=None): # Modify the specified old_entry with new_key_value_pairs, # or remove the old_entry if the new_key_value_pairs is None. @@ -616,8 +644,10 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None): key = self.key_makers[credential_type](**old_entry) with self._lock: is_at = credential_type == self.CredentialType.ACCESS_TOKEN - entries = self._cache.setdefault( - _access_token_bucket(old_entry) if is_at else credential_type, {}) + if is_at: + index = self._get_access_token_index() + bucket = _access_token_bucket(old_entry) if is_at else credential_type + entries = self._cache.setdefault(bucket, {}) if new_key_value_pairs: # Update with them entry = dict( old_entry, # Do not use entries[key] b/c it might not exist @@ -625,31 +655,35 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None): if is_at: if entry.get("key_id") is not None: entry["key_id"] = _key_id_to_str(entry["key_id"]) + identity = _access_token_identity(entry) if entry.get("key_id") or _token_type(entry) == "mtls_pop": # Replace older bound-token keys, not other certificates # or authentication schemes. - identity = _access_token_identity(entry) - for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS): - stored = self._cache.get(bucket, {}) - for stored_key in [ - k for k, value in stored.items() - if _access_token_identity(value) == identity]: - stored.pop(stored_key) - entries = self._cache.setdefault(_access_token_bucket(entry), {}) + for stored_bucket, stored_key in tuple(index.get(identity, ())): + self._remove_access_token_entry(stored_bucket, stored_key) + bucket = _access_token_bucket(entry) + entries = self._cache.setdefault(bucket, {}) key = self.key_makers[credential_type](**entry) + if key in entries: + self._remove_access_token_entry(bucket, key) entries[key] = entry + if is_at: + index.setdefault(identity, set()).add((bucket, key)) elif is_at: if entries.get(key) == old_entry: - entries.pop(key) + self._remove_access_token_entry(bucket, key) else: # Persisted caches may still use an older AT key format. - for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS): - entries = self._cache.get(bucket, {}) - for stored_key in [ - k for k, entry in entries.items() if entry == old_entry]: - entries.pop(stored_key) + for stored_bucket, stored_key in tuple( + index.get(_access_token_identity(old_entry), ())): + if self._cache[stored_bucket].get(stored_key) == old_entry: + self._remove_access_token_entry(stored_bucket, stored_key) else: # Remove old_entry entries.pop(key, None) + if is_at: + self._access_token_index_state = ( + self._cache, self._cache.get(self.CredentialType.ACCESS_TOKEN), + self._cache.get(_ACCESS_TOKEN_MTLS)) def remove_rt(self, rt_item): assert rt_item.get("credential_type") == self.CredentialType.REFRESH_TOKEN @@ -724,6 +758,8 @@ def deserialize(self, state): """Deserialize cache state, marking migrated bound-token layouts as changed.""" with self._lock: self._cache = json.loads(state) if state else {} + self._access_token_index = {} + self._access_token_index_state = None original_access_tokens = { bucket: dict(self._cache.get(bucket, {})) for bucket in (self.CredentialType.ACCESS_TOKEN, _ACCESS_TOKEN_MTLS)} diff --git a/tests/README.md b/tests/README.md index 293073dc..40948bfa 100644 --- a/tests/README.md +++ b/tests/README.md @@ -57,13 +57,79 @@ Run only the required certificate scenarios: python -m pytest -q tests/test_e2e.py::CertificateScenariosTestCase ``` -These cover ordinary SN/I certificate Bearer authentication, global X509 mTLS -PoP acquisition with a certificate-bound Graph resource call and cache reuse, -and ordinary certificate Bearer authentication in `westus3`. The X509 matrix -uses client `163ffef9-a313-45b4-ab2f-c7e2f5e0e23e` in tenant +The six required certificate methods use the MSID Lab certificate and the +preconfigured client `163ffef9-a313-45b4-ab2f-c7e2f5e0e23e` in tenant `bea21ebe-8b64-4d06-9f6d-6a889b120a7c`, matching the .NET and Go lab setup. -They do not require fetching unrelated user configuration from Key Vault. -Legacy interactive and other optional E2E skips are unchanged. +Global Graph PoP and regional `westus3` Key Vault PoP are separate cases. +Ordinary regional signed-certificate Bearer authentication is not +Bearer-over-mTLS: it uses the ordinary endpoint and retains the central +authority's signed-assertion audience. + +The global Graph case reuses the same real token for bound-certificate success +and three 401/403 controls: no certificate (`MissingClientCertificate`), the +correct certificate with the Bearer scheme (`Cnf claim not supported over`), +and an unrelated locally generated certificate +(`MtlsCnfClaimRequestDataValidationFailed`). It then verifies a cached repeat. +Both acquisition orders also verify Bearer/PoP isolation in the same +application, cache, and Key Vault scope. + +Graph and Key Vault PoP tokens require readable JWTs, matching SHA256 `cnf` +bindings, and the requested resource audience. Key Vault accepts its verified +URI or application-ID alias `cfa8b339-82a2-471a-a3c9-0fc0be7a4093`. +Recorders delegate to the real default `Session.send`, preserving the TLS +adapter, and verify signed certificate assertions against the certificate, +client, and expected audience. They retain bounded status/error metadata, +never raw forms, tokens, private keys, or client assertions. + +All six methods run without skips, including tagged releases. Missing +configuration, credential failures, and DNS/TLS failures are failures, not +acceptable rejection evidence. They do not fetch unrelated user configuration +from Key Vault. Legacy interactive and other optional E2E skips are unchanged. + +Offline guards are separate from live evidence: + +```powershell +python -m pytest -q tests\test_lab_config.py tests\test_mtls_e2e_helpers.py tests\test_token_cache.py +``` + +These checks use local fixtures or synthetic responses to verify strict +binding, safe recording, Graph rejection gates, the exact required-method +inventory, and real unittest-runner failure rather than skip when credentials +are missing. They render failure tracebacks with argument display enabled and +locals disabled to check token-result, cache, and Graph helpers for complete +tokens and truncated token fragments. The sample-output guard rejects dynamic +result values in printed summaries. + +Cache compatibility and write-scaling regressions count actual access-token +bucket visits and identity operations, requiring no steady-state bucket scans +and linear population work while preserving legacy renewal and persistence. + +### Pinned core certificate parity mapping + +The applicable active reference cases below are pinned: + +- .NET `c8b90e9de5bc4974de9928835532faac5c88e2d2`: + [client-credentials cases][dotnet-cc]. +- Go `903eb9f1e54e361525025ea27040ac9644b64358`: + [PoP, ordinary certificate, cache, and regional cases][go-pop]. + +`C` means `tests.test_e2e.CertificateScenariosTestCase`. Every row is live. + +| Active reference case(s) | Python method | Required observation | +| --- | --- | --- | +| Go `TestCredential_X509_Output_Pop`; .NET `Credential_X509_Output_Pop_TestAsync` | `C.test_credential_x509_output_pop` | Graph PoP, returned certificate and JWT SHA256 `cnf`, resource HTTP 200, all three same-token rejection markers, cached repeat. | +| Go `TestCredential_X509_Output_Bearer` | `C.test_credential_x509_output_bearer` | Ordinary westus3 Key Vault Bearer, no output binding, signed certificate assertion with central audience, cached repeat. | +| Go `TestCredential_X509_Output_Pop_Regional` | `C.test_credential_x509_output_pop_regional` | westus3 Key Vault PoP, certificate/JWT binding, same cached token and type; not a redirected global Graph cell. | +| Go `TestConfidentialClientSNIBearerAndMtlsPoPCacheIsolated` | `C.test_bearer_then_mtls_pop_cache_isolated` | One app/cache/scope, Bearer then PoP, distinct tokens and schemes, no Bearer binding, both cache results preserved. | +| Go `TestConfidentialClientSNIMtlsPoPThenBearerCacheIsolated` | `C.test_mtls_pop_then_bearer_cache_isolated` | Reverse acquisition order with the same isolation and reusable PoP result. | + +`C.test_subject_name_issuer_authentication` remains an additional required +legacy SN/I lab cell. FIC, Bearer-over-mTLS, and custom HTTP pipeline scenarios +belong to their feature PRs, not this core inventory. This suite does not +provision apps or claim repository-wide skip elimination. + +[dotnet-cc]: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/c8b90e9de5bc4974de9928835532faac5c88e2d2/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs +[go-pop]: https://github.com/AzureAD/microsoft-authentication-library-for-go/blob/903eb9f1e54e361525025ea27040ac9644b64358/apps/tests/integration/mtls_pop_integration_test.go ## 5) Manual-intervention E2E tests diff --git a/tests/mtls_e2e_helpers.py b/tests/mtls_e2e_helpers.py new file mode 100644 index 00000000..9920538a --- /dev/null +++ b/tests/mtls_e2e_helpers.py @@ -0,0 +1,168 @@ +"""Real-transport recording and certificate fixtures for the mTLS E2E tests.""" +import base64 +import json +import re +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import jwt +import requests +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +import msal +from msal.application import _load_mtls_cert_material +from msal.oauth2cli.oidc import decode_part + + +JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +JWT_POP = "urn:ietf:params:oauth:client-assertion-type:jwt-pop" +GRAPH_REJECTION_MARKERS = ( + "MissingClientCertificate", + # The stable prefix is explicitly documented by the pinned Go reference. + "Cnf claim not supported over", + "MtlsCnfClaimRequestDataValidationFailed", +) + + +def jwt_claims(token): + __tracebackhide__ = True + if not isinstance(token, str) or len(token.split(".")) != 3: + raise AssertionError("Expected a compact JWT with three segments.") + try: + claims = json.loads(decode_part(token.split(".")[1])) + except (ValueError, TypeError): + raise AssertionError("The JWT payload is not valid JSON.") from None + if not isinstance(claims, dict): + raise AssertionError("The JWT payload must be an object.") + return claims + + +def binding_from_credential(credential): + material = _load_mtls_cert_material(credential) + return msal.MtlsBindingContext( + material["cert_pem"], material["private_key_pem"], + material["x5c"], material["key_id"]) + + +def unrelated_binding(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "msal-python-unrelated-client")]) + now = datetime.now(timezone.utc) + certificate = (x509.CertificateBuilder() + .subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(hours=1)) + .not_valid_after(now + timedelta(hours=1)) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), False) + .sign(key, hashes.SHA256())) + return binding_from_credential({ + "private_key": key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()).decode("ascii"), + "public_certificate": certificate.public_bytes( + serialization.Encoding.PEM).decode("ascii"), + }) + + +def _verify_client_assertion(assertion, binding, endpoint, client_id): + __tracebackhide__ = True + if binding is None: + raise AssertionError("A certificate is required to verify the client assertion.") + try: + header = jwt.get_unverified_header(assertion) + certificate = x509.load_der_x509_certificate( + base64.b64decode(binding.x5c[0])) + claims = jwt.decode( + assertion, certificate.public_key(), algorithms=["PS256"], + audience=endpoint, issuer=client_id) + except (jwt.InvalidTokenError, ValueError, TypeError, IndexError): + raise AssertionError( + "The signed client assertion failed certificate/signature/audience validation." + ) from None + chain = header.get("x5c") + if (not isinstance(chain, list) or not chain or chain[0] != binding.x5c[0] + or claims.get("sub") != client_id + or not all(claims.get(field) for field in ("exp", "iat", "jti"))): + raise AssertionError( + "The client assertion must identify the configured certificate and client.") + + +def token_request_summary( + response, binding, adapter, expected_assertion=None, assertion_audience=None): + """Retain only safe metadata, never tokens, assertions, or raw POST bodies.""" + __tracebackhide__ = True + request = response.request + endpoint = urlparse(request.url)._replace(query="", fragment="").geturl() + if (request.method != "POST" + or not urlparse(endpoint).path.endswith("/oauth2/v2.0/token")): + return None + try: + raw = request.body.decode("utf-8") if isinstance( + request.body, bytes) else request.body + form = parse_qs(raw, keep_blank_values=True, strict_parsing=True) + payload = json.loads(response.text) + except (ValueError, TypeError, AttributeError): + raise AssertionError("The real token exchange did not carry a valid OAuth form/JSON response.") from None + if not isinstance(payload, dict): + raise AssertionError("The real token endpoint did not return an OAuth JSON object.") + error = payload.get("error") + if error is not None and ( + not isinstance(error, str) or not re.fullmatch(r"[a-z_]{1,64}", error)): + raise AssertionError("The token endpoint returned an invalid OAuth error code.") + codes = payload.get("error_codes", []) + if not isinstance(codes, list) or any(not isinstance(code, int) for code in codes): + raise AssertionError("OAuth error_codes must be a list of integers.") + fields = {key: values[0] for key, values in form.items()} + assertion = fields.get("client_assertion") + assertion_type = fields.get("client_assertion_type") + signed = False + if assertion and assertion_type == JWT_BEARER: + _verify_client_assertion( + assertion, binding, assertion_audience or endpoint, + fields.get("client_id")) + signed = True + return { + "url": endpoint, + "fields": frozenset(fields), + "grant_type": fields.get("grant_type"), + "requested_token_use": fields.get("requested_token_use"), + "scope": frozenset(fields.get("scope", "").split()), + "token_type": fields.get("token_type"), + "client_assertion_type": assertion_type, + "client_assertion_present": bool(assertion), + "signed_client_assertion": signed, + "assertion_matches": ( + expected_assertion is not None and assertion == expected_assertion), + "status_code": response.status_code, + "error": error, + "error_codes": tuple(codes[:8]), + "tls_adapter": type(adapter).__name__, + "middleware": request.headers.get("X-Msal-Pipeline"), + } + + +@contextmanager +def record_token_requests(binding, expected_assertion=None, assertion_audience=None): + """Delegate Session.send unchanged, including MSAL's built-in TLS adapter.""" + __tracebackhide__ = True + records = [] + send = requests.Session.send + + def recording_send(session, request, **kwargs): + response = send(session, request, **kwargs) + summary = token_request_summary( + response, binding, session.get_adapter(request.url), + expected_assertion=expected_assertion, + assertion_audience=assertion_audience) + if summary is not None: + records.append(summary) + return response + + with patch.object(requests.Session, "send", new=recording_send): + yield records diff --git a/tests/test_e2e.py b/tests/test_e2e.py index d4df481d..73f4900b 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -9,6 +9,7 @@ except: pass import base64 +from contextlib import nullcontext import logging import os import json @@ -32,6 +33,10 @@ get_user_config, get_app_config, get_user_password, get_secret, UserSecrets, AppSecrets, LAB_APP_CLIENT_ID, clean_env, ) +from tests.mtls_e2e_helpers import ( + GRAPH_REJECTION_MARKERS, JWT_BEARER, + binding_from_credential, jwt_claims, record_token_requests, unrelated_binding, +) logger = logging.getLogger(__name__) @@ -340,6 +345,205 @@ def test_access_token_should_be_obtained_for_a_supported_scope(self): self.assertIsNotNone(result.get("access_token")) +class MtlsPopTestCase(E2eTestCase): + _MTLS_POP_SCOPE = ["https://graph.microsoft.com/.default"] + _KEY_VAULT_SCOPE = ["https://vault.azure.net/.default"] + # Only the mTLS Graph host validates the certificate binding on the channel. + _MTLS_GRAPH_RESOURCE = "https://mtlstb.graph.microsoft.com/v1.0/applications?$top=1" + _SNI_ALLOWLISTED_CLIENT_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e" + _SNI_ALLOWLISTED_AUTHORITY = "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c" + _TOKEN_AUDIENCES = { + "https://graph.microsoft.com/.default": { + "https://graph.microsoft.com", "00000003-0000-0000-c000-000000000000"}, + "https://vault.azure.net/.default": { + "https://vault.azure.net", "cfa8b339-82a2-471a-a3c9-0fc0be7a4093"}, + "https://storage.azure.com/.default": { + "https://storage.azure.com", "e406a681-f3d4-42a8-90b6-c2b029497af1"}, + } + + @staticmethod + def _cnf_x5t_s256(access_token): + __tracebackhide__ = True + cnf = jwt_claims(access_token).get("cnf") + if not isinstance(cnf, dict) or not isinstance( + cnf.get("x5t#S256"), str) or not cnf["x5t#S256"]: + raise AssertionError("The PoP JWT must carry cnf['x5t#S256'].") + return cnf["x5t#S256"] + + @staticmethod + def _result_summary(result): + error = result.get("error") + if error is not None and (not isinstance(error, str) or error not in { + "invalid_client", "invalid_grant", "invalid_request", + "invalid_scope", "unauthorized_client", "unsupported_grant_type", + "temporarily_unavailable", "server_error", "interaction_required", + "token_type_mismatch"}): + error = "" + codes = result.get("error_codes") + codes = [code for code in codes[:8] if isinstance(code, int)] if isinstance( + codes, list) else [] + return "error={!r}, error_codes={!r}".format( + error, codes) + + def _assert_audience(self, result, scope): + __tracebackhide__ = True + audience = jwt_claims(result["access_token"]).get("aud") + self.assertTrue( + isinstance(audience, str) + and audience.rstrip("/") in self._TOKEN_AUDIENCES[scope[0]], + "The token audience must identify the requested resource.") + + def _assert_token_result(self, result, scope, token_type, expected_binding=None): + __tracebackhide__ = True + self.assertTrue(isinstance(result, dict), "MSAL must return a token result.") + self.assertTrue( + bool(result.get("access_token")), + "Token acquisition failed: " + self._result_summary(result)) + self.assertEqual(token_type, result.get("token_type")) + if token_type == "mtls_pop": + binding = result.get("binding_certificate") + self.assertTrue( + isinstance(binding, msal.MtlsBindingContext), + "PoP must return a usable binding context.") + self.assertTrue(binding.x5c and binding.thumbprint_sha256) + self.assertEqual(expected_binding.x5c, binding.x5c) + self.assertEqual( + expected_binding.thumbprint_sha256, binding.thumbprint_sha256) + self.assertFalse( + "PRIVATE KEY" in repr(binding), + "The binding context repr must not expose key material.") + # Reference FIC assertions are opaque; resource PoP JWTs require cnf. + if scope != ["api://AzureADTokenExchange/.default"]: + self.assertEqual( + binding.thumbprint_sha256, + self._cnf_x5t_s256(result["access_token"])) + self._assert_audience(result, scope) + else: + self.assertFalse( + "binding_certificate" in result, + "An ordinary Bearer result must not expose certificate binding.") + + @staticmethod + def _close_default_http_clients(app): + app.http_client.close() + if app._mtls_http_client is not None: + app._mtls_http_client.close() + + def _sni_app(self, credential, azure_region=False, http_client=None): + app = msal.ConfidentialClientApplication( + self._SNI_ALLOWLISTED_CLIENT_ID, + authority=self._SNI_ALLOWLISTED_AUTHORITY, + client_credential=credential, azure_region=azure_region, + http_client=http_client, timeout=30) + if http_client is None: + self.addCleanup(self._close_default_http_clients, app) + return app + + @staticmethod + def _certificate(): + from tests.lab_config import get_client_certificate + + credential = get_client_certificate() + return credential, binding_from_credential(credential) + + def _assert_cached_result(self, app, scope, original, pop=False): + __tracebackhide__ = True + cached = app.acquire_token_for_client( + scope, mtls_proof_of_possession=pop) + self._assert_token_result( + cached, scope, original["token_type"], + expected_binding=original.get("binding_certificate")) + self.assertEqual(app._TOKEN_SOURCE_CACHE, cached.get(app._TOKEN_SOURCE)) + self.assertTrue( + original["access_token"] == cached["access_token"], + "The cached token must be the originally acquired token.") + + def _assert_token_request( + self, records, scope, grant, region=False, assertion_type=None, + regular_endpoint=False): + self.assertEqual(1, len(records), "Exactly one real token POST is required.") + request = records[0] + host = ("login.microsoftonline.com" if regular_endpoint + else ("{}.".format(region) if region else "") + "mtlsauth.microsoft.com") + tenant = urlparse(self._SNI_ALLOWLISTED_AUTHORITY).path.strip("/") + self.assertEqual( + "https://{}/{}/oauth2/v2.0/token".format(host, tenant), + request["url"]) + self.assertEqual(grant, request["grant_type"]) + self.assertTrue(set(scope) <= request["scope"]) + self.assertEqual(assertion_type, request["client_assertion_type"]) + self.assertEqual( + assertion_type is not None, request["client_assertion_present"]) + self.assertEqual( + "HTTPAdapter" if regular_endpoint else "_MtlsHTTPAdapter", + request["tls_adapter"]) + for field in ("req_cnf", "key_id"): + self.assertNotIn(field, request["fields"]) + if assertion_type == JWT_BEARER: + self.assertTrue( + request["signed_client_assertion"], + "The private_key_jwt must verify against the configured certificate.") + return request + + def _assert_factory_used(self, http_client, expected_binding): + self.assertEqual(1, len(http_client._children)) + child = http_client._children[0] + self.assertIsNot(http_client, child) + self.assertEqual( + expected_binding.thumbprint_sha256, + child.binding_context.thumbprint_sha256) + self.assertEqual([], http_client.token_requests) + self.assertEqual(1, child.response_count) + self.assertEqual(1, len(child.token_requests)) + self.assertEqual("custom-policy", child.token_requests[0]["middleware"]) + self.assertFalse(http_client._closed or child._closed) + return child + + def _acquire_recorded( + self, app, scope, binding, pop=False, http_client=None, + expected_assertion=None): + __tracebackhide__ = True + if http_client is not None: + result = app.acquire_token_for_client( + scope, mtls_proof_of_possession=pop) + records = self._assert_factory_used( + http_client, binding).token_requests + else: + with record_token_requests( + binding, expected_assertion=expected_assertion) as records: + result = app.acquire_token_for_client( + scope, mtls_proof_of_possession=pop) + return result, records + + def _call_graph_with_mtls_pop_token(self, token, binding_certificate): + __tracebackhide__ = True + with binding_certificate.create_requests_session() as session: + resp = session.get( + self._MTLS_GRAPH_RESOURCE, + headers={"Authorization": "mtls_pop " + token}, + timeout=30, allow_redirects=False) + self.assertEqual( + 200, resp.status_code, + "The real Graph resource must accept the bound PoP token.") + return resp + + def _assert_graph_rejects(self, token, scheme, binding, marker): + __tracebackhide__ = True + with (binding.create_requests_session() if binding is not None + else requests.Session()) as session: + response = session.get( + self._MTLS_GRAPH_RESOURCE, + headers={"Authorization": scheme + " " + token}, + timeout=30, allow_redirects=False) + observed = tuple( + candidate for candidate in GRAPH_REJECTION_MARKERS + if candidate in response.text) + diagnostic = "HTTP {}; binding markers={!r}".format( + response.status_code, observed) + self.assertIn(response.status_code, (401, 403), diagnostic) + self.assertEqual((marker,), observed, diagnostic) + + class PublicCloudScenariosTestCase(E2eTestCase): # Historically this class was driven by tests/config.json for semi-automated runs. # It now uses lab config + env vars so it can run automatically on any CI @@ -429,138 +633,128 @@ def test_client_secret(self): self.assertCacheWorksForApp(result, scope) -class CertificateScenariosTestCase(E2eTestCase): +class CertificateScenariosTestCase(MtlsPopTestCase): # Required unattended scenarios must also run during tagged releases. __unittest_skip__ = False def test_subject_name_issuer_authentication(self): - from tests.lab_config import get_client_certificate - - self.app = msal.ConfidentialClientApplication( + credential, _ = self._certificate() + app = msal.ConfidentialClientApplication( LAB_APP_CLIENT_ID, authority="https://login.microsoftonline.com/microsoft.onmicrosoft.com", - client_credential=get_client_certificate(), + client_credential=credential, http_client=MinimalHttpClient()) - scope = ["https://graph.microsoft.com/.default"] - result = self.app.acquire_token_for_client(scope) - self.assertIn('access_token', result) - self.assertCacheWorksForApp(result, scope) - - # ── SN/I certificate over mTLS Proof-of-Possession ───────────────────────── - # These reuse the same lab SN/I cert as test_subject_name_issuer_authentication - # (it is not CNG-backed, so it works as a TLS client cert on the CI agents). - # NOTE: MSAL must own the TLS transport for mTLS, so unlike the tests above we - # do NOT pass http_client=MinimalHttpClient() here. - # ESTS gates mTLS PoP on the final resource audience (must be allow-listed, - # e.g. MS Graph), not on the client app, so we target Microsoft Graph. - _MTLS_POP_SCOPE = ["https://graph.microsoft.com/.default"] - # SN/I app + tenant that ESTS allow-lists for mTLS PoP. The generic - # LAB_APP_CLIENT_ID + microsoft.onmicrosoft.com works for Bearer SN/I but is - # rejected for mTLS PoP with AADSTS700025. Mirrors the MSAL .NET/Java e2e config. - _SNI_ALLOWLISTED_CLIENT_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e" - _SNI_ALLOWLISTED_AUTHORITY = "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c" - # The mTLS Graph host (NOT plain graph.microsoft.com): only this host performs - # the client-certificate TLS handshake that an mtls_pop token's binding is - # validated against. Presenting the token here, with the binding cert on the - # handshake, must return 200; a 401/403 means the binding was lost. - _MTLS_GRAPH_RESOURCE = "https://mtlstb.graph.microsoft.com/v1.0/applications?$top=1" - - def _assert_pop_cache_hit(self, app, scope): - # A second mTLS-PoP call for the same scope must be served from cache. - # (assertCacheWorksForApp cannot be reused: its follow-up call omits the - # flag and would look up a Bearer entry, missing the mtls_pop token.) - again = app.acquire_token_for_client(scope, mtls_proof_of_possession=True) - self.assertIsNotNone(again) - self.assertEqual(app._TOKEN_SOURCE_CACHE, again[app._TOKEN_SOURCE]) - - @staticmethod - def _cnf_x5t_s256(access_token): - # Best-effort decode of an mtls_pop JWT's cnf.x5t#S256 binding claim. - try: - payload = json.loads(decode_part(access_token.split(".")[1])) - return payload.get("cnf", {}).get("x5t#S256") - except Exception: # Opaque/non-JWT token - skip the offline binding check - return None - - def _call_graph_with_mtls_pop_token(self, token, binding_certificate): - """Prove an ``mtls_pop`` token is genuinely usable: present it to the - mTLS Graph endpoint over a TLS channel bound by the SAME certificate and - require HTTP 200. Acquiring the token is not enough - a resource only - honors it when the binding certificate is on the TLS handshake and the - auth scheme is ``mtls_pop``; a 401/403 means the binding was lost, i.e. a - real regression rather than a transient failure. - - Shared test infrastructure: the FIC two-leg e2e in PR #939 reuses this - as-is (please do not inline it). - """ - with binding_certificate.create_requests_session() as session: - resp = session.get( - self._MTLS_GRAPH_RESOURCE, - headers={"Authorization": "mtls_pop " + token}, - timeout=30) - self.assertEqual( - 200, resp.status_code, - "mtls_pop token rejected by the resource (HTTP %s). A 401/403 means " - "the binding certificate was not presented on the TLS handshake, or " - "the 'mtls_pop' auth scheme was wrong. Body[:500]=%s" % ( - resp.status_code, (resp.text or "")[:500])) - return resp + self.addCleanup(app.http_client.close) + result = app.acquire_token_for_client(self._MTLS_POP_SCOPE) + self._assert_token_result(result, self._MTLS_POP_SCOPE, "Bearer") + self._assert_cached_result(app, self._MTLS_POP_SCOPE, result) + + def _acquire_x509(self, scope, pop=False, send=False, region=False, custom=False): + credential, binding = self._certificate() + if send: + credential = dict(credential, send_certificate_over_mtls=True) + http_client = None + if custom: + from tests.mtls_http_e2e_helpers import RecordingPolicyHttpClient + + http_client = RecordingPolicyHttpClient(expected_binding=binding) + self.addCleanup(http_client.close) + app = self._sni_app(credential, azure_region=region, http_client=http_client) + with (nullcontext() if custom else record_token_requests(binding)) as records: + result = app.acquire_token_for_client( + scope, mtls_proof_of_possession=pop) + self._assert_token_result( + result, scope, "mtls_pop" if pop else "Bearer", binding) + self._assert_cached_result(app, scope, result, pop=pop) + if custom: + records = self._assert_factory_used( + http_client, binding).token_requests + request = self._assert_token_request( + records, scope, "client_credentials", region=region, + assertion_type=None if pop else JWT_BEARER) + self.assertEqual(200, request["status_code"]) + self.assertEqual("mtls_pop" if pop else None, request["token_type"]) + self.assertEqual(1, len(records), "A cache hit must not send another POST.") + if custom: + child = self._assert_factory_used(http_client, binding) + http_client.close() + self.assertTrue(http_client._closed and child._closed) + return result - # ── Credential x Output matrix (mirrors the MSAL .NET e2e naming) ────────── - # Credential__Output_. Task 1 covers the two X509 cells - # (this file); the Fic cells live in the FIC follow-up PR. snake_case per - # unittest, but the matrix names line up 1:1 with the sibling SDKs. def test_credential_x509_output_pop(self): - from tests.lab_config import get_client_certificate - cert = get_client_certificate() - self.app = msal.ConfidentialClientApplication( - self._SNI_ALLOWLISTED_CLIENT_ID, - authority=self._SNI_ALLOWLISTED_AUTHORITY, - client_credential=cert, - azure_region=False) - result = self.app.acquire_token_for_client( - self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) - self.assertIn("access_token", result, "mTLS PoP request failed: %s" % result) - self.assertEqual("mtls_pop", result.get("token_type")) - binding = result.get("binding_certificate") - self.assertTrue(binding and binding.get("x5c") and binding.get("thumbprint_sha256")) - self.assertIsInstance(binding, msal.MtlsBindingContext) - self.assertNotIn("private", repr(binding).lower()) - cnf = self._cnf_x5t_s256(result["access_token"]) - if cnf: # When the AT is a decodable JWT, its cnf must match our cert - self.assertEqual(binding["thumbprint_sha256"], cnf) - # The token must actually work at the resource, bound to our cert. - self._call_graph_with_mtls_pop_token(result["access_token"], binding) - self._assert_pop_cache_hit(self.app, self._MTLS_POP_SCOPE) + credential, expected_binding = self._certificate() + app = self._sni_app(credential) + with record_token_requests(expected_binding) as records: + result = app.acquire_token_for_client( + self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) + self._assert_token_result( + result, self._MTLS_POP_SCOPE, "mtls_pop", expected_binding) + request = self._assert_token_request( + records, self._MTLS_POP_SCOPE, "client_credentials") + self.assertEqual(200, request["status_code"]) + self.assertEqual("mtls_pop", request["token_type"]) + token, binding = result["access_token"], result["binding_certificate"] + self._call_graph_with_mtls_pop_token(token, binding) + for scheme, certificate, marker in ( + ("mtls_pop", None, GRAPH_REJECTION_MARKERS[0]), + ("Bearer", binding, GRAPH_REJECTION_MARKERS[1]), + ("mtls_pop", unrelated_binding(), GRAPH_REJECTION_MARKERS[2])): + with self.subTest(control=marker): + self._assert_graph_rejects(token, scheme, certificate, marker) + self._assert_cached_result(app, self._MTLS_POP_SCOPE, result, pop=True) + self.assertEqual(1, len(records), "The PoP repeat must come from cache.") + + def test_credential_x509_output_pop_regional(self): + # Go's regional PoP cell is westus3 + Key Vault, not global Graph. + self._acquire_x509( + self._KEY_VAULT_SCOPE, pop=True, region="westus3") def test_credential_x509_output_bearer(self): - # The SAME SN/I cert, but WITHOUT mtls_proof_of_possession, must still - # authenticate via a signed client_assertion (private_key_jwt) and yield - # an ordinary Bearer token. This proves the client_assertion is dropped - # only on the mTLS-PoP path, never on the classic certificate path. - from tests.lab_config import get_client_certificate - self.app = msal.ConfidentialClientApplication( - self._SNI_ALLOWLISTED_CLIENT_ID, - authority=self._SNI_ALLOWLISTED_AUTHORITY, - client_credential=get_client_certificate(), - # Regional is correct/desirable HERE and must stay: a Bearer - # token_type is deterministic, so this retains live regional-endpoint - # E2E coverage. Region is FORBIDDEN on the mtls_pop cell, where the - # regional slice intermittently downgrades PoP -> Bearer (a Bearer - # *token*, not a temporarily_unavailable error) - which is why all - # sibling SDKs (.NET/Java/JS/Go) have no regional PoP cell either. - azure_region="westus3") - result = self.app.acquire_token_for_client(self._MTLS_POP_SCOPE) - self.assertIn("access_token", result, "SN/I Bearer request failed: %s" % result) - self.assertEqual("Bearer", result.get("token_type")) - # Prove the cert binding is genuinely ABSENT, not merely that we skipped - # the flag. MSAL Python adds binding_certificate ONLY on the mtls_pop - # path, so assert both the SDK-internal contract (key omitted entirely) - # and the caller-visible one (a lookup yields None) - no cert is bound. - self.assertNotIn("binding_certificate", result) - self.assertIsNone(result.get("binding_certificate")) - self.assertCacheWorksForApp(result, self._MTLS_POP_SCOPE) - + credential, binding = self._certificate() + app = self._sni_app(credential, azure_region="westus3") + # Ordinary regional certificate auth retains the central assertion audience. + with record_token_requests( + binding, assertion_audience=app.authority.token_endpoint) as records: + result = app.acquire_token_for_client(self._KEY_VAULT_SCOPE) + self._assert_token_result(result, self._KEY_VAULT_SCOPE, "Bearer") + self._assert_cached_result(app, self._KEY_VAULT_SCOPE, result) + self.assertEqual(1, len(records)) + self.assertEqual( + "westus3.login.microsoft.com", urlparse(records[0]["url"]).hostname) + self.assertTrue(records[0]["signed_client_assertion"]) + self.assertEqual(JWT_BEARER, records[0]["client_assertion_type"]) + self.assertEqual("client_credentials", records[0]["grant_type"]) + self.assertEqual(200, records[0]["status_code"]) + self.assertTrue(set(self._KEY_VAULT_SCOPE) <= records[0]["scope"]) + self.assertEqual("HTTPAdapter", records[0]["tls_adapter"]) + + def _assert_cache_isolated(self, pop_first): + credential, binding = self._certificate() + app = self._sni_app(credential) + results = {} + with record_token_requests(binding) as records: + for pop in ((True, False) if pop_first else (False, True)): + result = app.acquire_token_for_client( + self._KEY_VAULT_SCOPE, mtls_proof_of_possession=pop) + self._assert_token_result( + result, self._KEY_VAULT_SCOPE, + "mtls_pop" if pop else "Bearer", binding) + self.assertEqual( + app._TOKEN_SOURCE_IDP, result.get(app._TOKEN_SOURCE)) + results[pop] = result + self.assertTrue( + results[False]["access_token"] != results[True]["access_token"], + "Bearer and PoP must not reuse the same access token.") + for pop, original in results.items(): + self._assert_cached_result( + app, self._KEY_VAULT_SCOPE, original, pop=pop) + self.assertEqual(2, len(records), "Both token types must remain cached.") + + def test_bearer_then_mtls_pop_cache_isolated(self): + self._assert_cache_isolated(pop_first=False) + + def test_mtls_pop_then_bearer_cache_isolated(self): + self._assert_cache_isolated(pop_first=True) class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once @classmethod diff --git a/tests/test_lab_config.py b/tests/test_lab_config.py index 43108d4b..0a97d7ea 100644 --- a/tests/test_lab_config.py +++ b/tests/test_lab_config.py @@ -11,6 +11,24 @@ class TestRequiredCertificateConfiguration(unittest.TestCase): + _REQUIRED_SCENARIOS = { + test_e2e.CertificateScenariosTestCase: { + "test_subject_name_issuer_authentication", + "test_credential_x509_output_pop", + "test_credential_x509_output_pop_regional", + "test_credential_x509_output_bearer", + "test_bearer_then_mtls_pop_cache_isolated", + "test_mtls_pop_then_bearer_cache_isolated", + }, + } + + def test_required_scenario_inventory_is_complete(self): + for case, expected in self._REQUIRED_SCENARIOS.items(): + with self.subTest(case=case.__name__): + self.assertEqual( + expected, + set(unittest.defaultTestLoader.getTestCaseNames(case))) + def test_missing_certificate_configuration_fails(self): for value in (None, "", "$(LAB_APP_CLIENT_CERT_PFX_PATH)"): with self.subTest(value=value), patch.dict(os.environ, {}, clear=True): @@ -57,11 +75,12 @@ def test_required_scenarios_fail_without_credentials_instead_of_skipping(self): with patch.dict(os.environ, {"TRAVIS_TAG": "test-release"}, clear=True), patch.object( test_e2e.E2eTestCase, "__unittest_skip__", True, create=True), patch( "tests.test_e2e.msal.ConfidentialClientApplication") as application: - suite = unittest.defaultTestLoader.loadTestsFromTestCase( - test_e2e.CertificateScenariosTestCase) + suite = unittest.TestSuite( + unittest.defaultTestLoader.loadTestsFromTestCase(case) + for case in self._REQUIRED_SCENARIOS) result = unittest.TextTestRunner(stream=io.StringIO()).run(suite) - self.assertEqual(3, result.testsRun) - self.assertEqual(3, len(result.errors)) + self.assertEqual(6, result.testsRun) + self.assertEqual(6, len(result.errors)) self.assertEqual([], result.skipped) self.assertEqual([], result.failures) for _, traceback in result.errors: @@ -69,6 +88,7 @@ def test_required_scenarios_fail_without_credentials_instead_of_skipping(self): application.assert_not_called() def test_required_scenarios_override_legacy_release_skip(self): - self.assertIn( - "__unittest_skip__", test_e2e.CertificateScenariosTestCase.__dict__) - self.assertFalse(test_e2e.CertificateScenariosTestCase.__unittest_skip__) + for case in self._REQUIRED_SCENARIOS: + with self.subTest(case=case.__name__): + self.assertIn("__unittest_skip__", case.__dict__) + self.assertFalse(case.__unittest_skip__) diff --git a/tests/test_mtls_e2e_helpers.py b/tests/test_mtls_e2e_helpers.py new file mode 100644 index 00000000..216e1ed5 --- /dev/null +++ b/tests/test_mtls_e2e_helpers.py @@ -0,0 +1,319 @@ +"""Offline guard/recorder checks, not evidence of live E2E acquisitions.""" +import base64 +from contextlib import nullcontext +from contextlib import redirect_stdout +import io +import json +import os +import time +import unittest +from unittest.mock import Mock, patch + +import jwt +import pytest +import requests + +from msal.application import _load_mtls_cert_material +from tests import test_e2e +from tests.mtls_e2e_helpers import ( + GRAPH_REJECTION_MARKERS, JWT_BEARER, + binding_from_credential, record_token_requests, token_request_summary, + unrelated_binding, +) + + +_CREDENTIAL = { + "private_key_pfx_path": os.path.join( + os.path.dirname(__file__), "certificate-with-password.pfx"), + "passphrase": "password", "public_certificate": True, +} +_ENDPOINT = ( + test_e2e.MtlsPopTestCase._SNI_ALLOWLISTED_AUTHORITY.replace( + "login.microsoftonline.com", "mtlsauth.microsoft.com") + + "/oauth2/v2.0/token") + + +def _jwt(claims): + def encode(value): + return base64.urlsafe_b64encode( + json.dumps(value).encode("utf-8")).rstrip(b"=").decode("ascii") + + return ".".join((encode({"alg": "none"}), encode(claims), "unit-signature")) + + +class CertificateFixture(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.binding = binding_from_credential(_CREDENTIAL) + cls.material = _load_mtls_cert_material(_CREDENTIAL) + + def _signed_form(self, audience=_ENDPOINT): + now = int(time.time()) + assertion = jwt.encode({ + "aud": audience, "iss": "client", "sub": "client", + "iat": now, "exp": now + 600, "jti": "unit-assertion", + }, self.material["private_key_pem"], algorithm="PS256", + headers={"x5c": self.binding.x5c}) + if isinstance(assertion, bytes): + assertion = assertion.decode("ascii") + return { + "client_id": "client", "client_assertion": assertion, + "client_assertion_type": JWT_BEARER, + "grant_type": "refresh_token", "refresh_token": "unit-refresh-secret", + "scope": "https://vault.azure.net/.default", + } + + def _response(self, form=None, status=400, payload=None): + response = requests.Response() + response.status_code = status + response._content = json.dumps( + {"error": "invalid_grant", "error_codes": [50027]} + if payload is None else payload).encode("utf-8") + response.request = requests.Request( + "POST", _ENDPOINT, + data=self._signed_form() if form is None else form).prepare() + return response + + + def _assert_private_traceback(self, failure, *secrets): + rendered = str(failure.getrepr( + style="long", showlocals=False, funcargs=True)) + self.assertIn(type(failure.value).__name__, rendered) + self.assertFalse( + "PRIVATE KEY" in rendered, + "Rendered tracebacks must not expose private key material.") + for secret in secrets: + fragments = (secret,) + ( + (secret[:32], secret[-32:]) if len(secret) > 64 else ()) + for fragment in fragments: + self.assertFalse( + fragment in rendered, + "Sensitive arguments must not appear in rendered tracebacks.") + + +class TestMtlsE2eHelpers(CertificateFixture): + def setUp(self): + self.scenario = test_e2e.CertificateScenariosTestCase( + "test_credential_x509_output_pop") + + def test_cnf_check_rejects_opaque_malformed_and_unbound_tokens(self): + for token in ( + "opaque", "a.invalid-json.c", _jwt([]), _jwt({}), + _jwt({"cnf": {}}), _jwt({"cnf": {"x5t#S256": ""}})): + with self.subTest(token_shape=token.count(".")): + with self.assertRaises(AssertionError): + self.scenario._cnf_x5t_s256(token) + + def test_pop_checks_the_jwt_and_returned_certificate_binding(self): + result = { + "token_type": "mtls_pop", "binding_certificate": self.binding, + "access_token": _jwt({ + "aud": "00000003-0000-0000-c000-000000000000", + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}}), + } + self.scenario._assert_token_result( + result, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + wrong = dict(result, access_token=_jwt({ + "aud": "https://graph.microsoft.com", + "cnf": {"x5t#S256": "different-certificate"}})) + with self.assertRaises(AssertionError): + self.scenario._assert_token_result( + wrong, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + + def test_key_vault_accepts_its_verified_application_id_not_another_resource(self): + for audience, accepted in ( + ("https://vault.azure.net", True), + ("cfa8b339-82a2-471a-a3c9-0fc0be7a4093", True), + ("00000003-0000-0000-c000-000000000000", False)): + result = { + "token_type": "mtls_pop", "binding_certificate": self.binding, + "access_token": _jwt({ + "aud": audience, + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}}), + } + with self.subTest(audience=audience): + with (nullcontext() if accepted else self.assertRaises(AssertionError)): + self.scenario._assert_token_result( + result, self.scenario._KEY_VAULT_SCOPE, "mtls_pop", self.binding) + + def test_failure_tracebacks_do_not_expose_token_arguments(self): + for token in ( + "opaque-" + self.binding.thumbprint_sha256, + _jwt({"aud": "https://wrong.example", "cnf": {}}), + _jwt({"aud": "https://wrong.example", "cnf": { + "x5t#S256": self.binding.thumbprint_sha256}})): + result = { + "access_token": token, "token_type": "mtls_pop", + "binding_certificate": self.binding, + } + with pytest.raises(AssertionError) as failure: + self.scenario._assert_token_result( + result, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + self._assert_private_traceback(failure, token) + + def test_cached_result_failure_traceback_hides_original_token(self): + claims = { + "aud": "https://graph.microsoft.com", + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}, + } + token = _jwt(dict(claims, nonce="original")) + cached_token = _jwt(dict(claims, nonce="cached")) + original = { + "access_token": token, "token_type": "mtls_pop", + "binding_certificate": self.binding, + } + app = Mock(_TOKEN_SOURCE_CACHE="cache", _TOKEN_SOURCE="token_source") + app.acquire_token_for_client.return_value = dict( + original, access_token=cached_token, token_source="cache") + with pytest.raises(AssertionError) as failure: + self.scenario._assert_cached_result( + app, self.scenario._MTLS_POP_SCOPE, original, pop=True) + self._assert_private_traceback(failure, token, cached_token) + + def test_graph_success_failure_traceback_hides_the_access_token(self): + token = _jwt({"aud": "https://graph.microsoft.com"}) + session = Mock() + session.__enter__ = Mock(return_value=session) + session.__exit__ = Mock(return_value=False) + session.get.return_value = Mock(status_code=403) + binding = Mock(spec=["create_requests_session"]) + binding.create_requests_session.return_value = session + with pytest.raises(AssertionError) as failure: + self.scenario._call_graph_with_mtls_pop_token(token, binding) + self._assert_private_traceback(failure, token) + + def test_graph_rejection_failure_traceback_hides_the_access_token(self): + token = _jwt({"aud": "https://graph.microsoft.com"}) + session = Mock() + session.__enter__ = Mock(return_value=session) + session.__exit__ = Mock(return_value=False) + session.get.return_value = Mock(status_code=401, text="Unexpected marker") + with patch("tests.test_e2e.requests.Session", return_value=session): + with pytest.raises(AssertionError) as failure: + self.scenario._assert_graph_rejects( + token, "mtls_pop", None, GRAPH_REJECTION_MARKERS[0]) + self._assert_private_traceback(failure, token) + + def test_unrelated_certificate_is_usable_and_not_the_fixture_certificate(self): + binding = unrelated_binding() + self.assertNotEqual( + self.binding.thumbprint_sha256, binding.thumbprint_sha256) + context = binding.create_ssl_context() + self.assertTrue(context.check_hostname) + self.assertFalse("PRIVATE KEY" in repr(binding)) + + def test_graph_controls_require_the_specific_marker_and_rejection_status(self): + session = Mock() + session.__enter__ = Mock(return_value=session) + session.__exit__ = Mock(return_value=False) + with patch("tests.test_e2e.requests.Session", return_value=session): + for marker in GRAPH_REJECTION_MARKERS: + for status in (401, 403): + session.get.return_value = Mock(status_code=status, text=marker) + self.scenario._assert_graph_rejects( + "unit-token", "mtls_pop", None, marker) + for status, body in ( + (200, marker), (400, marker), (503, marker), + (401, "InvalidAuthenticationToken"), + (401, " ".join(GRAPH_REJECTION_MARKERS))): + with self.subTest(marker=marker, status=status): + session.get.return_value = Mock(status_code=status, text=body) + with self.assertRaises(AssertionError): + self.scenario._assert_graph_rejects( + "unit-token", "mtls_pop", None, marker) + + def test_recorded_metadata_does_not_retain_secrets(self): + form = self._signed_form() + response = self._response(form=form) + summary = token_request_summary( + response, self.binding, requests.adapters.HTTPAdapter()) + self.assertTrue(summary["signed_client_assertion"]) + self.assertEqual(400, summary["status_code"]) + self.assertEqual((50027,), summary["error_codes"]) + for secret in (form["client_assertion"], form["refresh_token"]): + self.assertFalse(secret in repr(summary), "Recording must not retain secrets.") + self.assertNotIn("body", summary) + self.assertNotIn("client_assertion", summary) + + def test_unstructured_or_unbounded_error_metadata_is_not_recorded(self): + assertion = self._signed_form()["client_assertion"] + for error in (assertion, "x" * 65, {"private": assertion}): + with self.assertRaises(AssertionError) as raised: + token_request_summary( + self._response(payload={"error": error}), + self.binding, requests.adapters.HTTPAdapter()) + self.assertFalse(assertion in str(raised.exception)) + summary = self.scenario._result_summary({ + "error": {"private": assertion}, + "error_codes": [assertion, 50027] * 10}) + self.assertFalse(assertion in summary) + self.assertLess(len(summary), 128) + + def test_client_assertion_signature_audience_and_client_are_checked(self): + forms = [ + self._signed_form(audience="https://wrong.example/token"), + dict(self._signed_form(), client_id="other-client"), + dict(self._signed_form(), client_assertion="invalid.jwt.signature"), + ] + for form in forms: + with self.subTest(client_id=form["client_id"]): + with self.assertRaises(AssertionError) as raised: + token_request_summary( + self._response(form=form), self.binding, + requests.adapters.HTTPAdapter()) + self.assertFalse(form["client_assertion"] in str(raised.exception)) + + def test_regional_ordinary_assertion_uses_the_central_authority_audience(self): + central = _ENDPOINT.replace("mtlsauth.microsoft.com", "login.microsoftonline.com") + regional = _ENDPOINT.replace("mtlsauth.microsoft.com", "westus3.login.microsoft.com") + response = self._response(form=self._signed_form(audience=central)) + response.request.url = regional + with self.assertRaises(AssertionError): + token_request_summary( + response, self.binding, requests.adapters.HTTPAdapter()) + summary = token_request_summary( + response, self.binding, requests.adapters.HTTPAdapter(), + assertion_audience=central) + self.assertTrue(summary["signed_client_assertion"]) + self.assertEqual(regional, summary["url"]) + + def test_default_recorder_delegates_and_does_not_accept_transport_failures(self): + session = requests.Session() + self.addCleanup(session.close) + adapter = Mock(spec=requests.adapters.HTTPAdapter) + + def send(request, **kwargs): + response = self._response() + response.request = request + return response + + adapter.send.side_effect = send + session.mount("https://", adapter) + with record_token_requests(self.binding) as records: + response = session.post(_ENDPOINT, data=self._signed_form()) + self.assertEqual(400, response.status_code) + self.assertEqual(1, len(records)) + adapter.send.assert_called_once() + for error in ( + requests.exceptions.ConnectionError("offline connection failure"), + requests.exceptions.SSLError("offline TLS failure"), + requests.exceptions.Timeout("offline timeout")): + adapter.send.side_effect = error + with record_token_requests(self.binding) as records: + with self.assertRaises(type(error)): + session.post(_ENDPOINT, data=self._signed_form()) + self.assertEqual([], records) + + +class TestMtlsSampleLogging(unittest.TestCase): + def test_pop_summary_never_prints_dynamic_result_values(self): + from sample.confidential_client_mtls_pop_sample import _print_result_summary + + sensitive = "sensitive-unit-result-value" + for result in ( + {"access_token": sensitive, "token_type": sensitive}, + {"error": sensitive, "error_description": sensitive}): + with self.subTest(fields=tuple(result)), redirect_stdout(io.StringIO()) as output: + _print_result_summary(result, "Acquisition result") + self.assertNotIn(sensitive, output.getvalue()) + self.assertIn("acquired:", output.getvalue()) diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 587dc09f..8baae02c 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -56,6 +56,28 @@ def build_response( # simulate a response from AAD return response +class _CountingAccessTokenEntries(dict): + visits = 0 + + def __iter__(self): + for key in super().__iter__(): + self.visits += 1 + yield key + + def keys(self): + return iter(self) + + def items(self): + for item in super().items(): + self.visits += 1 + yield item + + def values(self): + for value in super().values(): + self.visits += 1 + yield value + + class TokenCacheTestCase(unittest.TestCase): def setUp(self): @@ -698,6 +720,25 @@ def search(self, scheme=None, key_id=None, target=None, **kwargs): return list(self.cache.search( "AccessToken", target=target, query=query, now=1000, **kwargs)) + def canonical_cache(self, count): + cache = TokenCache() + buckets = { + name: _CountingAccessTokenEntries() + for name in ("AccessToken", "AccessToken_mTLS")} + for i in range(count): + scheme = ("Bearer", "ssh-cert", "mtls_pop")[i % 3] + entry = dict( + credential_type="AccessToken", token_type=scheme, + home_account_id=None, environment="login.example.com", + client_id="fixture{}".format(i), realm="tenant", target="s1 s2", + secret="fixture", cached_at="1000", expires_on="4600") + if scheme != "Bearer": + entry["key_id"] = "Thumb" + bucket = "AccessToken_mTLS" if scheme == "mtls_pop" else "AccessToken" + buckets[bucket][cache.key_makers["AccessToken"](**entry)] = entry + cache._cache.update(buckets) + return cache, buckets + def released_application(self, cache): app = self.ReleasedApplication.__new__(self.ReleasedApplication) app.client_id = "client" @@ -896,6 +937,10 @@ def test_deserialize_marks_migration_dirty_until_persisted(self): self.assertFalse(self.cache.has_state_changed) self.cache.deserialize(persisted) self.assertFalse(self.cache.has_state_changed) + self.cache.add(self.event(scheme, "renewed"), now=1001) + self.assertEqual(["renewed"], [ + entry["secret"] for entry in self.search( + scheme, "Thumb", target=["s1"])]) def test_deserialize_does_not_invoke_persistence_hooks(self): class PersistenceHookCache(SerializableTokenCache): @@ -939,6 +984,10 @@ def test_persisted_cache_reloads_canonical_and_legacy_bound_tokens(self): found = list(cache.search( "AccessToken", query={"token_type": scheme}, now=1000)) self.assertEqual(["cached"], [item["secret"] for item in found]) + cache.add(self.event(scheme, "renewed"), now=1001) + found = list(cache.search( + "AccessToken", query={"token_type": scheme}, now=1001)) + self.assertEqual(["renewed"], [item["secret"] for item in found]) cache.remove_at(found[0]) reloaded = PersistedTokenCache(persistence) self.assertEqual([], list(reloaded.search( @@ -969,6 +1018,122 @@ def test_mtls_reload_identity_work_is_linear(self): self.assertEqual(count, len(list(cache.search( "AccessToken", now=1000)))) + def test_canonical_bound_writes_do_not_scan_either_bucket(self): + from msal import token_cache + + original_identity = token_cache._access_token_identity + for count in (0, 100, 1000, 10000): + for mtls_only in (False, True): + with self.subTest(count=count, mtls_only=mtls_only): + self.cache, buckets = self.canonical_cache(count) + if mtls_only: + self.cache._cache.pop("AccessToken") + self.cache.add(self.event( + "mtls_pop", "warmup", scopes=["warmup"]), now=1000) + for entries in buckets.values(): + entries.visits = 0 + identity_calls = 0 + + def counted_identity(entry): + nonlocal identity_calls + identity_calls += 1 + return original_identity(entry) + + with patch.object(token_cache, "_access_token_identity", + new=counted_identity): + for scheme, key_id in ( + ("Bearer", None), ("ssh-cert", "Thumb"), + ("mtls_pop", "Thumb")): + for i in range(10): + scopes = ["{}-{}".format(scheme, i)] + for secret in ("inserted", "renewed"): + self.cache.add(self.event( + scheme, secret, key_id=key_id, + scopes=scopes), now=1001) + self.assertEqual( + 0, sum(entries.visits for entries in buckets.values())) + self.assertLessEqual(identity_calls, 120) + for scheme in ("Bearer", "ssh-cert", "mtls_pop"): + self.assertEqual(["renewed"], [ + entry["secret"] for entry in self.search( + scheme, target=["{}-9".format(scheme)])]) + + def test_bound_token_population_work_is_linear(self): + from msal import token_cache + + original_identity = token_cache._access_token_identity + for count in (100, 200, 400): + with self.subTest(count=count): + self.cache, buckets = self.canonical_cache(0) + identity_calls = 0 + + def counted_identity(entry): + nonlocal identity_calls + identity_calls += 1 + return original_identity(entry) + + with patch.object(token_cache, "_access_token_identity", + new=counted_identity): + for i in range(count): + scheme = ("Bearer", "ssh-cert", "mtls_pop")[i % 3] + self.cache.add(self.event( + scheme, "inserted", scopes=["scope{}".format(i)], + key_id=None if scheme == "Bearer" else "Thumb"), + now=1000) + self.assertEqual( + 0, sum(entries.visits for entries in buckets.values())) + self.assertLessEqual(identity_calls, 2 * count) + self.assertEqual(count, sum(len(entries) for entries in buckets.values())) + + def test_bound_writes_reconcile_replaced_native_cache_or_bucket(self): + for scheme in ("ssh-cert", "mtls_pop"): + for replacement in ("cache", "AccessToken", "AccessToken_mTLS"): + with self.subTest(scheme=scheme, replacement=replacement): + self.cache = TokenCache() + self.cache.add(self.event(scheme, "old"), now=1000) + old = self.search(scheme, "Thumb")[0] + self.cache.add(self.event( + "mtls_pop", "other-certificate", key_id="thumb"), now=1000) + if replacement == "cache": + cache = dict(self.cache._cache) + cache.setdefault("AccessToken", {})["legacy:old"] = old + self.cache._cache = cache + else: + entries = dict(self.cache._cache.get(replacement, {})) + entries["legacy:old"] = old + self.cache._cache[replacement] = entries + self.cache.add(self.event(scheme, "renewed"), now=1001) + self.cache.remove_at(old) + self.assertEqual(["renewed"], [ + entry["secret"] for entry in self.search( + scheme, "Thumb", target=["s1"])]) + current = self.search(scheme, "Thumb")[0] + self.cache.remove_at(current) + self.assertEqual([], self.search(scheme, "Thumb")) + self.cache.add(self.event(scheme, "after-removal"), now=1002) + self.assertEqual(["after-removal"], [ + entry["secret"] for entry in self.search(scheme, "Thumb")]) + self.assertEqual(["other-certificate"], [ + entry["secret"] for entry in self.search("mtls_pop", "thumb")]) + + def test_bound_write_tracking_handles_shared_ssh_slots_and_stale_removal(self): + self.cache = TokenCache() + self.cache.add(self.event("ssh-cert", "old"), now=1000) + old = self.search("ssh-cert", "Thumb")[0] + for scheme, key_id in ( + ("Bearer", None), ("ssh-cert", "thumb"), ("ssh-cert", "Thumb")): + self.cache.add(self.event(scheme, "replacement", key_id=key_id), now=1001) + self.cache.remove_at(old) + self.assertEqual(["replacement"], [ + entry["secret"] for entry in self.search(scheme, key_id)]) + self.cache.add(self.event("mtls_pop", "separate"), now=1001) + self.cache.add(self.event("Bearer", "ordinary", key_id=None), now=1002) + self.cache.add(self.event("SSH-CERT", "renewed"), now=1003) + self.assertEqual(["renewed"], [ + entry["secret"] for entry in self.search("ssh-cert", "Thumb")]) + self.assertEqual(["separate"], [ + entry["secret"] for entry in self.search("mtls_pop", "Thumb")]) + def test_canonical_expiration_and_bulk_removal_do_not_scan_per_token(self): class CountingEntries(dict): scanned = 0 From 31861929d6a2a08957f1ebbb63c03f5b2f5cbe99 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 27 Oct 2026 10:57:02 -0400 Subject: [PATCH 21/27] Close default E2E transports across core and shared client layouts Use existing OAuth client transport ownership on the core and Bearer branches, prefer the shared transport when available, and avoid duplicate closes. Cover actual core global/regional clients plus ordinary, Bearer, and shared layouts offline. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 17 ++++++++-- tests/test_mtls_e2e_helpers.py | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 73f4900b..78be5fa0 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -425,9 +425,20 @@ def _assert_token_result(self, result, scope, token_type, expected_binding=None) @staticmethod def _close_default_http_clients(app): - app.http_client.close() - if app._mtls_http_client is not None: - app._mtls_http_client.close() + http_clients = [app.http_client] + shared = getattr(app, "_mtls_http_client", None) + if shared is not None: + http_clients.append(shared) + else: + clients = [app._mtls_client, app._mtls_regional_client] + clients.extend(getattr(app, "_mtls_bearer_clients", {}).values()) + http_clients.extend( + client._http_client for client in clients if client is not None) + closed = set() + for http_client in http_clients: + if id(http_client) not in closed: + http_client.close() + closed.add(id(http_client)) def _sni_app(self, credential, azure_region=False, http_client=None): app = msal.ConfidentialClientApplication( diff --git a/tests/test_mtls_e2e_helpers.py b/tests/test_mtls_e2e_helpers.py index 216e1ed5..e010042f 100644 --- a/tests/test_mtls_e2e_helpers.py +++ b/tests/test_mtls_e2e_helpers.py @@ -6,6 +6,7 @@ import json import os import time +from types import SimpleNamespace import unittest from unittest.mock import Mock, patch @@ -13,6 +14,7 @@ import pytest import requests +import msal from msal.application import _load_mtls_cert_material from tests import test_e2e from tests.mtls_e2e_helpers import ( @@ -317,3 +319,60 @@ def test_pop_summary_never_prints_dynamic_result_values(self): _print_result_summary(result, "Acquisition result") self.assertNotIn(sensitive, output.getvalue()) self.assertIn("acquired:", output.getvalue()) + + +class TestDefaultHttpClientCleanup(unittest.TestCase): + def test_base_application_closes_ordinary_global_and_regional_clients(self): + authority = test_e2e.MtlsPopTestCase._SNI_ALLOWLISTED_AUTHORITY + metadata = { + "authorization_endpoint": authority + "/oauth2/v2.0/authorize", + "token_endpoint": authority + "/oauth2/v2.0/token", + "issuer": authority + "/v2.0", + } + with patch("msal.authority.tenant_discovery", return_value=metadata), patch( + "msal.application._detect_region", return_value=None): + app = msal.ConfidentialClientApplication( + "client", authority=authority, client_credential=_CREDENTIAL, + azure_region=False, instance_discovery=False) + self.addCleanup(test_e2e.MtlsPopTestCase._close_default_http_clients, app) + global_client = app._get_mtls_client(app.authority) + app._region_configured = "westus3" + regional_client = app._get_mtls_client(app.authority) + with patch.object(app.http_client, "close") as ordinary_close, patch.object( + global_client._http_client, "close") as global_close, patch.object( + regional_client._http_client, "close") as regional_close: + test_e2e.MtlsPopTestCase._close_default_http_clients(app) + ordinary_close.assert_called_once_with() + global_close.assert_called_once_with() + regional_close.assert_called_once_with() + + def test_default_bearer_clients_are_closed_once_without_shared_transport(self): + ordinary, pop, bearer = Mock(), Mock(), Mock() + app = SimpleNamespace( + http_client=ordinary, _mtls_client=SimpleNamespace(_http_client=pop), + _mtls_regional_client=None, + _mtls_bearer_clients={ + "global": SimpleNamespace(_http_client=bearer), + "regional": SimpleNamespace(_http_client=bearer)}) + test_e2e.MtlsPopTestCase._close_default_http_clients(app) + for client in (ordinary, pop, bearer): + client.close.assert_called_once_with() + + def test_shared_transport_cleanup_does_not_close_endpoint_wrappers(self): + ordinary, shared, endpoint = Mock(), Mock(), Mock() + app = SimpleNamespace( + http_client=ordinary, _mtls_http_client=shared, + _mtls_client=SimpleNamespace(_http_client=endpoint), + _mtls_regional_client=SimpleNamespace(_http_client=endpoint), + _mtls_bearer_clients={"global": SimpleNamespace(_http_client=endpoint)}) + test_e2e.MtlsPopTestCase._close_default_http_clients(app) + ordinary.close.assert_called_once_with() + shared.close.assert_called_once_with() + endpoint.close.assert_not_called() + + def test_ordinary_application_without_mtls_transport_closes_normally(self): + ordinary = Mock() + app = SimpleNamespace( + http_client=ordinary, _mtls_client=None, _mtls_regional_client=None) + test_e2e.MtlsPopTestCase._close_default_http_clients(app) + ordinary.close.assert_called_once_with() From 1876aacd4023323064b74ae68dc1bee4f1b48f48 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 27 Oct 2026 10:58:50 -0400 Subject: [PATCH 22/27] Keep actual-client cleanup regression portable to shared transports Assert ownership of the current application layout so the same core regression also remains valid after custom HTTP transport integration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_mtls_e2e_helpers.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_mtls_e2e_helpers.py b/tests/test_mtls_e2e_helpers.py index e010042f..25133637 100644 --- a/tests/test_mtls_e2e_helpers.py +++ b/tests/test_mtls_e2e_helpers.py @@ -322,7 +322,7 @@ def test_pop_summary_never_prints_dynamic_result_values(self): class TestDefaultHttpClientCleanup(unittest.TestCase): - def test_base_application_closes_ordinary_global_and_regional_clients(self): + def test_application_closes_ordinary_and_owned_mtls_clients(self): authority = test_e2e.MtlsPopTestCase._SNI_ALLOWLISTED_AUTHORITY metadata = { "authorization_endpoint": authority + "/oauth2/v2.0/authorize", @@ -341,10 +341,18 @@ def test_base_application_closes_ordinary_global_and_regional_clients(self): with patch.object(app.http_client, "close") as ordinary_close, patch.object( global_client._http_client, "close") as global_close, patch.object( regional_client._http_client, "close") as regional_close: - test_e2e.MtlsPopTestCase._close_default_http_clients(app) + shared = getattr(app, "_mtls_http_client", None) + if shared is None: + test_e2e.MtlsPopTestCase._close_default_http_clients(app) + global_close.assert_called_once_with() + regional_close.assert_called_once_with() + else: + with patch.object(shared, "close") as shared_close: + test_e2e.MtlsPopTestCase._close_default_http_clients(app) + shared_close.assert_called_once_with() + global_close.assert_not_called() + regional_close.assert_not_called() ordinary_close.assert_called_once_with() - global_close.assert_called_once_with() - regional_close.assert_called_once_with() def test_default_bearer_clients_are_closed_once_without_shared_transport(self): ordinary, pop, bearer = Mock(), Mock(), Mock() From 7e14c4f7bcc672eba53d96d035c2d0a1fcb2fffc Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 27 Oct 2026 11:58:53 -0400 Subject: [PATCH 23/27] Sanitize real transport failures in shared mTLS E2E helpers Strip Requests and urllib3 exception tracebacks at hidden Graph and recorder boundaries without accepting transport errors as resource rejection evidence. Cover actual offline DNS failures for Graph and POST credentials, including token fragments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/mtls_e2e_helpers.py | 20 +++++++++++-- tests/test_e2e.py | 32 +++++++++++++-------- tests/test_mtls_e2e_helpers.py | 51 ++++++++++++++++++++++++++++++++-- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/tests/mtls_e2e_helpers.py b/tests/mtls_e2e_helpers.py index 9920538a..bacf20e8 100644 --- a/tests/mtls_e2e_helpers.py +++ b/tests/mtls_e2e_helpers.py @@ -29,6 +29,10 @@ ) +class _TokenTransportError(AssertionError): + pass + + def jwt_claims(token): __tracebackhide__ = True if not isinstance(token, str) or len(token.split(".")) != 3: @@ -155,7 +159,13 @@ def record_token_requests(binding, expected_assertion=None, assertion_audience=N send = requests.Session.send def recording_send(session, request, **kwargs): - response = send(session, request, **kwargs) + __tracebackhide__ = True + try: + response = send(session, request, **kwargs) + except requests.exceptions.RequestException: + raise _TokenTransportError( + "The real token request transport failed before receiving a response." + ) from None summary = token_request_summary( response, binding, session.get_adapter(request.url), expected_assertion=expected_assertion, @@ -165,4 +175,10 @@ def recording_send(session, request, **kwargs): return response with patch.object(requests.Session, "send", new=recording_send): - yield records + try: + yield records + except _TokenTransportError: + # Drop upper Requests frames whose arguments include the POST body. + raise AssertionError( + "The real token request transport failed before receiving a response." + ) from None diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 78be5fa0..5af9b45c 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -528,11 +528,16 @@ def _acquire_recorded( def _call_graph_with_mtls_pop_token(self, token, binding_certificate): __tracebackhide__ = True - with binding_certificate.create_requests_session() as session: - resp = session.get( - self._MTLS_GRAPH_RESOURCE, - headers={"Authorization": "mtls_pop " + token}, - timeout=30, allow_redirects=False) + try: + with binding_certificate.create_requests_session() as session: + resp = session.get( + self._MTLS_GRAPH_RESOURCE, + headers={"Authorization": "mtls_pop " + token}, + timeout=30, allow_redirects=False) + except requests.exceptions.RequestException: + raise AssertionError( + "The real Graph transport failed before receiving a response." + ) from None self.assertEqual( 200, resp.status_code, "The real Graph resource must accept the bound PoP token.") @@ -540,12 +545,17 @@ def _call_graph_with_mtls_pop_token(self, token, binding_certificate): def _assert_graph_rejects(self, token, scheme, binding, marker): __tracebackhide__ = True - with (binding.create_requests_session() if binding is not None - else requests.Session()) as session: - response = session.get( - self._MTLS_GRAPH_RESOURCE, - headers={"Authorization": scheme + " " + token}, - timeout=30, allow_redirects=False) + try: + with (binding.create_requests_session() if binding is not None + else requests.Session()) as session: + response = session.get( + self._MTLS_GRAPH_RESOURCE, + headers={"Authorization": scheme + " " + token}, + timeout=30, allow_redirects=False) + except requests.exceptions.RequestException: + raise AssertionError( + "The real Graph transport failed before receiving a response." + ) from None observed = tuple( candidate for candidate in GRAPH_REJECTION_MARKERS if candidate in response.text) diff --git a/tests/test_mtls_e2e_helpers.py b/tests/test_mtls_e2e_helpers.py index 25133637..c037d894 100644 --- a/tests/test_mtls_e2e_helpers.py +++ b/tests/test_mtls_e2e_helpers.py @@ -5,6 +5,7 @@ import io import json import os +import socket import time from types import SimpleNamespace import unittest @@ -13,6 +14,7 @@ import jwt import pytest import requests +from urllib3.exceptions import MaxRetryError import msal from msal.application import _load_mtls_cert_material @@ -196,6 +198,51 @@ def test_graph_rejection_failure_traceback_hides_the_access_token(self): token, "mtls_pop", None, GRAPH_REJECTION_MARKERS[0]) self._assert_private_traceback(failure, token) + def test_graph_dns_failure_tracebacks_hide_tokens_and_fragments(self): + token = _jwt({"aud": "https://graph.microsoft.com", "nonce": "dns-privacy"}) + with patch.dict(os.environ, {"NO_PROXY": "*"}), patch( + "socket.getaddrinfo", + side_effect=socket.gaierror("offline DNS failure")) as resolve: + with pytest.raises(AssertionError) as failure: + self.scenario._call_graph_with_mtls_pop_token(token, self.binding) + self._assert_private_traceback(failure, token) + self.assertTrue(failure.value.__suppress_context__) + self.assertIsInstance( + failure.value.__context__, requests.exceptions.ConnectionError) + self.assertIsInstance(failure.value.__context__.__context__, MaxRetryError) + for binding in (None, self.binding): + with self.subTest(binding=binding is not None): + with pytest.raises(AssertionError) as failure: + self.scenario._assert_graph_rejects( + token, "mtls_pop", binding, GRAPH_REJECTION_MARKERS[0]) + self._assert_private_traceback(failure, token) + self.assertTrue(failure.value.__suppress_context__) + self.assertIsInstance( + failure.value.__context__, requests.exceptions.ConnectionError) + self.assertIsInstance( + failure.value.__context__.__context__, MaxRetryError) + self.assertGreaterEqual(resolve.call_count, 3) + + def test_default_recorder_dns_traceback_hides_posted_credentials(self): + form = self._signed_form() + form["refresh_token"] = "unit-refresh-secret-" + "sensitive" * 20 + with requests.Session() as session: + session.trust_env = False + with patch("socket.getaddrinfo", side_effect=socket.gaierror( + "offline DNS failure")) as resolve: + with pytest.raises(AssertionError) as failure: + with record_token_requests(self.binding) as records: + session.post(_ENDPOINT, data=form, timeout=1) + self._assert_private_traceback( + failure, form["client_assertion"], form["refresh_token"]) + self.assertTrue(failure.value.__suppress_context__) + self.assertIsInstance( + failure.value.__context__.__context__, requests.exceptions.ConnectionError) + self.assertIsInstance( + failure.value.__context__.__context__.__context__, MaxRetryError) + self.assertEqual([], records) + resolve.assert_called_once() + def test_unrelated_certificate_is_usable_and_not_the_fixture_certificate(self): binding = unrelated_binding() self.assertNotEqual( @@ -301,8 +348,8 @@ def send(request, **kwargs): requests.exceptions.SSLError("offline TLS failure"), requests.exceptions.Timeout("offline timeout")): adapter.send.side_effect = error - with record_token_requests(self.binding) as records: - with self.assertRaises(type(error)): + with self.assertRaisesRegex(AssertionError, "token request transport failed"): + with record_token_requests(self.binding) as records: session.post(_ENDPOINT, data=self._signed_form()) self.assertEqual([], records) From 4206e321f6c472aaa1f34be5a41222500f670ae5 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 27 Oct 2026 12:42:33 -0400 Subject: [PATCH 24/27] Sanitize recorder validation and remove equal legacy token copies Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/token_cache.py | 13 ++++----- tests/README.md | 9 ++++-- tests/mtls_e2e_helpers.py | 22 ++++++++++++--- tests/test_mtls_e2e_helpers.py | 40 +++++++++++++++++++++++++++ tests/test_token_cache.py | 50 ++++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 15 deletions(-) diff --git a/msal/token_cache.py b/msal/token_cache.py index d3f8609e..1dfa2a42 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -670,14 +670,11 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None): if is_at: index.setdefault(identity, set()).add((bucket, key)) elif is_at: - if entries.get(key) == old_entry: - self._remove_access_token_entry(bucket, key) - else: - # Persisted caches may still use an older AT key format. - for stored_bucket, stored_key in tuple( - index.get(_access_token_identity(old_entry), ())): - if self._cache[stored_bucket].get(stored_key) == old_entry: - self._remove_access_token_entry(stored_bucket, stored_key) + # Equal copies can coexist under canonical and older AT keys. + for stored_bucket, stored_key in tuple( + index.get(_access_token_identity(old_entry), ())): + if self._cache[stored_bucket].get(stored_key) == old_entry: + self._remove_access_token_entry(stored_bucket, stored_key) else: # Remove old_entry entries.pop(key, None) if is_at: diff --git a/tests/README.md b/tests/README.md index 40948bfa..2aa00437 100644 --- a/tests/README.md +++ b/tests/README.md @@ -96,13 +96,16 @@ These checks use local fixtures or synthetic responses to verify strict binding, safe recording, Graph rejection gates, the exact required-method inventory, and real unittest-runner failure rather than skip when credentials are missing. They render failure tracebacks with argument display enabled and -locals disabled to check token-result, cache, and Graph helpers for complete -tokens and truncated token fragments. The sample-output guard rejects dynamic -result values in printed summaries. +locals disabled to check token-result, cache, Graph, and real Requests POST +failures for complete tokens and truncated token fragments. Recorder guards +cover transport failures, malformed JSON, and invalid assertion signatures. +The sample-output guard rejects dynamic result values in printed summaries. Cache compatibility and write-scaling regressions count actual access-token bucket visits and identity operations, requiring no steady-state bucket scans and linear population work while preserving legacy renewal and persistence. +Native removal must delete equal canonical and legacy copies without a reload, +while preserving renewed replacements and other certificates. ### Pinned core certificate parity mapping diff --git a/tests/mtls_e2e_helpers.py b/tests/mtls_e2e_helpers.py index bacf20e8..a10de269 100644 --- a/tests/mtls_e2e_helpers.py +++ b/tests/mtls_e2e_helpers.py @@ -33,6 +33,10 @@ class _TokenTransportError(AssertionError): pass +class _TokenValidationError(AssertionError): + pass + + def jwt_claims(token): __tracebackhide__ = True if not isinstance(token, str) or len(token.split(".")) != 3: @@ -166,10 +170,16 @@ def recording_send(session, request, **kwargs): raise _TokenTransportError( "The real token request transport failed before receiving a response." ) from None - summary = token_request_summary( - response, binding, session.get_adapter(request.url), - expected_assertion=expected_assertion, - assertion_audience=assertion_audience) + adapter = session.get_adapter(request.url) + try: + summary = token_request_summary( + response, binding, adapter, + expected_assertion=expected_assertion, + assertion_audience=assertion_audience) + except AssertionError: + raise _TokenValidationError( + "The real token exchange failed recording validation." + ) from None if summary is not None: records.append(summary) return response @@ -182,3 +192,7 @@ def recording_send(session, request, **kwargs): raise AssertionError( "The real token request transport failed before receiving a response." ) from None + except _TokenValidationError: + raise AssertionError( + "The real token exchange failed recording validation." + ) from None diff --git a/tests/test_mtls_e2e_helpers.py b/tests/test_mtls_e2e_helpers.py index c037d894..765c891a 100644 --- a/tests/test_mtls_e2e_helpers.py +++ b/tests/test_mtls_e2e_helpers.py @@ -243,6 +243,46 @@ def test_default_recorder_dns_traceback_hides_posted_credentials(self): self.assertEqual([], records) resolve.assert_called_once() + def test_default_recorder_validation_tracebacks_hide_posted_credentials(self): + for invalid_response in (True, False): + with self.subTest(invalid_response=invalid_response): + form = self._signed_form() + form["refresh_token"] = "unit-refresh-secret-" + "sensitive" * 20 + if not invalid_response: + parts = form["client_assertion"].split(".") + parts[2] = ("A" if parts[2][0] != "A" else "B") + parts[2][1:] + form["client_assertion"] = ".".join(parts) + response = self._response(form=form) + if invalid_response: + response._content = b"not-json" + + def send(request, **kwargs): + response.request = request + return response + + with requests.Session() as session: + session.trust_env = False + adapter = session.get_adapter(_ENDPOINT) + with patch.object(adapter, "send", side_effect=send) as sent: + with pytest.raises(AssertionError) as failure: + with record_token_requests(self.binding) as records: + session.post(_ENDPOINT, data=form) + self._assert_private_traceback( + failure, form["client_assertion"], form["refresh_token"]) + self.assertEqual( + "The real token exchange failed recording validation.", + str(failure.value)) + self.assertTrue(failure.value.__suppress_context__) + self.assertEqual([], records) + sent.assert_called_once() + + def test_default_recorder_preserves_unrelated_assertion_failures(self): + original = AssertionError("Unrelated caller assertion.") + with pytest.raises(AssertionError) as failure: + with record_token_requests(self.binding): + raise original + self.assertIs(original, failure.value) + def test_unrelated_certificate_is_usable_and_not_the_fixture_certificate(self): binding = unrelated_binding() self.assertNotEqual( diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 8baae02c..1380d4b2 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -1186,6 +1186,56 @@ def test_removal_preserves_replacements_and_cleans_legacy_duplicates(self): self.assertEqual(["renewed"], [ entry["secret"] for entry in self.search(scheme, "Thumb")]) + def test_native_removal_cleans_canonical_and_equal_legacy_duplicates(self): + for scheme in ("ssh-cert", "mtls_pop"): + for renewed in (False, True): + with self.subTest(scheme=scheme, renewed=renewed): + self.cache = TokenCache() + self.cache.add(self.event(scheme, "old"), now=1000) + old = self.search(scheme, "Thumb")[0] + self.cache.add(self.event( + scheme, "other-certificate", key_id="thumb", + scopes=["other-scope"]), now=1000) + if renewed: + self.cache.add(self.event(scheme, "renewed"), now=1001) + entries = dict(self.cache._cache.get("AccessToken", {})) + entries["legacy:duplicate"] = dict(old) + self.cache._cache["AccessToken"] = entries + self.cache.remove_at(old) + self.assertEqual( + ["renewed"] if renewed else [], + [entry["secret"] for entry in self.search(scheme, "Thumb")]) + self.assertEqual(["other-certificate"], [ + entry["secret"] for entry in self.search( + scheme, "thumb", target=["other-scope"])]) + self.assertNotIn("legacy:duplicate", entries) + + def test_native_duplicate_removal_uses_only_indexed_identity_locations(self): + from msal import token_cache + + for scheme in ("ssh-cert", "mtls_pop"): + for count in (100, 1000, 10000): + with self.subTest(scheme=scheme, count=count): + self.cache, buckets = self.canonical_cache(count) + self.cache.add(self.event(scheme, "old"), now=1000) + old = self.search(scheme, "Thumb")[0] + entries = _CountingAccessTokenEntries( + self.cache._cache["AccessToken"]) + entries["legacy:duplicate"] = dict(old) + self.cache._cache["AccessToken"] = entries + buckets["AccessToken"] = entries + self.cache._get_access_token_index() + for bucket in buckets.values(): + bucket.visits = 0 + with patch.object( + token_cache, "_access_token_identity", + wraps=token_cache._access_token_identity) as identity: + self.cache.remove_at(old) + self.assertEqual( + 0, sum(bucket.visits for bucket in buckets.values())) + self.assertLessEqual(identity.call_count, 3) + self.assertEqual([], self.search(scheme, "Thumb")) + class SerializableTokenCacheTestCase(unittest.TestCase): # Run all inherited test methods, and have extra check in tearDown() From c587a89951b8d155d198328148daa3ceed0e4d71 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Mon, 2 Nov 2026 08:28:11 -0500 Subject: [PATCH 25/27] Harden mTLS credential snapshots and live-test contracts Encrypt temporary TLS keys, validate certificate bindings before cache lookup, preserve per-application signing and TLS material, normalize the Government alias, and treat Graph tokens as opaque. Gate credentialed E2Es to trusted runs while retaining fail-closed lab configuration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .Pipelines/CI-AND-RELEASE-PIPELINES.md | 2 +- .Pipelines/template-pipeline-stages.yml | 10 +- msal/application.py | 77 +++++--- msal/mtls.py | 37 +++- tests/README.md | 38 +++- tests/test_application.py | 154 ++++++++++++++++ tests/test_e2e.py | 28 +-- tests/test_lab_config.py | 38 ++++ tests/test_mtls_e2e_helpers.py | 92 +++++++++- tests/test_mtls_transport.py | 231 +++++++++++++++++++++++- 10 files changed, 644 insertions(+), 63 deletions(-) diff --git a/.Pipelines/CI-AND-RELEASE-PIPELINES.md b/.Pipelines/CI-AND-RELEASE-PIPELINES.md index 59df0da9..a6d58258 100644 --- a/.Pipelines/CI-AND-RELEASE-PIPELINES.md +++ b/.Pipelines/CI-AND-RELEASE-PIPELINES.md @@ -41,7 +41,7 @@ PreBuildCheck ─► UnitTests ─► E2ETests ─► Benchmark (post-merge to d |-------|-------------|-------------| | **PreBuildCheck** | Runs SDL security scans: PoliCheck (policy/offensive content), CredScan (leaked credentials), and PostAnalysis (breaks the build on findings) | Always | | **UnitTests** | Runs the unit test suite on Python 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14 (no Key Vault required) | After PreBuildCheck | -| **E2ETests** | Fetches the MSID Lab certificate from Key Vault and runs `tests/test_e2e.py` + `tests/test_fmi_e2e.py` on the same Python matrix. On forked PRs the Key Vault tasks remain skipped to protect secrets. Required certificate scenarios fail when `LAB_APP_CLIENT_CERT_PFX_PATH` is missing or invalid; missing credentials cannot produce green skipped coverage. Legacy optional skips are unchanged. | After UnitTests | +| **E2ETests** | Fetches the MSID Lab certificate from Key Vault and runs `tests/test_e2e.py` + `tests/test_fmi_e2e.py` on the same Python matrix. Fork PRs run offline unit tests but skip this credentialed stage to protect secrets; stage exclusion is not live E2E coverage. On trusted runs, missing or invalid `LAB_APP_CLIENT_CERT_PFX_PATH` fails required certificate scenarios rather than skipping them. Legacy optional skips are unchanged. | After UnitTests, trusted runs only | | **Benchmark** | Runs performance benchmarks on Python 3.9 and publishes `benchmark-results` artifact | Post-merge pushes to `dev` and manual runs only | The `Validate` stage is **skipped** on PR/CI runs (it only applies to release builds). diff --git a/.Pipelines/template-pipeline-stages.yml b/.Pipelines/template-pipeline-stages.yml index 1f2077fa..98799b6f 100644 --- a/.Pipelines/template-pipeline-stages.yml +++ b/.Pipelines/template-pipeline-stages.yml @@ -182,18 +182,16 @@ stages: # ══════════════════════════════════════════════════════════════════════════════ # Stage 3 - E2ETests - runs only if unit tests pass. Fetches the MSID Lab # certificate from Key Vault. -# Fork behaviour: the stage still runs on forked PRs, but the -# Key Vault retrieval and certificate decoding steps are skipped -# via `ne(System.PullRequest.IsFork, 'True')`. Required certificate -# scenarios then fail because LAB_APP_CLIENT_CERT_PFX_PATH is unset; -# missing credentials must not produce green skipped E2E coverage. +# Fork behaviour: offline unit tests run, but this credentialed stage +# is skipped for forked PRs. Trusted runs require the lab certificate; +# missing credentials must fail, not produce green skipped coverage. # Legacy optional scenarios retain their existing skips. # ═══════════════════════════════════════════════════════════════════════════ - stage: E2ETests displayName: 'E2E tests' dependsOn: UnitTests - condition: eq(dependencies.UnitTests.result, 'Succeeded') + condition: and(eq(dependencies.UnitTests.result, 'Succeeded'), ne(variables['System.PullRequest.IsFork'], 'True')) jobs: - job: Pytest displayName: 'pytest' diff --git a/msal/application.py b/msal/application.py index 4b6d9ff5..bffc9b4c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -208,7 +208,7 @@ def _private_key_to_unencrypted_pem(private_key, passphrase_bytes=None): ``private_key`` may be a ``cryptography`` private-key object (as returned by ``_parse_pfx``) or a PEM string/bytes (the raw ``private_key`` credential). - The result is suitable for ``ssl.SSLContext.load_cert_chain`` via a temp file. + The result stays in memory; the TLS helper encrypts any temporary key file. """ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend @@ -234,15 +234,25 @@ def _private_key_to_unencrypted_pem(private_key, passphrase_bytes=None): def _load_mtls_cert_material(cert_credential): """Load client-cert material for an mTLS PoP handshake from a cert credential. - ``cert_credential`` is the app's main ``client_credential`` (vanilla SN/I), - a certificate credential dict. Returns a dict with the unencrypted-PEM private key, - the certificate chain PEM, ``x5c``, the SHA-256 thumbprint (hex), and ``key_id`` - (base64url ``x5t#S256``, used for cache binding). Raises ``ValueError`` when - the credential cannot yield mTLS-capable certificate material. + Accept a certificate credential dict or an existing process-local binding + context. Return the in-memory private key PEM, certificate chain PEM, ``x5c``, + SHA-256/SHA-1 thumbprints (hex), and ``key_id`` (base64url ``x5t#S256``). + Raise ``ValueError`` for mismatched keys or unusable certificate material. """ + if isinstance(cert_credential, mtls.MtlsBindingContext): + material = cert_credential._get_mtls_cert_material() + if (material["key_id"] != cert_credential.thumbprint_sha256 + or material["x5c"] != cert_credential.x5c): + raise ValueError( + "The binding context does not match its certificate material.") + return material passphrase_bytes = _str2bytes( cert_credential["passphrase"] ) if cert_credential.get("passphrase") else None + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes, serialization + if cert_credential.get("private_key_pfx_path"): private_key, sha256_thumbprint, _, x5c, cert_pem = _parse_pfx( cert_credential["private_key_pfx_path"], passphrase_bytes, @@ -254,8 +264,6 @@ def _load_mtls_cert_material(cert_credential): "mTLS Proof-of-Possession requires the certificate's public part. " "Provide 'public_certificate' (PEM) alongside 'private_key', " "or use 'private_key_pfx_path'.") - from cryptography import x509 - from cryptography.hazmat.backends import default_backend cert = x509.load_pem_x509_certificate( _str2bytes(public_cert), backend=default_backend()) sha256_thumbprint, _, x5c, cert_pem = _extract_cert_and_thumbprints(cert) @@ -273,14 +281,25 @@ def _load_mtls_cert_material(cert_credential): if not sha256_thumbprint: raise ValueError( "mTLS Proof-of-Possession requires a SHA-256 certificate thumbprint.") + private_key_pem = _private_key_to_unencrypted_pem( + private_key, passphrase_bytes) + key = _load_private_key_from_pem_str(private_key_pem, None) + leaf = x509.load_pem_x509_certificate( + _str2bytes(cert_pem), backend=default_backend()) + public_key_format = ( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) + if (key.public_key().public_bytes(*public_key_format) + != leaf.public_key().public_bytes(*public_key_format)): + raise ValueError( + "The mTLS private key does not match the leaf certificate.") key_id = base64.urlsafe_b64encode( bytes.fromhex(sha256_thumbprint)).rstrip(b"=").decode("ascii") return { - "private_key_pem": _private_key_to_unencrypted_pem( - private_key, passphrase_bytes), + "private_key_pem": private_key_pem, "cert_pem": cert_pem if isinstance(cert_pem, bytes) else _str2bytes(cert_pem), "x5c": x5c, "sha256_thumbprint": sha256_thumbprint, + "sha1_thumbprint": leaf.fingerprint(hashes.SHA1()).hex(), "key_id": key_id, } @@ -864,6 +883,9 @@ def get_client_assertion(): Note: Broker will NOT be used for OIDC authority. """ self.client_id = client_id + client_credential = ( + dict(client_credential) if isinstance(client_credential, dict) + else client_credential) self.client_credential = client_credential self.client_claims = client_claims self._client_capabilities = client_capabilities @@ -963,15 +985,9 @@ def get_client_assertion(): self._region_detected = None self._region_resolved = False self._region_to_use = None - self.client, self._regional_client = self._build_client( - client_credential, self.authority) - - # mTLS Proof-of-Possession state. The certificate material and the - # dedicated mTLS client(s) are parsed/built lazily on the first - # mtls_proof_of_possession request (see _get_mtls_pop_cert / - # _get_mtls_client), so Bearer-only cert apps pay nothing and the - # sovereign/tenanted guardrails fire at request time. - self._mtls_pop_cert_material = None # Cached parse of the cert below + # Signing and TLS use one application-local certificate snapshot. + # PFX signing resolves it during _build_client; PEM TLS remains lazy. + self._mtls_pop_cert_material = None self._mtls_client = None # Lazily built global mTLS client self._mtls_regional_client = None # Lazily built regional mTLS client self._mtls_lock = Lock() @@ -983,6 +999,9 @@ def get_client_assertion(): else: # No certificate available to present over mTLS self._mtls_cert_credential = None + self.client, self._regional_client = self._build_client( + client_credential, self.authority) + # Warn if using a static string/bytes client_assertion (discouraged for long-running apps) if isinstance(client_credential, dict) and isinstance( client_credential.get("client_assertion"), (str, bytes)): @@ -1267,9 +1286,11 @@ def _build_client(self, client_credential, authority, skip_regional_client=False client_credential["passphrase"] ) if client_credential.get("passphrase") else None if client_credential.get("private_key_pfx_path"): - private_key, sha256_thumbprint, sha1_thumbprint, x5c, _ = _parse_pfx( - client_credential["private_key_pfx_path"], - passphrase_bytes) + material = self._get_mtls_pop_cert() + private_key = material["private_key_pem"] + sha256_thumbprint = material["sha256_thumbprint"] + sha1_thumbprint = material["sha1_thumbprint"] + x5c = material["x5c"][:1] if client_credential.get("public_certificate") is True and x5c: headers["x5c"] = x5c elif client_credential.get("private_key"): # PEM blob @@ -2985,6 +3006,10 @@ def acquire_token_for_client( A ``post`` transport override is also rejected. If the provider returns another token type, the result is ``token_type_mismatch`` and the rejected response is not cached. + Nonempty constructor ``client_claims`` are rejected for this + certificate-only authentication: no signed client assertion is + sent. ``claims_challenge`` and ``forwarded_client_claims`` retain + their separate OAuth claims contracts. On success the result also contains an :py:class:`msal.MtlsBindingContext` as ``binding_certificate``. It @@ -3048,6 +3073,14 @@ def acquire_token_for_client( kwargs["data"] = kwargs.get("data", {}) _stash_client_claims(forwarded_client_claims, kwargs["data"]) if mtls_proof_of_possession: + if (self.client_claims + and isinstance(self.client_credential, dict) + and not self.client_credential.get("client_assertion") + and (self.client_credential.get("private_key_pfx_path") + or self.client_credential.get("private_key"))): + raise ValueError( + "Certificate-only mTLS PoP does not send a signed client " + "assertion and cannot use constructor client_claims.") if kwargs.get("cert") is not None: raise ValueError( "mtls_proof_of_possession=True does not support a cert " diff --git a/msal/mtls.py b/msal/mtls.py index 67b2e31b..33dd9329 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -40,6 +40,11 @@ "login.windows.net", ]) +_GOVERNMENT_LOGIN_HOSTS = frozenset([ + "login.microsoftonline.us", + "login.usgovcloudapi.net", + ]) + # ───────────────────────────────────────────────────────────────────────────── # SOVEREIGN GUARDRAIL - single override point for mTLS cloud availability. # @@ -47,9 +52,6 @@ # 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.", } @@ -70,7 +72,7 @@ def mtls_pop_host(instance, region=None): # Known public aliases (including legacy login.windows.net) # all normalize to the single global mTLS host. base = _PUBLIC_MTLS_HOST - elif instance == "login.microsoftonline.us": + elif instance in _GOVERNMENT_LOGIN_HOSTS: base = "mtlsauth.microsoftonline.us" else: raise ValueError( @@ -152,6 +154,14 @@ def create_requests_session(self, verify=True, proxies=None): _make_mtls_adapter(ssl_context_factory=self.create_ssl_context)) return session + def _get_mtls_cert_material(self): + from .application import _load_mtls_cert_material + + return _load_mtls_cert_material({ + "private_key": self.__key_pem, + "public_certificate": self.__cert_pem.decode("ascii"), + }) + def __getitem__(self, key): if key == "x5c": return self.x5c @@ -383,21 +393,30 @@ def _create_ssl_context(cert_pem, key_pem, verify=True): """Build a client ``ssl.SSLContext`` that presents ``cert_pem``/``key_pem``. ``ssl.SSLContext.load_cert_chain`` requires a file path, but our key is - in memory. We write a ``0600`` temp PEM (mkstemp defaults to owner-only), - load it, then unlink it immediately - the context keeps the material in - memory, so nothing sensitive lingers on disk. + in memory. Encrypt the temporary PKCS8 key with a random password kept only + in this process, load it, then unlink it. A crash or cleanup failure leaves + only encrypted key material, not the input's unencrypted private key. """ import os import tempfile + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + context = _new_verifying_context(verify) # Verifies the server (ESTS) + password = os.urandom(32).hex().encode("ascii") + key = serialization.load_pem_private_key( + key_pem, password=None, backend=default_backend()) + encrypted_key = key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(password)) fd, path = tempfile.mkstemp(suffix=".pem") # Owner-only (0600) by default try: # os.fdopen() takes ownership of fd and guarantees it is closed even if # writing raises; file.write() writes every byte (os.write() may perform # a short write). Closing the fd also lets Windows unlink the file below. with os.fdopen(fd, "wb") as f: - f.write(key_pem + b"\n" + cert_pem) - context.load_cert_chain(path) # Loads our client cert+key into memory + f.write(encrypted_key + b"\n" + cert_pem) + context.load_cert_chain(path, password=password) finally: try: os.remove(path) # Unlink immediately; minimal disk exposure diff --git a/tests/README.md b/tests/README.md index 2aa00437..314ffa4e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -37,6 +37,25 @@ Notes: from the MSID Lab `LabAuth` secret, without a PFX password. Missing, unreadable, or invalid PFX files fail these scenarios; they never count as skipped coverage. +### Exportable-certificate scope and deferred protected keys + +This feature supports exportable PEM/PFX certificate credentials. A custom HTTP +transport receives exportable certificate material; it does not add native +Windows CNG or non-exportable-key support. Each application retains a fixed +certificate snapshot for signing, TLS, and cache binding. Construct a new +application to rotate a file credential. Temporary TLS keys are encrypted with +a random process-local password and removed after loading. + +Protected-key support is a separate, deferred deliverable, not covered by this +lab's parity claims. Its Windows acceptance test must prove that the private +key cannot be exported, acquire a token using that key, call Graph successfully, +and repeat from cache with the same certificate binding. + +Government authorities `login.microsoftonline.us` and its legacy alias +`login.usgovcloudapi.net` both use `[region.]mtlsauth.microsoftonline.us`. +This normalizes the Government preferred network host; it does not enable +additional sovereign clouds. + ## 4) Run unit/integration tests Run all non-E2E tests quickly: @@ -73,18 +92,22 @@ and an unrelated locally generated certificate Both acquisition orders also verify Bearer/PoP isolation in the same application, cache, and Key Vault scope. -Graph and Key Vault PoP tokens require readable JWTs, matching SHA256 `cnf` -bindings, and the requested resource audience. Key Vault accepts its verified +Graph access tokens are opaque to the client: the suite checks the returned +binding identity and lets Graph validate the same token through the success +and rejection controls. Readable JWT, SHA256 `cnf`, and audience assertions are +reserved for lab resources with that token-format contract. Key Vault accepts its verified URI or application-ID alias `cfa8b339-82a2-471a-a3c9-0fc0be7a4093`. Recorders delegate to the real default `Session.send`, preserving the TLS adapter, and verify signed certificate assertions against the certificate, client, and expected audience. They retain bounded status/error metadata, never raw forms, tokens, private keys, or client assertions. -All six methods run without skips, including tagged releases. Missing +On trusted CI runs, all six methods run without skips, including tagged releases. Missing configuration, credential failures, and DNS/TLS failures are failures, not acceptable rejection evidence. They do not fetch unrelated user configuration from Key Vault. Legacy interactive and other optional E2E skips are unchanged. +Fork PRs run the offline suite but do not schedule the credentialed E2E stage; +that stage exclusion is not live E2E coverage. Offline guards are separate from live evidence: @@ -120,7 +143,7 @@ The applicable active reference cases below are pinned: | Active reference case(s) | Python method | Required observation | | --- | --- | --- | -| Go `TestCredential_X509_Output_Pop`; .NET `Credential_X509_Output_Pop_TestAsync` | `C.test_credential_x509_output_pop` | Graph PoP, returned certificate and JWT SHA256 `cnf`, resource HTTP 200, all three same-token rejection markers, cached repeat. | +| Go `TestCredential_X509_Output_Pop`; .NET `Credential_X509_Output_Pop_TestAsync` | `C.test_credential_x509_output_pop` | Opaque Graph PoP token, returned certificate identity, resource HTTP 200, all three same-token rejection markers, cached repeat. | | Go `TestCredential_X509_Output_Bearer` | `C.test_credential_x509_output_bearer` | Ordinary westus3 Key Vault Bearer, no output binding, signed certificate assertion with central audience, cached repeat. | | Go `TestCredential_X509_Output_Pop_Regional` | `C.test_credential_x509_output_pop_regional` | westus3 Key Vault PoP, certificate/JWT binding, same cached token and type; not a redirected global Graph cell. | | Go `TestConfidentialClientSNIBearerAndMtlsPoPCacheIsolated` | `C.test_bearer_then_mtls_pop_cache_isolated` | One app/cache/scope, Bearer then PoP, distinct tokens and schemes, no Bearer binding, both cache results preserved. | @@ -134,6 +157,13 @@ provision apps or claim repository-wide skip elimination. [dotnet-cc]: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/c8b90e9de5bc4974de9928835532faac5c88e2d2/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs [go-pop]: https://github.com/AzureAD/microsoft-authentication-library-for-go/blob/903eb9f1e54e361525025ea27040ac9644b64358/apps/tests/integration/mtls_pop_integration_test.go +The Government alias mapping follows the same pinned .NET +[known cloud metadata][dotnet-clouds] and +[regional/mTLS transformation][dotnet-mtls-hosts]. + +[dotnet-clouds]: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/c8b90e9de5bc4974de9928835532faac5c88e2d2/src/client/Microsoft.Identity.Client/Instance/Discovery/KnownCloudData.cs +[dotnet-mtls-hosts]: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/c8b90e9de5bc4974de9928835532faac5c88e2d2/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs + ## 5) Manual-intervention E2E tests Manual tests (interactive browser/device-flow/POP manual scenarios) are separated into: diff --git a/tests/test_application.py b/tests/test_application.py index 03c1607a..bd9b977a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2303,6 +2303,133 @@ def mock_post(url, headers=None, data=None, *a, **k): "access_token": "an_at", "expires_in": 3600, "token_type": token_type})) return mock_post + def test_constructor_claims_are_rejected_before_cold_or_warm_pop_cache(self): + for warm in (False, True): + with self.subTest(warm=warm): + producer = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority=self._AUTHORITY, azure_region=False) + self.addCleanup(producer.http_client.close) + if warm: + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([])): + producer.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.addCleanup(producer._mtls_client._http_client.close) + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + client_claims={"signed-claim": "must-not-be-dropped"}, + authority=self._AUTHORITY, azure_region=False, + token_cache=producer.token_cache) + self.addCleanup(app.http_client.close) + with patch.object(app, "_acquire_token_silent_with_error") as lookup: + with self.assertRaisesRegex(ValueError, "constructor client_claims"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + lookup.assert_not_called() + + def test_ordinary_bearer_preserves_signed_constructor_claims(self): + import jwt + from cryptography import x509 + from msal.application import _load_mtls_cert_material + + material = _load_mtls_cert_material(_MTLS_CERT_CRED) + certificate = x509.load_pem_x509_certificate(material["cert_pem"]) + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + client_claims={"signed-claim": "retained"}, + authority=self._AUTHORITY, azure_region=False) + self.addCleanup(app.http_client.close) + captured = [] + result = app.acquire_token_for_client( + ["s1"], post=self._capturing_post(captured, "Bearer")) + claims = jwt.decode( + captured[0]["data"]["client_assertion"], certificate.public_key(), + algorithms=["PS256"], audience=app.authority.token_endpoint, + issuer="cid") + self.assertEqual("retained", claims["signed-claim"]) + self.assertEqual("Bearer", result["token_type"]) + + def test_pop_preserves_challenge_and_forwarded_claims_without_signed_claims(self): + for constructor_claims in (None, {}): + with self.subTest(constructor_claims=constructor_claims): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + client_claims=constructor_claims, + authority=self._AUTHORITY, azure_region=False) + self.addCleanup(app.http_client.close) + captured = [] + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, + claims_challenge='{"access_token":{"challenge":null}}', + forwarded_client_claims='{"access_token":{"forwarded":null}}') + self.addCleanup(app._mtls_client._http_client.close) + self.assertEqual("mtls_pop", result["token_type"]) + self.assertEqual( + {"access_token": {"challenge": None, "forwarded": None}}, + json.loads(captured[0]["data"]["claims"])) + self.assertNotIn("client_assertion", captured[0]["data"]) + + def test_mismatched_pem_is_rejected_before_cold_or_warm_pop_cache(self): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from msal.application import ( + _load_mtls_cert_material, _private_key_to_unencrypted_pem) + + material = _load_mtls_cert_material(_MTLS_CERT_CRED) + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + plain = _private_key_to_unencrypted_pem(other_key) + encrypted = other_key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"synthetic-passphrase")) + for warm in (False, True): + producer = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority=self._AUTHORITY, azure_region=False) + self.addCleanup(producer.http_client.close) + if warm: + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([])): + producer.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.addCleanup(producer._mtls_client._http_client.close) + for key, password in ( + (plain.decode("ascii"), None), (plain, None), + (other_key, None), (encrypted, b"synthetic-passphrase")): + with self.subTest(warm=warm, key=type(key).__name__, + encrypted=password is not None): + app = ConfidentialClientApplication( + "cid", client_credential={ + "private_key": key, "passphrase": password, + "public_certificate": material["cert_pem"].decode("ascii"), + }, authority=self._AUTHORITY, azure_region=False, + token_cache=producer.token_cache) + self.addCleanup(app.http_client.close) + with patch.object( + app, "_acquire_token_silent_with_error") as lookup: + with self.assertRaisesRegex(ValueError, "does not match"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + lookup.assert_not_called() + valid = ConfidentialClientApplication( + "cid", client_credential={ + "private_key": material["private_key_pem"], + "public_certificate": material["cert_pem"].decode("ascii"), + }, authority=self._AUTHORITY, azure_region=False, + token_cache=producer.token_cache) + self.addCleanup(valid.http_client.close) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([])) as post: + result = valid.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual(0 if warm else 1, post.call_count) + self.assertEqual( + material["key_id"], result["binding_certificate"].thumbprint_sha256) + if valid._mtls_client is not None: + self.addCleanup(valid._mtls_client._http_client.close) + def test_certificate_override_is_rejected_before_cache_lookup(self): app = ConfidentialClientApplication( "cid", client_credential=_MTLS_CERT_CRED, @@ -2610,6 +2737,33 @@ def test_regional_mtls_endpoint(self): captured[0]["url"], "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + def test_government_alias_requests_normalized_global_and_regional_endpoints(self): + authority = "https://login.usgovcloudapi.net/my_tenant" + endpoint = authority + "/oauth2/v2.0/token" + with patch(_OIDC_DISCOVERY, return_value={ + "authorization_endpoint": endpoint.replace("/token", "/authorize"), + "token_endpoint": endpoint, "issuer": authority + "/v2.0"}), patch( + "msal.application._detect_region", return_value="usgovvirginia"): + for region in (False, "usgovvirginia"): + with self.subTest(region=region): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, + authority=authority, azure_region=region) + self.addCleanup(app.http_client.close) + captured = [] + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + client = app._mtls_regional_client if region else app._mtls_client + self.addCleanup(client._http_client.close) + host = ("usgovvirginia." if region else "") + ( + "mtlsauth.microsoftonline.us") + self.assertEqual( + "https://{}/my_tenant/oauth2/v2.0/token".format(host), + captured[0]["url"]) + self.assertEqual("mtls_pop", result["token_type"]) + def test_secret_credential_with_flag_raises(self): app = ConfidentialClientApplication( "cid", client_credential="a_secret", authority=self._AUTHORITY) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 5af9b45c..6c4d6e6c 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -353,8 +353,6 @@ class MtlsPopTestCase(E2eTestCase): _SNI_ALLOWLISTED_CLIENT_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e" _SNI_ALLOWLISTED_AUTHORITY = "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c" _TOKEN_AUDIENCES = { - "https://graph.microsoft.com/.default": { - "https://graph.microsoft.com", "00000003-0000-0000-c000-000000000000"}, "https://vault.azure.net/.default": { "https://vault.azure.net", "cfa8b339-82a2-471a-a3c9-0fc0be7a4093"}, "https://storage.azure.com/.default": { @@ -387,6 +385,8 @@ def _result_summary(result): def _assert_audience(self, result, scope): __tracebackhide__ = True + if scope[0] == "https://graph.microsoft.com/.default": + return # Only Graph can interpret its access tokens. audience = jwt_claims(result["access_token"]).get("aud") self.assertTrue( isinstance(audience, str) @@ -412,8 +412,9 @@ def _assert_token_result(self, result, scope, token_type, expected_binding=None) self.assertFalse( "PRIVATE KEY" in repr(binding), "The binding context repr must not expose key material.") - # Reference FIC assertions are opaque; resource PoP JWTs require cnf. - if scope != ["api://AzureADTokenExchange/.default"]: + # Graph and FIC tokens are opaque to the client. Check claims only + # where the lab resource contract explicitly requires readable JWTs. + if scope[0] in self._TOKEN_AUDIENCES: self.assertEqual( binding.thumbprint_sha256, self._cnf_x5t_s256(result["access_token"])) @@ -564,6 +565,17 @@ def _assert_graph_rejects(self, token, scheme, binding, marker): self.assertIn(response.status_code, (401, 403), diagnostic) self.assertEqual((marker,), observed, diagnostic) + def _assert_graph_token_binding(self, token, binding): + __tracebackhide__ = True + response = self._call_graph_with_mtls_pop_token(token, binding) + for scheme, certificate, marker in ( + ("mtls_pop", None, GRAPH_REJECTION_MARKERS[0]), + ("Bearer", binding, GRAPH_REJECTION_MARKERS[1]), + ("mtls_pop", unrelated_binding(), GRAPH_REJECTION_MARKERS[2])): + with self.subTest(control=marker): + self._assert_graph_rejects(token, scheme, certificate, marker) + return response + class PublicCloudScenariosTestCase(E2eTestCase): # Historically this class was driven by tests/config.json for semi-automated runs. @@ -715,13 +727,7 @@ def test_credential_x509_output_pop(self): self.assertEqual(200, request["status_code"]) self.assertEqual("mtls_pop", request["token_type"]) token, binding = result["access_token"], result["binding_certificate"] - self._call_graph_with_mtls_pop_token(token, binding) - for scheme, certificate, marker in ( - ("mtls_pop", None, GRAPH_REJECTION_MARKERS[0]), - ("Bearer", binding, GRAPH_REJECTION_MARKERS[1]), - ("mtls_pop", unrelated_binding(), GRAPH_REJECTION_MARKERS[2])): - with self.subTest(control=marker): - self._assert_graph_rejects(token, scheme, certificate, marker) + self._assert_graph_token_binding(token, binding) self._assert_cached_result(app, self._MTLS_POP_SCOPE, result, pop=True) self.assertEqual(1, len(records), "The PoP repeat must come from cache.") diff --git a/tests/test_lab_config.py b/tests/test_lab_config.py index 0a97d7ea..dc7301e8 100644 --- a/tests/test_lab_config.py +++ b/tests/test_lab_config.py @@ -1,5 +1,7 @@ import io import os +from pathlib import Path +import re import tempfile import unittest from unittest.mock import patch @@ -10,6 +12,42 @@ from tests import test_e2e +class TestCredentialedPipelineConfiguration(unittest.TestCase): + @classmethod + def setUpClass(cls): + template = (Path(__file__).resolve().parents[1] + / ".Pipelines" / "template-pipeline-stages.yml") + cls.stages = template.read_text(encoding="utf-8") + + def _stage(self, name): + match = re.search( + r"(?ms)^- stage: " + name + r"\n(.*?)(?=^- stage:|\Z)", + self.stages) + self.assertIsNotNone(match) + return match.group(1) + + def test_credentialed_e2e_requires_successful_units_and_a_trusted_run(self): + stage = self._stage("E2ETests") + condition = re.search(r"(?m)^ condition: (.+)$", stage) + self.assertIsNotNone(condition) + self.assertEqual( + "and(eq(dependencies.UnitTests.result, 'Succeeded'), " + "ne(variables['System.PullRequest.IsFork'], 'True'))", + condition.group(1)) + self.assertIn("tests/test_e2e.py tests/test_fmi_e2e.py", stage) + self.assertIn("failTaskOnFailedTests: true", stage) + self.assertIn("if [ -z \"${LAB_AUTH_B64:-}\" ]; then", stage) + self.assertIn("exit 1", stage) + + def test_offline_units_run_for_forks_without_lab_credentials(self): + stage = self._stage("UnitTests") + self.assertNotIn("System.PullRequest.IsFork", stage) + self.assertNotIn("AzureKeyVault", stage) + self.assertNotIn("LAB_APP_CLIENT_CERT_PFX_PATH", stage) + self.assertIn("--ignore=tests/test_e2e.py", stage) + self.assertIn("--ignore=tests/test_fmi_e2e.py", stage) + + class TestRequiredCertificateConfiguration(unittest.TestCase): _REQUIRED_SCENARIOS = { test_e2e.CertificateScenariosTestCase: { diff --git a/tests/test_mtls_e2e_helpers.py b/tests/test_mtls_e2e_helpers.py index 765c891a..7a764954 100644 --- a/tests/test_mtls_e2e_helpers.py +++ b/tests/test_mtls_e2e_helpers.py @@ -112,17 +112,99 @@ def test_pop_checks_the_jwt_and_returned_certificate_binding(self): result = { "token_type": "mtls_pop", "binding_certificate": self.binding, "access_token": _jwt({ - "aud": "00000003-0000-0000-c000-000000000000", + "aud": "https://vault.azure.net", "cnf": {"x5t#S256": self.binding.thumbprint_sha256}}), } self.scenario._assert_token_result( - result, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + result, self.scenario._KEY_VAULT_SCOPE, "mtls_pop", self.binding) wrong = dict(result, access_token=_jwt({ - "aud": "https://graph.microsoft.com", + "aud": "https://vault.azure.net", "cnf": {"x5t#S256": "different-certificate"}})) with self.assertRaises(AssertionError): self.scenario._assert_token_result( - wrong, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + wrong, self.scenario._KEY_VAULT_SCOPE, "mtls_pop", self.binding) + + def test_graph_accepts_opaque_tokens_but_still_checks_binding_identity(self): + for token in ("opaque-graph-access-token", "encrypted.token.with.five.segments"): + result = { + "access_token": token, "token_type": "mtls_pop", + "binding_certificate": self.binding, + } + self.scenario._assert_token_result( + result, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + with self.assertRaises(AssertionError): + self.scenario._assert_token_result( + result, self.scenario._MTLS_POP_SCOPE, + "mtls_pop", unrelated_binding()) + + def test_opaque_graph_live_leaf_preserves_same_token_resource_controls(self): + token = "opaque-graph-access-token" + graph_requests = [] + token_requests = [] + binding_sessions = [] + create_session = msal.MtlsBindingContext.create_requests_session + + def session_for(binding, *args, **kwargs): + binding_sessions.append(binding) + return create_session(binding, *args, **kwargs) + + def send(adapter, request, **kwargs): + response = requests.Response() + response.request = request + response.url = request.url + if request.url == _ENDPOINT: + token_requests.append(request) + response.status_code = 200 + response._content = json.dumps({ + "access_token": token, "token_type": "mtls_pop", + "expires_in": 3600, + }).encode("ascii") + else: + index = len(graph_requests) + graph_requests.append(request) + response.status_code = 200 if index == 0 else 401 + response._content = ( + "[]" if index == 0 else GRAPH_REJECTION_MARKERS[index - 1] + ).encode("ascii") + return response + + app = Mock(_TOKEN_SOURCE="token_source", _TOKEN_SOURCE_CACHE="cache") + + def acquire(scope, mtls_proof_of_possession=False): + self.assertTrue(mtls_proof_of_possession) + cached = bool(token_requests) + if not cached: + with self.binding.create_requests_session() as session: + session.post(_ENDPOINT, data={ + "client_id": self.scenario._SNI_ALLOWLISTED_CLIENT_ID, + "grant_type": "client_credentials", + "token_type": "mtls_pop", "scope": " ".join(scope), + }) + return { + "access_token": token, "token_type": "mtls_pop", + "binding_certificate": self.binding, + "token_source": "cache" if cached else "identity_provider", + } + + app.acquire_token_for_client.side_effect = acquire + with patch.object(self.scenario, "_certificate", + return_value=(_CREDENTIAL, self.binding)), patch.object( + self.scenario, "_sni_app", return_value=app), patch.object( + msal.MtlsBindingContext, "create_requests_session", + new=session_for), patch( + "requests.adapters.HTTPAdapter.send", autospec=True, + side_effect=send): + self.scenario.test_credential_x509_output_pop() + self.assertEqual(1, len(token_requests)) + self.assertEqual(2, app.acquire_token_for_client.call_count) + self.assertEqual( + ["mtls_pop " + token, "mtls_pop " + token, + "Bearer " + token, "mtls_pop " + token], + [request.headers["Authorization"] for request in graph_requests]) + self.assertEqual([self.binding] * 3, binding_sessions[:3]) + self.assertNotEqual( + self.binding.thumbprint_sha256, + binding_sessions[3].thumbprint_sha256) def test_key_vault_accepts_its_verified_application_id_not_another_resource(self): for audience, accepted in ( @@ -152,7 +234,7 @@ def test_failure_tracebacks_do_not_expose_token_arguments(self): } with pytest.raises(AssertionError) as failure: self.scenario._assert_token_result( - result, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + result, self.scenario._KEY_VAULT_SCOPE, "mtls_pop", self.binding) self._assert_private_traceback(failure, token) def test_cached_result_failure_traceback_hides_original_token(self): diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index e77aa4dc..5a2c5d30 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -3,6 +3,8 @@ import json import os import ssl +import subprocess +import sys import tempfile import unittest from datetime import datetime, timedelta, timezone @@ -66,10 +68,19 @@ def test_transform_preserves_non_default_port(self): "login.microsoftonline.com"), "https://mtlsauth.microsoft.com:8443/t/token") - def test_sovereign_us_gov_fails_fast(self): - with self.assertRaises(ValueError) as cm: - mtls.mtls_pop_host("login.usgovcloudapi.net") - self.assertIn("login.microsoftonline.us", str(cm.exception)) + def test_government_alias_normalizes_global_and_regional_hosts(self): + for alias in ("login.usgovcloudapi.net", "LOGIN.USGOVCLOUDAPI.NET", + "login.microsoftonline.us"): + for region in (None, "usgovvirginia"): + with self.subTest(alias=alias, region=region): + host = ("usgovvirginia." if region else "") + ( + "mtlsauth.microsoftonline.us") + self.assertEqual(host, mtls.mtls_pop_host(alias, region)) + self.assertEqual( + "https://{}/tenant/oauth2/v2.0/token".format(host), + mtls.transform_token_endpoint( + "https://{}/tenant/oauth2/v2.0/token".format(alias), + alias, region)) def test_sovereign_china_fails_fast(self): with self.assertRaises(ValueError) as cm: @@ -91,7 +102,7 @@ def test_non_login_host_fails_fast(self): class TestMtlsSslContext(unittest.TestCase): - """The mTLS SSLContext is built from in-memory PEM and leaves no key on disk.""" + """Temporary TLS keys are encrypted and removed after loading.""" _PFX = os.path.join(os.path.dirname(__file__), "certificate-with-password.pfx") _PASSPHRASE = "password" @@ -155,6 +166,112 @@ def spy_mkstemp(*a, **k): os.path.exists(created["path"]), "temp key file must be unlinked immediately after load_cert_chain") + def _assert_encrypted_key_file(self, path, material): + from cryptography.hazmat.primitives import serialization + + contents = Path(path).read_bytes() + self.assertIn(b"-----BEGIN ENCRYPTED PRIVATE KEY-----", contents) + self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", contents) + self.assertNotIn(material["private_key_pem"], contents) + with self.assertRaises(TypeError): + serialization.load_pem_private_key(contents, password=None) + + def test_cleanup_failure_leaves_only_encrypted_private_key(self): + material = self._material() + mkstemp = tempfile.mkstemp + with tempfile.TemporaryDirectory() as directory: + with patch("tempfile.mkstemp", side_effect=lambda **kwargs: mkstemp( + dir=directory, **kwargs)), patch( + "msal.mtls.os.remove", side_effect=PermissionError( + "synthetic cleanup failure")), self.assertLogs( + "msal.mtls", level="WARNING") as logs: + context = mtls._create_ssl_context( + material["cert_pem"], material["private_key_pem"]) + self.assertIsInstance(context, ssl.SSLContext) + paths = list(Path(directory).glob("*.pem")) + self.assertEqual(1, len(paths)) + self._assert_encrypted_key_file(paths[0], material) + self.assertEqual( + ["WARNING:msal.mtls:Unable to remove temporary mTLS key file"], + logs.output) + + def test_process_exit_leaves_only_encrypted_private_key(self): + script = """ +import os +import sys +import tempfile +from msal import mtls +from msal.application import _load_mtls_cert_material +material = _load_mtls_cert_material({ + "private_key_pfx_path": sys.argv[1], "passphrase": "password"}) +original = tempfile.mkstemp +tempfile.mkstemp = lambda **kwargs: original(dir=sys.argv[2], **kwargs) +class ExitDuringLoad: + def load_cert_chain(self, *args, **kwargs): + os._exit(73) +mtls._new_verifying_context = lambda verify: ExitDuringLoad() +mtls._create_ssl_context(material["cert_pem"], material["private_key_pem"]) +""" + material = self._material() + with tempfile.TemporaryDirectory() as directory: + process = subprocess.run( + [sys.executable, "-c", script, self._PFX, directory], + capture_output=True, timeout=30) + self.assertEqual(73, process.returncode) + paths = list(Path(directory).glob("*.pem")) + self.assertEqual(1, len(paths)) + self._assert_encrypted_key_file(paths[0], material) + + def test_certificate_material_validates_key_pair_for_supported_pem_forms(self): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + material = self._material() + key = serialization.load_pem_private_key( + material["private_key_pem"], password=None) + other = rsa.generate_private_key(public_exponent=65537, key_size=2048) + for matches, private_key in ((True, key), (False, other)): + plain = _private_key_to_unencrypted_pem(private_key) + encrypted = private_key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"synthetic-passphrase")) + for form, password in ( + (plain, None), (plain.decode("ascii"), None), + (private_key, None), (encrypted, b"synthetic-passphrase"), + (encrypted.decode("ascii"), "synthetic-passphrase")): + with self.subTest(matches=matches, form=type(form).__name__, + encrypted=password is not None): + credential = { + "private_key": form, "passphrase": password, + "public_certificate": material["cert_pem"].decode("ascii"), + } + if matches: + loaded = _load_mtls_cert_material(credential) + self.assertEqual(material["key_id"], loaded["key_id"]) + self.assertIsInstance(mtls._create_ssl_context( + loaded["cert_pem"], loaded["private_key_pem"]), + ssl.SSLContext) + else: + with self.assertRaisesRegex(ValueError, "does not match"): + _load_mtls_cert_material(credential) + + def test_existing_binding_material_is_validated_without_reopening_pfx(self): + material = self._material() + binding = MtlsBindingContext( + material["cert_pem"], material["private_key_pem"], + material["x5c"], material["key_id"]) + with patch("msal.application._parse_pfx") as parse: + self.assertEqual(material, _load_mtls_cert_material(binding)) + parse.assert_not_called() + for chain, key_id in ( + (material["x5c"], "incorrect-thumbprint"), + (["incorrect-chain"], material["key_id"])): + with self.subTest(chain_matches=chain == material["x5c"]): + with self.assertRaisesRegex(ValueError, "does not match"): + _load_mtls_cert_material(MtlsBindingContext( + material["cert_pem"], material["private_key_pem"], + chain, key_id)) + def test_adapter_injects_ssl_context(self): material = self._material() context = mtls._create_ssl_context( @@ -313,6 +430,7 @@ def make_cert(name, key, issuer, issuer_key, ca): cls.intermediate = make_cert( "intermediate", intermediate_key, cls.upper_intermediate.subject, upper_key, True) unrelated_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cls.unrelated_key = unrelated_key cls.unrelated = make_cert("unrelated", unrelated_key, None, unrelated_key, True) cls.impostor = make_cert("intermediate", unrelated_key, None, unrelated_key, True) cls.leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) @@ -455,6 +573,109 @@ def post(url, data=None, **kwargs): app.acquire_token_for_client(["s1"], post=post) + def test_application_pfx_snapshot_keeps_signing_tls_and_cache_consistent(self): + import jwt + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.serialization import pkcs12 + from msal.application import ConfidentialClientApplication, _parse_pfx + from tests.http_client import MinimalResponse + + authority = "https://login.microsoftonline.com/tenant" + endpoint = authority + "/oauth2/v2.0/token" + metadata = { + "authorization_endpoint": endpoint.replace("/token", "/authorize"), + "token_endpoint": endpoint, "issuer": authority + "/v2.0", + } + first = pkcs12.serialize_key_and_certificates( + b"first", self.leaf_key, self.leaf, + [self.intermediate, self.upper_intermediate], + serialization.NoEncryption()) + second = pkcs12.serialize_key_and_certificates( + b"second", self.unrelated_key, self.unrelated, None, + serialization.NoEncryption()) + with tempfile.TemporaryDirectory() as directory, patch( + "msal.authority.tenant_discovery", return_value=metadata): + path = Path(directory) / "rotating.pfx" + path.write_bytes(first) + credential = {"private_key_pfx_path": path, "public_certificate": True} + with patch("msal.application._parse_pfx", wraps=_parse_pfx) as parse: + app = ConfidentialClientApplication( + "cid", authority=authority, azure_region=False, + client_credential=credential) + self.addCleanup(app.http_client.close) + path.write_bytes(second) + credential["private_key_pfx_path"] = "not-the-original-file.pfx" + material = app._get_mtls_pop_cert() + self.assertEqual(self.key_id, material["key_id"]) + self._assert_chain_and_handshake(material) + rebuilt, _ = app._build_client( + app.client_credential, app.authority, skip_regional_client=True) + + def post(url, data=None, **kwargs): + assertion = data["client_assertion"] + self.assertEqual( + extract_certs(self.chain_pem.decode("ascii"))[:1], + jwt.get_unverified_header(assertion)["x5c"]) + jwt.decode(assertion, self.leaf.public_key(), + algorithms=["PS256"], audience=endpoint, issuer="cid") + return MinimalResponse( + status_code=200, + text='{"access_token":"ordinary","token_type":"Bearer"}') + + rebuilt.obtain_token_for_client(scope=["ordinary"], post=post) + app.acquire_token_for_client(["ordinary"], post=post) + with patch("msal.mtls._MtlsHttpClient.post", return_value=MinimalResponse( + status_code=200, text=json.dumps({ + "access_token": "opaque-bound-token", + "token_type": "mtls_pop", "expires_in": 3600, + }))) as send: + result = app.acquire_token_for_client( + ["pop"], mtls_proof_of_possession=True) + cached = app.acquire_token_for_client( + ["pop"], mtls_proof_of_possession=True) + self.addCleanup(app._mtls_client._http_client.close) + self.assertEqual(self.key_id, result["binding_certificate"].thumbprint_sha256) + self.assertEqual(self.key_id, cached["binding_certificate"].thumbprint_sha256) + self.assertEqual("cache", cached[app._TOKEN_SOURCE]) + self.assertEqual(1, send.call_count) + self.assertEqual(1, parse.call_count) + rotated = ConfidentialClientApplication( + "cid", authority=authority, azure_region=False, + client_credential={"private_key_pfx_path": path}) + self.addCleanup(rotated.http_client.close) + self.assertNotEqual(self.key_id, rotated._get_mtls_pop_cert()["key_id"]) + + def test_application_pem_snapshot_ignores_caller_credential_mutation(self): + from cryptography.hazmat.primitives import serialization + from msal.application import ConfidentialClientApplication + + authority = "https://login.microsoftonline.com/tenant" + endpoint = authority + "/oauth2/v2.0/token" + credential = { + "private_key": self.key_pem.decode("ascii"), + "public_certificate": self.chain_pem.decode("ascii"), + } + with patch("msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": endpoint.replace("/token", "/authorize"), + "token_endpoint": endpoint, "issuer": authority + "/v2.0"}): + app = ConfidentialClientApplication( + "cid", authority=authority, azure_region=False, + client_credential=credential) + self.addCleanup(app.http_client.close) + credential.update( + private_key=_private_key_to_unencrypted_pem( + self.unrelated_key).decode("ascii"), + public_certificate=self.unrelated.public_bytes( + serialization.Encoding.PEM).decode("ascii")) + self._assert_chain_and_handshake(app._get_mtls_pop_cert()) + rotated = ConfidentialClientApplication( + "cid", authority=authority, azure_region=False, + client_credential=credential) + self.addCleanup(rotated.http_client.close) + self.assertNotEqual( + app._get_mtls_pop_cert()["key_id"], + rotated._get_mtls_pop_cert()["key_id"]) + def test_issuer_signature_verification_on_older_cryptography(self): class LegacyCertificate: issuer = self.leaf.issuer From ddb028002286a3fc08d6ee2c2277e9be8b3b3e83 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Mon, 2 Nov 2026 08:40:09 -0500 Subject: [PATCH 26/27] Defer PFX chain verification while retaining one credential snapshot Keep ordinary PFX assertions leaf-only. Cache the original deserialized key, leaf, and additional certificates once per application and resolve the TLS chain lazily from that snapshot. Preserve strict mTLS issuer verification and add flagless compatibility coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 46 +++++++++++++++++------- tests/README.md | 2 ++ tests/test_mtls_transport.py | 68 ++++++++++++++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/msal/application.py b/msal/application.py index bffc9b4c..d4b96c7c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -166,8 +166,7 @@ def _is_certificate_issuer(cert, issuer): return True -def _parse_pfx(pfx_path, passphrase_bytes, *, include_chain=False): - # Cert concepts https://security.stackexchange.com/a/226758/125264 +def _load_pfx(pfx_path, passphrase_bytes): from cryptography.hazmat.primitives.serialization import pkcs12 with open(pfx_path, 'rb') as f: private_key, cert, additional_certs = pkcs12.load_key_and_certificates( # cryptography 2.5+ @@ -175,6 +174,15 @@ def _parse_pfx(pfx_path, passphrase_bytes, *, include_chain=False): f.read(), passphrase_bytes) if not (private_key and cert): raise ValueError("Your PFX file shall contain both private key and cert") + return private_key, cert, tuple(additional_certs or ()) + + +def _parse_pfx( + pfx_path, passphrase_bytes, *, include_chain=False, _pfx_data=None): + # Cert concepts https://security.stackexchange.com/a/226758/125264 + private_key, cert, additional_certs = ( + _load_pfx(pfx_path, passphrase_bytes) + if _pfx_data is None else _pfx_data) sha256_thumbprint, sha1_thumbprint, x5c, cert_pem = _extract_cert_and_thumbprints(cert) if include_chain: remaining = [candidate for candidate in additional_certs or [] if candidate != cert] @@ -231,7 +239,7 @@ def _private_key_to_unencrypted_pem(private_key, passphrase_bytes=None): encryption_algorithm=serialization.NoEncryption()) -def _load_mtls_cert_material(cert_credential): +def _load_mtls_cert_material(cert_credential, *, _pfx_data=None): """Load client-cert material for an mTLS PoP handshake from a cert credential. Accept a certificate credential dict or an existing process-local binding @@ -256,7 +264,7 @@ def _load_mtls_cert_material(cert_credential): if cert_credential.get("private_key_pfx_path"): private_key, sha256_thumbprint, _, x5c, cert_pem = _parse_pfx( cert_credential["private_key_pfx_path"], passphrase_bytes, - include_chain=True) + include_chain=True, _pfx_data=_pfx_data) elif cert_credential.get("private_key"): public_cert = cert_credential.get("public_certificate") if not isinstance(public_cert, str): @@ -985,8 +993,8 @@ def get_client_assertion(): self._region_detected = None self._region_resolved = False self._region_to_use = None - # Signing and TLS use one application-local certificate snapshot. - # PFX signing resolves it during _build_client; PEM TLS remains lazy. + # Signing snapshots the PFX once; full TLS chain validation stays lazy. + self._pfx_cert_snapshot = None self._mtls_pop_cert_material = None self._mtls_client = None # Lazily built global mTLS client self._mtls_regional_client = None # Lazily built regional mTLS client @@ -1176,6 +1184,17 @@ def _default_client_headers(self): default_headers['x-app-ver'] = self.app_version return default_headers + def _get_pfx_cert_snapshot(self): + with self._mtls_lock: + if self._pfx_cert_snapshot is None: + credential = self.client_credential + passphrase_bytes = ( + _str2bytes(credential["passphrase"]) + if credential.get("passphrase") else None) + self._pfx_cert_snapshot = _load_pfx( + credential["private_key_pfx_path"], passphrase_bytes) + return self._pfx_cert_snapshot + def _get_mtls_pop_cert(self): """Lazily parse and cache the client certificate material used as the TLS client certificate for mTLS PoP. Raises ``ValueError`` when no @@ -1186,10 +1205,15 @@ def _get_mtls_pop_cert(self): "to be configured with a certificate credential (a " "'private_key_pfx_path', or a 'private_key' plus " "'public_certificate').") + material_options = {} + if (self._mtls_cert_credential is self.client_credential + and isinstance(self.client_credential, dict) + and self.client_credential.get("private_key_pfx_path")): + material_options["_pfx_data"] = self._get_pfx_cert_snapshot() with self._mtls_lock: if self._mtls_pop_cert_material is None: self._mtls_pop_cert_material = _load_mtls_cert_material( - self._mtls_cert_credential) + self._mtls_cert_credential, **material_options) return self._mtls_pop_cert_material def _get_mtls_client(self, central_authority): @@ -1286,11 +1310,9 @@ def _build_client(self, client_credential, authority, skip_regional_client=False client_credential["passphrase"] ) if client_credential.get("passphrase") else None if client_credential.get("private_key_pfx_path"): - material = self._get_mtls_pop_cert() - private_key = material["private_key_pem"] - sha256_thumbprint = material["sha256_thumbprint"] - sha1_thumbprint = material["sha1_thumbprint"] - x5c = material["x5c"][:1] + private_key, sha256_thumbprint, sha1_thumbprint, x5c, _ = _parse_pfx( + client_credential["private_key_pfx_path"], passphrase_bytes, + _pfx_data=self._get_pfx_cert_snapshot()) if client_credential.get("public_certificate") is True and x5c: headers["x5c"] = x5c elif client_credential.get("private_key"): # PEM blob diff --git a/tests/README.md b/tests/README.md index 314ffa4e..23fc9e62 100644 --- a/tests/README.md +++ b/tests/README.md @@ -45,6 +45,8 @@ Windows CNG or non-exportable-key support. Each application retains a fixed certificate snapshot for signing, TLS, and cache binding. Construct a new application to rotate a file credential. Temporary TLS keys are encrypted with a random process-local password and removed after loading. +Ordinary PFX assertions retain leaf-only handling; full-chain issuer verification +is deferred until the application requests mTLS certificate material. Protected-key support is a separate, deferred deliverable, not covered by this lab's parity claims. Its Windows acceptance test must prove that the private diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index 5a2c5d30..1719be0a 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -573,11 +573,69 @@ def post(url, data=None, **kwargs): app.acquire_token_for_client(["s1"], post=post) + def test_ordinary_pfx_defers_issuer_verification_until_mtls_use(self): + import jwt + from cryptography.exceptions import UnsupportedAlgorithm + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.serialization import pkcs12 + from msal.application import ConfidentialClientApplication + from tests.http_client import MinimalResponse + + authority = "https://login.microsoftonline.com/tenant" + endpoint = authority + "/oauth2/v2.0/token" + with tempfile.TemporaryDirectory() as directory, patch( + "msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": endpoint.replace("/token", "/authorize"), + "token_endpoint": endpoint, "issuer": authority + "/v2.0"}): + path = Path(directory) / "chain.pfx" + path.write_bytes(pkcs12.serialize_key_and_certificates( + b"client", self.leaf_key, self.leaf, [self.intermediate], + serialization.NoEncryption())) + for public_certificate in (False, True): + with self.subTest(public_certificate=public_certificate), patch( + "msal.application._is_certificate_issuer", + side_effect=UnsupportedAlgorithm( + "Upgrade cryptography to verify RSA-PSS certificate chains") + ) as verify_issuer: + app = ConfidentialClientApplication( + "cid", authority=authority, azure_region=False, + client_credential={ + "private_key_pfx_path": path, + "public_certificate": public_certificate}) + self.addCleanup(app.http_client.close) + self.assertTrue( + app._mtls_pop_cert_material is None, + "Ordinary PFX signing must not resolve the TLS chain.") + + def post(url, data=None, **kwargs): + assertion = data["client_assertion"] + header = jwt.get_unverified_header(assertion) + if public_certificate: + self.assertEqual( + extract_certs(self.chain_pem.decode("ascii"))[:1], + header["x5c"]) + else: + self.assertNotIn("x5c", header) + jwt.decode(assertion, self.leaf.public_key(), + algorithms=["PS256"], audience=endpoint, issuer="cid") + return MinimalResponse( + status_code=200, + text='{"access_token":"ordinary","token_type":"Bearer"}') + + result = app.acquire_token_for_client(["ordinary"], post=post) + self.assertEqual("Bearer", result["token_type"]) + verify_issuer.assert_not_called() + with self.assertRaises(UnsupportedAlgorithm): + app._get_mtls_pop_cert() + self.assertTrue( + app._mtls_pop_cert_material is None, + "Failed TLS chain validation must not cache material.") + def test_application_pfx_snapshot_keeps_signing_tls_and_cache_consistent(self): import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.serialization import pkcs12 - from msal.application import ConfidentialClientApplication, _parse_pfx + from msal.application import ConfidentialClientApplication from tests.http_client import MinimalResponse authority = "https://login.microsoftonline.com/tenant" @@ -598,11 +656,17 @@ def test_application_pfx_snapshot_keeps_signing_tls_and_cache_consistent(self): path = Path(directory) / "rotating.pfx" path.write_bytes(first) credential = {"private_key_pfx_path": path, "public_certificate": True} - with patch("msal.application._parse_pfx", wraps=_parse_pfx) as parse: + with patch( + "cryptography.hazmat.primitives.serialization.pkcs12." + "load_key_and_certificates", + wraps=pkcs12.load_key_and_certificates) as parse: app = ConfidentialClientApplication( "cid", authority=authority, azure_region=False, client_credential=credential) self.addCleanup(app.http_client.close) + self.assertTrue( + app._mtls_pop_cert_material is None, + "Ordinary PFX signing must not resolve the TLS chain.") path.write_bytes(second) credential["private_key_pfx_path"] = "not-the-original-file.pfx" material = app._get_mtls_pop_cert() From 230a4cd9df6889dfa25e78372b50ab2e3e7bf421 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Mon, 2 Nov 2026 09:58:44 -0500 Subject: [PATCH 27/27] Retire original access token records when modify changes identity Remove indexed records equal to the original entry before inserting an updated access token, preserving renewed entries and bounded cache-index work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/token_cache.py | 5 +++ tests/test_token_cache.py | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/msal/token_cache.py b/msal/token_cache.py index 1dfa2a42..4eef1f3a 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -653,6 +653,11 @@ def modify(self, credential_type, old_entry, new_key_value_pairs=None): old_entry, # Do not use entries[key] b/c it might not exist **new_key_value_pairs) if is_at: + if entry != old_entry: + for stored_bucket, stored_key in tuple( + index.get(_access_token_identity(old_entry), ())): + if self._cache[stored_bucket].get(stored_key) == old_entry: + self._remove_access_token_entry(stored_bucket, stored_key) if entry.get("key_id") is not None: entry["key_id"] = _key_id_to_str(entry["key_id"]) identity = _access_token_identity(entry) diff --git a/tests/test_token_cache.py b/tests/test_token_cache.py index 1380d4b2..4c08f9b6 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -1210,6 +1210,81 @@ def test_native_removal_cleans_canonical_and_equal_legacy_duplicates(self): scheme, "thumb", target=["other-scope"])]) self.assertNotIn("legacy:duplicate", entries) + def test_modify_retires_original_access_token_identity(self): + changes = ( + {"key_id": "Other"}, + {"token_type": "Bearer", "key_id": None}, + {"target": "other-scope"}, + {"ext_cache_key": "other-claims"}, + ) + for cache_type in (TokenCache, SerializableTokenCache): + for change in changes: + with self.subTest(cache_type=cache_type.__name__, change=change): + self.cache = cache_type() + self.cache.add(self.event("mtls_pop", "old"), now=1000) + old = self.search("mtls_pop", "Thumb")[0] + self.cache.modify("AccessToken", old, change) + expected = dict(old, **change) + self.assertEqual([expected], self.search(for_removal=True)) + self.cache.remove_at(expected) + self.assertEqual([], self.search(for_removal=True)) + + def test_modify_preserves_renewed_original_and_unrelated_identities(self): + from msal import token_cache + + for change in ({"key_id": "Other"}, {"token_type": "Bearer"}): + with self.subTest(change=change): + self.cache = TokenCache() + self.cache.add(self.event("mtls_pop", "old"), now=1000) + old = self.search("mtls_pop", "Thumb")[0] + self.cache.add(self.event("mtls_pop", "renewed"), now=1001) + self.cache.add(self.event( + "mtls_pop", "other-certificate", key_id="unrelated"), now=1000) + self.cache.add(self.event( + "ssh-cert", "other-scheme", scopes=["other-scope"]), now=1000) + for bucket in ("AccessToken", "AccessToken_mTLS"): + entries = dict(self.cache._cache.get(bucket, {})) + entries["legacy:old"] = dict(old) + self.cache._cache[bucket] = entries + self.cache.modify("AccessToken", old, change) + expected = dict(old, **change) + identity = token_cache._access_token_identity(old) + self.assertEqual(["renewed"], [ + entry["secret"] for entry in self.search() + if token_cache._access_token_identity(entry) == identity]) + self.cache.remove_at(expected) + self.assertEqual( + ["other-certificate", "other-scheme", "renewed"], + sorted(entry["secret"] for entry in self.search())) + for bucket in self.cache._cache.values(): + self.assertNotIn("legacy:old", bucket) + + def test_modify_identity_change_uses_only_indexed_locations(self): + from msal import token_cache + + for count in (100, 1000, 10000): + with self.subTest(count=count): + self.cache, buckets = self.canonical_cache(count) + self.cache.add(self.event("mtls_pop", "old"), now=1000) + old = self.search("mtls_pop", "Thumb")[0] + entries = _CountingAccessTokenEntries( + self.cache._cache["AccessToken"]) + entries["legacy:duplicate"] = dict(old) + self.cache._cache["AccessToken"] = entries + buckets["AccessToken"] = entries + self.cache._get_access_token_index() + for bucket in buckets.values(): + bucket.visits = 0 + with patch.object( + token_cache, "_access_token_identity", + wraps=token_cache._access_token_identity) as identity: + self.cache.modify("AccessToken", old, {"key_id": "Other"}) + self.assertEqual(0, sum(bucket.visits for bucket in buckets.values())) + self.assertLessEqual(identity.call_count, 4) + self.assertEqual([], self.search("mtls_pop", "Thumb")) + self.assertEqual(["old"], [ + entry["secret"] for entry in self.search("mtls_pop", "Other")]) + def test_native_duplicate_removal_uses_only_indexed_identity_locations(self): from msal import token_cache