From dd4f13a15e239adf34f934d5363ed1487fcb20d7 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 1 Jul 2026 15:40:18 -0400 Subject: [PATCH 01/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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 01b676b0815fea5dbe980965db5f246ba6bf1f20 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Mon, 6 Jul 2026 19:06:20 -0400 Subject: [PATCH 07/46] Add FIC two-leg exchange over mTLS Proof-of-Possession Stacked on top of the SN/I mTLS PoP base support. Enables a Federated Identity Credential (FIC) two-leg exchange over mutual-TLS: a leg-1 cert-bound mtls_pop token is presented as a jwt-pop client assertion over an mTLS connection bound by the same certificate to obtain the final token. - Detect the client_credential mtls_binding_certificate sub-key (FIC leg 2) and carry the leg-1 assertion as a jwt-pop client assertion over mTLS - Route every FIC leg-2 request over the mTLS endpoint, so even a Bearer token travels the cert-bound connection (implicit Bearer-over-mTLS) - Add unit + E2E coverage, docs, and a two-leg sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 4 + msal/application.py | 126 +++++++++++++----- msal/oauth2cli/oauth2.py | 1 + sample/confidential_client_mtls_pop_sample.py | 52 ++++++++ tests/test_application.py | 52 ++++++++ tests/test_e2e.py | 37 +++++ 6 files changed, 240 insertions(+), 32 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 98659dcd..aad61ffc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -202,6 +202,10 @@ Notes and requirements: 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. +* For a Federated Identity Credential (FIC) exchange, configure the leg-2 client + with both a ``client_assertion`` (the leg-1 token) and an + ``mtls_binding_certificate`` sub-key; the leg-1 assertion is then sent as a + ``jwt-pop`` client assertion over the same mTLS connection. Exceptions diff --git a/msal/application.py b/msal/application.py index 08b9c810..c10bb6b0 100644 --- a/msal/application.py +++ b/msal/application.py @@ -143,8 +143,9 @@ 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, + ``cert_credential`` is a certificate credential dict - either the app's main + ``client_credential`` (vanilla SN/I) or the ``mtls_binding_certificate`` + sub-dict (FIC leg 2). 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. @@ -497,6 +498,30 @@ def get_client_assertion(): still supported for backward compatibility but is discouraged because the assertion will eventually expire. + .. admonition:: Binding an assertion to an mTLS certificate (FIC leg 2) + + *Added in version 1.38.0*: + For a Federated Identity Credential (FIC) exchange over mutual-TLS + Proof-of-Possession, the ``client_assertion`` container may also + carry an ``mtls_binding_certificate`` sub-key -- a certificate + credential (same shape as the top-level cert credential) that is + presented as the client TLS certificate during the token request:: + + { + "client_assertion": leg1_result["access_token"], # the leg-1 mtls_pop token + "mtls_binding_certificate": { + "private_key_pfx_path": "/path/to/your.pfx", + "public_certificate": True, + }, + } + + When ``mtls_binding_certificate`` is present, MSAL sends the + assertion with ``client_assertion_type`` set to the ``jwt-pop`` + type and routes the request over mTLS using that binding + certificate. See + :func:`~msal.ConfidentialClientApplication.acquire_token_for_client`'s + ``mtls_proof_of_possession`` parameter for the full two-leg flow. + .. admonition:: Supporting reading client certificates from PFX files This usage will automatically use SHA-256 thumbprint of the certificate. @@ -848,13 +873,25 @@ def get_client_assertion(): 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( + if isinstance(client_credential, dict) and client_credential.get( + "mtls_binding_certificate"): # FIC leg 2 (assertion + binding cert) + if not client_credential.get("client_assertion"): + raise ValueError( + "mtls_binding_certificate (FIC leg 2) also requires a " + "client_assertion (the leg-1 mtls_pop token) in the same " + "client_credential; the binding certificate is presented " + "to carry that assertion.") + self._mtls_cert_credential = client_credential["mtls_binding_certificate"] + self._mtls_is_fic_leg2 = True + elif 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 + self._mtls_is_fic_leg2 = False else: # No certificate available to present over mTLS self._mtls_cert_credential = None + self._mtls_is_fic_leg2 = False # Warn if using a static string/bytes client_assertion (discouraged for long-running apps) if isinstance(client_credential, dict) and isinstance( @@ -1014,10 +1051,11 @@ def _get_mtls_pop_cert(self): suitable certificate credential is configured.""" if self._mtls_cert_credential is None: raise ValueError( - "mtls_proof_of_possession=True requires this confidential client " + "mTLS Proof-of-Possession requires this confidential client " "to be configured with a certificate credential (a " "'private_key_pfx_path', or a 'private_key' plus " - "'public_certificate').") + "'public_certificate'); or, for a Federated Identity Credential " + "exchange, a 'client_assertion' plus an 'mtls_binding_certificate'.") with self._mtls_lock: if self._mtls_pop_cert_material is None: self._mtls_pop_cert_material = _load_mtls_cert_material( @@ -1029,13 +1067,17 @@ def _get_mtls_client(self, 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. + presents the configured certificate over mutual-TLS. For a FIC leg-2 + credential (``client_assertion`` + ``mtls_binding_certificate``) the + assertion is sent with ``client_assertion_type = ...:jwt-pop``. """ 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 " + "mTLS 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. mTLS is engaged when you pass " + "mtls_proof_of_possession=True, or when the client_credential " + "carries mtls_binding_certificate (FIC leg 2). Omit the " "http_client argument to use MSAL's built-in mTLS transport.") region = self._compute_region_to_use() with self._mtls_lock: @@ -1059,13 +1101,20 @@ 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. + if self._mtls_is_fic_leg2: # FIC leg 2: assertion carried as jwt-pop + client_assertion = self.client_credential["client_assertion"] + client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT_POP + else: # Vanilla SN/I: the TLS certificate alone authenticates the client + client_assertion = None + client_assertion_type = None client = _MtlsClient( configuration, self.client_id, http_client=http_client, default_headers=self._default_client_headers(), default_body={"client_info": 1}, + client_assertion=client_assertion, + client_assertion_type=client_assertion_type, # 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( @@ -2779,7 +2828,9 @@ def acquire_token_for_client( 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 + ``public_certificate``) - or, for a Federated Identity Credential + (FIC) exchange, a ``client_assertion`` plus an + ``mtls_binding_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``. @@ -2813,31 +2864,41 @@ def acquire_token_for_client( "fmi_path must be a string, got {}".format(type(fmi_path).__name__)) 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 mtls_proof_of_possession or self._mtls_is_fic_leg2: + # An mTLS transport is required whenever we present the certificate: + # for a cert-bound PoP request (mtls_proof_of_possession=True) and + # for every FIC leg-2 request - there the leg-1 assertion is itself + # bound to the certificate, so even a Bearer final token must travel + # over the same mTLS connection ("implicit Bearer-over-mTLS"). 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 " + "mTLS 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. mTLS is engaged " + "when you pass mtls_proof_of_possession=True, or when the " + "client_credential carries mtls_binding_certificate (FIC " + "leg 2). 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.") + "mTLS Proof-of-Possession requires a tenanted authority. It " + "is engaged when you pass mtls_proof_of_possession=True, or " + "when the client_credential carries mtls_binding_certificate " + "(FIC leg 2). 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"] + if mtls_proof_of_possession: + # 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["token_type"] = "mtls_pop" + data["key_id"] = mtls_cert["key_id"] + # else: FIC leg-2 without the flag -> Bearer-over-mTLS. No token_type + # / key_id, so the final Bearer token caches normally; the mTLS + # client is still selected because self._mtls_is_fic_leg2 is True. 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: @@ -2856,8 +2917,8 @@ def acquire_token_for_client( "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). + # callers - including FIC leg-2 / cross-app hand-off - can correlate + # the token to its cert. Survives _clean_up (key has no "_" prefix). result["binding_certificate"] = { "x5c": mtls_cert["x5c"], "thumbprint_sha256": mtls_cert["key_id"], # base64url x5t#S256 @@ -2882,9 +2943,10 @@ def _acquire_token_for_client( telemetry_context = self._build_telemetry_context( 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. + if is_mtls_pop or self._mtls_is_fic_leg2: + # Present the certificate over mTLS to obtain a cert-bound token + # (mtls_pop), or to carry a cert-bound FIC leg-1 assertion as jwt-pop + # (Bearer-over-mTLS when the flag is absent). client = self._get_mtls_client(self.authority) else: client = self._regional_client or self.client diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index 4590d52d..fa2419f9 100644 --- a/msal/oauth2cli/oauth2.py +++ b/msal/oauth2cli/oauth2.py @@ -43,6 +43,7 @@ def encode_saml_assertion(assertion): CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" CLIENT_ASSERTION_TYPE_SAML2 = "urn:ietf:params:oauth:client-assertion-type:saml2-bearer" + CLIENT_ASSERTION_TYPE_JWT_POP = "urn:ietf:params:oauth:client-assertion-type:jwt-pop" client_assertion_encoders = {CLIENT_ASSERTION_TYPE_SAML2: encode_saml_assertion} @property diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index cf399e37..44e58cd1 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -4,6 +4,9 @@ mutual-TLS (mTLS) handshake to Microsoft Entra, and obtains an mTLS-bound Proof-of-Possession (PoP) access token (``token_type == "mtls_pop"``). +It also shows the optional two-leg Federated Identity Credential (FIC) exchange +over mTLS PoP. + Prerequisites ------------- * A confidential-client app registration configured with a certificate. @@ -95,6 +98,55 @@ def vanilla_sni_mtls_pop(): return result +def fic_two_leg_over_mtls_pop(): + """Optional: two-leg Federated Identity Credential (FIC) exchange over mTLS PoP. + + Leg 1: the SN/I confidential client acquires a cert-bound mtls_pop token for + the token-exchange audience. + Leg 2: a second confidential client presents that token as a ``jwt-pop`` + client assertion, over an mTLS connection bound by the SAME cert, to + obtain the final token (Bearer or mtls_pop). + """ + cert = _build_cert_credential() + + # --- Leg 1: acquire the federated (cert-bound) assertion --------------- + leg1_app = msal.ConfidentialClientApplication( + os.environ["CLIENT_ID"], + authority=os.environ["AUTHORITY"], + client_credential=cert, + azure_region=os.getenv("AZURE_REGION"), + ) + # The exchange audience is caller-supplied, not hard-coded by MSAL. + exchange_scope = os.getenv( + "FIC_EXCHANGE_SCOPE", "api://AzureADTokenExchange/.default") + leg1 = leg1_app.acquire_token_for_client( + [exchange_scope], mtls_proof_of_possession=True) + if "access_token" not in leg1: + _print_result_summary(leg1, "FIC leg 1 result:") + return leg1 + + # --- Leg 2: exchange the leg-1 token for the final token --------------- + leg2_app = msal.ConfidentialClientApplication( + os.getenv("LEG2_CLIENT_ID", os.environ["CLIENT_ID"]), + authority=os.environ["AUTHORITY"], + client_credential={ + "client_assertion": leg1["access_token"], # the leg-1 mtls_pop token + "mtls_binding_certificate": cert, # binds the leg-2 TLS handshake + }, + azure_region=os.getenv("AZURE_REGION"), + ) + + # Final token as mTLS PoP (drop mtls_proof_of_possession for Bearer-over-mTLS): + leg2 = leg2_app.acquire_token_for_client( + os.environ["SCOPE"].split(), mtls_proof_of_possession=True) + _print_result_summary(leg2, "FIC leg 2 result:") + return leg2 + + if __name__ == "__main__": print("=== Vanilla SN/I -> mTLS PoP ===") vanilla_sni_mtls_pop() + + if os.getenv("RUN_FIC_SAMPLE"): + print("\n=== FIC two-leg over mTLS PoP ===") + fic_two_leg_over_mtls_pop() diff --git a/tests/test_application.py b/tests/test_application.py index cc54e1ff..48f182fc 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1602,6 +1602,7 @@ def mock_post(url, headers=None, data=None, *args, **kwargs): "issuer": "https://login.microsoftonline.com/my_tenant/v2.0", }) _JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +_JWT_POP = "urn:ietf:params:oauth:client-assertion-type:jwt-pop" @patch(_OIDC_DISCOVERY, new=_MTLS_OIDC_MOCK) @@ -1701,12 +1702,63 @@ def test_regional_mtls_endpoint(self): captured[0]["url"], "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + def test_fic_leg2_pop_sends_jwt_pop_over_mtls(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": "LEG1_TOKEN", + "mtls_binding_certificate": _MTLS_CERT_CRED}) + captured = [] + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, post=self._capturing_post(captured)) + req = captured[0] + self.assertTrue(req["url"].startswith("https://mtlsauth.microsoft.com/")) + self.assertEqual("LEG1_TOKEN", req["data"].get("client_assertion")) + self.assertEqual(_JWT_POP, req["data"].get("client_assertion_type")) + self.assertEqual("mtls_pop", req["data"].get("token_type")) + self.assertEqual("mtls_pop", result.get("token_type")) + + def test_fic_leg2_bearer_still_travels_over_mtls(self): + # No flag -> Bearer final token, but the cert-bound leg-1 assertion still + # requires the mTLS endpoint and jwt-pop ("implicit Bearer-over-mTLS"). + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": "LEG1_TOKEN", + "mtls_binding_certificate": _MTLS_CERT_CRED}) + captured = [] + result = app.acquire_token_for_client( + ["s1"], post=self._capturing_post(captured, token_type="Bearer")) + req = captured[0] + self.assertTrue(req["url"].startswith("https://mtlsauth.microsoft.com/")) + self.assertEqual(_JWT_POP, req["data"].get("client_assertion_type")) + self.assertNotIn("token_type", req["data"]) + 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_binding_cert_without_client_assertion_raises(self): + # FIC leg 2 needs the binding cert AND the leg-1 assertion. A binding + # cert with no client_assertion must fail fast at construction with a + # clear ValueError, not a later KeyError when the request is built. + with self.assertRaises(ValueError): + ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={"mtls_binding_certificate": _MTLS_CERT_CRED}) + 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_string_assertion_without_binding_cert_with_flag_raises(self): + app = ConfidentialClientApplication( + "cid", client_credential={"client_assertion": "just_a_string"}, + 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, diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9d80a9b6..9f58deb2 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -508,6 +508,43 @@ def test_sni_mtls_pop_for_client_regional(self): self.assertIn("access_token", result, "Regional mTLS PoP failed: %s" % result) self.assertEqual("mtls_pop", result.get("token_type")) + def test_fic_two_leg_over_mtls_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") + authority = "https://login.microsoftonline.com/microsoft.onmicrosoft.com" + # Leg 1: SN/I cert -> cert-bound (mtls_pop) federated assertion for the + # caller-supplied token-exchange audience. + leg1_app = msal.ConfidentialClientApplication( + LAB_APP_CLIENT_ID, authority=authority, + client_credential=get_client_certificate()) + leg1 = leg1_app.acquire_token_for_client( + ["api://AzureADTokenExchange/.default"], mtls_proof_of_possession=True) + if "access_token" not in leg1: + self.skipTest("FIC leg 1 not pre-authorized for token exchange: %s" % leg1) + self.assertEqual("mtls_pop", leg1.get("token_type"), "Leg 1 must be mtls_pop") + leg1_thumbprint = leg1["binding_certificate"]["thumbprint_sha256"] + + # Leg 2: same cert binds the TLS handshake, leg-1 token carried as jwt-pop. + leg2_app = msal.ConfidentialClientApplication( + LAB_APP_CLIENT_ID, authority=authority, + client_credential={ + "client_assertion": leg1["access_token"], + "mtls_binding_certificate": get_client_certificate()}) + # Leg 2 -> Bearer over mTLS (implicit Bearer-over-mTLS, no flag) + leg2_bearer = leg2_app.acquire_token_for_client(self._MTLS_POP_SCOPE) + self.assertIn("access_token", leg2_bearer, "Leg 2 (Bearer) failed: %s" % leg2_bearer) + # Leg 2 -> mTLS PoP final token + leg2_pop = leg2_app.acquire_token_for_client( + self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) + self.assertIn("access_token", leg2_pop, "Leg 2 (PoP) failed: %s" % leg2_pop) + self.assertEqual("mtls_pop", leg2_pop.get("token_type")) + # The final PoP token must be bound to the leg-1 certificate thumbprint + cnf = self._cnf_x5t_s256(leg2_pop["access_token"]) + if cnf: + self.assertEqual(leg1_thumbprint, cnf, + "Final token cnf must be bound to the leg-1 cert thumbprint") + class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once @classmethod From 90d0e67a4e44718bf7ac54007e3806971c64fc32 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 17:00:24 -0400 Subject: [PATCH 08/46] Fail fast when a FIC leg-2 app is used with a non-mTLS flow A confidential client configured for a Federated Identity Credential (FIC) leg-2 exchange (a client_assertion plus an mtls_binding_certificate) is only meaningful through acquire_token_for_client, which presents the binding certificate over mutual-TLS and carries the leg-1 assertion as jwt-pop. If such an app is instead used with any other flow (authorization code, on-behalf-of, refresh token, username/password, or user-FIC), MSAL would send that cert-bound leg-1 assertion as an ordinary jwt-bearer over a non-mTLS connection. ESTS rejects it, but with a confusing server error. Add a small _reject_if_mtls_binding_cert guard and call it at the top of those flows so the misuse fails fast with a clear ValueError that points the caller to acquire_token_for_client. The guard is a no-op for every non-FIC-leg-2 app, so public clients and ordinary confidential clients are unaffected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 22 ++++++++++++++++++++++ tests/test_application.py | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/msal/application.py b/msal/application.py index c10bb6b0..241ddc95 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1137,6 +1137,22 @@ def _get_mtls_client(self, central_authority): client = self._mtls_client return client + def _reject_if_mtls_binding_cert(self, api_name): + # A FIC leg-2 credential (client_assertion + mtls_binding_certificate) + # is bound to the certificate: the leg-1 assertion is only valid when + # presented over the mTLS connection that acquire_token_for_client() + # establishes. Any other flow would send that cert-bound assertion as + # an ordinary jwt-bearer over a non-mTLS connection, which ESTS rejects. + # Fail fast with a clear message instead of a confusing server error. + if getattr(self, "_mtls_is_fic_leg2", False): + raise ValueError( + "This application is configured for a Federated Identity " + "Credential (FIC) leg-2 exchange (a 'client_assertion' plus an " + "'mtls_binding_certificate'). That credential can only be " + "presented over mutual-TLS, so {}() is not supported on this " + "application - use acquire_token_for_client() instead.".format( + api_name)) + def _build_client(self, client_credential, authority, skip_regional_client=False): client_assertion = None client_assertion_type = None @@ -1502,6 +1518,7 @@ def authorize(): # A controller in a web app pass # Simply ignore them return redirect(url_for("index")) """ + self._reject_if_mtls_binding_cert("acquire_token_by_auth_code_flow") self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID) @@ -1567,6 +1584,7 @@ def acquire_token_by_authorization_code( - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ + self._reject_if_mtls_binding_cert("acquire_token_by_authorization_code") # If scope is absent on the wire, STS will give you a token associated # to the FIRST scope sent during the authorization request. # So in theory, you can omit scope here when you were working with only @@ -2179,6 +2197,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): * A dict contains "error" and some other keys, when error happened. * A dict contains no "error" key means migration was successful. """ + self._reject_if_mtls_binding_cert("acquire_token_by_refresh_token") self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_REFRESH_TOKEN, @@ -2233,6 +2252,7 @@ def acquire_token_by_username_password( Migration guide: https://aka.ms/msal-ropc-migration """ + self._reject_if_mtls_binding_cert("acquire_token_by_username_password") is_confidential_app = self.client_credential or isinstance( self, ConfidentialClientApplication) if not is_confidential_app: @@ -3001,6 +3021,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ + self._reject_if_mtls_binding_cert("acquire_token_on_behalf_of") telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID) # The implementation is NOT based on Token Exchange (RFC 8693) @@ -3057,6 +3078,7 @@ def acquire_token_by_user_federated_identity_credential( - A successful response would contain "access_token" key, - an error response would contain "error" and usually "error_description". """ + self._reject_if_mtls_binding_cert("acquire_token_by_user_federated_identity_credential") # Input validation if not assertion: raise ValueError("assertion is required and must be non-empty") diff --git a/tests/test_application.py b/tests/test_application.py index 48f182fc..3d6db5a6 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1746,6 +1746,30 @@ def test_binding_cert_without_client_assertion_raises(self): "cid", authority=self._AUTHORITY, client_credential={"mtls_binding_certificate": _MTLS_CERT_CRED}) + def test_fic_leg2_app_rejects_non_client_credential_flows(self): + # A FIC leg-2 app (client_assertion + mtls_binding_certificate) is only + # valid via acquire_token_for_client over mTLS. Any other flow would put + # the cert-bound leg-1 assertion on a non-mTLS jwt-bearer request, so it + # must fail fast with a clear ValueError rather than a confusing 4xx. + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": "LEG1_TOKEN", + "mtls_binding_certificate": _MTLS_CERT_CRED}) + with self.assertRaises(ValueError): + app.acquire_token_by_authorization_code("code", ["s1"]) + with self.assertRaises(ValueError): + app.acquire_token_on_behalf_of("user_token", ["s1"]) + with self.assertRaises(ValueError): + app.acquire_token_by_refresh_token("refresh_token", ["s1"]) + with self.assertRaises(ValueError): + app.acquire_token_by_username_password("user", "password", ["s1"]) + with self.assertRaises(ValueError): + app.acquire_token_by_auth_code_flow({}, {}) + with self.assertRaises(ValueError): + app.acquire_token_by_user_federated_identity_credential( + ["s1"], "assertion_token", username="user@contoso.com") + def test_secret_credential_with_flag_raises(self): app = ConfidentialClientApplication( "cid", client_credential="a_secret", authority=self._AUTHORITY) From acd78ed25346cdb6dd09ee6adfc5a9a191840f31 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 17:58:26 -0400 Subject: [PATCH 09/46] Reject FIC leg-2 apps on silent flows too The _reject_if_mtls_binding_cert guard already covered the six non-client-credential acquisition flows, but not acquire_token_silent / acquire_token_silent_with_error. With a shared token cache holding user refresh tokens, a FIC leg-2 app (client_assertion + mtls_binding_certificate) could silently redeem one of those RTs, putting the cert-bound leg-1 assertion on a non-mTLS jwt-bearer request -- which ESTS rejects with a confusing invalid_client rather than a clear message. Add the guard to both public silent entry points. Both call the private _acquire_token_silent_with_error directly (not each other), and acquire_token_for_client also uses that private helper, so the guard lives on the two public wrappers -- never on the private method -- to keep the legitimate mTLS client-credential flow working. No-op for normal apps. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 2 ++ tests/test_application.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/msal/application.py b/msal/application.py index 241ddc95..8dfdfc7c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1804,6 +1804,7 @@ def acquire_token_silent( if cache lookup succeeded. - None when cache lookup does not yield a token. """ + self._reject_if_mtls_binding_cert("acquire_token_silent") if not account: return None # A backward-compatible NO-OP to drop the account=None usage result = _clean_up(self._acquire_token_silent_with_error( @@ -1858,6 +1859,7 @@ def acquire_token_silent_with_error( - None when there is simply no token in the cache. - A dict containing an "error" key, when token refresh failed. """ + self._reject_if_mtls_binding_cert("acquire_token_silent_with_error") if not account: return None # A backward-compatible NO-OP to drop the account=None usage return _clean_up(self._acquire_token_silent_with_error( diff --git a/tests/test_application.py b/tests/test_application.py index 3d6db5a6..b4e5966a 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1769,6 +1769,16 @@ def test_fic_leg2_app_rejects_non_client_credential_flows(self): with self.assertRaises(ValueError): app.acquire_token_by_user_federated_identity_credential( ["s1"], "assertion_token", username="user@contoso.com") + # Silent flows are also unsupported: a shared cache could hold user RTs + # whose redemption would put the cert-bound assertion on a non-mTLS + # jwt-bearer request. Guard both silent entry points (they call the + # private _acquire_token_silent_with_error directly, not each other). + account = {"home_account_id": "uid.utid", + "environment": "login.microsoftonline.com", "username": "u"} + with self.assertRaises(ValueError): + app.acquire_token_silent(["s1"], account) + with self.assertRaises(ValueError): + app.acquire_token_silent_with_error(["s1"], account) def test_secret_credential_with_flag_raises(self): app = ConfidentialClientApplication( From b79166dd1ffea1d09cad9c616a962e67338bc14e Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 30 Jul 2026 15:41:35 -0400 Subject: [PATCH 10/46] 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 11/46] 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 12/46] 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 13/46] 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 f98961f60cb82687cee40532e08f57f447e19d5f Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:11:36 -0400 Subject: [PATCH 14/46] Add Bearer-over-mTLS: app-level flag + client-credentials path Introduce an app-level opt-in `send_certificate_over_mtls` on the confidential-client `client_credential` dict. When set, MSAL presents the SN/I certificate on the TLS handshake to the [region.]mtlsauth.* endpoint and signs a private_key_jwt client_assertion (x5c forced), but requests a plain Bearer token (no token_type=mtls_pop / no key_id / no req_cnf). The result is an ordinary, non-cert-bound Bearer access token. - Validate the flag at construction: it requires a certificate credential, else ValueError naming the flag. - Add _get_mtls_bearer_client(): reuses the Task-1 mTLS transport (msal/mtls.py) + the login.*->[region.]mtlsauth.* endpoint transform, adds a signed x5c-forced assertion, and caches a plain (non-key_id-fenced) token. - Route acquire_token_for_client() to it when the flag is set and the request is not mtls_pop. A per-request mtls_proof_of_possession=True always wins. - Tests (RED-first) in TestSendCertificateOverMtls: config/builder (default false, stored true, flag + non-cert credential raises) and CC behavior (global/regional mtlsauth endpoint, client_assertion + jwt-bearer, x5c forced, no PoP wire markers, plain-Bearer result, mtls_pop precedence). Ports MSAL.NET CertificateOptions.SendCertificateOverMtls (client-credentials leg). OBO/refresh/auth-code coverage follows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 105 +++++++++++++++++++++++++++++++ tests/test_application.py | 128 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) diff --git a/msal/application.py b/msal/application.py index 74395c33..ff0fa384 100644 --- a/msal/application.py +++ b/msal/application.py @@ -880,6 +880,11 @@ def get_client_assertion(): 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 + # Bearer-over-mTLS reuses the same mTLS transport but signs a + # private_key_jwt client_assertion (x5c forced) instead of requesting a + # cert-bound token; its client(s) are likewise built lazily. + self._mtls_bearer_client = None # Lazily built global Bearer-over-mTLS client + self._mtls_bearer_regional_client = None # Lazily built regional one self._mtls_lock = Lock() if isinstance(client_credential, dict) and not client_credential.get( "client_assertion") and ( @@ -889,6 +894,20 @@ def get_client_assertion(): else: # No certificate available to present over mTLS self._mtls_cert_credential = None + # Bearer-over-mTLS opt-in (app-level): present the certificate on the + # TLS handshake to the mTLS endpoint but receive a plain Bearer token + # (NOT cert-bound). Requires a certificate credential; a per-request + # mtls_proof_of_possession=True always takes precedence over this flag. + self._send_certificate_over_mtls = bool( + isinstance(client_credential, dict) + and client_credential.get("send_certificate_over_mtls")) + if self._send_certificate_over_mtls and self._mtls_cert_credential is None: + raise ValueError( + "send_certificate_over_mtls=True requires this confidential " + "client to be configured with a certificate credential (a " + "'private_key_pfx_path', or a 'private_key' plus " + "'public_certificate').") + # 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)): @@ -1124,6 +1143,88 @@ def _get_mtls_client(self, central_authority): client = self._mtls_client return client + def _get_mtls_bearer_client(self, central_authority): + """Lazily build (and cache) the Bearer-over-mTLS client for + ``central_authority``. + + Like :func:`_get_mtls_client`, the token endpoint host is transformed + ``login.* -> [region.]mtlsauth.*`` and the certificate is presented over + mutual-TLS. UNLIKE mtls_pop, the request also carries a signed + private_key_jwt ``client_assertion`` (with the x5c chain FORCED on), and + does NOT request a cert-bound token (no ``token_type=mtls_pop`` / no + ``key_id`` / no ``req_cnf``). The result is therefore a plain Bearer + access token whose cache entry is not fenced by the certificate. + """ + if self._http_client_is_custom: + raise ValueError( + "send_certificate_over_mtls=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_bearer_regional_client if region + else self._mtls_bearer_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("Bearer-over-mTLS 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, + ) + # Sign a private_key_jwt assertion, FORCING the x5c chain onto the + # header regardless of the app's public_certificate opt-in (Bearer-over- + # mTLS always attaches the chain), so ESTS accepts the assertion on the + # mTLS endpoint. This is the seam that distinguishes it from mtls_pop, + # whose vanilla SN/I request sends no client_assertion at all. + assertion = JwtAssertionCreator( + cert["private_key_pem"], algorithm="PS256", + sha256_thumbprint=cert["sha256_thumbprint"], + headers={"x5c": cert["x5c"]}) + client_assertion = assertion.create_regenerative_assertion( + audience=token_endpoint, issuer=self.client_id, + additional_claims=self.client_claims or {}) + client = _ClientWithCcsRoutingInfo( + configuration, + self.client_id, + http_client=http_client, + default_headers=self._default_client_headers(), + default_body={"client_info": 1}, + client_assertion=client_assertion, + client_assertion_type=Client.CLIENT_ASSERTION_TYPE_JWT, + # Cache under the ORIGINAL login.* host (like the rest of the app), + # as a PLAIN Bearer AT (no key_id fence), so ordinary Bearer cache + # lookups in this app return it. + 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 (mirrors _get_mtls_client): another + # thread may have built and cached a client while we built ours. + if region: + if self._mtls_bearer_regional_client is None: + self._mtls_bearer_regional_client = client + client = self._mtls_bearer_regional_client + else: + if self._mtls_bearer_client is None: + self._mtls_bearer_client = client + client = self._mtls_bearer_client + return client + def _build_client(self, client_credential, authority, skip_regional_client=False): client_assertion = None client_assertion_type = None @@ -2984,6 +3085,10 @@ def _acquire_token_for_client( # Present the certificate over mTLS to obtain a cert-bound # (mtls_pop) token. client = self._get_mtls_client(self.authority) + elif self._send_certificate_over_mtls: + # Bearer-over-mTLS: present the certificate over mTLS but receive a + # plain Bearer token. (mtls_pop above always wins over this flag.) + client = self._get_mtls_bearer_client(self.authority) else: client = self._regional_client or self.client request_data = kwargs.pop("data", {}) diff --git a/tests/test_application.py b/tests/test_application.py index e1bf9a02..9ccc0217 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2175,3 +2175,131 @@ def test_common_authority_with_flag_raises(self): authority="https://login.microsoftonline.com/common") with self.assertRaises(ValueError): app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) + + +_MTLS_CERT_CRED_NO_X5C = { # A cert cred that does NOT opt into public_certificate + "private_key_pfx_path": _MTLS_PFX, + "passphrase": "password", +} + + +def _jwt_header_of(assertion): + """Decode the (unverified) JOSE header of a compact-serialized JWT.""" + if isinstance(assertion, bytes): # create_normal_assertion returns bytes + assertion = assertion.decode("ascii") + header_b64 = assertion.split(".")[0] + header_b64 += "=" * (-len(header_b64) % 4) # Restore base64url padding + return json.loads(base64.urlsafe_b64decode(header_b64)) + + +@patch(_OIDC_DISCOVERY, new=_MTLS_OIDC_MOCK) +class TestSendCertificateOverMtls(unittest.TestCase): + """Bearer-over-mTLS: present the SN/I cert as the client TLS cert on the + handshake to the mTLS endpoint, but receive a *plain Bearer* token (the + token is NOT cert-bound). Distinct from mtls_pop (which binds the token and + fences the cache entry by key_id) and from the classic private_key_jwt + Bearer path (which never puts the cert on the TLS handshake).""" + + _AUTHORITY = "https://login.microsoftonline.com/my_tenant" + + def _capturing_post(self, captured, token_type="Bearer"): + 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 + + # --- A. Config / builder ------------------------------------------------- + def test_flag_defaults_false(self): + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + self.assertFalse(app._send_certificate_over_mtls) + + def test_flag_stored_true_on_cert_credential(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + self.assertTrue(app._send_certificate_over_mtls) + + def test_flag_with_non_cert_credential_raises_at_construction(self): + with self.assertRaises(ValueError) as ctx: + ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": "a.static.jwt", + "send_certificate_over_mtls": True}) + self.assertIn("send_certificate_over_mtls", str(ctx.exception)) + + # --- B. Client-credentials behavior ------------------------------------- + def test_cc_flag_no_region_uses_global_mtls_and_returns_bearer(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + captured = [] + result = app.acquire_token_for_client( + ["s1"], post=self._capturing_post(captured)) + req = captured[0] + # Routed to the GLOBAL mtls host (login.* -> mtlsauth.*) + self.assertEqual( + req["url"], + "https://mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + # Body: signed private_key_jwt client_assertion is present ... + self.assertTrue(req["data"].get("client_assertion")) + self.assertEqual(_JWT_BEARER, req["data"].get("client_assertion_type")) + # ... but NONE of the PoP wire markers + self.assertNotEqual("mtls_pop", req["data"].get("token_type")) + self.assertNotIn("req_cnf", req["data"]) + self.assertNotIn("key_id", req["data"]) + # Result is a plain Bearer token, NOT cert-bound + self.assertEqual("Bearer", result.get("token_type")) + self.assertNotIn("binding_certificate", result) + + def test_cc_x5c_is_forced_on_assertion(self): + # Bearer-over-mTLS forces the x5c chain onto the assertion header even + # when public_certificate was NOT opted into; the regular (no-flag) + # path with the same cred omits it. That contrast proves "forced". + forced = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED_NO_X5C, send_certificate_over_mtls=True)) + captured = [] + forced.acquire_token_for_client(["s1"], post=self._capturing_post(captured)) + self.assertIn( + "x5c", _jwt_header_of(captured[0]["data"]["client_assertion"]), + "Bearer-over-mTLS must force x5c onto the assertion header") + + plain = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED_NO_X5C, authority=self._AUTHORITY) + plain_captured = [] + plain.acquire_token_for_client(["s1"], post=self._capturing_post(plain_captured)) + self.assertNotIn( + "x5c", _jwt_header_of(plain_captured[0]["data"]["client_assertion"]), + "The no-flag path must NOT force x5c (contrast)") + + def test_cc_flag_with_region_uses_regional_mtls(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, azure_region="westus3", + client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + captured = [] + app.acquire_token_for_client(["s1"], post=self._capturing_post(captured)) + self.assertEqual( + captured[0]["url"], + "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + + def test_cc_per_request_mtls_pop_wins_over_flag(self): + # Precedence: a per-request mtls_proof_of_possession=True ALWAYS wins + # over the app-level Bearer-over-mTLS flag. + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + captured = [] + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, + post=self._capturing_post(captured, token_type="mtls_pop")) + req = captured[0] + self.assertEqual("mtls_pop", req["data"].get("token_type")) + # The mtls_pop path is vanilla SN/I: no signed assertion on the wire + self.assertNotIn("client_assertion", req["data"]) + self.assertEqual("mtls_pop", result.get("token_type")) + self.assertTrue(result.get("binding_certificate")) From 93faa2fa30fe9df7953ecd81cdb69c88607f0129 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:20:51 -0400 Subject: [PATCH 15/46] Route OBO/refresh/auth-code through Bearer-over-mTLS when flag is set Extend the app-level send_certificate_over_mtls opt-in beyond client-credentials to every confidential-client user flow: on-behalf-of, refresh-token, and authorization-code. Add _client_for_confidential_request(): when the flag is set it returns the Bearer-over-mTLS client (SN/I cert on the TLS handshake, routed to the [region.]mtlsauth.microsoft.com endpoint, private_key_jwt client_assertion with the x5c chain forced, plain Bearer token back), otherwise the regular client. Swap it in at the three user-flow call sites. Client-credentials routing stays in _acquire_token_for_client, where a per-request mtls_proof_of_possession still wins over the app-level flag. Tests (tests/test_application.py, TestSendCertificateOverMtls): OBO / refresh / auth-code with the flag hit the global mtlsauth endpoint, carry client_assertion + client_assertion_type=jwt-bearer with no key_id/req_cnf, and use the correct grant (on_behalf_of / refresh_token / authorization_code); OBO with the flag + a configured region hits the regional mtlsauth host; OBO without the flag stays on the regular login endpoint (negative control). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 17 ++++++-- tests/test_application.py | 81 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/msal/application.py b/msal/application.py index ff0fa384..f6b835aa 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1225,6 +1225,17 @@ def _get_mtls_bearer_client(self, central_authority): client = self._mtls_bearer_client return client + def _client_for_confidential_request(self): + """Pick the OAuth2 client for a confidential-client user flow (OBO, + refresh-token, auth-code): the Bearer-over-mTLS client when the + app-level send_certificate_over_mtls flag is set, else the regular + client. (Client-credentials routing lives in _acquire_token_for_client, + which additionally honors a per-request mtls_pop that wins over the flag.) + """ + if self._send_certificate_over_mtls: + return self._get_mtls_bearer_client(self.authority) + return self.client + def _build_client(self, client_credential, authority, skip_regional_client=False): client_assertion = None client_assertion_type = None @@ -1683,7 +1694,7 @@ def acquire_token_by_authorization_code( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID) _data = kwargs.pop("data", {}) _stash_client_claims(forwarded_client_claims, _data) - response = _clean_up(self.client.obtain_token_by_authorization_code( + response = _clean_up(self._client_for_confidential_request().obtain_token_by_authorization_code( code, redirect_uri=redirect_uri, scope=self._decorate_scope(scopes), headers=telemetry_context.generate_headers(), @@ -2312,7 +2323,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs): telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_REFRESH_TOKEN, refresh_reason=msal.telemetry.FORCE_REFRESH) - response = _clean_up(self.client.obtain_token_by_refresh_token( + response = _clean_up(self._client_for_confidential_request().obtain_token_by_refresh_token( refresh_token, scope=self._decorate_scope(scopes), headers=telemetry_context.generate_headers(), @@ -3166,7 +3177,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No _data = kwargs.pop("data", {}) _stash_client_claims(forwarded_client_claims, _data) # The implementation is NOT based on Token Exchange (RFC 8693) - response = _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521 + response = _clean_up(self._client_for_confidential_request().obtain_token_by_assertion( # bases on assertion RFC 7521 user_assertion, self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs scope=self._decorate_scope(scopes), # Decoration is used for: diff --git a/tests/test_application.py b/tests/test_application.py index 9ccc0217..95f43175 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2303,3 +2303,84 @@ def test_cc_per_request_mtls_pop_wins_over_flag(self): self.assertNotIn("client_assertion", req["data"]) self.assertEqual("mtls_pop", result.get("token_type")) self.assertTrue(result.get("binding_certificate")) + + # --- C. User flows (OBO / refresh / auth-code) -------------------------- + def _capturing_post_full(self, captured): + # A capturing post that returns a full user-token response (AT + + # id_token + client_info), needed by flows that create an account. + 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=_build_user_token_response()) + return mock_post + + def test_obo_flag_uses_global_mtls_and_obo_grant(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + captured = [] + app.acquire_token_on_behalf_of( + "a_user_assertion", ["s1"], post=self._capturing_post(captured)) + req = captured[0] + self.assertEqual( + req["url"], + "https://mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + self.assertEqual("on_behalf_of", req["data"].get("requested_token_use")) + self.assertTrue(req["data"].get("client_assertion")) + self.assertEqual(_JWT_BEARER, req["data"].get("client_assertion_type")) + self.assertNotEqual("mtls_pop", req["data"].get("token_type")) + self.assertNotIn("key_id", req["data"]) + + def test_obo_flag_with_region_uses_regional_mtls(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, azure_region="westus3", + client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + captured = [] + app.acquire_token_on_behalf_of( + "a_user_assertion", ["s1"], post=self._capturing_post(captured)) + self.assertEqual( + captured[0]["url"], + "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + + def test_refresh_flag_uses_global_mtls_and_refresh_grant(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + captured = [] + app.acquire_token_by_refresh_token( + "a_rt", ["s1"], post=self._capturing_post(captured)) + req = captured[0] + self.assertEqual( + req["url"], + "https://mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + self.assertEqual("refresh_token", req["data"].get("grant_type")) + self.assertTrue(req["data"].get("client_assertion")) + self.assertNotEqual("mtls_pop", req["data"].get("token_type")) + + def test_auth_code_flag_uses_global_mtls_and_auth_code_grant(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + captured = [] + app.acquire_token_by_authorization_code( + "a_code", ["s1"], post=self._capturing_post_full(captured)) + req = captured[0] + self.assertEqual( + req["url"], + "https://mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") + self.assertEqual("authorization_code", req["data"].get("grant_type")) + self.assertTrue(req["data"].get("client_assertion")) + self.assertNotEqual("mtls_pop", req["data"].get("token_type")) + + def test_obo_without_flag_uses_regular_login_endpoint(self): + # Negative control: without the app-level flag, OBO stays on the + # regular login.* endpoint (no mTLS routing). + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + captured = [] + app.acquire_token_on_behalf_of( + "a_user_assertion", ["s1"], post=self._capturing_post(captured)) + self.assertEqual( + captured[0]["url"], + "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/token") From bf79927f719a5408c7a01b19c3f62f9d0a2c8b8a Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:26:45 -0400 Subject: [PATCH 16/46] Add Bearer-over-mTLS second-call cache-hit regression Guard the .NET MtlsBearerUserFlowTests second-call regression, adapted to msal-python's cache seam: a 2nd Bearer-over-mTLS client-credentials call is served from cache with no 2nd network round-trip and no crash on region/instance-metadata resolution, returning the cached plain Bearer AT (token_type=Bearer, NOT cert-bound). In .NET the 2nd call crashes because the AT is cached under the mtlsauth.* host and instance-metadata discovery rejects that host. msal-python structurally avoids this by decoupling the cache/metadata authority (login.*, where the plain Bearer AT lives) from the transport endpoint (mtlsauth.*): the 2nd lookup resolves login.* -- the normal, always-safe path -- so the cached Bearer AT is returned without feeding the mtlsauth.* host into discovery. Only client-credentials auto-serves from cache in msal-python (OBO, refresh, and auth-code have no silent-first lookup), so CC is the flow that exercises this regression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_application.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_application.py b/tests/test_application.py index 95f43175..88fd6d76 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2384,3 +2384,30 @@ def test_obo_without_flag_uses_regular_login_endpoint(self): self.assertEqual( captured[0]["url"], "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/token") + + def test_cc_second_call_is_bearer_cache_hit(self): + # Regression (mirrors .NET MtlsBearerUserFlowTests' 2nd-call test, + # adapted to msal-python's cache seam): a 2nd Bearer-over-mTLS call is + # served from cache with no 2nd network round-trip and no crash on + # region/instance-metadata resolution. In .NET the crash arises because + # the AT is cached under the mtlsauth.* host and instance discovery + # rejects that host. msal-python decouples the cache/metadata authority + # (login.*, where this plain Bearer AT lives) from the transport + # endpoint (mtlsauth.*), so the 2nd lookup resolves login.* -- the + # normal, always-safe path -- and returns the cached plain Bearer AT + # (NOT cert-bound). Only client-credentials auto-serves from cache in + # msal-python (OBO/refresh/auth-code have no silent-first lookup), so CC + # is the flow that exercises this regression. + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + first = app.acquire_token_for_client( + ["s1"], post=self._capturing_post([])) + self.assertEqual("Bearer", first.get("token_type")) + second = [] + result = app.acquire_token_for_client( + ["s1"], 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.assertEqual("Bearer", result.get("token_type")) + self.assertNotIn("binding_certificate", result) From ab024e7db5ef4e9f27cc3036cbd843dfc2952042 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:43:16 -0400 Subject: [PATCH 17/46] Add Bearer-over-mTLS end-to-end cells Cover the send_certificate_over_mtls feature in tests/test_e2e.py, mirroring MSAL .NET's live + capture split and reusing the SNI allow-listed app/authority constants already in PublicCloudScenariosTestCase. - test_credential_x509_send_certificate_over_mtls_output_bearer: LIVE client-credentials acquisition (allow-listed app, westus3, Key Vault resource) that presents the SN/I cert on the TLS handshake to the mTLS endpoint and asserts a PLAIN Bearer token back (token_type=Bearer, binding_certificate absent) plus a normal flagless cache hit -- the caller-visible proof that the AT is an ordinary Bearer entry, not a key-id-fenced mtls_pop entry. - test_send_certificate_over_mtls_user_flows_capture_request: request-capture for OBO, refresh-token, and auth-code over the real lab SN/I certificate -- asserts each builds the mtlsauth endpoint + a private_key_jwt client_assertion + the flow's own grant with none of the mtls_pop markers, without needing those (non-mTLS-enabled) apps allow-listed. - test_send_certificate_over_mtls_user_flows_live_acquire: skip-gated live OBO/refresh placeholder documenting the pending server-side app mTLS-enablement (AADSTS700027 / AADSTS392189), mirroring MSAL .NET's [Ignore]. All three are gated (lab cert / explicit skip), collect cleanly, and skip locally; the live and capture cells run in the lab pipeline. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 200c5cdb..ef3a76d4 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -567,6 +567,105 @@ def test_credential_x509_output_bearer(self): self.assertIsNone(result.get("binding_certificate")) self.assertCacheWorksForApp(result, self._MTLS_POP_SCOPE) + # ── Bearer-over-mTLS (send_certificate_over_mtls) ────────────────────────── + # Present the SN/I cert as the client TLS certificate ON the handshake to the + # mTLS endpoint, but receive a *plain Bearer* token (the cert authenticates + # the transport; the token is NOT cert-bound). Opt-in via the app-level + # client_credential flag send_certificate_over_mtls=True. Distinct from the + # two cells above: mtls_pop BINDS the token; the classic X509 Bearer cell + # signs a private_key_jwt to the REGULAR login.* endpoint (cert never on the + # TLS handshake). Ports MSAL .NET CertificateOptions.SendCertificateOverMtls. + def test_credential_x509_send_certificate_over_mtls_output_bearer(self): + # LIVE client-credentials acquisition, allow-listed (mirrors the MSAL + # .NET live CC cell: westus3 + the Key Vault resource). Proves the cert + # goes on the TLS handshake to mtlsauth.* yet ESTS returns a plain Bearer. + 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") + scope = ["https://vault.azure.net/.default"] + self.app = msal.ConfidentialClientApplication( + self._SNI_ALLOWLISTED_CLIENT_ID, + authority=self._SNI_ALLOWLISTED_AUTHORITY, + azure_region="westus3", + client_credential=dict( + get_client_certificate(), send_certificate_over_mtls=True)) + result = self.app.acquire_token_for_client(scope) + self.assertIn( + "access_token", result, + "Bearer-over-mTLS request failed: %s" % result) + # A PLAIN Bearer token, NOT cert-bound (contrast the mtls_pop cell). + self.assertEqual("Bearer", result.get("token_type")) + self.assertNotIn("binding_certificate", result) + self.assertIsNone(result.get("binding_certificate")) + # The AT is an ordinary Bearer entry (no key_id/thumbprint fence), so a + # normal flagless acquire_token_for_client lookup returns it from cache + # -- the caller-visible proof of the feature's goal. (mtls_pop needed the + # special _assert_pop_cache_hit because its AT is key-id-fenced; Bearer- + # over-mTLS deliberately is not.) + self.assertCacheWorksForApp(result, scope) + + def test_send_certificate_over_mtls_user_flows_capture_request(self): + # The allow-listed app is entitled to Bearer-over-mTLS for + # client-credentials only; the OBO/refresh/auth-code apps are not + # mTLS-enabled server-side (AADSTS700027 / AADSTS392189), exactly as + # MSAL .NET [Ignore]s those live. So prove MSAL builds the correct wire + # request for every confidential user flow -- the mtlsauth endpoint + a + # private_key_jwt client_assertion + the flow's own grant, with none of + # the mtls_pop markers -- by capturing the outgoing POST over the REAL + # lab SN/I certificate, without needing those apps allow-listed. + 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") + cred = dict( + get_client_certificate(), send_certificate_over_mtls=True) + def _capture(sink): + def post(url, headers=None, data=None, *a, **k): + sink.append({"url": url, "data": dict(data or {})}) + return MinimalResponse(status_code=400, text=json.dumps({ + "error": "invalid_grant", + "error_description": "request captured; live acquisition " + "not required for this app"})) + return post + def _new_app(): + return msal.ConfidentialClientApplication( + self._SNI_ALLOWLISTED_CLIENT_ID, + authority=self._SNI_ALLOWLISTED_AUTHORITY, + client_credential=cred) + scope = ["https://graph.microsoft.com/.default"] + def _assert_mtls(req, grant_key, grant_value): + self.assertTrue( + req["url"].startswith("https://mtlsauth.microsoft.com/"), + "Expected the mTLS endpoint, got %s" % req["url"]) + self.assertEqual(grant_value, req["data"].get(grant_key)) + self.assertTrue(req["data"].get("client_assertion")) + self.assertNotEqual("mtls_pop", req["data"].get("token_type")) + self.assertNotIn("key_id", req["data"]) + self.assertNotIn("req_cnf", req["data"]) + cap = [] + _new_app().acquire_token_on_behalf_of( + "a_user_assertion", scope, post=_capture(cap)) + _assert_mtls(cap[0], "requested_token_use", "on_behalf_of") + cap = [] + _new_app().acquire_token_by_refresh_token( + "a_refresh_token", scope, post=_capture(cap)) + _assert_mtls(cap[0], "grant_type", "refresh_token") + cap = [] + _new_app().acquire_token_by_authorization_code( + "an_auth_code", scope, post=_capture(cap)) + _assert_mtls(cap[0], "grant_type", "authorization_code") + + def test_send_certificate_over_mtls_user_flows_live_acquire(self): + # Live OBO/refresh acquisition with Bearer-over-mTLS is pending the + # downstream apps being mTLS-enabled server-side; today ESTS rejects it + # (AADSTS700027 / AADSTS392189) -- the same class of block as the FIC + # leg-1 gate (AADSTS51000) and MSAL .NET's [Ignore]'d live user-flow + # cells. Kept as an explicit, self-documenting gate that flips to a real + # acquisition once the apps are enabled; the request shape is already + # proven by test_send_certificate_over_mtls_user_flows_capture_request. + self.skipTest( + "pending app mTLS-enablement for OBO/refresh Bearer-over-mTLS " + "(AADSTS700027 / AADSTS392189)") + class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once @classmethod From 92421b0bb96f5584ca0976eed72be7a44feff031 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:45:05 -0400 Subject: [PATCH 18/46] Document the send_certificate_over_mtls client_credential flag Add a docstring admonition to the client_credential parameter describing the Bearer-over-mTLS opt-in: set send_certificate_over_mtls=True on the certificate credential dictionary to present the cert on the mTLS handshake and receive a plain (non-cert-bound) Bearer token. Notes that it covers all four confidential flows, defaults to False, requires a certificate credential, and that a per-request mtls_proof_of_possession=True always takes precedence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/msal/application.py b/msal/application.py index f6b835aa..2d2649f4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -545,6 +545,33 @@ def get_client_assertion(): If your .pfx file contains both the private key and public cert, you can opt in for Subject Name/Issuer Auth by setting "public_certificate" to ``True``. + .. admonition:: Sending the certificate over mTLS for a Bearer token + + A certificate credential can additionally opt in to + **Bearer-over-mTLS**: MSAL presents the certificate as the client + certificate on the mutual-TLS (mTLS) handshake to Microsoft + Entra's mTLS endpoint, but requests an ordinary, *non-cert-bound* + Bearer access token (contrast ``mtls_proof_of_possession``, which + binds the token). Set ``send_certificate_over_mtls`` to ``True`` + on the certificate credential dictionary:: + + { + "private_key_pfx_path": "/path/to/your.pfx", + "public_certificate": True, + "send_certificate_over_mtls": True, + } + + It applies to every confidential-client flow + (:func:`~acquire_token_for_client`, + :func:`~acquire_token_on_behalf_of`, + :func:`~acquire_token_by_authorization_code`, and + :func:`~acquire_token_by_refresh_token`), defaults to ``False`` + (so leaving it unset changes no behavior), and requires a + certificate credential -- otherwise construction raises + ``ValueError``. A per-request ``mtls_proof_of_possession=True`` on + :func:`~acquire_token_for_client` always takes precedence, yielding + a cert-bound ``mtls_pop`` token instead of a Bearer one. + :type client_credential: Union[dict, str, None] :param dict client_claims: From 60a9f248622f1a219cdc0ad01cc90f277aefa9a6 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:49:59 -0400 Subject: [PATCH 19/46] Harden Bearer-over-mTLS 2nd-call cache test with an env-lock assertion Add a negative assertion to test_cc_second_call_is_bearer_cache_hit that the cached plain Bearer AT is stored under the ORIGINAL login.* host and its environment never contains "mtlsauth". This is the assertion that would fail if the cache seam were ever switched to the .NET-style mtlsauth.* environment (Option A), which would silently break msal-python's login.*-keyed cache lookup and defeat the 2nd-call cache hit. Also assert result1 == result2 (the same cached AT is returned) alongside the existing exactly-one-network-POST guard. Mirrors the cross-SDK finding that a 2nd-call cache test can pass without actually proving the cache environment; this makes the env dependence explicit and deterministic. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_application.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_application.py b/tests/test_application.py index 88fd6d76..f9374a62 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2408,6 +2408,19 @@ def test_cc_second_call_is_bearer_cache_hit(self): result = app.acquire_token_for_client( ["s1"], post=self._capturing_post(second)) self.assertEqual([], second, "Second call must be served from cache") + # result1 == result2: the SAME cached AT is returned on the 2nd call. + self.assertEqual(first.get("access_token"), result.get("access_token")) + # Env-lock (hardens Option B, per the cross-SDK 2nd-call finding): the + # plain Bearer AT is cached under the ORIGINAL login.* host, NEVER the + # rewritten mtlsauth.* endpoint. This is the assertion that would fail if + # anyone re-cached under mtlsauth.* (Option A) -- which would silently + # break the login.*-keyed lookup that served the 2nd call above. + at_entries = list(app.token_cache.search( + msal.TokenCache.CredentialType.ACCESS_TOKEN, query={})) + self.assertEqual(1, len(at_entries), "exactly one plain Bearer AT cached") + cached_env = at_entries[0].get("environment") + self.assertEqual("login.microsoftonline.com", cached_env) + self.assertNotIn("mtlsauth", cached_env or "") self.assertEqual(app._TOKEN_SOURCE_CACHE, result[app._TOKEN_SOURCE]) self.assertEqual("Bearer", result.get("token_type")) self.assertNotIn("binding_certificate", result) From 6e2c371473a65ff9576cff88084eabd58f65099d Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 21 Oct 2026 16:18:22 -0400 Subject: [PATCH 20/46] Address mTLS review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 3 + msal/application.py | 71 +++++++----- msal/mtls.py | 64 +++++------ msal/token_cache.py | 87 ++++++++------ tests/test_application.py | 213 ++++++++++++++++++++++++++++++++--- tests/test_mtls_transport.py | 173 +++++++++++++++++++++++++++- tests/test_token_cache.py | 107 +++++++++++++++++- 7 files changed, 602 insertions(+), 116 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 477c9675..0e2f7576 100644 --- a/msal/application.py +++ b/msal/application.py @@ -130,12 +130,17 @@ def _parse_pfx(pfx_path, passphrase_bytes): # 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) + for additional_cert in additional_certs or []: + _, _, additional_x5c, additional_pem = _extract_cert_and_thumbprints( + additional_cert) + x5c.extend(additional_x5c) + cert_pem += additional_pem return private_key, sha256_thumbprint, sha1_thumbprint, x5c, cert_pem @@ -182,7 +187,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. """ @@ -202,6 +207,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 +355,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( @@ -576,10 +582,12 @@ def get_client_assertion(): It applies to every confidential-client flow (:func:`~acquire_token_for_client`, :func:`~acquire_token_on_behalf_of`, + :func:`~acquire_token_by_auth_code_flow`, :func:`~acquire_token_by_authorization_code`, and - :func:`~acquire_token_by_refresh_token`), defaults to ``False`` - (so leaving it unset changes no behavior), and requires a - certificate credential -- otherwise construction raises + :func:`~acquire_token_by_refresh_token`), including refresh-token + redemption during :func:`~acquire_token_silent`. It defaults to + ``False`` (so leaving it unset changes no behavior) and requires + a certificate credential -- otherwise construction raises ``ValueError``. A per-request ``mtls_proof_of_possession=True`` on :func:`~acquire_token_for_client` always takes precedence, yielding a cert-bound ``mtls_pop`` token instead of a Bearer one. @@ -930,8 +938,7 @@ def get_client_assertion(): # Bearer-over-mTLS reuses the same mTLS transport but signs a # private_key_jwt client_assertion (x5c forced) instead of requesting a # cert-bound token; its client(s) are likewise built lazily. - self._mtls_bearer_client = None # Lazily built global Bearer-over-mTLS client - self._mtls_bearer_regional_client = None # Lazily built regional one + self._mtls_bearer_clients = {} # Keyed by authority and resolved region self._mtls_lock = Lock() if isinstance(client_credential, dict) and not client_credential.get( "client_assertion") and ( @@ -948,7 +955,11 @@ def get_client_assertion(): self._send_certificate_over_mtls = bool( isinstance(client_credential, dict) and client_credential.get("send_certificate_over_mtls")) - if self._send_certificate_over_mtls and self._mtls_cert_credential is None: + if self._send_certificate_over_mtls and ( + self._mtls_cert_credential is None + or (not client_credential.get("private_key_pfx_path") and not ( + isinstance(client_credential.get("public_certificate"), str) + and client_credential["public_certificate"].strip()))): raise ValueError( "send_certificate_over_mtls=True requires this confidential " "client to be configured with a certificate credential (a " @@ -1227,10 +1238,10 @@ def _get_mtls_bearer_client(self, central_authority): "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() + cache_key = ( + central_authority.instance, central_authority.token_endpoint, region) with self._mtls_lock: - cached = ( - self._mtls_bearer_regional_client if region - else self._mtls_bearer_client) + cached = self._mtls_bearer_clients.get(cache_key) if cached is not None: return cached cert = self._get_mtls_pop_cert() # May raise ValueError (no cert configured) @@ -1280,14 +1291,7 @@ def _get_mtls_bearer_client(self, central_authority): with self._mtls_lock: # Double-check under the lock (mirrors _get_mtls_client): another # thread may have built and cached a client while we built ours. - if region: - if self._mtls_bearer_regional_client is None: - self._mtls_bearer_regional_client = client - client = self._mtls_bearer_regional_client - else: - if self._mtls_bearer_client is None: - self._mtls_bearer_client = client - client = self._mtls_bearer_client + client = self._mtls_bearer_clients.setdefault(cache_key, client) return client def _client_for_confidential_request(self): @@ -1669,7 +1673,7 @@ def authorize(): # A controller in a web app self._validate_ssh_cert_input_data(kwargs.get("data", {})) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID) - response = _clean_up(self.client.obtain_token_by_auth_code_flow( + response = _clean_up(self._client_for_confidential_request().obtain_token_by_auth_code_flow( auth_code_flow, auth_response, scope=self._decorate_scope(scopes) if scopes else None, @@ -2298,9 +2302,12 @@ def _acquire_token_silent_by_finding_specific_refresh_token( response = None # A distinguishable value to mean cache is empty if not matches: # Then exit early to avoid expensive operations return response - client, _ = self._build_client( - # Potentially expensive if building regional client - self.client_credential, authority, skip_regional_client=True) + if self._send_certificate_over_mtls: + client = self._get_mtls_bearer_client(authority) + else: + client, _ = self._build_client( + # Potentially expensive if building regional client + self.client_credential, authority, skip_regional_client=True) telemetry_context = self._build_telemetry_context( self.ACQUIRE_TOKEN_SILENT_ID, correlation_id=correlation_id, refresh_reason=refresh_reason) @@ -2999,7 +3006,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. @@ -3041,6 +3048,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):: @@ -3092,6 +3102,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: @@ -3122,8 +3136,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..1e3bab22 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -39,20 +39,15 @@ # ───────────────────────────────────────────────────────────────────────────── # SOVEREIGN GUARDRAIL - single override point for mTLS cloud availability. # -# mTLS PoP is currently rejected for the deprecated sovereign login hosts below -# and for any non-"login." host. ``mtlsauth.*`` is rolling out across clouds -# (Azure Government / AGC: available; Bleu / Delos: TBD). To enable a cloud, -# remove its entry here (and, if needed, relax the non-"login." host check in -# ``mtls_pop_host``). This is the ONLY place cloud eligibility is enforced, so -# do not scatter equivalent checks elsewhere in the codebase. +# Only public-cloud aliases and Azure Government are supported. Add an explicit +# mapping here when another cloud becomes available. # ───────────────────────────────────────────────────────────────────────────── _MTLS_POP_UNSUPPORTED_HOSTS = { "login.usgovcloudapi.net": "login.usgovcloudapi.net is not supported for mTLS PoP, " "please use login.microsoftonline.us", "login.chinacloudapi.cn": - "login.chinacloudapi.cn is not supported for mTLS PoP, " - "please use login.partner.microsoftonline.cn", + "login.chinacloudapi.cn is not supported for mTLS PoP.", } @@ -62,8 +57,7 @@ def mtls_pop_host(instance, region=None): :param str instance: The authority host, e.g. ``login.microsoftonline.com``. :param region: Optional region, e.g. ``westus3``. When provided, a regional mTLS host ``{region}.mtlsauth...`` is returned; otherwise the global one. - :raises ValueError: When mTLS PoP is not supported for the given host - (the sovereign guardrail above, or a non-``login.`` host). + :raises ValueError: When mTLS PoP is not supported for the given host. """ instance = instance.lower() if instance in _MTLS_POP_UNSUPPORTED_HOSTS: # Sovereign guardrail @@ -72,12 +66,12 @@ def mtls_pop_host(instance, region=None): # Known public aliases (incl. legacy login.windows.net / sts.windows.net) # all normalize to the single global mTLS host. base = _PUBLIC_MTLS_HOST - elif instance.startswith("login."): # e.g. login.microsoftonline.us - base = "mtlsauth" + instance[len("login"):] # -> mtlsauth.microsoftonline.us + elif instance == "login.microsoftonline.us": + base = "mtlsauth.microsoftonline.us" else: raise ValueError( - "mTLS PoP is only supported for hosts that start with 'login.'. " - "The provided authority host ({}) does not meet this requirement. " + "mTLS PoP is only supported for public-cloud and Azure Government " + "authority hosts. The provided authority host ({}) is not supported. " "See {} for details.".format(instance, _MTLS_POP_DOC_LINK)) return "{}.{}".format(region, base) if region else base @@ -121,40 +115,45 @@ def __init__(self, cert_pem, key_pem, *, self._proxies = proxies self._timeout = timeout self._session = None + self._sessions = {} self._session_lock = threading.Lock() - def _ensure_session(self): - # Double-checked locking: confidential clients are typically - # multi-threaded servers, and a cold-start burst must not build N - # sessions (each writing the private key to a temp file N times) and - # orphan all but one. Mirrors the client-level lock in application.py. - if self._session is None: - with self._session_lock: - if self._session is None: - import requests # Lazy import, same as the rest of MSAL + def _ensure_session(self, url="", verify=None): + import requests + with self._session_lock: + session = self._session or requests.Session() + session.verify = self._verify + if self._proxies: + session.proxies = self._proxies + effective_verify = session.merge_environment_settings( + url, {}, None, verify, None)["verify"] + if effective_verify not in self._sessions: + if self._sessions: session = requests.Session() session.verify = self._verify if self._proxies: session.proxies = self._proxies - adapter = _make_mtls_adapter(_create_ssl_context( - self._cert_pem, self._key_pem, self._verify)) - session.mount("https://", adapter) - self._session = session - return self._session + adapter = _make_mtls_adapter(_create_ssl_context( + self._cert_pem, self._key_pem, effective_verify)) + session.mount("https://", adapter) + self._sessions[effective_verify] = session + self._session = self._session or session + return self._sessions[effective_verify] def post(self, url, **kwargs): if self._timeout is not None: kwargs.setdefault("timeout", self._timeout) - return self._ensure_session().post(url, **kwargs) + return self._ensure_session(url, kwargs.get("verify")).post(url, **kwargs) def get(self, url, **kwargs): if self._timeout is not None: kwargs.setdefault("timeout", self._timeout) - return self._ensure_session().get(url, **kwargs) + return self._ensure_session(url, kwargs.get("verify")).get(url, **kwargs) def close(self): - if self._session is not None: - self._session.close() + with self._session_lock: + for session in self._sessions.values(): + session.close() def _make_mtls_adapter(ssl_context): @@ -229,4 +228,3 @@ def _create_ssl_context(cert_pem, key_pem, verify=True): except OSError: # pragma: no cover logger.warning("Unable to remove temporary mTLS key file") return context - diff --git a/msal/token_cache.py b/msal/token_cache.py index 1b1a8d65..034649ee 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 @@ -592,13 +600,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 @@ -681,4 +701,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 0e2e4abb..5f525b8c 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2278,8 +2278,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 +2303,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) @@ -2332,9 +2359,10 @@ def test_token_type_downgrade_fails_closed(self): # 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")) + 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) @@ -2345,14 +2373,31 @@ def test_token_type_downgrade_fails_closed(self): 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") @@ -2439,6 +2484,25 @@ def test_flag_with_non_cert_credential_raises_at_construction(self): "send_certificate_over_mtls": True}) self.assertIn("send_certificate_over_mtls", str(ctx.exception)) + def test_flag_with_incomplete_pem_credential_raises_at_construction(self): + from msal.application import _load_mtls_cert_material + material = _load_mtls_cert_material(_MTLS_CERT_CRED) + credential = { + "private_key": material["private_key_pem"].decode("ascii"), + "thumbprint": "0" * 40, + } + ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=credential) + for public_certificate in (None, "", " ", True): + with self.subTest(public_certificate=public_certificate): + with self.assertRaisesRegex( + ValueError, "send_certificate_over_mtls.*public_certificate"): + ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential=dict( + credential, send_certificate_over_mtls=True, + public_certificate=public_certificate)) + # --- B. Client-credentials behavior ------------------------------------- def test_cc_flag_no_region_uses_global_mtls_and_returns_bearer(self): app = ConfidentialClientApplication( @@ -2502,9 +2566,10 @@ def test_cc_per_request_mtls_pop_wins_over_flag(self): "cid", authority=self._AUTHORITY, client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) captured = [] - result = app.acquire_token_for_client( - ["s1"], mtls_proof_of_possession=True, - post=self._capturing_post(captured, token_type="mtls_pop")) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured, token_type="mtls_pop")): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) req = captured[0] self.assertEqual("mtls_pop", req["data"].get("token_type")) # The mtls_pop path is vanilla SN/I: no signed assertion on the wire @@ -2581,6 +2646,126 @@ def test_auth_code_flag_uses_global_mtls_and_auth_code_grant(self): self.assertTrue(req["data"].get("client_assertion")) self.assertNotEqual("mtls_pop", req["data"].get("token_type")) + def test_auth_code_flow_uses_mtls_and_preserves_state_validation(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + flow = app.initiate_auth_code_flow( + ["s1"], redirect_uri="https://localhost/callback", + response_mode="form_post") + captured = [] + with patch.object(app.http_client, "post", + side_effect=AssertionError("Expected mTLS transport")), \ + patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)) as post: + with self.assertRaises(ValueError): + app.acquire_token_by_auth_code_flow( + flow, {"code": "a_code", "state": "wrong_state"}) + post.assert_not_called() + result = app.acquire_token_by_auth_code_flow( + flow, {"code": "a_code", "state": flow["state"]}) + self.assertIn("access_token", result) + self.assertEqual( + "https://mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token", + captured[0]["url"]) + self.assertEqual("authorization_code", captured[0]["data"]["grant_type"]) + self.assertEqual(flow["code_verifier"], captured[0]["data"]["code_verifier"]) + + def test_silent_refresh_uses_mtls_and_preserves_cache_callbacks(self): + for succeeds in (True, False): + with self.subTest(succeeds=succeeds): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + app.acquire_token_on_behalf_of( + "user_assertion", ["s1"], + post=lambda *a, **k: MinimalResponse( + status_code=200, text=_build_user_token_response( + client_id="cid", refresh_token="original_rt"))) + account = next(app.token_cache.search( + msal.TokenCache.CredentialType.ACCOUNT)) + captured = [] + + def refresh(url, data=None, **kwargs): + captured.append({"url": url, "data": data}) + if succeeds: + app.token_cache.remove_account(account) + return MinimalResponse( + status_code=200 if succeeds else 400, + text=_build_user_token_response( + client_id="cid", refresh_token="rotated_rt") + if succeeds else '{"error": "invalid_grant"}') + + with patch.object(app.http_client, "post", + side_effect=AssertionError("Expected mTLS transport")), \ + patch("msal.mtls._MtlsHttpClient.post", side_effect=refresh), \ + patch.object(app, "_get_authority_aliases", return_value=[]): + result = app.acquire_token_silent_with_error( + ["s1"], account, force_refresh=True) + self.assertEqual( + "https://mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token", + captured[0]["url"]) + self.assertEqual("refresh_token", captured[0]["data"]["grant_type"]) + refresh_tokens = list(app.token_cache.search( + msal.TokenCache.CredentialType.REFRESH_TOKEN)) + self.assertEqual(1, len(refresh_tokens)) + self.assertEqual( + "rotated_rt" if succeeds else "original_rt", + refresh_tokens[0]["secret"]) + if succeeds: + self.assertIn("access_token", result) + self.assertEqual([], list(app.token_cache.search( + msal.TokenCache.CredentialType.ACCOUNT))) + else: + self.assertEqual("invalid_grant", result["error"]) + + def test_silent_refresh_preserves_effective_authority(self): + from msal.authority import Authority + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + alias = "login.windows.net" + endpoint = "https://{}/other_tenant/oauth2/v2.0/token".format(alias) + with patch(_OIDC_DISCOVERY, return_value={ + "authorization_endpoint": endpoint.replace("/token", "/authorize"), + "token_endpoint": endpoint, + "issuer": "https://{}/other_tenant/v2.0".format(alias)}): + authority = Authority( + "https://{}/other_tenant".format(alias), app.http_client, + instance_discovery=False) + rt = { + "credential_type": "RefreshToken", + "environment": alias, "home_account_id": "uid.utid", + "client_id": "cid", "secret": "a_rt", + } + app.token_cache.modify(msal.TokenCache.CredentialType.REFRESH_TOKEN, rt, rt) + for region in (None, "westus3"): + with self.subTest(region=region), patch.object( + app, "_compute_region_to_use", return_value=region): + original_client = app._get_mtls_bearer_client(app.authority) + captured = [] + with patch.object(app.http_client, "post", + side_effect=AssertionError("Expected mTLS transport")), \ + patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)), \ + patch.object(app.token_cache, "add", + wraps=app.token_cache.add) as add: + result = app._acquire_token_silent_by_finding_specific_refresh_token( + authority, ["s1"], { + "environment": alias, "home_account_id": "uid.utid", + "client_id": "cid"}) + expected = "https://{}mtlsauth.microsoft.com/other_tenant/oauth2/v2.0/token".format( + region + "." if region else "") + self.assertIn("access_token", result) + self.assertEqual(expected, captured[0]["url"]) + event = add.call_args[0][0] + self.assertEqual(alias, event["environment"]) + self.assertTrue(event["skip_account_creation"]) + alias_client = app._get_mtls_bearer_client(authority) + self.assertIsNot(original_client, alias_client) + self.assertIs(alias_client, app._get_mtls_bearer_client(authority)) + self.assertIs(original_client, app._get_mtls_bearer_client(app.authority)) + def test_obo_without_flag_uses_regular_login_endpoint(self): # Negative control: without the app-level flag, OBO stays on the # regular login.* endpoint (no mTLS routing). diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index 6baa69cd..ae7b6e99 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -1,10 +1,15 @@ +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) + _load_mtls_cert_material, _private_key_to_unencrypted_pem, extract_certs) class TestMtlsEndpointTransform(unittest.TestCase): @@ -66,7 +71,15 @@ 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): @@ -143,6 +156,162 @@ 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) + intermediate_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cls.intermediate = make_cert( + "intermediate", intermediate_key, root.subject, root_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.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_bearer_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")) + for send_over_mtls in (False, True): + with self.subTest(send_over_mtls=send_over_mtls), 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"), + "send_certificate_over_mtls": send_over_mtls, + }) + + 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], + serialization.NoEncryption())) + material = _load_mtls_cert_material({"private_key_pfx_path": path}) + self._assert_chain_and_handshake(material) + + 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 758fae0a..923ab7fc 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -292,12 +292,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 1e41f24eea25e6d0f39e53a58eb713ddbb160e93 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 22 Oct 2026 08:53:20 -0400 Subject: [PATCH 21/46] Address FIC mTLS review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 10 +- msal/application.py | 43 +++- sample/confidential_client_mtls_pop_sample.py | 34 ++- tests/test_application.py | 36 +++ tests/test_e2e.py | 222 +++++++++++++----- 5 files changed, 264 insertions(+), 81 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index f060eae0..02d04a52 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -209,9 +209,15 @@ Notes and requirements: (non-mTLS) certificate path is unchanged and still sends a signed ``client_assertion`` (``private_key_jwt``) to obtain an ordinary token. * For a Federated Identity Credential (FIC) exchange, configure the leg-2 client - with both a ``client_assertion`` (the leg-1 token) and an + with both a ``client_assertion`` callable (acquiring the leg-1 token) and an ``mtls_binding_certificate`` sub-key; the leg-1 assertion is then sent as a - ``jwt-pop`` client assertion over the same mTLS connection. + ``jwt-pop`` client assertion over mTLS using the same certificate as leg 1. + A fixed assertion string expires and emits a deprecation warning; the callable + lets the leg-1 application reuse cached tokens and acquire fresh ones as needed. + The target application must have a federated identity credential trusting the + leg-1 token's issuer, subject, and audience. See + ``fic_two_leg_over_mtls_pop()`` in + ``sample/confidential_client_mtls_pop_sample.py`` for the two-leg setup. Exceptions diff --git a/msal/application.py b/msal/application.py index 7cf63e20..30ddd966 100644 --- a/msal/application.py +++ b/msal/application.py @@ -537,15 +537,29 @@ def get_client_assertion(): .. admonition:: Binding an assertion to an mTLS certificate (FIC leg 2) - *Added in version 1.38.0*: For a Federated Identity Credential (FIC) exchange over mutual-TLS Proof-of-Possession, the ``client_assertion`` container may also carry an ``mtls_binding_certificate`` sub-key -- a certificate credential (same shape as the top-level cert credential) that is - presented as the client TLS certificate during the token request:: + presented as the client TLS certificate during the token request. + An explicitly supplied binding certificate must be a non-empty + certificate credential dictionary; invalid or incomplete values + raise ``ValueError``. + Use a callable to acquire the leg-1 token on demand; the leg-1 + application's cache reuses valid tokens and renews expired ones:: + + def get_leg1_assertion(): + result = leg1_app.acquire_token_for_client( + ["api://AzureADTokenExchange/.default"], + mtls_proof_of_possession=True, + ) + if "access_token" not in result: + raise RuntimeError( + "FIC leg 1 failed: {}".format(result.get("error"))) + return result["access_token"] { - "client_assertion": leg1_result["access_token"], # the leg-1 mtls_pop token + "client_assertion": get_leg1_assertion, "mtls_binding_certificate": { "private_key_pfx_path": "/path/to/your.pfx", "public_certificate": True, @@ -554,8 +568,10 @@ def get_client_assertion(): When ``mtls_binding_certificate`` is present, MSAL sends the assertion with ``client_assertion_type`` set to the ``jwt-pop`` - type and routes the request over mTLS using that binding - certificate. See + type and routes the request over mTLS using the same certificate + as the leg-1 application. A fixed token string is supported for + short-lived use, but emits the static-assertion deprecation + warning and cannot be renewed automatically. See :func:`~msal.ConfidentialClientApplication.acquire_token_for_client`'s ``mtls_proof_of_possession`` parameter for the full two-leg flow. @@ -926,15 +942,26 @@ def get_client_assertion(): 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 client_credential.get( - "mtls_binding_certificate"): # FIC leg 2 (assertion + binding cert) + if (isinstance(client_credential, dict) + and "mtls_binding_certificate" in client_credential): + binding_certificate = client_credential["mtls_binding_certificate"] + if not isinstance(binding_certificate, dict) or not ( + binding_certificate.get("private_key_pfx_path") + or (binding_certificate.get("private_key") + and isinstance( + binding_certificate.get("public_certificate"), str) + and binding_certificate["public_certificate"])): + raise ValueError( + "mtls_binding_certificate must be a non-empty certificate " + "credential dictionary with 'private_key_pfx_path', or " + "'private_key' plus a PEM 'public_certificate'.") if not client_credential.get("client_assertion"): raise ValueError( "mtls_binding_certificate (FIC leg 2) also requires a " "client_assertion (the leg-1 mtls_pop token) in the same " "client_credential; the binding certificate is presented " "to carry that assertion.") - self._mtls_cert_credential = client_credential["mtls_binding_certificate"] + self._mtls_cert_credential = binding_certificate self._mtls_is_fic_leg2 = True elif isinstance(client_credential, dict) and not client_credential.get( "client_assertion") and ( diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index e2765620..7750bbc0 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -27,6 +27,17 @@ SCOPE e.g. https://graph.microsoft.com/.default AZURE_REGION (optional) e.g. westus3; omit for the global mtls endpoint +For the optional FIC sample, also configure:: + + RUN_FIC_SAMPLE set to 1 to run the two-leg exchange + LEG2_CLIENT_ID target application with a provisioned federated identity credential + LEG2_AUTHORITY tenanted authority for that target application + FIC_EXCHANGE_SCOPE (optional) defaults to api://AzureADTokenExchange/.default + +The leg-1 app must be authorized for mTLS PoP at the exchange audience. The +leg-2 app's federated identity credential must trust the leg-1 token's issuer, +subject, and audience. Both legs use the SAME local certificate. + Then run:: python confidential_client_mtls_pop_sample.py @@ -120,19 +131,24 @@ def fic_two_leg_over_mtls_pop(): # The exchange audience is caller-supplied, not hard-coded by MSAL. exchange_scope = os.getenv( "FIC_EXCHANGE_SCOPE", "api://AzureADTokenExchange/.default") - leg1 = leg1_app.acquire_token_for_client( - [exchange_scope], mtls_proof_of_possession=True) - if "access_token" not in leg1: - _print_result_summary(leg1, "FIC leg 1 result:") - return leg1 + + def get_leg1_assertion(): + # Reuse the leg-1 cache, acquiring a fresh token when the old one expires. + leg1 = leg1_app.acquire_token_for_client( + [exchange_scope], mtls_proof_of_possession=True) + if "access_token" not in leg1: + _print_result_summary(leg1, "FIC leg 1 result:") + raise RuntimeError( + "FIC leg 1 failed: {}".format(leg1.get("error"))) + return leg1["access_token"] # --- Leg 2: exchange the leg-1 token for the final token --------------- leg2_app = msal.ConfidentialClientApplication( - os.getenv("LEG2_CLIENT_ID", os.environ["CLIENT_ID"]), - authority=os.environ["AUTHORITY"], + os.environ["LEG2_CLIENT_ID"], + authority=os.environ["LEG2_AUTHORITY"], client_credential={ - "client_assertion": leg1["access_token"], # the leg-1 mtls_pop token - "mtls_binding_certificate": cert, # binds the leg-2 TLS handshake + "client_assertion": get_leg1_assertion, + "mtls_binding_certificate": cert, }, azure_region=os.getenv("AZURE_REGION"), ) diff --git a/tests/test_application.py b/tests/test_application.py index 6550d615..4c47d27b 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2402,6 +2402,42 @@ def test_binding_cert_without_client_assertion_raises(self): "cid", authority=self._AUTHORITY, client_credential={"mtls_binding_certificate": _MTLS_CERT_CRED}) + def test_invalid_binding_certificate_is_rejected(self): + for certificate in ( + {}, None, "", [], ["certificate"], True, 1, "certificate", + {"passphrase": "password"}, {"private_key": "key"}, + {"private_key": "key", "public_certificate": True}): + with self.subTest(certificate=certificate): + with self.assertRaisesRegex(ValueError, "mtls_binding_certificate"): + ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": lambda: "LEG1_TOKEN", + "mtls_binding_certificate": certificate}) + + def test_fic_callable_renews_assertion_without_deprecation_warning(self): + assertion = Mock(side_effect=["LEG1_TOKEN", "RENEWED_LEG1_TOKEN"]) + captured = [] + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": assertion, + "mtls_binding_certificate": _MTLS_CERT_CRED}) + for scope in ("s1", "s2"): + result = app.acquire_token_for_client( + [scope], mtls_proof_of_possession=True, + post=self._capturing_post(captured)) + self.assertIn("access_token", result) + self.assertEqual( + ["LEG1_TOKEN", "RENEWED_LEG1_TOKEN"], + [request["data"]["client_assertion"] for request in captured]) + self.assertTrue(all( + request["data"]["client_assertion_type"] == _JWT_POP + and request["url"].startswith("https://mtlsauth.microsoft.com/") + for request in captured)) + def test_fic_leg2_app_rejects_non_client_credential_flows(self): # A FIC leg-2 app (client_assertion + mtls_binding_certificate) is only # valid via acquire_token_for_client over mTLS. Any other flow would put diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 1f68dd6b..73e6b119 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,5 +1,11 @@ """If the following ENV VAR were available, many end-to-end test cases would run. LAB_APP_CLIENT_CERT_PFX_PATH=... + +The FIC two-leg test additionally requires LAB_FIC_LEG1_CLIENT_ID, +LAB_FIC_LEG1_AUTHORITY, LAB_FIC_LEG2_CLIENT_ID, LAB_FIC_LEG2_AUTHORITY, and +LAB_FIC_EXCHANGE_SCOPE. Provision the leg-1 app for mTLS PoP at the exchange +audience, and a federated identity credential on the leg-2 app trusting the +leg-1 token's issuer, subject, and audience. Both legs use the lab certificate. """ try: from dotenv import load_dotenv # Use this only in local dev machine @@ -15,9 +21,9 @@ from urllib.parse import urlparse, parse_qs import sys try: - from unittest.mock import patch, ANY + from unittest.mock import patch, ANY, Mock except: - from mock import patch, ANY + from mock import patch, ANY, Mock import requests @@ -338,7 +344,55 @@ def test_access_token_should_be_obtained_for_a_supported_scope(self): self.assertIsNotNone(result.get("access_token")) -class PublicCloudScenariosTestCase(E2eTestCase): +class MtlsPopTestCase(E2eTestCase): + _MTLS_POP_SCOPE = ["https://graph.microsoft.com/.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" + + @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, 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. + + 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 + 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 + + +class PublicCloudScenariosTestCase(MtlsPopTestCase): # 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 # (including Azure DevOps) as long as LAB_APP_CLIENT_CERT_PFX_PATH is set. @@ -448,17 +502,11 @@ def test_subject_name_issuer_authentication(self): # 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. @@ -468,48 +516,6 @@ def _assert_pop_cache_hit(self, app, scope): 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, 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. - - 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 - 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 @@ -567,32 +573,45 @@ def test_credential_x509_output_bearer(self): self.assertIsNone(result.get("binding_certificate")) self.assertCacheWorksForApp(result, self._MTLS_POP_SCOPE) + +class FicTwoLegTestCase(MtlsPopTestCase): def test_fic_two_leg_over_mtls_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") - authority = "https://login.microsoftonline.com/microsoft.onmicrosoft.com" + required = ( + "LAB_APP_CLIENT_CERT_PFX_PATH", + "LAB_FIC_LEG1_CLIENT_ID", "LAB_FIC_LEG1_AUTHORITY", + "LAB_FIC_LEG2_CLIENT_ID", "LAB_FIC_LEG2_AUTHORITY", + "LAB_FIC_EXCHANGE_SCOPE", + ) + config = {name: clean_env(name) for name in required} + missing = [name for name, value in config.items() if not value] + if missing: + self.skipTest("FIC configuration missing: " + ", ".join(missing)) + cert = get_client_certificate() # Leg 1: SN/I cert -> cert-bound (mtls_pop) federated assertion for the # caller-supplied token-exchange audience. leg1_app = msal.ConfidentialClientApplication( - LAB_APP_CLIENT_ID, authority=authority, - client_credential=get_client_certificate()) + config["LAB_FIC_LEG1_CLIENT_ID"], + authority=config["LAB_FIC_LEG1_AUTHORITY"], + client_credential=cert) leg1 = leg1_app.acquire_token_for_client( - ["api://AzureADTokenExchange/.default"], mtls_proof_of_possession=True) - if "access_token" not in leg1: - self.skipTest("FIC leg 1 not pre-authorized for token exchange: %s" % leg1) + [config["LAB_FIC_EXCHANGE_SCOPE"]], mtls_proof_of_possession=True) + self.assertIn("access_token", leg1, "FIC leg 1 failed: %s" % leg1) self.assertEqual("mtls_pop", leg1.get("token_type"), "Leg 1 must be mtls_pop") leg1_thumbprint = leg1["binding_certificate"]["thumbprint_sha256"] # Leg 2: same cert binds the TLS handshake, leg-1 token carried as jwt-pop. leg2_app = msal.ConfidentialClientApplication( - LAB_APP_CLIENT_ID, authority=authority, + config["LAB_FIC_LEG2_CLIENT_ID"], + authority=config["LAB_FIC_LEG2_AUTHORITY"], client_credential={ - "client_assertion": leg1["access_token"], - "mtls_binding_certificate": get_client_certificate()}) + "client_assertion": lambda: leg1["access_token"], + "mtls_binding_certificate": cert}) # Leg 2 -> Bearer over mTLS (implicit Bearer-over-mTLS, no flag) leg2_bearer = leg2_app.acquire_token_for_client(self._MTLS_POP_SCOPE) self.assertIn("access_token", leg2_bearer, "Leg 2 (Bearer) failed: %s" % leg2_bearer) + self.assertEqual("Bearer", leg2_bearer.get("token_type")) + self.assertNotIn("binding_certificate", leg2_bearer) # Leg 2 -> mTLS PoP final token leg2_pop = leg2_app.acquire_token_for_client( self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) @@ -603,6 +622,85 @@ def test_fic_two_leg_over_mtls_pop(self): if cnf: self.assertEqual(leg1_thumbprint, cnf, "Final token cnf must be bound to the leg-1 cert thumbprint") + self._call_graph_with_mtls_pop_token(leg2_pop["access_token"], cert) + + +class FicScenarioTestCase(unittest.TestCase): + """Exercise the FIC integration test's failure gates without lab access.""" + + def setUp(self): + self.config = { + "LAB_APP_CLIENT_CERT_PFX_PATH": "configured.pfx", + "LAB_FIC_LEG1_CLIENT_ID": "leg1-client", + "LAB_FIC_LEG1_AUTHORITY": "https://login.microsoftonline.com/leg1-tenant", + "LAB_FIC_LEG2_CLIENT_ID": "leg2-client", + "LAB_FIC_LEG2_AUTHORITY": "https://login.microsoftonline.com/leg2-tenant", + "LAB_FIC_EXCHANGE_SCOPE": "api://exchange/.default", + } + self.leg1 = Mock() + self.leg1.acquire_token_for_client.return_value = { + "access_token": "leg1-token", "token_type": "mtls_pop", + "binding_certificate": {"thumbprint_sha256": "cert-thumbprint"}, + } + self.leg2 = Mock() + self.leg2.acquire_token_for_client.side_effect = [ + {"access_token": "bearer-token", "token_type": "Bearer"}, + {"access_token": "opaque-pop-token", "token_type": "mtls_pop"}, + ] + self.scenario = FicTwoLegTestCase("test_fic_two_leg_over_mtls_pop") + self.cert = {"private_key_pfx_path": "configured.pfx"} + self.factory = Mock(side_effect=[self.leg1, self.leg2]) + patchers = ( + patch.dict(os.environ, self.config, clear=True), + patch("tests.lab_config.get_client_certificate", return_value=self.cert), + patch("msal.ConfidentialClientApplication", self.factory), + patch.object(self.scenario, "_call_graph_with_mtls_pop_token"), + ) + for patcher in patchers: + patcher.start() + self.addCleanup(patcher.stop) + + def test_missing_configuration_skips_before_acquisition(self): + for name in self.config: + with self.subTest(name=name), patch.dict(os.environ, {name: ""}): + with self.assertRaisesRegex(unittest.SkipTest, name): + self.scenario.test_fic_two_leg_over_mtls_pop() + self.factory.assert_not_called() + + def test_configured_leg1_errors_fail_instead_of_skipping(self): + self.leg1.acquire_token_for_client.return_value = { + "error": "token_type_mismatch"} + with self.assertRaisesRegex(AssertionError, "token_type_mismatch"): + self.scenario.test_fic_two_leg_over_mtls_pop() + self.leg2.acquire_token_for_client.assert_not_called() + + def test_explicit_configuration_and_opaque_token_resource_validation(self): + self.scenario.test_fic_two_leg_over_mtls_pop() + calls = self.factory.call_args_list + self.assertEqual("leg1-client", calls[0].args[0]) + self.assertEqual(self.config["LAB_FIC_LEG1_AUTHORITY"], + calls[0].kwargs["authority"]) + self.assertEqual("leg2-client", calls[1].args[0]) + self.assertEqual(self.config["LAB_FIC_LEG2_AUTHORITY"], + calls[1].kwargs["authority"]) + credential = calls[1].kwargs["client_credential"] + self.assertIs(self.cert, credential["mtls_binding_certificate"]) + self.assertEqual("leg1-token", credential["client_assertion"]()) + self.leg1.acquire_token_for_client.assert_called_once_with( + [self.config["LAB_FIC_EXCHANGE_SCOPE"]], mtls_proof_of_possession=True) + self.scenario._call_graph_with_mtls_pop_token.assert_called_once_with( + "opaque-pop-token", self.cert) + + def test_bearer_contract_is_checked(self): + for response in ( + {"access_token": "token", "token_type": "mtls_pop"}, + {"access_token": "token", "token_type": "Bearer", + "binding_certificate": {}}): + with self.subTest(response=response): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg2.acquire_token_for_client.side_effect = [response] + with self.assertRaises(AssertionError): + self.scenario.test_fic_two_leg_over_mtls_pop() class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once From a3ee8075651bf9ee486417190a1d2901f4100846 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 22 Oct 2026 09:56:55 -0400 Subject: [PATCH 22/46] Fix Bearer-over-mTLS transport enforcement and PFX chains Reject post overrides centrally for Bearer-over-mTLS grants and capture requests through the mTLS transport in tests. Build ordered PFX issuer chains using signature verification, excluding unrelated and duplicate certificates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 66 ++++++++++++++++++++++++++-- tests/test_application.py | 85 ++++++++++++++++++++++++++++-------- tests/test_e2e.py | 12 ++--- tests/test_mtls_transport.py | 80 ++++++++++++++++++++++++++++++--- 4 files changed, 211 insertions(+), 32 deletions(-) diff --git a/msal/application.py b/msal/application.py index 0e2f7576..2507f0b7 100644 --- a/msal/application.py +++ b/msal/application.py @@ -126,6 +126,45 @@ 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 _is_certificate_issuer(cert, issuer): + from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm + from cryptography.hazmat.primitives.asymmetric import ( + rsa, ec, dsa, ed25519, ed448, 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) + elif 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): # Cert concepts https://security.stackexchange.com/a/226758/125264 from cryptography.hazmat.primitives.serialization import pkcs12 @@ -136,11 +175,20 @@ def _parse_pfx(pfx_path, 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) - for additional_cert in additional_certs or []: + remaining = [candidate for candidate in additional_certs or [] if candidate != cert] + current = cert + while remaining: + additional_cert = next( + (candidate for candidate in remaining + if _is_certificate_issuer(current, candidate)), None) + if additional_cert is None: + break # A PFX may contain only a partial chain. + remaining = [candidate for candidate in remaining if candidate != additional_cert] _, _, additional_x5c, additional_pem = _extract_cert_and_thumbprints( additional_cert) x5c.extend(additional_x5c) cert_pem += additional_pem + current = additional_cert return private_key, sha256_thumbprint, sha1_thumbprint, x5c, cert_pem @@ -333,6 +381,15 @@ def obtain_token_by_username_password(self, username, password, **kwargs): username, password, headers=headers, **kwargs) +class _MtlsBearerClient(_ClientWithCcsRoutingInfo): + def _obtain_token(self, *args, **kwargs): + if "post" in kwargs: + raise ValueError( + "send_certificate_over_mtls=True does not support a post " + "override. Omit post to use MSAL's built-in mTLS transport.") + return super(_MtlsBearerClient, self)._obtain_token(*args, **kwargs) + + class _MtlsClient(_ClientWithCcsRoutingInfo): """A confidential-client OAuth2 client whose token request presents a client certificate over mutual-TLS (mTLS Proof-of-Possession). @@ -588,7 +645,10 @@ def get_client_assertion(): redemption during :func:`~acquire_token_silent`. It defaults to ``False`` (so leaving it unset changes no behavior) and requires a certificate credential -- otherwise construction raises - ``ValueError``. A per-request ``mtls_proof_of_possession=True`` on + ``ValueError``. Custom ``http_client`` instances and per-request + ``post`` overrides are rejected when making an mTLS token request, + because MSAL must own the certificate-bearing transport. + A per-request ``mtls_proof_of_possession=True`` on :func:`~acquire_token_for_client` always takes precedence, yielding a cert-bound ``mtls_pop`` token instead of a Bearer one. @@ -1273,7 +1333,7 @@ def _get_mtls_bearer_client(self, central_authority): client_assertion = assertion.create_regenerative_assertion( audience=token_endpoint, issuer=self.client_id, additional_claims=self.client_claims or {}) - client = _ClientWithCcsRoutingInfo( + client = _MtlsBearerClient( configuration, self.client_id, http_client=http_client, diff --git a/tests/test_application.py b/tests/test_application.py index 5f525b8c..980bffc5 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2509,8 +2509,9 @@ def test_cc_flag_no_region_uses_global_mtls_and_returns_bearer(self): "cid", authority=self._AUTHORITY, client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) captured = [] - result = app.acquire_token_for_client( - ["s1"], post=self._capturing_post(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + result = app.acquire_token_for_client(["s1"]) req = captured[0] # Routed to the GLOBAL mtls host (login.* -> mtlsauth.*) self.assertEqual( @@ -2535,7 +2536,9 @@ def test_cc_x5c_is_forced_on_assertion(self): "cid", authority=self._AUTHORITY, client_credential=dict( _MTLS_CERT_CRED_NO_X5C, send_certificate_over_mtls=True)) captured = [] - forced.acquire_token_for_client(["s1"], post=self._capturing_post(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + forced.acquire_token_for_client(["s1"]) self.assertIn( "x5c", _jwt_header_of(captured[0]["data"]["client_assertion"]), "Bearer-over-mTLS must force x5c onto the assertion header") @@ -2554,7 +2557,9 @@ def test_cc_flag_with_region_uses_regional_mtls(self): client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) captured = [] - app.acquire_token_for_client(["s1"], post=self._capturing_post(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + app.acquire_token_for_client(["s1"]) self.assertEqual( captured[0]["url"], "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") @@ -2592,8 +2597,9 @@ def test_obo_flag_uses_global_mtls_and_obo_grant(self): "cid", authority=self._AUTHORITY, client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) captured = [] - app.acquire_token_on_behalf_of( - "a_user_assertion", ["s1"], post=self._capturing_post(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + app.acquire_token_on_behalf_of("a_user_assertion", ["s1"]) req = captured[0] self.assertEqual( req["url"], @@ -2610,8 +2616,9 @@ def test_obo_flag_with_region_uses_regional_mtls(self): client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) captured = [] - app.acquire_token_on_behalf_of( - "a_user_assertion", ["s1"], post=self._capturing_post(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + app.acquire_token_on_behalf_of("a_user_assertion", ["s1"]) self.assertEqual( captured[0]["url"], "https://westus3.mtlsauth.microsoft.com/my_tenant/oauth2/v2.0/token") @@ -2621,8 +2628,9 @@ def test_refresh_flag_uses_global_mtls_and_refresh_grant(self): "cid", authority=self._AUTHORITY, client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) captured = [] - app.acquire_token_by_refresh_token( - "a_rt", ["s1"], post=self._capturing_post(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + app.acquire_token_by_refresh_token("a_rt", ["s1"]) req = captured[0] self.assertEqual( req["url"], @@ -2636,8 +2644,9 @@ def test_auth_code_flag_uses_global_mtls_and_auth_code_grant(self): "cid", authority=self._AUTHORITY, client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) captured = [] - app.acquire_token_by_authorization_code( - "a_code", ["s1"], post=self._capturing_post_full(captured)) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post_full(captured)): + app.acquire_token_by_authorization_code("a_code", ["s1"]) req = captured[0] self.assertEqual( req["url"], @@ -2677,11 +2686,11 @@ def test_silent_refresh_uses_mtls_and_preserves_cache_callbacks(self): app = ConfidentialClientApplication( "cid", authority=self._AUTHORITY, client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) - app.acquire_token_on_behalf_of( - "user_assertion", ["s1"], - post=lambda *a, **k: MinimalResponse( + with patch("msal.mtls._MtlsHttpClient.post", + return_value=MinimalResponse( status_code=200, text=_build_user_token_response( - client_id="cid", refresh_token="original_rt"))) + client_id="cid", refresh_token="original_rt"))): + app.acquire_token_on_behalf_of("user_assertion", ["s1"]) account = next(app.token_cache.search( msal.TokenCache.CredentialType.ACCOUNT)) captured = [] @@ -2794,8 +2803,9 @@ def test_cc_second_call_is_bearer_cache_hit(self): app = ConfidentialClientApplication( "cid", authority=self._AUTHORITY, client_credential=dict( _MTLS_CERT_CRED, send_certificate_over_mtls=True)) - first = app.acquire_token_for_client( - ["s1"], post=self._capturing_post([])) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([])): + first = app.acquire_token_for_client(["s1"]) self.assertEqual("Bearer", first.get("token_type")) second = [] result = app.acquire_token_for_client( @@ -2817,3 +2827,42 @@ def test_cc_second_call_is_bearer_cache_hit(self): self.assertEqual(app._TOKEN_SOURCE_CACHE, result[app._TOKEN_SOURCE]) self.assertEqual("Bearer", result.get("token_type")) self.assertNotIn("binding_certificate", result) + + def test_post_override_rejected_for_all_mtls_bearer_grants(self): + for grant in ("client_credentials", "obo", "auth_code", "auth_code_flow", + "refresh", "silent_refresh"): + with self.subTest(grant=grant): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + with patch("msal.mtls._MtlsHttpClient.post", + return_value=MinimalResponse( + status_code=200, text=_build_user_token_response( + client_id="cid", refresh_token="a_rt"))): + app.acquire_token_on_behalf_of("assertion", ["s1"]) + account = next(app.token_cache.search( + msal.TokenCache.CredentialType.ACCOUNT)) + with patch("msal.mtls._MtlsHttpClient.post") as transport, \ + patch.object(app.http_client, "post") as regular_transport, \ + patch.object(app, "_get_authority_aliases", return_value=[]): + override = Mock() + with self.assertRaisesRegex(ValueError, "does not support a post"): + if grant == "client_credentials": + app.acquire_token_for_client(["s2"], post=override) + elif grant == "obo": + app.acquire_token_on_behalf_of("assertion", ["s2"], post=override) + elif grant == "auth_code": + app.acquire_token_by_authorization_code("code", ["s2"], post=override) + elif grant == "auth_code_flow": + flow = app.initiate_auth_code_flow(["s2"]) + app.acquire_token_by_auth_code_flow( + flow, {"code": "code", "state": flow["state"]}, + post=override) + elif grant == "refresh": + app.acquire_token_by_refresh_token("a_rt", ["s2"], post=override) + else: + app.acquire_token_silent_with_error( + ["s1"], account, force_refresh=True, post=override) + override.assert_not_called() + transport.assert_not_called() + regular_transport.assert_not_called() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 02fa1e51..8fb9bb1a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -642,16 +642,16 @@ def _assert_mtls(req, grant_key, grant_value): self.assertNotIn("key_id", req["data"]) self.assertNotIn("req_cnf", req["data"]) cap = [] - _new_app().acquire_token_on_behalf_of( - "a_user_assertion", scope, post=_capture(cap)) + with patch("msal.mtls._MtlsHttpClient.post", side_effect=_capture(cap)): + _new_app().acquire_token_on_behalf_of("a_user_assertion", scope) _assert_mtls(cap[0], "requested_token_use", "on_behalf_of") cap = [] - _new_app().acquire_token_by_refresh_token( - "a_refresh_token", scope, post=_capture(cap)) + with patch("msal.mtls._MtlsHttpClient.post", side_effect=_capture(cap)): + _new_app().acquire_token_by_refresh_token("a_refresh_token", scope) _assert_mtls(cap[0], "grant_type", "refresh_token") cap = [] - _new_app().acquire_token_by_authorization_code( - "an_auth_code", scope, post=_capture(cap)) + with patch("msal.mtls._MtlsHttpClient.post", side_effect=_capture(cap)): + _new_app().acquire_token_by_authorization_code("an_auth_code", scope) _assert_mtls(cap[0], "grant_type", "authorization_code") def test_send_certificate_over_mtls_user_flows_live_acquire(self): diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index ae7b6e99..50e82fb7 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -9,7 +9,8 @@ from msal import mtls from msal.application import ( - _load_mtls_cert_material, _private_key_to_unencrypted_pem, extract_certs) + _is_certificate_issuer, _load_mtls_cert_material, + _private_key_to_unencrypted_pem, extract_certs) class TestMtlsEndpointTransform(unittest.TestCase): @@ -185,14 +186,21 @@ def make_cert(name, key, issuer, issuer_key, ca): 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, root.subject, root_key, True) + "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)) + 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) @@ -275,7 +283,11 @@ def post(url, data=None, **kwargs): return MinimalResponse( status_code=200, text='{"access_token": "at", "token_type": "Bearer"}') - app.acquire_token_for_client(["s1"], post=post) + if send_over_mtls: + with patch("msal.mtls._MtlsHttpClient.post", side_effect=post): + app.acquire_token_for_client(["s1"]) + else: + app.acquire_token_for_client(["s1"], post=post) def test_pfx_intermediates_reach_tls_handshake(self): from cryptography.hazmat.primitives import serialization @@ -284,11 +296,69 @@ def test_pfx_intermediates_reach_tls_handshake(self): 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], + 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_excludes_unrelated_and_wrong_signer(self): + 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 + expected_x5c = extract_certs(expected_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, material["cert_pem"]) + self.assertEqual(expected_x5c, material["x5c"]) + self.assertEqual(self.key_id, material["key_id"]) + for send_over_mtls in (False, True): + with self.subTest(send_over_mtls=send_over_mtls), 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, + "send_certificate_over_mtls": send_over_mtls}) + + 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_x5c, decoded["x5c"]) + return MinimalResponse( + status_code=200, + text='{"access_token": "at", "token_type": "Bearer"}') + + if send_over_mtls: + with patch("msal.mtls._MtlsHttpClient.post", side_effect=post): + app.acquire_token_for_client(["s1"]) + else: + 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") From 0424c5397735c78002e6ac7bf4881bdba9b7dc82 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 22 Oct 2026 09:58:29 -0400 Subject: [PATCH 23/46] 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 24/46] 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 25/46] 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 d2b9ab22967afa16a31c3b8d9fa5fb228c64a611 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 10:26:45 -0400 Subject: [PATCH 26/46] Align FIC Bearer and PoP result binding Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 6 ++-- msal/application.py | 8 ++--- sample/confidential_client_mtls_pop_sample.py | 7 +++-- tests/test_application.py | 3 +- tests/test_e2e.py | 31 +++++++++---------- 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index a94dd2a2..8a2d1ad5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -241,8 +241,10 @@ Notes and requirements: leg-1 token's issuer, subject, and audience. See ``fic_two_leg_over_mtls_pop()`` in ``sample/confidential_client_mtls_pop_sample.py`` for the two-leg setup. - Both final ``mtls_pop`` and Bearer-over-mTLS results include an - ``MtlsBindingContext`` for the downstream resource connection. + A final ``mtls_pop`` result includes an ``MtlsBindingContext`` for the + downstream resource connection. If leg 2 does not request PoP, its bound + assertion still travels as ``jwt-pop`` over mTLS, but the final Bearer token + is not certificate-bound and does not include ``binding_certificate``. .. autoclass:: msal.MtlsBindingContext :members: diff --git a/msal/application.py b/msal/application.py index cec24559..a79307b4 100644 --- a/msal/application.py +++ b/msal/application.py @@ -3167,8 +3167,7 @@ def acquire_token_for_client( kwargs["data"] = data result = _clean_up(self._acquire_token_silent_with_error( scopes, None, claims_challenge=claims_challenge, **kwargs)) - if ((mtls_proof_of_possession or self._mtls_is_fic_leg2) - and result and "access_token" in result): + 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 @@ -3183,8 +3182,9 @@ def acquire_token_for_client( "of 'mtls_pop'; the access token is not " "certificate-bound.".format(result.get("token_type"))), } - # Attach process-local TLS capability after cache cleanup. The - # private key is never added to the token result or token cache. + # Attach process-local TLS capability only when the final token is + # certificate-bound. A FIC assertion can travel as jwt-pop over + # mTLS while the final token remains an ordinary Bearer token. result["binding_certificate"] = mtls.MtlsBindingContext( mtls_cert["cert_pem"], mtls_cert["private_key_pem"], diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index 9409f841..00050a0f 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -131,7 +131,8 @@ def fic_two_leg_over_mtls_pop(): the token-exchange audience. Leg 2: a second confidential client presents that token as a ``jwt-pop`` client assertion, over an mTLS connection bound by the SAME cert, to - obtain the final token (Bearer or mtls_pop). + obtain the final token. Requesting PoP produces ``mtls_pop`` and a + binding context; omitting PoP produces Bearer without one. """ cert = _build_cert_credential() @@ -167,7 +168,9 @@ def get_leg1_assertion(): azure_region=os.getenv("AZURE_REGION"), ) - # Final token as mTLS PoP (drop mtls_proof_of_possession for Bearer-over-mTLS): + # Final token as mTLS PoP. Omit mtls_proof_of_possession for a final Bearer + # token; its assertion still travels as jwt-pop over mTLS, but the result + # does not contain binding_certificate. leg2 = leg2_app.acquire_token_for_client( os.environ["SCOPE"].split(), mtls_proof_of_possession=True) _print_result_summary(leg2, "FIC leg 2 result:") diff --git a/tests/test_application.py b/tests/test_application.py index 372ff0a4..431712e8 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2546,8 +2546,7 @@ def test_fic_leg2_bearer_still_travels_over_mtls(self): self.assertNotIn("token_type", req["data"]) self.assertEqual("4|730,2|", req["headers"].get(CLIENT_CURRENT_TELEMETRY)) self.assertEqual("Bearer", result.get("token_type")) - self.assertIsInstance( - result.get("binding_certificate"), msal.MtlsBindingContext) + self.assertNotIn("binding_certificate", result) def test_fic_leg2_rejects_post_override(self): app = ConfidentialClientApplication( diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9e6219c7..b17571fa 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -358,26 +358,25 @@ 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, binding_certificate, token_type="mtls_pop"): - """Prove an mTLS token is genuinely usable: present it to the + 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 matches the token type; a 401/403 means the binding was - lost, i.e. a real regression rather than a transient failure. + auth scheme is ``mtls_pop``; a 401/403 means the binding was lost, i.e. + a real regression rather than a transient failure. """ with binding_certificate.create_requests_session() as session: resp = session.get( self._MTLS_GRAPH_RESOURCE, - headers={"Authorization": token_type + " " + token}, + headers={"Authorization": "mtls_pop " + token}, timeout=30) self.assertEqual( 200, resp.status_code, - "%s token rejected by the resource (HTTP %s). A 401/403 means " + "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 auth scheme was wrong. Body[:500]=%s" % ( - token_type, resp.status_code, (resp.text or "")[:500])) + resp.status_code, (resp.text or "")[:500])) return resp @@ -565,6 +564,8 @@ def test_credential_x509_output_bearer(self): class FicTwoLegTestCase(MtlsPopTestCase): + _FIC_BEARER_SCOPE = ["https://storage.azure.com/.default"] + def test_fic_two_leg_over_mtls_pop(self): from tests.lab_config import get_client_certificate required = ( @@ -598,13 +599,10 @@ def test_fic_two_leg_over_mtls_pop(self): "client_assertion": lambda: leg1["access_token"], "mtls_binding_certificate": cert}) # Leg 2 -> Bearer over mTLS (implicit Bearer-over-mTLS, no flag) - leg2_bearer = leg2_app.acquire_token_for_client(self._MTLS_POP_SCOPE) + leg2_bearer = leg2_app.acquire_token_for_client(self._FIC_BEARER_SCOPE) self.assertIn("access_token", leg2_bearer, "Leg 2 (Bearer) failed: %s" % leg2_bearer) self.assertEqual("Bearer", leg2_bearer.get("token_type")) - bearer_binding = leg2_bearer.get("binding_certificate") - self.assertIsInstance(bearer_binding, msal.MtlsBindingContext) - self._call_graph_with_mtls_pop_token( - leg2_bearer["access_token"], bearer_binding, token_type="Bearer") + self.assertNotIn("binding_certificate", leg2_bearer) # Leg 2 -> mTLS PoP final token leg2_pop = leg2_app.acquire_token_for_client( self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) @@ -617,6 +615,9 @@ def test_fic_two_leg_over_mtls_pop(self): "Final token cnf must be bound to the leg-1 cert thumbprint") pop_binding = leg2_pop.get("binding_certificate") self.assertIsInstance(pop_binding, msal.MtlsBindingContext) + self.assertEqual( + leg1_thumbprint, pop_binding.get("thumbprint_sha256"), + "Final binding context must carry the leg-1 certificate") self._call_graph_with_mtls_pop_token( leg2_pop["access_token"], pop_binding) @@ -638,14 +639,13 @@ def setUp(self): "access_token": "leg1-token", "token_type": "mtls_pop", "binding_certificate": {"thumbprint_sha256": "cert-thumbprint"}, } - self.bearer_binding = Mock(spec=msal.MtlsBindingContext) self.pop_binding = Mock(spec=msal.MtlsBindingContext) + self.pop_binding.get.return_value = "cert-thumbprint" self.leg2 = Mock() self.leg2.acquire_token_for_client.side_effect = [ { "access_token": "bearer-token", "token_type": "Bearer", - "binding_certificate": self.bearer_binding, }, { "access_token": "opaque-pop-token", @@ -696,7 +696,6 @@ def test_explicit_configuration_and_opaque_token_resource_validation(self): [self.config["LAB_FIC_EXCHANGE_SCOPE"]], mtls_proof_of_possession=True) self.assertEqual( [ - call("bearer-token", self.bearer_binding, token_type="Bearer"), call("opaque-pop-token", self.pop_binding), ], self.scenario._call_graph_with_mtls_pop_token.call_args_list) From 28b0126a5193fbdd86fcb1a651490a0e985425c0 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 11:28:10 -0400 Subject: [PATCH 27/46] Add certificate-aware mTLS HTTP client extensibility Allow custom HTTP pipelines to create isolated certificate-bearing token clients using MtlsBindingContext. Preserve networking policy, pooling and Requests TLS compatibility, enforce observable transport contracts, and document ownership and trust boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 112 ++- msal/application.py | 154 ++-- msal/mtls.py | 100 ++- ...idential_client_mtls_http_client_sample.py | 114 +++ sample/confidential_client_mtls_pop_sample.py | 4 +- tests/test_mtls_http_client.py | 674 ++++++++++++++++++ 6 files changed, 1072 insertions(+), 86 deletions(-) create mode 100644 sample/confidential_client_mtls_http_client_sample.py create mode 100644 tests/test_mtls_http_client.py diff --git a/docs/index.rst b/docs/index.rst index e450c78f..1b49f116 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -211,9 +211,11 @@ certificate credential and pass ``mtls_proof_of_possession=True`` to 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. +* The default HTTP transport handles mTLS automatically. A custom + ``http_client`` must explicitly implement the certificate-aware factory + described below; legacy custom clients fail with ``ValueError`` for mTLS. + Per-request ``post`` and ``cert`` overrides and redirects are rejected for + mTLS token requests, including when a token could otherwise come from cache. * 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 @@ -235,6 +237,110 @@ Notes and requirements: .. autoclass:: msal.MtlsBindingContext :members: +Custom HTTP pipelines for mTLS token requests +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An application that does not supply ``http_client`` needs no new configuration. +MSAL owns its certificate-bearing Requests transport, separate from discovery +and ordinary token traffic. + +A custom HTTP client opts in by implementing this additional method:: + + def create_mtls_client(self, binding_context): + # binding_context is an MSAL-created msal.MtlsBindingContext. + # Return a NEW pipeline with the same middleware and network policy, + # but isolated certificate-bearing connections. + ... + +There is no marker class to inherit and no additional constructor parameter. +The ordinary client's ``get`` and ``post`` still handle discovery and non-mTLS +requests. On the first mTLS token network request, MSAL calls the factory with +the application's selected certificate. The returned object must have a +callable ``post(url, **kwargs)`` using the normal MSAL HTTP response contract +(``status_code``, ``text``, optional ``headers``, and ``raise_for_status()``). +It must accept and honor ``allow_redirects=False``. Factory and request +exceptions propagate; MSAL never falls back to the ordinary client. + +For Requests-based pipelines, mount +``binding_context.create_requests_adapter(...)`` on a **new** session. This +helper accepts ``HTTPAdapter`` options for retry and pooling policy, retains +MSAL's Requests-version compatibility handling, and selects separate +certificate-bearing TLS contexts and pools using the effective Requests +``verify`` value. Session/request CA paths, ``REQUESTS_CA_BUNDLE`` and +``CURL_CA_BUNDLE`` (when ``session.trust_env`` allows them), and HTTPS proxies +retain their Requests semantics. For other HTTP libraries, +``binding_context.create_ssl_context(verify=...)`` creates the certificate-bearing +``ssl.SSLContext``; the implementer must resolve its own library's effective +server verification and proxy settings before using it. + +For example, inside a Requests-based factory:: + + session = MyPolicySession() # A fresh session retaining your middleware + session.verify = self.ca_bundle + session.trust_env = self.trust_env + session.proxies.update(self.proxies) + session.mount("https://", binding_context.create_requests_adapter( + max_retries=self.retries, + pool_connections=self.pool_connections, + pool_maxsize=self.pool_maxsize, + pool_block=True, + )) + # Set the pipeline's timeout, hooks and other policy as for ordinary traffic. + # Keep session alive until all applications using this factory have finished. + return session + +The runnable ``sample/confidential_client_mtls_http_client_sample.py`` shows a +factory that retains response middleware, a header policy, CA/proxy/environment +settings, timeouts and pooling, and closes its derived pipelines explicitly. +It uses the same certificate and environment configuration as the default +mTLS-PoP sample. + +Trust, isolation and lifetime contract +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* Return a fresh pipeline for every factory call. Do not mutate or return the + ordinary client, its shared transport, or a previously returned pipeline. + Never share certificate-bearing connection pools, adapters, mutable TLS + contexts or TLS session caches with another certificate or ordinary traffic. + Shared immutable policy is fine. +* Preserve all required middleware and networking policy explicitly: MSAL cannot + clone an opaque client. Constructor ``verify``, ``proxies`` and ``timeout`` + options still do not override a custom HTTP client's settings. +* MSAL serializes factory creation within each application and caches one + returned pipeline for that application's certificate. Global and regional + token endpoints reuse it. Separate applications invoke the factory separately, + even when they share an HTTP factory or caches. The factory must be safe to + call concurrently from different applications, and its returned pipelines + must support the application's concurrency requirements. +* Certificate credentials are fixed for the application's lifetime. To rotate, + create a new application and pipeline, stop in-flight work using the old + application, then release its pipelines. Do not mutate a supplied binding + context or a TLS context already used by a connection pool. +* The custom factory owns and must retain/close every client it creates, + including partial constructions when its own code raises. MSAL does not + close caller-owned clients or the ordinary pipeline. Close them only after + all applications and in-flight requests using them have finished. +* MSAL rejects a missing/non-callable factory, a return value without callable + ``post``, returning the ordinary client itself, and returning a client already + assigned to another live application. This identity check does not keep + applications alive or require custom clients to support weak references. + MSAL restricts its token calls to the configured HTTPS endpoint, passes + ``allow_redirects=False``, + and rejects redirect responses or observable redirect history/final-endpoint + changes (excluding query parameters). These checks cannot undo a redirect + already followed by a faulty custom implementation. +* This is a **trusted implementation contract**, not proof that arbitrary code + uses the supplied certificate. MSAL cannot inspect nested transports, detect + all shared pools (or all reuse after an application is collected), enforce an + opaque client's verification/proxy policy, or prevent custom middleware from + ignoring TLS or redirect instructions. The implementer is responsible for + those behaviors; do not opt in unless the complete pipeline honors them. + +The factory receives a process-local certificate capability, not a successful +token result. ``binding_certificate`` remains present only on a final +``mtls_pop`` result, never on an ordinary Bearer result. This extension does not +change downstream resource TLS or the requirements for mTLS PoP. + Exceptions ---------- diff --git a/msal/application.py b/msal/application.py index 13400341..5347f1bd 100644 --- a/msal/application.py +++ b/msal/application.py @@ -673,6 +673,23 @@ def get_client_assertion(): If you are providing your own http_client, it will be your http_client's duty to decide whether to perform retry. + To support mTLS token requests, it must also implement + ``create_mtls_client(binding_context)``. MSAL passes an + :py:class:`msal.MtlsBindingContext`; the factory returns a new, + dedicated client with a callable ``post(url, **kwargs)`` that uses + that certificate's TLS configuration and honors + ``allow_redirects=False``. The factory must preserve its networking + policy and middleware, isolate connection pools and TLS sessions + from ordinary traffic and other certificates, and own cleanup of + its returned clients. MSAL reuses one returned client per + application, including regional token endpoints. Creating a new + application is required to rotate the certificate. + + MSAL validates the returned interface and rejects reuse of the + ordinary client or a client assigned to another live application, + but cannot prove that arbitrary custom code uses the supplied TLS + configuration or disables redirects. + :param verify: (optional) It will be passed to the `verify parameter in the underlying requests library @@ -872,11 +889,8 @@ 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) + # A custom pipeline must explicitly opt in to certificate-aware TLS. + self._custom_http_client = http_client if http_client else None self._http_client_config = { "verify": verify, "proxies": proxies, "timeout": timeout} self._http_cache = http_cache # Reused by the lazily-built mTLS client @@ -958,6 +972,7 @@ def get_client_assertion(): 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_http_client = None # Shared by this certificate's endpoints self._mtls_lock = Lock() if isinstance(client_credential, dict) and not client_credential.get( "client_assertion") and ( @@ -1153,6 +1168,17 @@ def _get_mtls_pop_cert(self): self._mtls_cert_credential) return self._mtls_pop_cert_material + def _get_mtls_http_client_factory(self): + if self._custom_http_client is None: + return None + factory = getattr(self._custom_http_client, "create_mtls_client", None) + if not callable(factory): + raise ValueError( + "mTLS requires a custom http_client to implement " + "create_mtls_client(binding_context). Omit http_client to use " + "MSAL's built-in mTLS transport.") + return factory + def _get_mtls_client(self, central_authority): """Lazily build (and cache) the mTLS PoP client for ``central_authority``. @@ -1160,17 +1186,8 @@ def _get_mtls_client(self, central_authority): (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.") + factory = self._get_mtls_http_client_factory() 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) @@ -1181,44 +1198,53 @@ def _get_mtls_client(self, central_authority): "device_authorization_endpoint": central_authority.device_authorization_endpoint, } - http_client = ThrottledHttpClient( - mtls._MtlsHttpClient( - cert["cert_pem"], cert["private_key_pem"], - **self._http_client_config), - # 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 - # 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, - 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. + cached = self._mtls_regional_client if region else self._mtls_client + if cached is not None: + return cached + if self._mtls_http_client is None: + if factory is None: + http_client = mtls._MtlsHttpClient( + cert["cert_pem"], cert["private_key_pem"], + **self._http_client_config) + else: + binding = mtls.MtlsBindingContext( + cert["cert_pem"], cert["private_key_pem"], + cert["x5c"], cert["key_id"]) + http_client = factory(binding) + if (http_client is self._custom_http_client + or http_client is self.http_client + or http_client is self.http_client.http_client): + raise ValueError( + "create_mtls_client must return a new, dedicated " + "client, not the ordinary http_client.") + if not callable(getattr(http_client, "post", None)): + raise TypeError( + "create_mtls_client must return a client with " + "a callable post(url, **kwargs).") + mtls._claim_mtls_http_client(http_client, self) + self._mtls_http_client = http_client + http_client = ThrottledHttpClient( + mtls._MtlsTokenHttpClient(self._mtls_http_client, token_endpoint), + # The TLS credential is absent from HTTP cache keys. + http_cache={}, + default_throttle_time=5, + ) + # The TLS certificate authenticates this path; no client assertion. + client = _MtlsClient( + configuration, + self.client_id, + http_client=http_client, + default_headers=self._default_client_headers(), + default_body={"client_info": 1}, + 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) if region: - if self._mtls_regional_client is None: - self._mtls_regional_client = client - client = self._mtls_regional_client + self._mtls_regional_client = client else: - if self._mtls_client is None: - self._mtls_client = client - client = self._mtls_client + self._mtls_client = client return client def _build_client(self, client_credential, authority, skip_regional_client=False): @@ -2958,12 +2984,13 @@ def acquire_token_for_client( 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``. - 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. + ``/common`` or ``/organizations``); and a custom ``http_client`` + must implement ``create_mtls_client(binding_context)`` as described + in :py:class:`msal.ClientApplication`. Any of these unmet raises + ``ValueError``. The default transport needs no extra configuration. + ``post`` and ``cert`` overrides and redirects are 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 an :py:class:`msal.MtlsBindingContext` as ``binding_certificate``. It @@ -3026,19 +3053,8 @@ 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: - 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.") + mtls._validate_mtls_request(kwargs) + self._get_mtls_http_client_factory() if self.authority.tenant.lower() in ("common", "organizations"): raise ValueError( "mtls_proof_of_possession=True requires a tenanted authority. " diff --git a/msal/mtls.py b/msal/mtls.py index 6710ae0e..11aaafcc 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -13,6 +13,7 @@ """ import logging import threading +import weakref try: from collections.abc import Mapping except ImportError: # Python 2 @@ -26,6 +27,8 @@ logger = logging.getLogger(__name__) _MTLS_POP_DOC_LINK = "https://aka.ms/msal-net-pop" +_MTLS_HTTP_CLIENT_OWNERS = weakref.WeakValueDictionary() +_MTLS_HTTP_CLIENT_OWNERS_LOCK = threading.Lock() # The global public mTLS host. The three public "login.*" hosts all normalize to # this single global endpoint (ESTSR provides regional failover), matching @@ -97,9 +100,10 @@ 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. + successful mTLS-PoP token result. MSAL also supplies an instance to a custom + HTTP client's ``create_mtls_client(binding_context)`` factory before sending + mTLS token requests. The certificate metadata remains available through + mapping access, while the private key is exposed only through TLS helpers. This object is process-local and must not be serialized or persisted. """ @@ -133,6 +137,23 @@ def create_ssl_context(self, verify=True): """ return _create_ssl_context(self.__cert_pem, self.__key_pem, verify) + def create_requests_adapter(self, **kwargs): + """Create a certificate-bearing ``requests.adapters.HTTPAdapter``. + + Mount this on a new, dedicated session, never on a session used for + ordinary requests or another certificate. ``kwargs`` are HTTPAdapter + options, such as ``max_retries``, ``pool_connections``, ``pool_maxsize`` + and ``pool_block``. By default, one connection retry is enabled. + + Server verification follows the effective Requests ``verify`` setting, + including environment, session and request CA configuration. Separate + TLS contexts and pools are used for different verification settings. + A ``cert`` override is rejected to preserve the selected certificate. + The caller owns the adapter and its connection pools. + """ + return _make_mtls_adapter( + ssl_context_factory=self.create_ssl_context, **kwargs) + def create_requests_session(self, verify=True, proxies=None): """Create a ``requests.Session`` that presents the binding certificate. @@ -145,9 +166,7 @@ def create_requests_session(self, verify=True, proxies=None): session.verify = effective_verify if proxies: session.proxies.update(proxies) - session.mount( - "https://", - _make_mtls_adapter(ssl_context_factory=self.create_ssl_context)) + session.mount("https://", self.create_requests_adapter()) return session def __getitem__(self, key): @@ -178,9 +197,8 @@ 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). + MSAL owns this default transport. Custom pipelines instead opt in through + ``http_client.create_mtls_client(binding_context)`` (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. @@ -240,7 +258,60 @@ def close(self): session.close() -def _make_mtls_adapter(ssl_context=None, ssl_context_factory=None): +def _claim_mtls_http_client(http_client, application): + # Values are weak: tracking direct reuse must not keep applications alive. + with _MTLS_HTTP_CLIENT_OWNERS_LOCK: + owner = _MTLS_HTTP_CLIENT_OWNERS.get(id(http_client)) + if owner is not None and owner is not application: + raise ValueError( + "create_mtls_client must return a new, dedicated client for " + "each application, not a client already used by another application.") + _MTLS_HTTP_CLIENT_OWNERS[id(http_client)] = application + + +def _validate_mtls_request(kwargs): + if "post" in kwargs: + raise ValueError( + "mtls_proof_of_possession=True does not support a post override. " + "Use the configured certificate-aware HTTP transport.") + if kwargs.get("cert") is not None: + raise ValueError( + "mTLS token requests do not support a cert override. " + "Use the application's certificate credential.") + if kwargs.get("allow_redirects", False) is not False: + raise ValueError("mTLS token requests do not support redirects.") + + +class _MtlsTokenHttpClient(object): + """Restrict a certificate-scoped pipeline to its token endpoint.""" + + def __init__(self, http_client, token_endpoint): + if urlparse(token_endpoint).scheme != "https": + raise ValueError("The mTLS token endpoint must use HTTPS.") + self._http_client = http_client + self._token_endpoint = token_endpoint + + def post(self, url, **kwargs): + if url != self._token_endpoint: + raise ValueError( + "The mTLS HTTP transport can only call its token endpoint.") + _validate_mtls_request(kwargs) + kwargs["allow_redirects"] = False + response = self._http_client.post(url, **kwargs) + response_url = getattr(response, "url", None) + # Request params may change the query without a redirect. + endpoint_changed = response_url is not None and ( + urlparse(response_url)._replace(query="", fragment="") + != urlparse(url)._replace(query="", fragment="")) + if (300 <= response.status_code < 400 + or getattr(response, "history", None) + or endpoint_changed): + raise ValueError("The mTLS token endpoint must not redirect.") + return response + + +def _make_mtls_adapter( + ssl_context=None, ssl_context_factory=None, **adapter_kwargs): """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 @@ -250,16 +321,21 @@ def __init__(self, ssl_context): self._ssl_context = ssl_context self._adapters = {} self._adapter_lock = threading.Lock() - super(_MtlsHTTPAdapter, self).__init__(max_retries=1) + options = dict(adapter_kwargs) + options.setdefault("max_retries", 1) + super(_MtlsHTTPAdapter, self).__init__(**options) def send(self, request, **kwargs): + if kwargs.get("cert") is not None: + raise ValueError( + "The mTLS adapter does not support a cert override.") 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)) + ssl_context_factory(verify), **adapter_kwargs) adapter = self._adapters[verify] return adapter.send(request, **kwargs) diff --git a/sample/confidential_client_mtls_http_client_sample.py b/sample/confidential_client_mtls_http_client_sample.py new file mode 100644 index 00000000..233fef29 --- /dev/null +++ b/sample/confidential_client_mtls_http_client_sample.py @@ -0,0 +1,114 @@ +"""A custom HTTP pipeline for MSAL's mTLS token transport. + +Use the CLIENT_ID, AUTHORITY, PFX_PATH, optional PFX_PASSPHRASE, SCOPE and +optional AZURE_REGION environment variables from the default mTLS-PoP sample. +REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE and proxy environment variables retain their +Requests behavior. Run: + + python sample\\confidential_client_mtls_http_client_sample.py + +The factory owns both ordinary and derived sessions. Quiesce all applications +and in-flight work before leaving its context manager. No token or private-key +material is logged. +""" +import os +import threading + +import requests + +import msal + + +class PolicyHttpClient: + """A small example; apply your production policy in both pipeline paths.""" + + def __init__( + self, verify=True, proxies=None, timeout=30, trust_env=True, + binding_context=None): + self._config = { + "verify": verify, "proxies": dict(proxies or {}), + "timeout": timeout, "trust_env": trust_env} + self._lock = threading.Lock() + self._closed = False + self._children = [] + self.response_count = 0 + self._session = requests.Session() + self._session.verify = verify + self._session.trust_env = trust_env + self._session.proxies.update(self._config["proxies"]) + self._session.headers["X-Msal-Pipeline"] = "custom-policy" + self._session.hooks["response"].append(self._record_response) + options = { + "max_retries": 1, "pool_connections": 4, + "pool_maxsize": 8, "pool_block": True} + self._session.mount("https://", + binding_context.create_requests_adapter(**options) + if binding_context is not None + else requests.adapters.HTTPAdapter(**options)) + + def _record_response(self, response, **kwargs): + with self._lock: + self.response_count += 1 + return response + + def _check_open(self): + if self._closed: + raise RuntimeError("The HTTP pipeline has been closed.") + + def create_mtls_client(self, binding_context): + with self._lock: + self._check_open() + child = PolicyHttpClient( + binding_context=binding_context, **self._config) + self._children.append(child) + return child + + def get(self, url, **kwargs): + self._check_open() + kwargs.setdefault("timeout", self._config["timeout"]) + return self._session.get(url, **kwargs) + + def post(self, url, **kwargs): + self._check_open() + kwargs.setdefault("timeout", self._config["timeout"]) + return self._session.post(url, **kwargs) + + def close(self): + with self._lock: + if not self._closed: + self._closed = True + for child in self._children: + child.close() + self._session.close() + + def __enter__(self): + self._check_open() + return self + + def __exit__(self, *args): + self.close() + + +def main(): + credential = { + "private_key_pfx_path": os.environ["PFX_PATH"], + "passphrase": os.getenv("PFX_PASSPHRASE"), + "public_certificate": True, + } + with PolicyHttpClient() as http_client: + app = msal.ConfidentialClientApplication( + os.environ["CLIENT_ID"], + authority=os.environ["AUTHORITY"], + client_credential=credential, + http_client=http_client, + azure_region=os.getenv("AZURE_REGION"), + ) + result = app.acquire_token_for_client( + os.environ["SCOPE"].split(), mtls_proof_of_possession=True) + print("Acquired mTLS-PoP token:", result.get("token_type") == "mtls_pop") + if "error" in result: + print("Error code:", result["error"]) + + +if __name__ == "__main__": + main() diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index 4918559c..f0d95a41 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -83,8 +83,8 @@ def vanilla_sni_mtls_pop(): 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. + # The default transport handles mTLS automatically. For a custom + # pipeline, see confidential_client_mtls_http_client_sample.py. azure_region=os.getenv("AZURE_REGION"), # optional; None -> global endpoint ) diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py new file mode 100644 index 00000000..bea0bb3c --- /dev/null +++ b/tests/test_mtls_http_client.py @@ -0,0 +1,674 @@ +import ipaddress +import json +import os +import select +import socket +import ssl +import tempfile +import threading +import time +import unittest +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from types import SimpleNamespace +from unittest.mock import Mock, patch +from urllib.parse import parse_qs + +import requests + +import msal +from msal import mtls +from sample.confidential_client_mtls_http_client_sample import PolicyHttpClient +from tests.http_client import MinimalResponse + + +_AUTHORITY = "https://login.microsoftonline.com/tenant" +_METADATA = { + "authorization_endpoint": _AUTHORITY + "/oauth2/v2.0/authorize", + "token_endpoint": _AUTHORITY + "/oauth2/v2.0/token", + "issuer": _AUTHORITY + "/v2.0", +} +_CREDENTIAL = { + "private_key_pfx_path": os.path.join( + os.path.dirname(__file__), "certificate-with-password.pfx"), + "passphrase": "password", + "public_certificate": True, +} +_TOKEN = '{"access_token": "at", "token_type": "mtls_pop", "expires_in": 3600}' +_TOKEN_PATH = "/tenant/oauth2/v2.0/token" +_ORDINARY_PATH = "/tenant/oauth2/v2.0/ordinary" + + +@patch("msal.authority.tenant_discovery", return_value=_METADATA) +class TestMtlsHttpClientFactory(unittest.TestCase): + def setUp(self): + self.pipeline = Mock(spec=["post"]) + self.pipeline.post.return_value = MinimalResponse( + status_code=200, text=_TOKEN) + self.ordinary = Mock(spec=["get", "post", "close", "create_mtls_client"]) + self.ordinary.create_mtls_client.return_value = self.pipeline + self.ordinary.post.return_value = MinimalResponse( + status_code=200, + text='{"access_token": "ordinary", "token_type": "Bearer"}') + + def _app(self, http_client=None, **kwargs): + return msal.ConfidentialClientApplication( + "cid", authority=_AUTHORITY, client_credential=_CREDENTIAL, + http_client=self.ordinary if http_client is None else http_client, + azure_region=False, instance_discovery=False, **kwargs) + + def test_factory_receives_selected_binding_and_request_options(self, discovery): + app = self._app(verify=False, timeout=999, + proxies={"https": "http://unused.example"}) + self.ordinary.create_mtls_client.assert_not_called() + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, timeout=7, + verify="request-ca.pem", proxies={"https": "http://request.example"}) + binding = self.ordinary.create_mtls_client.call_args.args[0] + self.assertIsInstance(binding, msal.MtlsBindingContext) + self.assertEqual(binding.thumbprint_sha256, + result["binding_certificate"].thumbprint_sha256) + self.assertNotIn("PRIVATE KEY", repr(binding)) + request = self.pipeline.post.call_args + self.assertEqual( + "https://mtlsauth.microsoft.com/tenant/oauth2/v2.0/token", + request.args[0]) + self.assertEqual(7, request.kwargs["timeout"]) + self.assertEqual("request-ca.pem", request.kwargs["verify"]) + self.assertEqual({"https": "http://request.example"}, + request.kwargs["proxies"]) + self.assertIs(False, request.kwargs["allow_redirects"]) + self.assertNotIn("client_assertion", request.kwargs["data"]) + self.assertNotIn("key_id", request.kwargs["data"]) + self.ordinary.post.assert_not_called() + + def test_ordinary_http_and_post_override_do_not_use_factory(self, discovery): + app = self._app() + result = app.acquire_token_for_client(["ordinary"]) + self.assertEqual("Bearer", result["token_type"]) + self.assertNotIn("binding_certificate", result) + self.assertNotIn("allow_redirects", self.ordinary.post.call_args.kwargs) + post = Mock(return_value=self.ordinary.post.return_value) + result = app.acquire_token_for_client(["override"], post=post) + post.assert_called_once() + self.assertNotIn("binding_certificate", result) + self.ordinary.create_mtls_client.assert_not_called() + self.pipeline.post.assert_not_called() + + def test_factory_is_reused_for_network_calls_and_not_called_on_cache_hit( + self, discovery): + app = self._app() + for scope in ("s1", "s1", "s2"): + result = app.acquire_token_for_client( + [scope], mtls_proof_of_possession=True) + self.assertIsInstance( + result["binding_certificate"], msal.MtlsBindingContext) + self.ordinary.create_mtls_client.assert_called_once() + self.assertEqual(2, self.pipeline.post.call_count) + other = self._app(token_cache=app.token_cache) + result = other.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual("cache", result["token_source"]) + self.assertIsNone(other._mtls_http_client) + self.ordinary.create_mtls_client.assert_called_once() + + def test_global_and_regional_clients_share_certificate_pipeline(self, discovery): + app = self._app() + with patch.object(app, "_compute_region_to_use", + side_effect=[None, "westus3", "westus3"]): + central = app._get_mtls_client(app.authority) + regional = app._get_mtls_client(app.authority) + self.assertIs(regional, app._get_mtls_client(app.authority)) + self.assertIsNot(central, regional) + self.assertIn("westus3.mtlsauth.microsoft.com", + regional.configuration["token_endpoint"]) + self.ordinary.create_mtls_client.assert_called_once() + + def test_concurrent_creation_does_not_orphan_pipelines(self, discovery): + app = self._app() + + def factory(binding): + time.sleep(0.02) + return self.pipeline + + self.ordinary.create_mtls_client.side_effect = factory + with ThreadPoolExecutor(max_workers=12) as executor: + clients = list(executor.map( + lambda _: app._get_mtls_client(app.authority), range(24))) + self.assertTrue(all(client is clients[0] for client in clients)) + self.ordinary.create_mtls_client.assert_called_once() + + def test_legacy_or_noncallable_capability_fails_before_cache_lookup( + self, discovery): + legacy = Mock(spec=["get", "post"]) + malformed = Mock(spec=["get", "post", "create_mtls_client"]) + malformed.create_mtls_client = 42 + for client in (legacy, malformed): + with self.subTest(client=client): + app = self._app(http_client=client) + with patch.object(app, "_acquire_token_silent_with_error") as cache: + with self.assertRaisesRegex(ValueError, "create_mtls_client"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + cache.assert_not_called() + client.post.assert_not_called() + + def test_malformed_factory_results_fail_without_fallback(self, discovery): + for result in (None, object(), SimpleNamespace(post=None), + SimpleNamespace(post=42)): + with self.subTest(result=result): + self.ordinary.create_mtls_client.return_value = result + app = self._app() + with self.assertRaisesRegex(TypeError, "callable post"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertIsNone(app._mtls_http_client) + self.ordinary.post.assert_not_called() + + def test_factory_cannot_return_ordinary_client_or_msal_wrapper(self, discovery): + app = self._app() + for client in (self.ordinary, app.http_client): + with self.subTest(client=client): + self.ordinary.create_mtls_client.return_value = client + with self.assertRaisesRegex(ValueError, "new, dedicated"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertIsNone(app._mtls_http_client) + self.ordinary.post.assert_not_called() + + def test_factory_cannot_reuse_client_across_live_applications(self, discovery): + first, second = self._app(), self._app() + first._get_mtls_client(first.authority) + with self.assertRaisesRegex(ValueError, "another application"): + second._get_mtls_client(second.authority) + self.assertIsNone(second._mtls_http_client) + self.pipeline.post.assert_not_called() + + def test_client_identity_tracking_does_not_retain_application(self, discovery): + import gc + import weakref + + app = self._app() + # A post-only, non-weak-referenceable client is a valid pipeline. + self.ordinary.create_mtls_client.return_value = SimpleNamespace( + post=self.pipeline.post) + app._get_mtls_client(app.authority) + reference = weakref.ref(app) + del app + gc.collect() + self.assertIsNone(reference()) + + def test_factory_errors_propagate_and_creation_can_be_retried(self, discovery): + app = self._app() + for error in (TypeError("factory type error"), OSError("factory failed"), + ValueError("factory configuration error")): + with self.subTest(error=error): + self.ordinary.create_mtls_client.side_effect = error + with self.assertRaises(type(error)) as raised: + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertIs(error, raised.exception) + self.assertIsNone(app._mtls_http_client) + self.ordinary.create_mtls_client.side_effect = None + self.assertIn("access_token", app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True)) + self.ordinary.post.assert_not_called() + + def test_request_errors_propagate_without_replacing_pipeline(self, discovery): + app = self._app() + error = requests.exceptions.ConnectionError("network policy failure") + self.pipeline.post.side_effect = [ + error, MinimalResponse(status_code=200, text=_TOKEN)] + with self.assertRaises(requests.exceptions.ConnectionError) as raised: + app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) + self.assertIs(error, raised.exception) + self.assertIn("access_token", app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True)) + self.ordinary.create_mtls_client.assert_called_once() + self.ordinary.post.assert_not_called() + + def test_request_overrides_are_rejected_before_cache_lookup(self, discovery): + for custom in (False, True): + app = self._app() if custom else msal.ConfidentialClientApplication( + "cid", authority=_AUTHORITY, client_credential=_CREDENTIAL, + azure_region=False, instance_discovery=False) + self.addCleanup(app.http_client.close) + for kwargs in ({"post": None}, {"post": Mock()}, + {"cert": ("other.pem", "other.key")}, + {"allow_redirects": True}, {"allow_redirects": None}): + with self.subTest(custom=custom, kwargs=kwargs): + with patch.object( + app, "_acquire_token_silent_with_error") as cache: + with self.assertRaises(ValueError): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, **kwargs) + cache.assert_not_called() + self.ordinary.create_mtls_client.assert_not_called() + + def test_observable_redirects_are_not_cached(self, discovery): + endpoint = "https://mtlsauth.microsoft.com/tenant/oauth2/v2.0/token" + for changes in ({"status_code": 307}, {"history": [object()]}, + {"url": "https://other.example/token"}): + with self.subTest(changes=changes): + response = SimpleNamespace( + status_code=200, text=_TOKEN, url=endpoint, headers={}) + for name, value in changes.items(): + setattr(response, name, value) + pipeline = Mock(spec=["post"]) + pipeline.post.return_value = response + self.ordinary.create_mtls_client.return_value = pipeline + app = self._app() + with self.assertRaisesRegex(ValueError, "must not redirect"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual({}, app.token_cache._cache.get("AccessToken", {})) + self.ordinary.post.assert_not_called() + + def test_downgraded_custom_response_has_no_binding_or_cache_entry(self, discovery): + app = self._app() + self.pipeline.post.return_value = self.ordinary.post.return_value + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual("token_type_mismatch", result["error"]) + self.assertNotIn("binding_certificate", result) + self.assertEqual({}, app.token_cache._cache.get("AccessToken", {})) + + def test_request_query_parameters_are_not_treated_as_redirects(self, discovery): + self.pipeline.post.return_value = SimpleNamespace( + status_code=200, text=_TOKEN, headers={}, + url="https://mtlsauth.microsoft.com/tenant/oauth2/v2.0/token?extra=value") + result = self._app().acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True, params={"extra": "value"}) + self.assertEqual("mtls_pop", result["token_type"]) + self.assertEqual({"extra": "value"}, self.pipeline.post.call_args.kwargs["params"]) + + +class TestMtlsPipelineHelpers(unittest.TestCase): + def test_token_transport_is_restricted_to_https_token_endpoint(self): + pipeline = Mock(spec=["post"]) + with self.assertRaisesRegex(ValueError, "HTTPS"): + mtls._MtlsTokenHttpClient(pipeline, "http://token.example") + client = mtls._MtlsTokenHttpClient(pipeline, "https://token.example/token") + with self.assertRaisesRegex(ValueError, "only call its token endpoint"): + client.post("https://token.example/discovery") + pipeline.post.assert_not_called() + + def test_public_adapter_retains_pool_and_retry_options(self): + from msal.application import _load_mtls_cert_material + + material = _load_mtls_cert_material(_CREDENTIAL) + binding = msal.MtlsBindingContext( + material["cert_pem"], material["private_key_pem"], + material["x5c"], material["key_id"]) + adapter = binding.create_requests_adapter( + pool_connections=3, pool_maxsize=7, pool_block=True, max_retries=0) + self.addCleanup(adapter.close) + request = requests.Request("POST", "https://token.example/token").prepare() + with patch("requests.adapters.HTTPAdapter.send"): + adapter.send(request, verify=False) + adapter.send(request, verify=False) + child = adapter._adapters[False] + self.assertEqual(3, child._pool_connections) + self.assertEqual(7, child._pool_maxsize) + self.assertTrue(child._pool_block) + self.assertEqual(0, child.max_retries.total) + self.assertEqual(ssl.CERT_NONE, child._ssl_context.verify_mode) + self.assertEqual(1, len(adapter._adapters)) + for cert in ("other.pem", ("other.pem", "other.key")): + with self.subTest(cert=cert), self.assertRaisesRegex( + ValueError, "cert override"): + adapter.send(request, cert=cert) + + +class TestCustomPipelineLocalTls(unittest.TestCase): + """Real loopback TLS, with no lab credentials or external network access.""" + + @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 ExtendedKeyUsageOID, NameOID + + def key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + def certificate(name, private_key, issuer, issuer_key, ca=False, server=False): + now = datetime.now(timezone.utc) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)]) + builder = (x509.CertificateBuilder() + .subject_name(subject).issuer_name(issuer.subject if issuer else subject) + .public_key(private_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( + private_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=not ca, data_encipherment=False, + key_agreement=False, key_cert_sign=ca, crl_sign=ca, + encipher_only=False, decipher_only=False), True)) + if not ca: + builder = builder.add_extension(x509.ExtendedKeyUsage([ + ExtendedKeyUsageOID.SERVER_AUTH if server + else ExtendedKeyUsageOID.CLIENT_AUTH]), False) + if server: + builder = builder.add_extension(x509.SubjectAlternativeName([ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), False) + return builder.sign(issuer_key, hashes.SHA256()) + + def pem(private_key): + return private_key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + + root_key = key() + root = certificate("test root", root_key, None, root_key, ca=True) + cls.root_pem = root.public_bytes(serialization.Encoding.PEM) + server_key = key() + server = certificate( + "localhost", server_key, root, root_key, server=True) + cls.server_pem = server.public_bytes(serialization.Encoding.PEM) + pem(server_key) + cls.credentials = [] + cls.client_der = [] + for name in ("first client", "rotated client"): + private_key = key() + cert = certificate(name, private_key, root, root_key) + cls.credentials.append({ + "private_key": pem(private_key).decode("ascii"), + "public_certificate": cert.public_bytes( + serialization.Encoding.PEM).decode("ascii"), + "thumbprint": cert.fingerprint(hashes.SHA1()).hex(), + }) + cls.client_der.append(cert.public_bytes(serialization.Encoding.DER)) + + def _start_server(self, handler, context=None): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + if context is not None: + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread( + target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(thread.join, 5) + self.addCleanup(server.shutdown) + return server + + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.ca_path = os.path.join(directory.name, "ca.pem") + server_path = os.path.join(directory.name, "server.pem") + for path, content in ((self.ca_path, self.root_pem), + (server_path, self.server_pem)): + with open(path, "wb") as stream: + stream.write(content) + environment = patch.dict(os.environ, { + "REQUESTS_CA_BUNDLE": "", "CURL_CA_BUNDLE": "", + "HTTP_PROXY": "", "HTTPS_PROXY": "", "ALL_PROXY": ""}) + environment.start() + self.addCleanup(environment.stop) + owner = self + self.records = [] + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def do_GET(self): + self.do_POST() + + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + data = parse_qs(body.decode("ascii")) + certificate = self.connection.getpeercert(binary_form=True) + owner.records.append({ + "path": self.path, "certificate": certificate, "data": data, + "port": self.client_address[1], + "middleware": self.headers.get("X-Msal-Pipeline"), + }) + status = 200 + response = { + "access_token": "local-at", "token_type": "Bearer", + "expires_in": 3600} + if self.path == "/metadata": + response = dict(_METADATA, token_endpoint=owner.url + _ORDINARY_PATH) + elif data.get("scope") == ["redirect"]: + status = 307 + response = {} + elif self.path == _TOKEN_PATH: + if not certificate or (data.get("scope") == ["reject-first"] + and certificate == owner.client_der[0]): + status, response = 400, {"error": "invalid_client"} + else: + response["token_type"] = "mtls_pop" + payload = json.dumps(response).encode("utf-8") + self.send_response(status) + if status == 307: + self.send_header("Location", owner.url + "/forbidden") + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(server_path) + context.load_verify_locations(cafile=self.ca_path) + context.verify_mode = ssl.CERT_OPTIONAL + self.server = self._start_server(Handler, context) + self.url = "https://127.0.0.1:{}".format(self.server.server_port) + self.http_client = PolicyHttpClient( + verify=self.ca_path, timeout=(2, 3), trust_env=False) + self.addCleanup(self.http_client.close) + + def _app(self, http_client=None, certificate=0, discover=True, **kwargs): + def discovery(endpoint, client): + response = client.get(self.url + "/metadata") + response.raise_for_status() + return json.loads(response.text) + + with patch("msal.authority.tenant_discovery", + side_effect=discovery if discover else None, + return_value=dict(_METADATA, token_endpoint=self.url + _ORDINARY_PATH)): + return msal.ConfidentialClientApplication( + "cid", authority=_AUTHORITY, + client_credential=self.credentials[certificate], + http_client=self.http_client if http_client is None else http_client, + azure_region=False, instance_discovery=False, **kwargs) + + def _acquire(self, app, scope="s1", **kwargs): + with patch("msal.mtls.transform_token_endpoint", + return_value=self.url + _TOKEN_PATH): + return app.acquire_token_for_client( + [scope], mtls_proof_of_possession=True, **kwargs) + + def test_custom_pipeline_preserves_certificate_middleware_policy_and_pooling(self): + adapter = self.http_client._session.adapters["https://"] + app = self._app(verify=False, timeout=0.001, + proxies={"https": "http://unused.example"}) + ordinary = app.acquire_token_for_client(["ordinary"]) + first = self._acquire(app) + child = self.http_client._children[0] + with patch.object(child._session, "send", wraps=child._session.send) as send: + self._acquire(app, "s2") + self.assertEqual((2, 3), send.call_args.kwargs["timeout"]) + self.assertEqual(self.ca_path, send.call_args.kwargs["verify"]) + self.assertEqual(2, child.response_count) + self.assertIs(adapter, self.http_client._session.adapters["https://"]) + self.assertEqual(1, len(self.http_client._children)) + self.assertNotIn("binding_certificate", ordinary) + self.assertIsInstance(first["binding_certificate"], msal.MtlsBindingContext) + self.assertEqual( + ["/metadata", _ORDINARY_PATH, _TOKEN_PATH, _TOKEN_PATH], + [record["path"] for record in self.records]) + self.assertTrue(all(record["middleware"] == "custom-policy" + for record in self.records)) + self.assertTrue(all(record["certificate"] is None for record in self.records[:2])) + self.assertTrue(all(record["certificate"] == self.client_der[0] + for record in self.records[2:])) + self.assertEqual(self.records[2]["port"], self.records[3]["port"]) + self.assertNotEqual(self.records[1]["port"], self.records[2]["port"]) + self._acquire(app) + self.assertEqual(4, len(self.records), "A cache hit must not use HTTP") + + def test_certificate_rotation_isolates_connections_tokens_and_http_errors(self): + token_cache, http_cache = msal.TokenCache(), {} + first = self._app(token_cache=token_cache, http_cache=http_cache) + rotated = self._app(certificate=1, token_cache=token_cache, http_cache=http_cache) + results = [self._acquire(app) for app in (first, rotated)] + self.assertNotEqual(results[0]["binding_certificate"].thumbprint_sha256, + results[1]["binding_certificate"].thumbprint_sha256) + self._acquire(first, "s2") + tokens = [record for record in self.records if record["path"] == _TOKEN_PATH] + self.assertEqual( + [self.client_der[0], self.client_der[1], self.client_der[0]], + [record["certificate"] for record in tokens]) + self.assertEqual(tokens[0]["port"], tokens[2]["port"]) + self.assertNotEqual(tokens[0]["port"], tokens[1]["port"]) + self.assertEqual(2, len(self.http_client._children)) + for _ in range(2): + self.assertEqual("invalid_client", self._acquire(first, "reject-first")["error"]) + self.assertEqual("mtls_pop", self._acquire(rotated, "reject-first")["token_type"]) + failures = [record for record in self.records + if record["data"].get("scope") == ["reject-first"]] + self.assertEqual([self.client_der[0], self.client_der[1]], + [record["certificate"] for record in failures]) + cached = self._acquire(first) + self.assertEqual("cache", cached["token_source"]) + self.assertEqual(results[0]["binding_certificate"].thumbprint_sha256, + cached["binding_certificate"].thumbprint_sha256) + + def test_effective_ca_settings_and_verification_pools(self): + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + + expected_root = x509.load_pem_x509_certificate(self.root_pem).public_bytes( + serialization.Encoding.DER) + for setting in ("session", "request", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): + environment = {"REQUESTS_CA_BUNDLE": "", "CURL_CA_BUNDLE": ""} + if setting in environment: + environment[setting] = self.ca_path + with self.subTest(setting=setting), patch.dict(os.environ, environment): + with PolicyHttpClient( + verify=self.ca_path if setting == "session" else True, + timeout=(2, 3)) as client: + app = self._app(http_client=client, discover=False) + options = {"verify": self.ca_path} if setting == "request" else {} + self._acquire(app, **options) + adapter = client._children[0]._session.adapters["https://"] + verified = adapter._adapters[self.ca_path] + self.assertEqual([expected_root], + verified._ssl_context.get_ca_certs(binary_form=True)) + with self.assertWarns(requests.packages.urllib3.exceptions.InsecureRequestWarning): + self._acquire(app, "unverified", verify=False) + self._acquire(app, "verified-again", **options) + self.assertIs(verified, adapter._adapters[self.ca_path]) + self.assertEqual(ssl.CERT_NONE, + adapter._adapters[False]._ssl_context.verify_mode) + records = self.records[-3:] + self.assertEqual(records[0]["port"], records[2]["port"]) + self.assertNotEqual(records[0]["port"], records[1]["port"]) + self.assertTrue(all(record["certificate"] == self.client_der[0] + for record in records)) + + def test_untrusted_server_failure_does_not_fall_back(self): + app = self._app() + with self.assertRaises(requests.exceptions.SSLError): + self._acquire(app, verify=True) + self.assertEqual(["/metadata"], [record["path"] for record in self.records]) + self.assertEqual(1, len(self.http_client._children)) + self.assertEqual("mtls_pop", self._acquire(app)["token_type"]) + self.assertEqual(self.client_der[0], self.records[-1]["certificate"]) + + def test_default_and_custom_pipelines_do_not_follow_token_redirects(self): + custom = self._app() + with patch("msal.authority.tenant_discovery", + return_value=dict(_METADATA, token_endpoint=self.url + _ORDINARY_PATH)): + default = msal.ConfidentialClientApplication( + "cid", authority=_AUTHORITY, client_credential=self.credentials[0], + verify=self.ca_path, timeout=(2, 3), azure_region=False, + instance_discovery=False) + self.addCleanup(default.http_client.close) + for app in (default, custom): + with self.subTest(custom=app is custom): + self.assertEqual("mtls_pop", self._acquire(app)["token_type"]) + with self.assertRaisesRegex(ValueError, "must not redirect"): + self._acquire(app, "redirect") + default._mtls_http_client.close() + self.assertNotIn("/forbidden", [record["path"] for record in self.records]) + tokens = [record for record in self.records if record["path"] == _TOKEN_PATH] + self.assertEqual(4, len(tokens)) + self.assertTrue(all(record["certificate"] == self.client_der[0] for record in tokens)) + + def test_factory_owns_lifecycle_and_closing_child_does_not_close_ordinary(self): + app = self._app() + self._acquire(app) + child = self.http_client._children[0] + child.close() + with self.assertRaisesRegex(RuntimeError, "closed"): + self._acquire(app, "after-close") + self.assertEqual(1, len(self.http_client._children)) + response = self.http_client.get(self.url + _ORDINARY_PATH) + self.assertEqual(200, response.status_code) + self.assertIsNone(self.records[-1]["certificate"]) + self.http_client.close() + self.http_client.close() + with self.assertRaisesRegex(RuntimeError, "closed"): + self.http_client.create_mtls_client(None) + self.assertTrue(child._closed) + + def test_https_proxy_preserves_custom_pipeline_and_certificate(self): + target = self.server.server_address + tunnels = [] + + class ProxyHandler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_CONNECT(self): + if self.path != "{}:{}".format(*target): + self.send_error(403) + return + tunnels.append(self.path) + with socket.create_connection(target, timeout=3) as upstream: + self.send_response(200) + self.end_headers() + self.close_connection = True + sockets = (self.connection, upstream) + while True: + readable, _, _ = select.select(sockets, [], [], 3) + if not readable: + return + for source in readable: + data = source.recv(65536) + if not data: + return + destination = upstream if source is self.connection else self.connection + destination.sendall(data) + + proxy = self._start_server(ProxyHandler) + proxies = {"https": "http://127.0.0.1:{}".format(proxy.server_port)} + with PolicyHttpClient( + verify=self.ca_path, proxies=proxies, timeout=(2, 3), + trust_env=False) as client: + app = self._app(http_client=client) + result = self._acquire(app) + self.assertEqual("mtls_pop", result["token_type"]) + self.assertEqual(proxies, client._children[0]._session.proxies) + self.assertEqual(2, len(tunnels)) + self.assertEqual([None, self.client_der[0]], + [record["certificate"] for record in self.records]) + self.assertTrue(all(record["middleware"] == "custom-policy" + for record in self.records)) + + +if __name__ == "__main__": + unittest.main() From e574edd663080a7f074faa48b1f2b97d986651d1 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 11:51:31 -0400 Subject: [PATCH 28/46] 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 29/46] 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 30/46] 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 31/46] 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 18bfd0d7a9e11d411cae6b228306d846c7450d7d Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 13:53:49 -0400 Subject: [PATCH 32/46] Fix custom mTLS transport response handling and dependency validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- msal/mtls.py | 6 ++- tests/test_mtls_http_client.py | 92 +++++++++++++++++++++++++++++++++- tests/test_mtls_transport.py | 3 +- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/msal/mtls.py b/msal/mtls.py index 572ade03..f2550421 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -152,6 +152,7 @@ def create_requests_adapter(self, **kwargs): A ``cert`` override is rejected to preserve the selected certificate. The caller owns the adapter and its connection pools. """ + _require_mtls_dependencies() return _make_mtls_adapter( ssl_context_factory=self.create_ssl_context, **kwargs) @@ -317,11 +318,14 @@ def post(self, url, **kwargs): response_url = getattr(response, "url", None) # Request params may change the query without a redirect. endpoint_changed = response_url is not None and ( - urlparse(response_url)._replace(query="", fragment="") + urlparse(str(response_url))._replace(query="", fragment="") != urlparse(url)._replace(query="", fragment="")) if (300 <= response.status_code < 400 or getattr(response, "history", None) or endpoint_changed): + close = getattr(response, "close", None) + if callable(close): + close() raise ValueError("The mTLS token endpoint must not redirect.") return response diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py index db69c5d2..f149f293 100644 --- a/tests/test_mtls_http_client.py +++ b/tests/test_mtls_http_client.py @@ -265,6 +265,53 @@ def test_observable_redirects_are_not_cached(self, discovery): self.assertEqual({}, app.token_cache._cache.get("AccessToken", {})) self.ordinary.post.assert_not_called() + def test_rejected_responses_are_closed_without_closing_pipeline(self, discovery): + endpoint = "https://mtlsauth.microsoft.com/tenant/oauth2/v2.0/token" + for changes in ({"status_code": 307}, {"history": [object()]}, + {"url": "https://other.example/token"}): + with self.subTest(changes=changes): + response = SimpleNamespace( + status_code=200, text=_TOKEN, url=endpoint, headers={}, + close=Mock()) + for name, value in changes.items(): + setattr(response, name, value) + pipeline = Mock(spec=["post", "close"]) + pipeline.post.return_value = response + self.ordinary.create_mtls_client.return_value = pipeline + app = self._app() + with self.assertRaisesRegex(ValueError, "must not redirect"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + response.close.assert_called_once_with() + pipeline.close.assert_not_called() + self.ordinary.close.assert_not_called() + self.assertEqual({}, app.token_cache._cache.get("AccessToken", {})) + + def test_url_objects_preserve_endpoint_and_redirect_validation(self, discovery): + from urllib3.util import parse_url + + endpoint = "https://mtlsauth.microsoft.com/tenant/oauth2/v2.0/token" + for url in (endpoint + "?extra=value", "https://other.example/token"): + with self.subTest(url=url): + response = SimpleNamespace( + status_code=200, text=_TOKEN, headers={}, + url=parse_url(url), close=Mock()) + pipeline = Mock(spec=["post"]) + pipeline.post.return_value = response + self.ordinary.create_mtls_client.return_value = pipeline + app = self._app() + if url.startswith(endpoint): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual("mtls_pop", result["token_type"]) + response.close.assert_not_called() + else: + with self.assertRaisesRegex(ValueError, "must not redirect"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual( + {}, app.token_cache._cache.get("AccessToken", {})) + def test_downgraded_custom_response_has_no_binding_or_cache_entry(self, discovery): app = self._app() self.pipeline.post.return_value = self.ordinary.post.return_value @@ -285,6 +332,17 @@ def test_request_query_parameters_are_not_treated_as_redirects(self, discovery): class TestMtlsPipelineHelpers(unittest.TestCase): + def test_noncallable_response_close_does_not_mask_redirect_error(self): + endpoint = "https://token.example/token" + for close in (None, 42): + with self.subTest(close=close): + pipeline = Mock(spec=["post"]) + pipeline.post.return_value = SimpleNamespace( + status_code=307, url=endpoint, close=close) + client = mtls._MtlsTokenHttpClient(pipeline, endpoint) + with self.assertRaisesRegex(ValueError, "must not redirect"): + client.post(endpoint) + def test_token_transport_is_restricted_to_https_token_endpoint(self): pipeline = Mock(spec=["post"]) with self.assertRaisesRegex(ValueError, "HTTPS"): @@ -444,6 +502,9 @@ def do_POST(self): elif data.get("scope") == ["redirect"]: status = 307 response = {} + elif data.get("scope") == ["redirect-without-location"]: + status = 307 + response = {} elif self.path == _TOKEN_PATH: if not certificate or (data.get("scope") == ["reject-first"] and certificate == owner.client_der[0]): @@ -452,7 +513,7 @@ def do_POST(self): response["token_type"] = "mtls_pop" payload = json.dumps(response).encode("utf-8") self.send_response(status) - if status == 307: + if status == 307 and data.get("scope") != ["redirect-without-location"]: self.send_header("Location", owner.url + "/forbidden") self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) @@ -629,6 +690,35 @@ def test_factory_owns_lifecycle_and_closing_child_does_not_close_ordinary(self): self.http_client.create_mtls_client(None) self.assertTrue(child._closed) + def test_rejected_streamed_response_releases_blocking_pool_slot(self): + app = self._app() + binding = self._acquire(app)["binding_certificate"] + child = self.http_client._children[0] + adapter = binding.create_requests_adapter( + pool_connections=1, pool_maxsize=1, pool_block=True) + self.addCleanup(adapter.close) + child._session.adapters["https://"].close() + child._session.mount("https://", adapter) + responses = [] + + def capture(response, **kwargs): + responses.append(response) + self.addCleanup(response.close) + return response + + child._session.hooks["response"].append(capture) + with self.assertRaisesRegex(ValueError, "must not redirect"): + self._acquire(app, "redirect-without-location", stream=True) + response = responses[0] + self.assertFalse(response._content_consumed) + self.assertIsNone(response.raw._connection) + verified = adapter._adapters[self.ca_path] + pool = verified.poolmanager.connection_from_url( + self.url, pool_kwargs={"ssl_context": verified._ssl_context}) + self.assertEqual(1, pool.pool.qsize()) + self.assertEqual("mtls_pop", self._acquire(app, "after-redirect")["token_type"]) + self.assertFalse(child._closed) + def test_https_proxy_preserves_custom_pipeline_and_certificate(self): target = self.server.server_address tunnels = [] diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index e77aa4dc..af2802e3 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -226,7 +226,8 @@ def test_unsupported_mtls_dependencies_fail_with_upgrade_instructions(self): with dependency: for factory in ( lambda: mtls._MtlsHttpClient(b"cert", b"key"), - binding.create_requests_session): + binding.create_requests_session, + binding.create_requests_adapter): with self.subTest(factory=factory), self.assertRaisesRegex( ValueError, r"mTLS requires Requests >= 2\.25\.0.*" r"python -m pip install --upgrade"): From 30281ce10572ad4f24ccc5f96c8b438f2052861a Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 14:06:01 -0400 Subject: [PATCH 33/46] Fix Bearer-over-mTLS routing, certificate overrides and PFX compatibility Route password, federated password and user FIC grants through the mTLS selector. Reject certificate overrides before transport access, preserve ordinary PFX loading and restore lazy EdDSA imports. Add focused regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 54 ++++++++------ msal/mtls.py | 4 + tests/test_application.py | 138 +++++++++++++++++++++++++---------- tests/test_mtls_transport.py | 75 ++++++++++++++++++- 4 files changed, 207 insertions(+), 64 deletions(-) diff --git a/msal/application.py b/msal/application.py index c4f52fcf..ee361318 100644 --- a/msal/application.py +++ b/msal/application.py @@ -128,8 +128,7 @@ def _extract_cert_and_thumbprints(cert): def _is_certificate_issuer(cert, issuer): from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm - from cryptography.hazmat.primitives.asymmetric import ( - rsa, ec, dsa, ed25519, ed448, padding) + from cryptography.hazmat.primitives.asymmetric import rsa, ec, dsa, padding from cryptography.x509.oid import SignatureAlgorithmOID if cert.issuer != issuer.subject: @@ -156,10 +155,12 @@ def _is_certificate_issuer(cert, issuer): key.verify( cert.signature, cert.tbs_certificate_bytes, cert.signature_hash_algorithm) - elif isinstance(key, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)): - key.verify(cert.signature, cert.tbs_certificate_bytes) else: - raise UnsupportedAlgorithm("Unsupported certificate issuer key") + 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 @@ -386,10 +387,12 @@ def obtain_token_by_username_password(self, username, password, **kwargs): class _MtlsBearerClient(_ClientWithCcsRoutingInfo): def _obtain_token(self, *args, **kwargs): - if "post" in kwargs: - raise ValueError( - "send_certificate_over_mtls=True does not support a post " - "override. Omit post to use MSAL's built-in mTLS transport.") + for option in ("post", "cert"): + if option in kwargs: + raise ValueError( + "send_certificate_over_mtls=True does not support a {} " + "override. Omit {} to use MSAL's built-in mTLS transport " + "with the configured certificate.".format(option, option)) return super(_MtlsBearerClient, self)._obtain_token(*args, **kwargs) @@ -657,14 +660,17 @@ def get_client_assertion(): (:func:`~acquire_token_for_client`, :func:`~acquire_token_on_behalf_of`, :func:`~acquire_token_by_auth_code_flow`, - :func:`~acquire_token_by_authorization_code`, and - :func:`~acquire_token_by_refresh_token`), including refresh-token - redemption during :func:`~acquire_token_silent`. It defaults to + :func:`~acquire_token_by_authorization_code`, + :func:`~acquire_token_by_refresh_token`, + :func:`~acquire_token_by_username_password`, and + :func:`~acquire_token_by_user_federated_identity_credential`), + including refresh-token redemption during + :func:`~acquire_token_silent`. It defaults to ``False`` (so leaving it unset changes no behavior) and requires a certificate credential -- otherwise construction raises ``ValueError``. Custom ``http_client`` instances and per-request - ``post`` overrides are rejected when making an mTLS token request, - because MSAL must own the certificate-bearing transport. + ``post`` or ``cert`` overrides are rejected when making an mTLS + token request, because MSAL must own the certificate-bearing transport. A per-request ``mtls_proof_of_possession=True`` on :func:`~acquire_token_for_client` always takes precedence, yielding a cert-bound ``mtls_pop`` token instead of a Bearer one. @@ -1379,10 +1385,9 @@ def _get_mtls_bearer_client(self, central_authority): return client def _client_for_confidential_request(self): - """Pick the OAuth2 client for a confidential-client user flow (OBO, - refresh-token, auth-code): the Bearer-over-mTLS client when the - app-level send_certificate_over_mtls flag is set, else the regular - client. (Client-credentials routing lives in _acquire_token_for_client, + """Pick the Bearer-over-mTLS client for a confidential-client user flow + when the app-level send_certificate_over_mtls flag is set, else the + regular client. (Client-credentials routing lives in _acquire_token_for_client, which additionally honors a per-request mtls_pop that wins over the flag.) """ if self._send_certificate_over_mtls: @@ -1417,7 +1422,7 @@ def _build_client(self, client_credential, authority, skip_regional_client=False 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, include_chain=True) + passphrase_bytes) if client_credential.get("public_certificate") is True and x5c: headers["x5c"] = x5c elif client_credential.get("private_key"): # PEM blob @@ -2570,7 +2575,7 @@ def acquire_token_by_username_password( data=data, headers=headers, **kwargs)) if response is None: # Either ADFS or not federated - response = _clean_up(self.client.obtain_token_by_username_password( + response = _clean_up(self._client_for_confidential_request().obtain_token_by_username_password( username, password, scope=scopes, headers=headers, data=data, @@ -2612,9 +2617,10 @@ def _acquire_token_by_username_password_federated( if not grant_type: raise RuntimeError( "RSTR returned unknown token type: %s", wstrust_result.get("type")) - self.client.grant_assertion_encoders.setdefault( # Register a non-standard type - grant_type, self.client.encode_saml_assertion) - return self.client.obtain_token_by_assertion( + client = self._client_for_confidential_request() + client.grant_assertion_encoders.setdefault( # Register a non-standard type + grant_type, client.encode_saml_assertion) + return client.obtain_token_by_assertion( wstrust_result["token"], grant_type, scope=scopes, on_obtaining_tokens=lambda event: self.token_cache.add(dict( event, @@ -3441,7 +3447,7 @@ def acquire_token_by_user_federated_identity_credential( user_object_id, self.authority.tenant) _data = kwargs.pop("data", {}) _stash_client_claims(forwarded_client_claims, _data) - response = _clean_up(self.client.obtain_token_by_user_fic( + response = _clean_up(self._client_for_confidential_request().obtain_token_by_user_fic( scope=self._decorate_scope(scopes), assertion=assertion, username=username, diff --git a/msal/mtls.py b/msal/mtls.py index 67b2e31b..d6aab526 100644 --- a/msal/mtls.py +++ b/msal/mtls.py @@ -236,6 +236,10 @@ def get(self, url, **kwargs): def _request(self, method, url, **kwargs): from requests.exceptions import HTTPError + if "cert" in kwargs: + raise ValueError( + "MSAL's mTLS transport does not support a cert override. " + "Use the configured client certificate.") if self._timeout is not None: kwargs.setdefault("timeout", self._timeout) # The certificate and token request must stay at the selected authority. diff --git a/tests/test_application.py b/tests/test_application.py index eb7804d1..f2e57261 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -2847,6 +2847,53 @@ def test_refresh_flag_uses_global_mtls_and_refresh_grant(self): self.assertTrue(req["data"].get("client_assertion")) self.assertNotEqual("mtls_pop", req["data"].get("token_type")) + def test_password_and_user_fic_flows_honor_mtls_flag(self): + from msal.wstrust_response import SAML_TOKEN_TYPE_V1 + + saml_grant = "urn:ietf:params:oauth:grant-type:saml1_1-bearer" + for send_over_mtls in (False, True): + for grant in ("password", saml_grant, "user_fic"): + with self.subTest(send_over_mtls=send_over_mtls, grant=grant): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, azure_region=False, + client_credential=dict( + _MTLS_CERT_CRED, + send_certificate_over_mtls=send_over_mtls)) + regular, mtls = [], [] + realm = {"account_type": ( + "Federated" if grant == saml_grant else "Managed"), + "federation_active_auth_url": "https://federation.example"} + with patch.object( + app.authority, "user_realm_discovery", return_value=realm), \ + patch("msal.application.wst_send_request", return_value={ + "token": b"saml_assertion", "type": SAML_TOKEN_TYPE_V1}), \ + patch.object(app.http_client, "post", + side_effect=self._capturing_post(regular)), \ + patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(mtls)): + if grant == "user_fic": + result = app.acquire_token_by_user_federated_identity_credential( + ["s1"], "fic_assertion", user_object_id="uid") + else: + result = app.acquire_token_by_username_password( + "user@example.com", "password", ["s1"]) + requests = mtls if send_over_mtls else regular + self.assertEqual([], regular if send_over_mtls else mtls) + self.assertEqual(1, len(requests)) + self.assertEqual( + "https://{}/my_tenant/oauth2/v2.0/token".format( + "mtlsauth.microsoft.com" if send_over_mtls + else "login.microsoftonline.com"), + requests[0]["url"]) + self.assertEqual(grant, requests[0]["data"]["grant_type"]) + self.assertTrue(requests[0]["data"]["client_assertion"]) + self.assertEqual("Bearer", result["token_type"]) + self.assertNotIn("binding_certificate", result) + if grant == saml_grant: + self.assertEqual( + base64.urlsafe_b64encode(b"saml_assertion").rstrip(b"="), + requests[0]["data"]["assertion"]) + def test_auth_code_flag_uses_global_mtls_and_auth_code_grant(self): app = ConfidentialClientApplication( "cid", authority=self._AUTHORITY, client_credential=dict( @@ -3036,41 +3083,58 @@ def test_cc_second_call_is_bearer_cache_hit(self): self.assertEqual("Bearer", result.get("token_type")) self.assertNotIn("binding_certificate", result) - def test_post_override_rejected_for_all_mtls_bearer_grants(self): + def test_transport_overrides_rejected_for_all_mtls_bearer_grants(self): for grant in ("client_credentials", "obo", "auth_code", "auth_code_flow", - "refresh", "silent_refresh"): - with self.subTest(grant=grant): - app = ConfidentialClientApplication( - "cid", authority=self._AUTHORITY, client_credential=dict( - _MTLS_CERT_CRED, send_certificate_over_mtls=True)) - with patch("msal.mtls._MtlsHttpClient.post", - return_value=MinimalResponse( - status_code=200, text=_build_user_token_response( - client_id="cid", refresh_token="a_rt"))): - app.acquire_token_on_behalf_of("assertion", ["s1"]) - account = next(app.token_cache.search( - msal.TokenCache.CredentialType.ACCOUNT)) - with patch("msal.mtls._MtlsHttpClient.post") as transport, \ - patch.object(app.http_client, "post") as regular_transport, \ - patch.object(app, "_get_authority_aliases", return_value=[]): - override = Mock() - with self.assertRaisesRegex(ValueError, "does not support a post"): - if grant == "client_credentials": - app.acquire_token_for_client(["s2"], post=override) - elif grant == "obo": - app.acquire_token_on_behalf_of("assertion", ["s2"], post=override) - elif grant == "auth_code": - app.acquire_token_by_authorization_code("code", ["s2"], post=override) - elif grant == "auth_code_flow": - flow = app.initiate_auth_code_flow(["s2"]) - app.acquire_token_by_auth_code_flow( - flow, {"code": "code", "state": flow["state"]}, - post=override) - elif grant == "refresh": - app.acquire_token_by_refresh_token("a_rt", ["s2"], post=override) - else: - app.acquire_token_silent_with_error( - ["s1"], account, force_refresh=True, post=override) - override.assert_not_called() - transport.assert_not_called() - regular_transport.assert_not_called() + "refresh", "silent_refresh", "password", "federated", "user_fic"): + for option in ("post", "cert"): + with self.subTest(grant=grant, option=option): + self._assert_transport_override_rejected(grant, option) + + def _assert_transport_override_rejected(self, grant, option): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + with patch("msal.mtls._MtlsHttpClient.post", + return_value=MinimalResponse( + status_code=200, text=_build_user_token_response( + client_id="cid", refresh_token="a_rt"))): + app.acquire_token_on_behalf_of("assertion", ["s1"]) + account = next(app.token_cache.search( + msal.TokenCache.CredentialType.ACCOUNT)) + with patch("msal.mtls._MtlsHttpClient.post") as transport, \ + patch.object(app.http_client, "post") as regular_transport, \ + patch.object(app, "_get_authority_aliases", return_value=[]), \ + patch.object(app.authority, "user_realm_discovery", return_value={ + "account_type": "Federated" if grant == "federated" else "Managed", + "federation_active_auth_url": "https://federation.example"}), \ + patch("msal.application.wst_send_request", return_value={ + "token": b"assertion", + "type": msal.application.SAML_TOKEN_TYPE_V1}): + override = Mock() + kwargs = {option: override} + with self.assertRaisesRegex( + ValueError, "does not support a {} override".format(option)): + if grant == "client_credentials": + app.acquire_token_for_client(["s2"], **kwargs) + elif grant == "obo": + app.acquire_token_on_behalf_of("assertion", ["s2"], **kwargs) + elif grant == "auth_code": + app.acquire_token_by_authorization_code("code", ["s2"], **kwargs) + elif grant == "auth_code_flow": + flow = app.initiate_auth_code_flow(["s2"]) + app.acquire_token_by_auth_code_flow( + flow, {"code": "code", "state": flow["state"]}, **kwargs) + elif grant == "refresh": + app.acquire_token_by_refresh_token("a_rt", ["s2"], **kwargs) + elif grant in ("password", "federated"): + app.acquire_token_by_username_password( + "user@example.com", "password", ["s2"], **kwargs) + elif grant == "user_fic": + app.acquire_token_by_user_federated_identity_credential( + ["s2"], "assertion", user_object_id="uid", **kwargs) + else: + app.acquire_token_silent_with_error( + ["s1"], account, force_refresh=True, **kwargs) + override.assert_not_called() + transport.assert_not_called() + regular_transport.assert_not_called() diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py index 070e5c1e..2560091f 100644 --- a/tests/test_mtls_transport.py +++ b/tests/test_mtls_transport.py @@ -214,6 +214,17 @@ def test_owned_transport_disables_redirects_even_with_override(self): method.upper(), "https://mtlsauth.microsoft.com/token", allow_redirects=False, timeout=30) + def test_owned_transport_rejects_certificate_overrides_before_session_access(self): + client = mtls._MtlsHttpClient(b"cert", b"key") + with patch.object(client, "_ensure_session") as ensure_session: + for method in ("get", "post"): + for cert in (None, "replacement.pem", ("cert.pem", "key.pem")): + with self.subTest(method=method, cert=cert): + with self.assertRaisesRegex(ValueError, "cert override"): + getattr(client, method)( + "https://mtlsauth.microsoft.com/token", cert=cert) + ensure_session.assert_not_called() + def test_unsupported_mtls_dependencies_fail_with_upgrade_instructions(self): binding = MtlsBindingContext(b"cert", b"key", [], "thumbprint") unsupported = ( @@ -457,7 +468,9 @@ 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_x5c, decoded["x5c"]) + self.assertEqual( + expected_x5c if send_over_mtls else expected_x5c[:1], + decoded["x5c"]) return MinimalResponse( status_code=200, text='{"access_token": "at", "token_type": "Bearer"}') @@ -468,7 +481,54 @@ def post(url, data=None, **kwargs): else: app.acquire_token_for_client(["s1"], post=post) + def test_flagless_pfx_does_not_require_issuer_signature_verification(self): + from cryptography.exceptions import UnsupportedAlgorithm + 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") + with patch("cryptography.hazmat.primitives.serialization.pkcs12." + "load_key_and_certificates", + return_value=(self.leaf_key, self.leaf, [self.intermediate])), \ + patch("msal.application._is_certificate_issuer", side_effect=UnsupportedAlgorithm( + "Upgrade cryptography to verify RSA-PSS certificate chains")), \ + patch("msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": endpoint.replace("/token", "/authorize"), + "token_endpoint": endpoint, + "issuer": "https://login.microsoftonline.com/tenant/v2.0"}): + for public_certificate in (False, True): + with self.subTest(public_certificate=public_certificate): + app = ConfidentialClientApplication( + "cid", authority="https://login.microsoftonline.com/tenant", + azure_region=False, client_credential={ + "private_key_pfx_path": path, + "public_certificate": public_certificate}) + captured = [] + + def post(url, data=None, **kwargs): + captured.append(data["client_assertion"]) + return MinimalResponse( + status_code=200, + text='{"access_token": "at", "token_type": "Bearer"}') + + result = app.acquire_token_for_client(["s1"], post=post) + self.assertEqual("at", result["access_token"]) + header = captured[0].split(b".")[0] + decoded = json.loads(base64.urlsafe_b64decode( + header + b"=" * (-len(header) % 4))) + if public_certificate: + self.assertEqual( + extract_certs(self.chain_pem.decode("ascii"))[:1], + decoded["x5c"]) + else: + self.assertNotIn("x5c", decoded) + with self.assertRaises(UnsupportedAlgorithm): + _load_mtls_cert_material({"private_key_pfx_path": path}) + def test_issuer_signature_verification_on_older_cryptography(self): + import builtins + class LegacyCertificate: issuer = self.leaf.issuer signature = self.leaf.signature @@ -476,8 +536,17 @@ class LegacyCertificate: 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)) + import_module = builtins.__import__ + + def legacy_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "cryptography.hazmat.primitives.asymmetric" and any( + module in fromlist for module in ("ed25519", "ed448")): + raise ImportError("EdDSA modules require cryptography 2.6") + return import_module(name, globals, locals, fromlist, level) + + with patch("builtins.__import__", side_effect=legacy_import): + 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: From b70842b4c9469d9e40e95ee8f51efbba9b9d6906 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 14:07:04 -0400 Subject: [PATCH 34/46] Address FIC mTLS review feedback Validate final token types before returning or caching responses, reject unsupported FIC outputs, validate credentials before client construction, and guard the sample resource call. Cover cache isolation and same-scope assertion renewal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- msal/application.py | 39 ++++-- sample/confidential_client_mtls_pop_sample.py | 2 +- tests/test_application.py | 119 +++++++++++++++++- 3 files changed, 143 insertions(+), 17 deletions(-) diff --git a/msal/application.py b/msal/application.py index 1c8e042b..b739417c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -386,15 +386,14 @@ def obtain_token_by_username_password(self, username, password, **kwargs): username, password, headers=headers, **kwargs) -def _validate_mtls_pop_response(response): +def _validate_mtls_response(response, expected_token_type="mtls_pop"): if (response and "access_token" in response - and (response.get("token_type") or "").lower() != "mtls_pop"): + and (response.get("token_type") or "").lower() != expected_token_type): 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"))), + "of {!r}.".format(response.get("token_type"), expected_token_type)), } return response @@ -414,6 +413,7 @@ class _MtlsClient(_ClientWithCcsRoutingInfo): 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 + expected_token_type = (data.get("token_type") or "Bearer").lower() if key_id: outer = kwargs.pop("on_obtaining_tokens", None) @@ -429,11 +429,18 @@ def _reinject_key_id(event): (outer or self.on_obtaining_tokens)(event) kwargs["on_obtaining_tokens"] = _reinject_key_id + else: + outer = kwargs.pop("on_obtaining_tokens", None) + + def _cache_bearer_response(event): + response = event.get("response") or {} + if (response.get("token_type") or "").lower() == "bearer": + (outer or self.on_obtaining_tokens)(event) + + kwargs["on_obtaining_tokens"] = _cache_bearer_response response = super(_MtlsClient, self).obtain_token_for_client( scope=scope, data=data, **kwargs) - return (_validate_mtls_pop_response(response) - if (data.get("token_type") or "").lower() == "mtls_pop" - else response) + return _validate_mtls_response(response, expected_token_type) def _msal_extension_check(): @@ -1006,9 +1013,6 @@ def get_leg1_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 / @@ -1049,6 +1053,9 @@ def get_leg1_assertion(): self._mtls_cert_credential = None self._mtls_is_fic_leg2 = False + 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)): @@ -3085,6 +3092,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``. + FIC exchanges only support final ``Bearer`` or ``mtls_pop`` tokens; + requesting other token types, including SSH certificates, 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. @@ -3137,6 +3147,13 @@ def acquire_token_for_client( raise ValueError( "Request mTLS PoP using mtls_proof_of_possession=True, " "not data['token_type'].") + if (self._mtls_is_fic_leg2 + and (requested_token_type or "").lower() not in ( + "", "bearer", "mtls_pop")): + raise ValueError( + "FIC leg-2 exchanges only support Bearer or mtls_pop final " + "tokens; token_type {!r} is not supported.".format( + requested_token_type)) if fmi_path is not None: if not isinstance(fmi_path, str): raise ValueError( @@ -3202,7 +3219,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 _validate_mtls_pop_response(result) + return _validate_mtls_response(result) # Attach process-local TLS capability only when the final token is # certificate-bound. A FIC assertion can travel as jwt-pop over # mTLS while the final token remains an ordinary Bearer token. diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index a1c048dd..88f0c370 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -175,7 +175,7 @@ def get_leg1_assertion(): os.environ["SCOPE"].split(), mtls_proof_of_possession=True) _print_result_summary(leg2, "FIC leg 2 result:") resource_url = os.getenv("RESOURCE_URL") - if resource_url and "access_token" in leg2: + if resource_url and "access_token" in leg2 and "binding_certificate" in leg2: with leg2["binding_certificate"].create_requests_session() as session: response = session.get( resource_url, diff --git a/tests/test_application.py b/tests/test_application.py index 91f76270..92190053 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -5,6 +5,7 @@ import logging import os import pickle +import runpy import sys import time import warnings @@ -2662,11 +2663,103 @@ def test_fic_leg2_rejects_post_override(self): app.acquire_token_for_client( ["s1"], post=self._capturing_post([])) + def test_fic_leg2_bearer_rejects_unexpected_response_type(self): + for token_type in ("mtls_pop", "ssh-cert", None): + with self.subTest(token_type=token_type): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": lambda: "LEG1_TOKEN", + "mtls_binding_certificate": _MTLS_CERT_CRED}) + with patch( + "msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post( + [], token_type=token_type)): + result = app.acquire_token_for_client(["s1"]) + self.assertEqual("token_type_mismatch", result.get("error")) + self.assertNotIn("access_token", result) + self.assertEqual([], list(app.token_cache.search( + msal.TokenCache.CredentialType.ACCESS_TOKEN, + for_removal=True))) + + def test_fic_leg2_rejects_unsupported_final_token_types_before_cache_lookup(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": lambda: "LEG1_TOKEN", + "mtls_binding_certificate": _MTLS_CERT_CRED}) + for token_type in ("ssh-cert", "pop", "unknown"): + for request_pop in (False, True): + with self.subTest(token_type=token_type, request_pop=request_pop): + with patch.object( + app, "_acquire_token_silent_with_error") as lookup: + with self.assertRaisesRegex( + ValueError, "only support Bearer or mtls_pop"): + app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=request_pop, + data={ + "token_type": token_type, + "key_id": "ssh-key", "req_cnf": "{}"}) + lookup.assert_not_called() + + def test_fic_leg2_bearer_and_pop_cache_entries_are_isolated(self): + for order in ((False, True), (True, False)): + with self.subTest(order=order): + assertion = Mock(return_value="LEG1_TOKEN") + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": assertion, + "mtls_binding_certificate": _MTLS_CERT_CRED}) + for request_pop in order: + captured = [] + token_type = "mtls_pop" if request_pop else "Bearer" + with patch( + "msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post( + captured, token_type=token_type)): + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=request_pop) + self.assertEqual(1, len(captured)) + self.assertEqual(token_type, result["token_type"]) + with patch("msal.mtls._MtlsHttpClient.post") as post: + for request_pop in order: + cached = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=request_pop) + self.assertEqual( + app._TOKEN_SOURCE_CACHE, cached[app._TOKEN_SOURCE]) + self.assertEqual( + "mtls_pop" if request_pop else "Bearer", + cached["token_type"]) + self.assertEqual( + request_pop, "binding_certificate" in cached) + post.assert_not_called() + self.assertEqual(2, assertion.call_count) + + def test_fic_sample_skips_resource_call_for_bearer_result(self): + sample = runpy.run_path(os.path.join( + os.path.dirname(__file__), os.pardir, "sample", + "confidential_client_mtls_pop_sample.py")) + result = {"access_token": "token", "token_type": "Bearer"} + app = Mock() + app.acquire_token_for_client.return_value = result + with patch.dict(os.environ, { + "CLIENT_ID": "leg1", "AUTHORITY": self._AUTHORITY, + "LEG2_CLIENT_ID": "leg2", "LEG2_AUTHORITY": self._AUTHORITY, + "PFX_PATH": "unused.pfx", "SCOPE": "s1", + "RESOURCE_URL": "https://resource.invalid"}), patch( + "msal.ConfidentialClientApplication", + return_value=app), patch("builtins.print"), patch( + "msal.MtlsBindingContext.create_requests_session") as session: + self.assertIs(result, sample["fic_two_leg_over_mtls_pop"]()) + session.assert_not_called() + def test_binding_cert_without_client_assertion_raises(self): # FIC leg 2 needs the binding cert AND the leg-1 assertion. A binding # cert with no client_assertion must fail fast at construction with a # clear ValueError, not a later KeyError when the request is built. - with self.assertRaises(ValueError): + with self.assertRaisesRegex( + ValueError, "also requires a client_assertion"): ConfidentialClientApplication( "cid", authority=self._AUTHORITY, client_credential={"mtls_binding_certificate": _MTLS_CERT_CRED}) @@ -2697,10 +2790,26 @@ def test_fic_callable_renews_assertion_without_deprecation_warning(self): with patch( "msal.mtls._MtlsHttpClient.post", side_effect=self._capturing_post(captured)): - for scope in ("s1", "s2"): - result = app.acquire_token_for_client( - [scope], mtls_proof_of_possession=True) - self.assertIn("access_token", result) + first = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertIn("access_token", first) + cached = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertEqual(app._TOKEN_SOURCE_CACHE, cached[app._TOKEN_SOURCE]) + self.assertIsInstance( + cached.get("binding_certificate"), msal.MtlsBindingContext) + self.assertEqual(1, assertion.call_count) + self.assertEqual(1, len(captured)) + for token in list(app.token_cache.search( + msal.TokenCache.CredentialType.ACCESS_TOKEN, + for_removal=True)): + app.token_cache.modify( + msal.TokenCache.CredentialType.ACCESS_TOKEN, token, + {"expires_on": "0"}) + renewed = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=True) + self.assertIn("access_token", renewed) + self.assertEqual(app._TOKEN_SOURCE_IDP, renewed[app._TOKEN_SOURCE]) self.assertEqual( ["LEG1_TOKEN", "RENEWED_LEG1_TOKEN"], [request["data"]["client_assertion"] for request in captured]) From 820c96f928a5c0d3d4a517c09dea233978b6e015 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 15:09:37 -0400 Subject: [PATCH 35/46] Align Bearer-over-mTLS E2E failure and coverage semantics Fail missing lab credentials instead of skipping supported scenarios and remove the unsupported live user-flow placeholder. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 706dc5b9..aedc16b7 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -573,8 +573,6 @@ def test_credential_x509_send_certificate_over_mtls_output_bearer(self): # .NET live CC cell: westus3 + the Key Vault resource). Proves the cert # goes on the TLS handshake to mtlsauth.* yet ESTS returns a plain Bearer. 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") scope = ["https://vault.azure.net/.default"] self.app = msal.ConfidentialClientApplication( self._SNI_ALLOWLISTED_CLIENT_ID, @@ -597,17 +595,11 @@ def test_credential_x509_send_certificate_over_mtls_output_bearer(self): self.assertCacheWorksForApp(result, scope) def test_send_certificate_over_mtls_user_flows_capture_request(self): - # The allow-listed app is entitled to Bearer-over-mTLS for - # client-credentials only; the OBO/refresh/auth-code apps are not - # mTLS-enabled server-side (AADSTS700027 / AADSTS392189), exactly as - # MSAL .NET [Ignore]s those live. So prove MSAL builds the correct wire - # request for every confidential user flow -- the mtlsauth endpoint + a - # private_key_jwt client_assertion + the flow's own grant, with none of - # the mtls_pop markers -- by capturing the outgoing POST over the REAL - # lab SN/I certificate, without needing those apps allow-listed. + # Like .NET/Go, cover these flows through request capture while their + # apps lack live mTLS enablement (AADSTS700027 / AADSTS392189). + # Replacing post below proves request construction, not a TLS handshake + # or successful live acquisition. 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") cred = dict( get_client_certificate(), send_certificate_over_mtls=True) def _capture(sink): @@ -646,17 +638,20 @@ def _assert_mtls(req, grant_key, grant_value): _new_app().acquire_token_by_authorization_code("an_auth_code", scope) _assert_mtls(cap[0], "grant_type", "authorization_code") - def test_send_certificate_over_mtls_user_flows_live_acquire(self): - # Live OBO/refresh acquisition with Bearer-over-mTLS is pending the - # downstream apps being mTLS-enabled server-side; today ESTS rejects it - # (AADSTS700027 / AADSTS392189) -- the same class of block as the FIC - # leg-1 gate (AADSTS51000) and MSAL .NET's [Ignore]'d live user-flow - # cells. Kept as an explicit, self-documenting gate that flips to a real - # acquisition once the apps are enabled; the request shape is already - # proven by test_send_certificate_over_mtls_user_flows_capture_request. - self.skipTest( - "pending app mTLS-enablement for OBO/refresh Bearer-over-mTLS " - "(AADSTS700027 / AADSTS392189)") + +class BearerOverMtlsScenarioTestCase(unittest.TestCase): + def test_missing_certificate_fails_before_acquisition(self): + for test_name in ( + "test_credential_x509_send_certificate_over_mtls_output_bearer", + "test_send_certificate_over_mtls_user_flows_capture_request"): + with self.subTest(test_name=test_name): + scenario = PublicCloudScenariosTestCase(test_name) + with patch.dict(os.environ, {}, clear=True), patch( + "msal.ConfidentialClientApplication") as factory: + with self.assertRaisesRegex( + EnvironmentError, "LAB_APP_CLIENT_CERT_PFX_PATH"): + getattr(scenario, test_name)() + factory.assert_not_called() class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once From 3bc3505ac51b44c36105525a0d63293ab4da9221 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 15:09:40 -0400 Subject: [PATCH 36/46] Clarify default-only mTLS live E2E coverage Distinguish the inherited live matrix from custom HTTP factory loopback TLS coverage without claiming cross-SDK live factory parity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_e2e.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 65093477..b498053d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -444,8 +444,9 @@ def test_subject_name_issuer_authentication(self): # ── 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. + # These live matrix cells exercise MSAL's default mTLS transport, not the + # custom HTTP factory. Custom-pipeline coverage remains local TLS coverage + # in test_mtls_http_client.py; these tests do not establish live factory parity. # 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"] From 84ff3e860335cce8b185d8a5c7b9dbc8ccda7c47 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 15:12:00 -0400 Subject: [PATCH 37/46] Align FIC E2E cells with shared SDK lab setup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/README.md | 24 ++++++ tests/test_e2e.py | 210 ++++++++++++++++++++++++++++++---------------- 2 files changed, 164 insertions(+), 70 deletions(-) diff --git a/tests/README.md b/tests/README.md index 0b044a60..3d1c9eb0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -34,6 +34,30 @@ 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. +### FIC over mTLS lab parity + +The FIC tests reuse the existing MSAL.NET/Go SN/I lab configuration for both +legs: app `163ffef9-a313-45b4-ab2f-c7e2f5e0e23e`, authority +`https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c`, +and the shared LabAuth certificate supplied through +`LAB_APP_CLIENT_CERT_PFX_PATH`. No Python-only `LAB_FIC_*` configuration or +new app provisioning is needed. + +Leg 1 requests `api://AzureADTokenExchange/.default` as an mTLS PoP assertion. +The PoP-output cell uses global endpoints for both legs, requests +`https://graph.microsoft.com/.default`, and calls the mTLS Graph resource with +the final token's `binding_certificate`. The Bearer-output cell uses `westus3` +for both legs and requests `https://storage.azure.com/.default`; its final +token must not contain `binding_certificate` or trigger a downstream mTLS call. +Missing or invalid certificates and real token-acquisition errors fail the +FIC tests rather than skipping or accepting an error as success. + +Run these two live cells with: + +```powershell +python -m pytest -q tests\test_e2e.py::FicTwoLegTestCase +``` + ## 4) Run unit/integration tests Run all non-E2E tests quickly: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 7f49a65f..c980eb90 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,11 +1,9 @@ """If the following ENV VAR were available, many end-to-end test cases would run. LAB_APP_CLIENT_CERT_PFX_PATH=... -The FIC two-leg test additionally requires LAB_FIC_LEG1_CLIENT_ID, -LAB_FIC_LEG1_AUTHORITY, LAB_FIC_LEG2_CLIENT_ID, LAB_FIC_LEG2_AUTHORITY, and -LAB_FIC_EXCHANGE_SCOPE. Provision the leg-1 app for mTLS PoP at the exchange -audience, and a federated identity credential on the leg-2 app trusting the -leg-1 token's issuer, subject, and audience. Both legs use the lab certificate. +The FIC two-leg tests use the same preconfigured SN/I lab app, tenant, and +LabAuth certificate as MSAL.NET and Go. No LAB_FIC_* variables are required. +Missing certificates and token-acquisition errors fail these tests. """ try: from dotenv import load_dotenv # Use this only in local dev machine @@ -565,48 +563,48 @@ def test_credential_x509_output_bearer(self): class FicTwoLegTestCase(MtlsPopTestCase): + __unittest_skip__ = False # Required cells must also run on tagged releases. + _FIC_EXCHANGE_SCOPE = ["api://AzureADTokenExchange/.default"] _FIC_BEARER_SCOPE = ["https://storage.azure.com/.default"] - def test_fic_two_leg_over_mtls_pop(self): + def _acquire_fic_token( + self, scope, azure_region, mtls_proof_of_possession=False): from tests.lab_config import get_client_certificate - required = ( - "LAB_APP_CLIENT_CERT_PFX_PATH", - "LAB_FIC_LEG1_CLIENT_ID", "LAB_FIC_LEG1_AUTHORITY", - "LAB_FIC_LEG2_CLIENT_ID", "LAB_FIC_LEG2_AUTHORITY", - "LAB_FIC_EXCHANGE_SCOPE", - ) - config = {name: clean_env(name) for name in required} - missing = [name for name, value in config.items() if not value] - if missing: - self.skipTest("FIC configuration missing: " + ", ".join(missing)) cert = get_client_certificate() - # Leg 1: SN/I cert -> cert-bound (mtls_pop) federated assertion for the - # caller-supplied token-exchange audience. leg1_app = msal.ConfidentialClientApplication( - config["LAB_FIC_LEG1_CLIENT_ID"], - authority=config["LAB_FIC_LEG1_AUTHORITY"], - client_credential=cert) + PublicCloudScenariosTestCase._SNI_ALLOWLISTED_CLIENT_ID, + authority=PublicCloudScenariosTestCase._SNI_ALLOWLISTED_AUTHORITY, + client_credential=cert, + azure_region=azure_region) leg1 = leg1_app.acquire_token_for_client( - [config["LAB_FIC_EXCHANGE_SCOPE"]], mtls_proof_of_possession=True) + self._FIC_EXCHANGE_SCOPE, mtls_proof_of_possession=True) self.assertIn("access_token", leg1, "FIC leg 1 failed: %s" % leg1) self.assertEqual("mtls_pop", leg1.get("token_type"), "Leg 1 must be mtls_pop") leg1_thumbprint = leg1["binding_certificate"]["thumbprint_sha256"] # Leg 2: same cert binds the TLS handshake, leg-1 token carried as jwt-pop. leg2_app = msal.ConfidentialClientApplication( - config["LAB_FIC_LEG2_CLIENT_ID"], - authority=config["LAB_FIC_LEG2_AUTHORITY"], + PublicCloudScenariosTestCase._SNI_ALLOWLISTED_CLIENT_ID, + authority=PublicCloudScenariosTestCase._SNI_ALLOWLISTED_AUTHORITY, client_credential={ "client_assertion": lambda: leg1["access_token"], - "mtls_binding_certificate": cert}) - # Leg 2 -> Bearer over mTLS (implicit Bearer-over-mTLS, no flag) - leg2_bearer = leg2_app.acquire_token_for_client(self._FIC_BEARER_SCOPE) + "mtls_binding_certificate": cert}, + azure_region=azure_region) + result = leg2_app.acquire_token_for_client( + scope, mtls_proof_of_possession=mtls_proof_of_possession) + return leg1_thumbprint, result + + def test_credential_fic_output_bearer(self): + _, leg2_bearer = self._acquire_fic_token( + self._FIC_BEARER_SCOPE, azure_region="westus3") self.assertIn("access_token", leg2_bearer, "Leg 2 (Bearer) failed: %s" % leg2_bearer) self.assertEqual("Bearer", leg2_bearer.get("token_type")) self.assertNotIn("binding_certificate", leg2_bearer) - # Leg 2 -> mTLS PoP final token - leg2_pop = leg2_app.acquire_token_for_client( - self._MTLS_POP_SCOPE, mtls_proof_of_possession=True) + + def test_credential_fic_output_pop(self): + leg1_thumbprint, leg2_pop = self._acquire_fic_token( + self._MTLS_POP_SCOPE, azure_region=False, + mtls_proof_of_possession=True) self.assertIn("access_token", leg2_pop, "Leg 2 (PoP) failed: %s" % leg2_pop) self.assertEqual("mtls_pop", leg2_pop.get("token_type")) # The final PoP token must be bound to the leg-1 certificate thumbprint @@ -629,11 +627,6 @@ class FicScenarioTestCase(unittest.TestCase): def setUp(self): self.config = { "LAB_APP_CLIENT_CERT_PFX_PATH": "configured.pfx", - "LAB_FIC_LEG1_CLIENT_ID": "leg1-client", - "LAB_FIC_LEG1_AUTHORITY": "https://login.microsoftonline.com/leg1-tenant", - "LAB_FIC_LEG2_CLIENT_ID": "leg2-client", - "LAB_FIC_LEG2_AUTHORITY": "https://login.microsoftonline.com/leg2-tenant", - "LAB_FIC_EXCHANGE_SCOPE": "api://exchange/.default", } self.leg1 = Mock() self.leg1.acquire_token_for_client.return_value = { @@ -643,23 +636,17 @@ def setUp(self): self.pop_binding = Mock(spec=msal.MtlsBindingContext) self.pop_binding.get.return_value = "cert-thumbprint" self.leg2 = Mock() - self.leg2.acquire_token_for_client.side_effect = [ - { - "access_token": "bearer-token", - "token_type": "Bearer", - }, - { - "access_token": "opaque-pop-token", - "token_type": "mtls_pop", - "binding_certificate": self.pop_binding, - }, - ] - self.scenario = FicTwoLegTestCase("test_fic_two_leg_over_mtls_pop") - self.cert = {"private_key_pfx_path": "configured.pfx"} + self.leg2.acquire_token_for_client.return_value = { + "access_token": "opaque-pop-token", + "token_type": "mtls_pop", + "binding_certificate": self.pop_binding, + } + self.scenario = FicTwoLegTestCase("test_credential_fic_output_pop") + self.cert = { + "private_key_pfx_path": "configured.pfx", "public_certificate": True} self.factory = Mock(side_effect=[self.leg1, self.leg2]) patchers = ( patch.dict(os.environ, self.config, clear=True), - patch("tests.lab_config.get_client_certificate", return_value=self.cert), patch("msal.ConfidentialClientApplication", self.factory), patch.object(self.scenario, "_call_graph_with_mtls_pop_token"), ) @@ -667,40 +654,112 @@ def setUp(self): patcher.start() self.addCleanup(patcher.stop) - def test_missing_configuration_skips_before_acquisition(self): - for name in self.config: - with self.subTest(name=name), patch.dict(os.environ, {name: ""}): - with self.assertRaisesRegex(unittest.SkipTest, name): - self.scenario.test_fic_two_leg_over_mtls_pop() + def test_missing_certificate_fails_before_acquisition(self): + for method in ( + self.scenario.test_credential_fic_output_pop, + self.scenario.test_credential_fic_output_bearer): + for value in ("", "$(LAB_APP_CLIENT_CERT_PFX_PATH)"): + with self.subTest(method=method.__name__, value=value): + with patch.dict( + os.environ, {"LAB_APP_CLIENT_CERT_PFX_PATH": value}): + with self.assertRaisesRegex( + EnvironmentError, "LAB_APP_CLIENT_CERT_PFX_PATH"): + method() + self.factory.assert_not_called() + + def test_missing_certificate_is_a_runner_error_even_on_tagged_releases(self): + with patch.object(E2eTestCase, "__unittest_skip__", True, create=True): + with patch.dict(os.environ, {}, clear=True): + for name in ( + "test_credential_fic_output_pop", + "test_credential_fic_output_bearer"): + with self.subTest(name=name): + result = unittest.TestResult() + FicTwoLegTestCase(name).run(result) + self.assertEqual(1, result.testsRun) + self.assertEqual([], result.skipped) + self.assertEqual(1, len(result.errors)) + self.assertIn( + "LAB_APP_CLIENT_CERT_PFX_PATH", result.errors[0][1]) self.factory.assert_not_called() def test_configured_leg1_errors_fail_instead_of_skipping(self): - self.leg1.acquire_token_for_client.return_value = { - "error": "token_type_mismatch"} - with self.assertRaisesRegex(AssertionError, "token_type_mismatch"): - self.scenario.test_fic_two_leg_over_mtls_pop() + for method in ( + self.scenario.test_credential_fic_output_pop, + self.scenario.test_credential_fic_output_bearer): + for error in ("token_type_mismatch", "invalid_client", "invalid_grant"): + with self.subTest(method=method.__name__, error=error): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg1.acquire_token_for_client.return_value = { + "error": error} + with self.assertRaisesRegex(AssertionError, error): + method() self.leg2.acquire_token_for_client.assert_not_called() - def test_explicit_configuration_and_opaque_token_resource_validation(self): - self.scenario.test_fic_two_leg_over_mtls_pop() + def test_shared_lab_defaults_and_opaque_token_resource_validation(self): + self.scenario.test_credential_fic_output_pop() calls = self.factory.call_args_list - self.assertEqual("leg1-client", calls[0].args[0]) - self.assertEqual(self.config["LAB_FIC_LEG1_AUTHORITY"], - calls[0].kwargs["authority"]) - self.assertEqual("leg2-client", calls[1].args[0]) - self.assertEqual(self.config["LAB_FIC_LEG2_AUTHORITY"], - calls[1].kwargs["authority"]) + self.assertEqual(2, len(calls)) + for app_call in calls: + self.assertEqual( + "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e", app_call.args[0]) + self.assertEqual( + "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c", + app_call.kwargs["authority"]) + self.assertIs(False, app_call.kwargs["azure_region"]) + self.assertEqual(self.cert, calls[0].kwargs["client_credential"]) credential = calls[1].kwargs["client_credential"] - self.assertIs(self.cert, credential["mtls_binding_certificate"]) + self.assertIs( + calls[0].kwargs["client_credential"], + credential["mtls_binding_certificate"]) self.assertEqual("leg1-token", credential["client_assertion"]()) self.leg1.acquire_token_for_client.assert_called_once_with( - [self.config["LAB_FIC_EXCHANGE_SCOPE"]], mtls_proof_of_possession=True) + ["api://AzureADTokenExchange/.default"], mtls_proof_of_possession=True) + self.leg2.acquire_token_for_client.assert_called_once_with( + ["https://graph.microsoft.com/.default"], mtls_proof_of_possession=True) self.assertEqual( [ call("opaque-pop-token", self.pop_binding), ], self.scenario._call_graph_with_mtls_pop_token.call_args_list) + def test_bearer_uses_regional_shared_lab_without_downstream_mtls(self): + self.leg2.acquire_token_for_client.return_value = { + "access_token": "bearer-token", "token_type": "Bearer"} + self.scenario.test_credential_fic_output_bearer() + calls = self.factory.call_args_list + self.assertEqual(2, len(calls)) + for app_call in calls: + self.assertEqual( + "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e", app_call.args[0]) + self.assertEqual( + "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c", + app_call.kwargs["authority"]) + self.assertEqual("westus3", app_call.kwargs["azure_region"]) + credential = calls[1].kwargs["client_credential"] + self.assertIs( + calls[0].kwargs["client_credential"], + credential["mtls_binding_certificate"]) + self.assertEqual("leg1-token", credential["client_assertion"]()) + self.leg1.acquire_token_for_client.assert_called_once_with( + ["api://AzureADTokenExchange/.default"], mtls_proof_of_possession=True) + self.leg2.acquire_token_for_client.assert_called_once_with( + ["https://storage.azure.com/.default"], mtls_proof_of_possession=False) + self.scenario._call_graph_with_mtls_pop_token.assert_not_called() + + def test_leg2_errors_fail_instead_of_skipping(self): + for method in ( + self.scenario.test_credential_fic_output_pop, + self.scenario.test_credential_fic_output_bearer): + for error in ("invalid_client", "invalid_grant", "temporarily_unavailable"): + with self.subTest(method=method.__name__, error=error): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg2.acquire_token_for_client.return_value = { + "error": error} + with self.assertRaisesRegex(AssertionError, error): + method() + self.scenario._call_graph_with_mtls_pop_token.assert_not_called() + def test_bearer_contract_is_checked(self): for response in ( {"access_token": "token", "token_type": "mtls_pop"}, @@ -708,9 +767,20 @@ def test_bearer_contract_is_checked(self): "binding_certificate": {}}): with self.subTest(response=response): self.factory.side_effect = [self.leg1, self.leg2] - self.leg2.acquire_token_for_client.side_effect = [response] + self.leg2.acquire_token_for_client.return_value = response + with self.assertRaises(AssertionError): + self.scenario.test_credential_fic_output_bearer() + + def test_pop_requires_matching_final_binding_certificate(self): + for binding in (None, {}, Mock(spec=msal.MtlsBindingContext)): + with self.subTest(binding=binding): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg2.acquire_token_for_client.return_value = { + "access_token": "opaque-pop-token", "token_type": "mtls_pop", + "binding_certificate": binding} with self.assertRaises(AssertionError): - self.scenario.test_fic_two_leg_over_mtls_pop() + self.scenario.test_credential_fic_output_pop() + self.scenario._call_graph_with_mtls_pop_token.assert_not_called() class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once From 1fceb2fa4b9d6dbd72d3e5e8d7281fc4b4b34b56 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 15:12:16 -0400 Subject: [PATCH 38/46] 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 751aa0b0576c7d39777dacb0c7e0b314968e3be9 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 15:15:18 -0400 Subject: [PATCH 39/46] Make required E2E fixture checks independent of release environment Allow the mocked legacy release-skip attribute to be created when the E2E module was imported without TRAVIS_TAG. 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 f67c6808fa9969952d3caf0e8ef32aea91cd16d3 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 15:15:44 -0400 Subject: [PATCH 40/46] 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 6b0694c7d4c6d6b7f400219d50c5ec24a74e067a Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 16:41:14 -0400 Subject: [PATCH 41/46] Share certificate transport across mTLS flows Apply the approved sibling-feature integration: Bearer-over-mTLS uses the same certificate-scoped custom factory and endpoint protections as PoP and FIC. Retain one owned pipeline per application, jwt-pop FIC assertions, cache isolation, and caller cleanup ownership. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/index.rst | 5 ++ msal/application.py | 79 +++++++++-------- tests/test_mtls_http_client.py | 158 ++++++++++++++++++++++++++++++++- 3 files changed, 201 insertions(+), 41 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index bb302351..1b9689bb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -297,6 +297,11 @@ callable ``post(url, **kwargs)`` using the normal MSAL HTTP response contract It must accept and honor ``allow_redirects=False``. Factory and request exceptions propagate; MSAL never falls back to the ordinary client. +The same certificate-scoped pipeline handles mTLS PoP, Bearer-over-mTLS, and +FIC leg-2 requests. A FIC assertion remains ``jwt-pop`` even when the final +token is ``Bearer``; only a final ``mtls_pop`` token includes +``binding_certificate`` in its result. + For Requests-based pipelines, mount ``binding_context.create_requests_adapter(...)`` on a **new** session. This helper accepts ``HTTPAdapter`` options for retry and pooling policy, retains diff --git a/msal/application.py b/msal/application.py index b4c6f91f..1ad10a91 100644 --- a/msal/application.py +++ b/msal/application.py @@ -392,8 +392,9 @@ def _obtain_token(self, *args, **kwargs): if option in kwargs: raise ValueError( "send_certificate_over_mtls=True does not support a {} " - "override. Omit {} to use MSAL's built-in mTLS transport " + "override. Omit {} to use the certificate-aware mTLS transport " "with the configured certificate.".format(option, option)) + mtls._validate_mtls_request(kwargs) return super(_MtlsBearerClient, self)._obtain_token(*args, **kwargs) @@ -1341,6 +1342,31 @@ def _get_mtls_http_client_factory(self): "MSAL's built-in mTLS transport.") return factory + def _get_mtls_http_client_locked(self, cert, factory): + if self._mtls_http_client is None: + if factory is None: + http_client = mtls._MtlsHttpClient( + cert["cert_pem"], cert["private_key_pem"], + **self._http_client_config) + else: + binding = mtls.MtlsBindingContext( + cert["cert_pem"], cert["private_key_pem"], + cert["x5c"], cert["key_id"]) + http_client = factory(binding) + if (http_client is self._custom_http_client + or http_client is self.http_client + or http_client is self.http_client.http_client): + raise ValueError( + "create_mtls_client must return a new, dedicated " + "client, not the ordinary http_client.") + if not callable(getattr(http_client, "post", None)): + raise TypeError( + "create_mtls_client must return a client with " + "a callable post(url, **kwargs).") + mtls._claim_mtls_http_client(http_client, self) + self._mtls_http_client = http_client + return self._mtls_http_client + def _get_mtls_client(self, central_authority): """Lazily build (and cache) the mTLS PoP client for ``central_authority``. @@ -1376,30 +1402,10 @@ def _get_mtls_client(self, central_authority): cached = self._mtls_regional_client if region else self._mtls_client if cached is not None: return cached - if self._mtls_http_client is None: - if factory is None: - http_client = mtls._MtlsHttpClient( - cert["cert_pem"], cert["private_key_pem"], - **self._http_client_config) - else: - binding = mtls.MtlsBindingContext( - cert["cert_pem"], cert["private_key_pem"], - cert["x5c"], cert["key_id"]) - http_client = factory(binding) - if (http_client is self._custom_http_client - or http_client is self.http_client - or http_client is self.http_client.http_client): - raise ValueError( - "create_mtls_client must return a new, dedicated " - "client, not the ordinary http_client.") - if not callable(getattr(http_client, "post", None)): - raise TypeError( - "create_mtls_client must return a client with " - "a callable post(url, **kwargs).") - mtls._claim_mtls_http_client(http_client, self) - self._mtls_http_client = http_client http_client = ThrottledHttpClient( - mtls._MtlsTokenHttpClient(self._mtls_http_client, token_endpoint), + mtls._MtlsTokenHttpClient( + self._get_mtls_http_client_locked(cert, factory), + token_endpoint), # The TLS credential is absent from HTTP cache keys. http_cache={}, default_throttle_time=5, @@ -1450,12 +1456,7 @@ def _get_mtls_bearer_client(self, central_authority): ``key_id`` / no ``req_cnf``). The result is therefore a plain Bearer access token whose cache entry is not fenced by the certificate. """ - if self._custom_http_client is not None: - raise ValueError( - "send_certificate_over_mtls=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.") + factory = self._get_mtls_http_client_factory() region = self._compute_region_to_use() cache_key = ( central_authority.instance, central_authority.token_endpoint, region) @@ -1473,13 +1474,14 @@ def _get_mtls_bearer_client(self, central_authority): "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, - ) + with self._mtls_lock: + http_client = ThrottledHttpClient( + mtls._MtlsTokenHttpClient( + self._get_mtls_http_client_locked(cert, factory), + token_endpoint), + http_cache={}, + default_throttle_time=5, + ) # Sign a private_key_jwt assertion, FORCING the x5c chain onto the # header regardless of the app's public_certificate opt-in (Bearer-over- # mTLS always attaches the chain), so ESTS accepts the assertion on the @@ -3281,8 +3283,7 @@ def acquire_token_for_client( requesting other token types, including SSH certificates, raises ``ValueError``. ``post`` and ``cert`` overrides and redirects are rejected. If the - provider - returns another token type, the result is ``token_type_mismatch`` + provider returns another token type, the result is ``token_type_mismatch`` and the rejected response is not cached. On success the result also contains an diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py index f149f293..098812f8 100644 --- a/tests/test_mtls_http_client.py +++ b/tests/test_mtls_http_client.py @@ -52,12 +52,166 @@ def setUp(self): status_code=200, text='{"access_token": "ordinary", "token_type": "Bearer"}') - def _app(self, http_client=None, **kwargs): + def _app(self, http_client=None, client_credential=None, **kwargs): return msal.ConfidentialClientApplication( - "cid", authority=_AUTHORITY, client_credential=_CREDENTIAL, + "cid", authority=_AUTHORITY, + client_credential=( + _CREDENTIAL if client_credential is None else client_credential), http_client=self.ordinary if http_client is None else http_client, azure_region=False, instance_discovery=False, **kwargs) + def test_bearer_factory_handles_client_and_user_grants(self, discovery): + for grant in ("client_credentials", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "authorization_code", "refresh_token"): + with self.subTest(grant=grant): + pipeline = Mock(spec=["post"]) + pipeline.post.return_value = MinimalResponse( + status_code=200, text=json.dumps({ + "access_token": "bearer-at", "token_type": "Bearer", + "expires_in": 3600})) + self.ordinary.create_mtls_client.return_value = pipeline + self.ordinary.reset_mock() + app = self._app(client_credential=dict( + _CREDENTIAL, send_certificate_over_mtls=True)) + if grant == "client_credentials": + result = app.acquire_token_for_client(["s1"]) + elif grant == "authorization_code": + result = app.acquire_token_by_authorization_code("code", ["s1"]) + elif grant == "refresh_token": + result = app.acquire_token_by_refresh_token("rt", ["s1"]) + else: + result = app.acquire_token_on_behalf_of("assertion", ["s1"]) + self.assertEqual("Bearer", result["token_type"]) + self.assertNotIn("binding_certificate", result) + self.ordinary.create_mtls_client.assert_called_once() + binding = self.ordinary.create_mtls_client.call_args.args[0] + self.assertIsInstance(binding, msal.MtlsBindingContext) + request = pipeline.post.call_args + self.assertEqual( + "https://mtlsauth.microsoft.com/tenant/oauth2/v2.0/token", + request.args[0]) + data = request.kwargs["data"] + self.assertEqual(grant, data["grant_type"]) + self.assertTrue(data["client_assertion"]) + self.assertEqual( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + data["client_assertion_type"]) + for field in ("token_type", "req_cnf", "key_id"): + self.assertNotIn(field, data) + self.assertIs(False, request.kwargs["allow_redirects"]) + self.ordinary.post.assert_not_called() + + def test_bearer_and_pop_share_pipeline_without_sharing_token_cache_keys( + self, discovery): + app = self._app(client_credential=dict( + _CREDENTIAL, send_certificate_over_mtls=True)) + self.pipeline.post.side_effect = [ + MinimalResponse(status_code=200, text=json.dumps({ + "access_token": "bearer-at", "token_type": "Bearer", + "expires_in": 3600})), + MinimalResponse(status_code=200, text=_TOKEN)] + bearer = app.acquire_token_for_client(["s1"]) + pop = app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) + for bound, original in ((False, bearer), (True, pop)): + cached = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=bound) + self.assertEqual("cache", cached["token_source"]) + self.assertEqual(original["access_token"], cached["access_token"]) + self.assertEqual(bound, "binding_certificate" in cached) + self.ordinary.create_mtls_client.assert_called_once() + self.assertEqual(2, self.pipeline.post.call_count) + self.ordinary.post.assert_not_called() + + def test_bearer_global_and_regional_clients_share_pipeline(self, discovery): + app = self._app(client_credential=dict( + _CREDENTIAL, send_certificate_over_mtls=True)) + with patch.object(app, "_compute_region_to_use", + side_effect=[None, "westus3"]): + central = app._get_mtls_bearer_client(app.authority) + regional = app._get_mtls_bearer_client(app.authority) + self.assertIsNot(central, regional) + self.assertEqual( + "https://westus3.mtlsauth.microsoft.com/tenant/oauth2/v2.0/token", + regional.configuration["token_endpoint"]) + self.ordinary.create_mtls_client.assert_called_once() + + def test_mixed_bearer_pop_creation_retains_one_pipeline(self, discovery): + app = self._app(client_credential=dict( + _CREDENTIAL, send_certificate_over_mtls=True)) + + def factory(binding): + time.sleep(0.02) + return self.pipeline + + def client(index): + return (app._get_mtls_client(app.authority) if index % 2 + else app._get_mtls_bearer_client(app.authority)) + + self.ordinary.create_mtls_client.side_effect = factory + with ThreadPoolExecutor(max_workers=12) as executor: + clients = list(executor.map(client, range(24))) + self.assertTrue(all(client is clients[0] for client in clients[::2])) + self.assertTrue(all(client is clients[1] for client in clients[1::2])) + self.ordinary.create_mtls_client.assert_called_once() + + def test_fic_factory_keeps_jwt_pop_and_only_binds_final_pop(self, discovery): + for bound in (False, True): + with self.subTest(bound=bound): + pipeline = Mock(spec=["post"]) + pipeline.post.return_value = MinimalResponse( + status_code=200, text=json.dumps({ + "access_token": "fic-at", + "token_type": "mtls_pop" if bound else "Bearer", + "expires_in": 3600})) + self.ordinary.create_mtls_client.return_value = pipeline + app = self._app(client_credential={ + "client_assertion": lambda: "leg1-assertion", + "mtls_binding_certificate": _CREDENTIAL}) + result = app.acquire_token_for_client( + ["s1"], mtls_proof_of_possession=bound) + self.assertEqual(bound, "binding_certificate" in result) + data = pipeline.post.call_args.kwargs["data"] + self.assertEqual("leg1-assertion", data["client_assertion"]) + self.assertEqual( + "urn:ietf:params:oauth:client-assertion-type:jwt-pop", + data["client_assertion_type"]) + self.assertEqual( + "mtls_pop" if bound else None, data.get("token_type")) + self.assertNotIn("key_id", data) + self.ordinary.post.assert_not_called() + + def test_bearer_factory_rejects_observable_redirects(self, discovery): + endpoint = "https://mtlsauth.microsoft.com/tenant/oauth2/v2.0/token" + for changes in ({"status_code": 307}, {"history": [object()]}, + {"url": "https://other.example/token"}): + with self.subTest(changes=changes): + response = SimpleNamespace( + status_code=200, text='{"access_token": "must-not-cache"}', + url=endpoint, headers={}, close=Mock()) + for name, value in changes.items(): + setattr(response, name, value) + pipeline = Mock(spec=["post"]) + pipeline.post.return_value = response + self.ordinary.create_mtls_client.return_value = pipeline + app = self._app(client_credential=dict( + _CREDENTIAL, send_certificate_over_mtls=True)) + with self.assertRaisesRegex(ValueError, "must not redirect"): + app.acquire_token_for_client(["s1"]) + response.close.assert_called_once_with() + self.assertEqual({}, app.token_cache._cache.get("AccessToken", {})) + self.ordinary.post.assert_not_called() + + def test_bearer_legacy_client_has_no_transport_fallback(self, discovery): + legacy = Mock(spec=["get", "post"]) + app = self._app( + http_client=legacy, + client_credential=dict( + _CREDENTIAL, send_certificate_over_mtls=True)) + with self.assertRaisesRegex(ValueError, "create_mtls_client"): + app.acquire_token_for_client(["s1"]) + legacy.post.assert_not_called() + def test_factory_receives_selected_binding_and_request_options(self, discovery): app = self._app(verify=False, timeout=999, proxies={"https": "http://unused.example"}) From f5435b46332d7eb42e92683fb04fcc0ec96aa410 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 17:09:45 -0400 Subject: [PATCH 42/46] Expand required live mTLS E2E parity coverage Add strict Graph controls, regional PoP, both live cache orders, real default/custom grant-rejection recording, and two-leg FIC factory evidence. Inventory the 21 mandatory live methods and map pinned .NET/Go assertions in the test setup documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/README.md | 116 ++++- tests/mtls_e2e_helpers.py | 197 ++++++++ tests/test_e2e.py | 830 ++++++++++++++++++--------------- tests/test_lab_config.py | 51 +- tests/test_mtls_e2e_helpers.py | 435 +++++++++++++++++ 5 files changed, 1223 insertions(+), 406 deletions(-) create mode 100644 tests/mtls_e2e_helpers.py create mode 100644 tests/test_mtls_e2e_helpers.py diff --git a/tests/README.md b/tests/README.md index ba2fee7d..39ac421a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -55,7 +55,12 @@ token must not contain `binding_certificate` or trigger a downstream mTLS call. Missing or invalid certificates and real token-acquisition errors fail the FIC tests rather than skipping or accepting an error as success. -Run these two live cells with: +Both cells also run through a caller-owned recording HTTP factory. The factory +uses the sample's real certificate-scoped Requests adapter, preserves its policy +header and response middleware, and owns cleanup of the derived client. These +are live exchanges, not mocked factory responses. + +Run all four live FIC variants with: ```powershell python -m pytest -q tests\test_e2e.py::FicTwoLegTestCase @@ -72,27 +77,108 @@ python -m pytest -q tests -k "not e2e" Run full E2E unattended suite: ```powershell -python -m pytest -q tests/test_e2e.py +python -m pytest -q tests\test_e2e.py ``` Run only the required certificate scenarios: ```powershell -python -m pytest -q tests/test_e2e.py::CertificateScenariosTestCase +python -m pytest -q tests\test_e2e.py::CertificateScenariosTestCase +``` + +The certificate matrix uses client `163ffef9-a313-45b4-ab2f-c7e2f5e0e23e` +in tenant `bea21ebe-8b64-4d06-9f6d-6a889b120a7c`, matching the .NET and Go +lab setup. Global Graph PoP and regional `westus3` Key Vault PoP are separate +cases. Ordinary regional signed-certificate authentication and regional +Bearer-over-mTLS are also separate cases. + +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. + +OBO, refresh-token, and authorization-code captures delegate to the real +transport and require an actual expected HTTP/OAuth rejection for intentionally +invalid grants. They check the selected endpoint, signed client assertion, +grant-specific fields, and absence of PoP request markers. They do not claim +successful user-token issuance. A flag-off OBO control must use the ordinary +endpoint. Default and custom-pipeline variants run independently; a flag-off +custom client must not invoke the mTLS factory. + +All 21 required live methods (17 certificate and four FIC) 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 recorder/guard tests are separate from live evidence: + +```powershell +python -m pytest -q tests\test_lab_config.py tests\test_mtls_e2e_helpers.py tests\test_mtls_http_client.py ``` -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. - -Bearer-over-mTLS also has a live client-credentials case for `westus3` and -`https://vault.azure.net/.default`. OBO, refresh-token, and authorization-code -coverage captures request construction only, matching the reference SDKs' -live-app enablement limitation; it does not prove live token issuance. -They do not require fetching unrelated user configuration from Key Vault. -Legacy interactive and other optional E2E skips are unchanged. +These checks use local fixtures or synthetic responses to verify strict +binding, safe recording, failure gates, the exact required-method inventory, +and real unittest-runner failure rather than skip when credentials are missing. +The existing custom-HTTP loopback TLS tests remain in +`tests\test_mtls_http_client.py`; they complement, not replace, live factory +coverage. Recorders retain bounded status/error metadata, never raw forms, +tokens, private keys, or client assertions. + +### Pinned confidential-client mTLS parity mapping + +The applicable active reference cases below are pinned, rather than inferred +from similarly named scenarios: + +- .NET `c8b90e9de5bc4974de9928835532faac5c88e2d2`: + [client-credentials/FIC cases][dotnet-cc] and [transport/factory cases][dotnet-transport]. +- Go `903eb9f1e54e361525025ea27040ac9644b64358`: + [PoP, ordinary certificate, cache, and regional cases][go-pop]. +- Go `30f4770ffcbd9c4c51d278cdab6dbda71a0f7337`: + [regional FIC Bearer addition][go-fic] and [Bearer transport cases][go-bom]. +- Go HTTP follow-up `45e70aa9590f231fc1ef739da78eb91804c11dd6`: + [same active Bearer transport assertions][go-http], with API/comment changes, + not additional active custom-factory E2E cases. + +`C` means `tests.test_e2e.CertificateScenariosTestCase`; `F` means +`tests.test_e2e.FicTwoLegTestCase`. Every row is applicable and live. + +| Active reference case(s) | Python method(s) | Required observation | +| --- | --- | --- | +| Go `TestCredential_X509_Output_Pop`; .NET `Credential_X509_Output_Pop_TestAsync` | `C.test_credential_x509_output_pop`, `C.test_credential_x509_custom_http_output_pop` | Nonempty Graph PoP, returned certificate and JWT SHA256 `cnf` binding, resource HTTP 200; default case adds all three same-token rejection markers and cached repeat. Custom case proves the derived factory pipeline. | +| Go `TestCredential_X509_Output_Bearer` | `C.test_credential_x509_output_bearer` | Ordinary westus3 Key Vault Bearer, no output binding, signed certificate assertion, cache repeat. The ordinary regional POST retains the central assertion audience by design. | +| 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. | +| Go `TestSendCertificateOverMtls_ClientCredential_Live`; .NET `Credential_X509_Output_Bearer_TestAsync` | `C.test_credential_x509_send_certificate_over_mtls_output_bearer` | Built-in real mTLS POST, westus3 Key Vault Bearer, signed client assertion, no PoP markers/output binding, cached repeat. | +| Go `TestTwoLegFICMtlsPoP_SNI`; .NET `Credential_Fic_Output_Pop_TestAsync` / `RunTwoLegS2sFicBothLegsPopAsync` | `F.test_credential_fic_output_pop`, `F.test_credential_fic_custom_http_output_pop` | Global exchange leg 1 to Graph leg 2, provider invoked, exact leg-1 assertion sent as `jwt-pop`, certificate continuity, final audience/type/`cnf`, resource HTTP 200. | +| Go `TestTwoLegFICMtlsBearer_SNI`; .NET `Credential_Fic_Output_Bearer_TestAsync` | `F.test_credential_fic_output_bearer`, `F.test_credential_fic_custom_http_output_bearer` | westus3 exchange to Storage, provider and actual `jwt-pop` wire shape, certificate continuity, Storage Bearer audience, no final binding or downstream mTLS resource call. | +| Go `TestSendCertificateOverMtls_OnBehalfOf_RequestCapture`; .NET `OboFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet` | `C.test_send_certificate_over_mtls_on_behalf_of_request_capture`, `C.test_custom_http_send_certificate_over_mtls_on_behalf_of_request_capture` | Live invalid-grant rejection over real mTLS; OBO grant and `requested_token_use`, signed `jwt-bearer`, mtlsauth URL, no PoP markers. | +| .NET `RefreshTokenFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet` | `C.test_send_certificate_over_mtls_refresh_token_request_capture`, `C.test_custom_http_send_certificate_over_mtls_refresh_token_request_capture` | Live invalid-grant rejection, actual refresh-token grant and signed assertion over the real selected mTLS pipeline. | +| Go `TestSendCertificateOverMtls_AuthCode_RequestCapture`; .NET `AuthCodeFlow_WithSendCertificateOverMtls_BothMtlsConditionsMetAsync` | `C.test_send_certificate_over_mtls_auth_code_request_capture`, `C.test_custom_http_send_certificate_over_mtls_auth_code_request_capture` | Live invalid-grant rejection, authorization-code/redirect fields, signed assertion, real mTLS endpoint, no PoP markers. | +| .NET `OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointAsync` | `C.test_without_send_certificate_over_mtls_on_behalf_of_regular_endpoint`, `C.test_custom_http_without_send_certificate_over_mtls_on_behalf_of_regular_endpoint` | Live rejection capture at the ordinary endpoint with signed assertion; custom control creates no certificate-scoped child. | +| .NET `ClientCredentials_WithSendCertificateOverMtls_BothMtlsConditionsMet` | `C.test_credential_x509_custom_http_send_certificate_over_mtls_output_bearer` | Successful westus3 Key Vault Bearer acquisition through `create_mtls_client(binding_context)` and its derived real POST pipeline, middleware and caller-owned cleanup intact. | + +The retained `C.test_subject_name_issuer_authentication` is an additional +required legacy SN/I lab cell. Supported PoP/FIC custom-pipeline combinations +exercise the combined features without adding SDK APIs. + +Excluded explicitly: IMDSv2; non-exportable/VBS/provider-backed keys; managed +identity; unrelated legacy skips; Go's disabled/opt-in successful OBO and +refresh acquisitions; .NET's disabled `OboFlow_AcquiresTokenAsync` and +`RefreshTokenFlow_AcquiresTokenAsync`; and .NET-only callback correlation-ID +options without a Python API equivalent. 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 +[dotnet-transport]: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/c8b90e9de5bc4974de9928835532faac5c88e2d2/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +[go-pop]: https://github.com/AzureAD/microsoft-authentication-library-for-go/blob/903eb9f1e54e361525025ea27040ac9644b64358/apps/tests/integration/mtls_pop_integration_test.go +[go-fic]: https://github.com/AzureAD/microsoft-authentication-library-for-go/blob/30f4770ffcbd9c4c51d278cdab6dbda71a0f7337/apps/tests/integration/mtls_pop_integration_test.go +[go-bom]: https://github.com/AzureAD/microsoft-authentication-library-for-go/blob/30f4770ffcbd9c4c51d278cdab6dbda71a0f7337/apps/tests/integration/bearer_mtls_integration_test.go +[go-http]: https://github.com/AzureAD/microsoft-authentication-library-for-go/blob/45e70aa9590f231fc1ef739da78eb91804c11dd6/apps/tests/integration/bearer_mtls_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..ff8e7e85 --- /dev/null +++ b/tests/mtls_e2e_helpers.py @@ -0,0 +1,197 @@ +"""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 +from sample.confidential_client_mtls_http_client_sample import PolicyHttpClient + + +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): + 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): + 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.""" + 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.""" + 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 + + +class RecordingPolicyHttpClient(PolicyHttpClient): + """Add safe recording to the existing sample's real certificate pipeline.""" + + def __init__(self, binding_context=None, expected_binding=None, + expected_assertion=None, **kwargs): + self.binding_context = binding_context + self.expected_binding = expected_binding or binding_context + self.expected_assertion = expected_assertion + self.token_requests = [] + super().__init__(binding_context=binding_context, **kwargs) + + def create_mtls_client(self, binding_context): + with self._lock: + self._check_open() + child = RecordingPolicyHttpClient( + binding_context=binding_context, + expected_binding=self.expected_binding, + expected_assertion=self.expected_assertion, **self._config) + self._children.append(child) + return child + + def post(self, url, **kwargs): + response = super().post(url, **kwargs) + summary = token_request_summary( + response, self.expected_binding, + self._session.get_adapter(response.request.url), + expected_assertion=self.expected_assertion) + if summary is not None: + self.token_requests.append(summary) + return response diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b106dcf5..f84f4d63 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -11,6 +11,7 @@ except: pass import base64 +from contextlib import nullcontext import logging import os import json @@ -19,9 +20,9 @@ from urllib.parse import urlparse, parse_qs import sys try: - from unittest.mock import patch, ANY, Mock, call, mock_open + from unittest.mock import patch, ANY except: - from mock import patch, ANY, Mock, call, mock_open + from mock import patch, ANY import requests @@ -34,6 +35,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, JWT_POP, RecordingPolicyHttpClient, + binding_from_credential, jwt_claims, record_token_requests, unrelated_binding, +) logger = logging.getLogger(__name__) @@ -344,39 +349,192 @@ def test_access_token_should_be_obtained_for_a_supported_scope(self): 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"}, + "api://AzureADTokenExchange/.default": {"api://AzureADTokenExchange"}, + "https://storage.azure.com/.default": {"https://storage.azure.com"}, + } @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 + 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): + 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): + 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.assertEqual( + binding.thumbprint_sha256, + self._cnf_x5t_s256(result["access_token"])) + self.assertFalse( + "PRIVATE KEY" in repr(binding), + "The binding context repr must not expose key material.") + 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): + 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): + 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): - """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. - """ with binding_certificate.create_requests_session() as session: resp = session.get( self._MTLS_GRAPH_RESOURCE, headers={"Authorization": "mtls_pop " + token}, - timeout=30) + timeout=30, allow_redirects=False) 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 auth scheme was wrong. Body[:500]=%s" % ( - resp.status_code, (resp.text or "")[:500])) + "The real Graph resource must accept the bound PoP token.") return resp + def _assert_graph_rejects(self, token, scheme, binding, marker): + 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(MtlsPopTestCase): # Historically this class was driven by tests/config.json for semi-automated runs. @@ -472,172 +630,210 @@ class CertificateScenariosTestCase(MtlsPopTestCase): __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). - # These live matrix cells exercise MSAL's default mTLS transport, not the - # custom HTTP factory. Custom-pipeline coverage remains local TLS coverage - # in test_mtls_http_client.py; these tests do not establish live factory parity. - # 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. - # 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" + 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 = RecordingPolicyHttpClient( + expected_binding=binding) if custom else None + if custom: + 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 - 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]) - - # ── 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) - - # ── Bearer-over-mTLS (send_certificate_over_mtls) ────────────────────────── - # Present the SN/I cert as the client TLS certificate ON the handshake to the - # mTLS endpoint, but receive a *plain Bearer* token (the cert authenticates - # the transport; the token is NOT cert-bound). Opt-in via the app-level - # client_credential flag send_certificate_over_mtls=True. Distinct from the - # two cells above: mtls_pop BINDS the token; the classic X509 Bearer cell - # signs a private_key_jwt to the REGULAR login.* endpoint (cert never on the - # TLS handshake). Ports MSAL .NET CertificateOptions.SendCertificateOverMtls. + 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 test_credential_x509_send_certificate_over_mtls_output_bearer(self): - # LIVE client-credentials acquisition, allow-listed (mirrors the MSAL - # .NET live CC cell: westus3 + the Key Vault resource). Proves the cert - # goes on the TLS handshake to mtlsauth.* yet ESTS returns a plain Bearer. - from tests.lab_config import get_client_certificate - scope = ["https://vault.azure.net/.default"] - self.app = msal.ConfidentialClientApplication( - self._SNI_ALLOWLISTED_CLIENT_ID, - authority=self._SNI_ALLOWLISTED_AUTHORITY, - azure_region="westus3", - client_credential=dict( - get_client_certificate(), send_certificate_over_mtls=True)) - result = self.app.acquire_token_for_client(scope) - self.assertIn( - "access_token", result, - "Bearer-over-mTLS request failed: %s" % result) - # A PLAIN Bearer token, NOT cert-bound (contrast the mtls_pop cell). - self.assertEqual("Bearer", result.get("token_type")) - self.assertNotIn("binding_certificate", result) - # The AT is an ordinary Bearer entry (no key_id/thumbprint fence), so a - # normal flagless acquire_token_for_client lookup returns it from cache - # -- the caller-visible proof of the feature's goal. (mtls_pop needed the - # special _assert_pop_cache_hit because its AT is key-id-fenced; Bearer- - # over-mTLS deliberately is not.) - self.assertCacheWorksForApp(result, scope) + self._acquire_x509( + self._KEY_VAULT_SCOPE, send=True, region="westus3") - def test_send_certificate_over_mtls_user_flows_capture_request(self): - # Like .NET/Go, cover these flows through request capture while their - # apps lack live mTLS enablement (AADSTS700027 / AADSTS392189). - # Replacing post below proves request construction, not a TLS handshake - # or successful live acquisition. - from tests.lab_config import get_client_certificate - cred = dict( - get_client_certificate(), send_certificate_over_mtls=True) - def _capture(sink): - def post(url, headers=None, data=None, *a, **k): - sink.append({"url": url, "data": dict(data or {})}) - return MinimalResponse(status_code=400, text=json.dumps({ - "error": "invalid_grant", - "error_description": "request captured; live acquisition " - "not required for this app"})) - return post - def _new_app(): - return msal.ConfidentialClientApplication( - self._SNI_ALLOWLISTED_CLIENT_ID, - authority=self._SNI_ALLOWLISTED_AUTHORITY, - client_credential=cred) - scope = ["https://graph.microsoft.com/.default"] - def _assert_mtls(req, grant_key, grant_value): + def test_credential_x509_custom_http_output_pop(self): + result = self._acquire_x509(self._MTLS_POP_SCOPE, pop=True, custom=True) + self._call_graph_with_mtls_pop_token( + result["access_token"], result["binding_certificate"]) + + def test_credential_x509_custom_http_send_certificate_over_mtls_output_bearer(self): + self._acquire_x509( + self._KEY_VAULT_SCOPE, send=True, region="westus3", custom=True) + + 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( - req["url"].startswith("https://mtlsauth.microsoft.com/"), - "Expected the mTLS endpoint, got %s" % req["url"]) - self.assertEqual(grant_value, req["data"].get(grant_key)) - self.assertTrue(req["data"].get("client_assertion")) - self.assertNotEqual("mtls_pop", req["data"].get("token_type")) - self.assertNotIn("key_id", req["data"]) - self.assertNotIn("req_cnf", req["data"]) - cap = [] - with patch("msal.mtls._MtlsHttpClient.post", side_effect=_capture(cap)): - _new_app().acquire_token_on_behalf_of("a_user_assertion", scope) - _assert_mtls(cap[0], "requested_token_use", "on_behalf_of") - cap = [] - with patch("msal.mtls._MtlsHttpClient.post", side_effect=_capture(cap)): - _new_app().acquire_token_by_refresh_token("a_refresh_token", scope) - _assert_mtls(cap[0], "grant_type", "refresh_token") - cap = [] - with patch("msal.mtls._MtlsHttpClient.post", side_effect=_capture(cap)): - _new_app().acquire_token_by_authorization_code("an_auth_code", scope) - _assert_mtls(cap[0], "grant_type", "authorization_code") + 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) + + def _capture_invalid_grant(self, grant, send=True, custom=False): + credential, binding = self._certificate() + credential = dict(credential, send_certificate_over_mtls=send) + http_client = RecordingPolicyHttpClient( + expected_binding=binding) if custom else None + if custom: + self.addCleanup(http_client.close) + app = self._sni_app(credential, http_client=http_client) + + def redeem(): + if grant == "urn:ietf:params:oauth:grant-type:jwt-bearer": + return app.acquire_token_on_behalf_of( + "fake-user-assertion-for-request-capture", self._KEY_VAULT_SCOPE) + if grant == "refresh_token": + return app.acquire_token_by_refresh_token( + "fake-refresh-token-for-request-capture", self._KEY_VAULT_SCOPE) + return app.acquire_token_by_authorization_code( + "fake-auth-code-for-request-capture", self._KEY_VAULT_SCOPE, + redirect_uri="https://localhost/redirect") + + if custom: + result = redeem() + records = (self._assert_factory_used(http_client, binding).token_requests + if send else http_client.token_requests) + if not send: + self.assertEqual([], http_client._children) + else: + with record_token_requests(binding) as records: + result = redeem() + request = self._assert_token_request( + records, self._KEY_VAULT_SCOPE, grant, + assertion_type=JWT_BEARER, regular_endpoint=not send) + self.assertNotIn("token_type", request["fields"]) + field = { + "urn:ietf:params:oauth:grant-type:jwt-bearer": "assertion", + "refresh_token": "refresh_token", "authorization_code": "code", + }[grant] + self.assertIn(field, request["fields"]) + if field == "assertion": + self.assertEqual("on_behalf_of", request["requested_token_use"]) + if field == "code": + self.assertIn("redirect_uri", request["fields"]) + diagnostic = "Expected an invalid-grant service rejection: {!r}".format(request) + self.assertEqual(400, request["status_code"], diagnostic) + self.assertEqual("invalid_grant", request["error"], diagnostic) + self.assertEqual("invalid_grant", result.get("error"), diagnostic) + self.assertFalse( + "access_token" in result, "Intentionally invalid input must not acquire a token.") + + def test_send_certificate_over_mtls_on_behalf_of_request_capture(self): + self._capture_invalid_grant("urn:ietf:params:oauth:grant-type:jwt-bearer") + + def test_send_certificate_over_mtls_refresh_token_request_capture(self): + self._capture_invalid_grant("refresh_token") + + def test_send_certificate_over_mtls_auth_code_request_capture(self): + self._capture_invalid_grant("authorization_code") + + def test_without_send_certificate_over_mtls_on_behalf_of_regular_endpoint(self): + self._capture_invalid_grant( + "urn:ietf:params:oauth:grant-type:jwt-bearer", send=False) + + def test_custom_http_send_certificate_over_mtls_on_behalf_of_request_capture(self): + self._capture_invalid_grant( + "urn:ietf:params:oauth:grant-type:jwt-bearer", custom=True) + + def test_custom_http_send_certificate_over_mtls_refresh_token_request_capture(self): + self._capture_invalid_grant("refresh_token", custom=True) + + def test_custom_http_send_certificate_over_mtls_auth_code_request_capture(self): + self._capture_invalid_grant("authorization_code", custom=True) + + def test_custom_http_without_send_certificate_over_mtls_on_behalf_of_regular_endpoint(self): + self._capture_invalid_grant( + "urn:ietf:params:oauth:grant-type:jwt-bearer", send=False, custom=True) class FicTwoLegTestCase(MtlsPopTestCase): @@ -646,223 +842,89 @@ class FicTwoLegTestCase(MtlsPopTestCase): _FIC_BEARER_SCOPE = ["https://storage.azure.com/.default"] def _acquire_fic_token( - self, scope, azure_region, mtls_proof_of_possession=False): - from tests.lab_config import get_client_certificate - cert = get_client_certificate() - leg1_app = msal.ConfidentialClientApplication( - CertificateScenariosTestCase._SNI_ALLOWLISTED_CLIENT_ID, - authority=CertificateScenariosTestCase._SNI_ALLOWLISTED_AUTHORITY, - client_credential=cert, - azure_region=azure_region) - leg1 = leg1_app.acquire_token_for_client( - self._FIC_EXCHANGE_SCOPE, mtls_proof_of_possession=True) - self.assertIn("access_token", leg1, "FIC leg 1 failed: %s" % leg1) - self.assertEqual("mtls_pop", leg1.get("token_type"), "Leg 1 must be mtls_pop") - leg1_thumbprint = leg1["binding_certificate"]["thumbprint_sha256"] - - # Leg 2: same cert binds the TLS handshake, leg-1 token carried as jwt-pop. - leg2_app = msal.ConfidentialClientApplication( - CertificateScenariosTestCase._SNI_ALLOWLISTED_CLIENT_ID, - authority=CertificateScenariosTestCase._SNI_ALLOWLISTED_AUTHORITY, - client_credential={ - "client_assertion": lambda: leg1["access_token"], - "mtls_binding_certificate": cert}, - azure_region=azure_region) - result = leg2_app.acquire_token_for_client( - scope, mtls_proof_of_possession=mtls_proof_of_possession) - return leg1_thumbprint, result + self, scope, azure_region, mtls_proof_of_possession=False, custom=False): + credential, binding = self._certificate() + leg1_http = RecordingPolicyHttpClient( + expected_binding=binding) if custom else None + if custom: + self.addCleanup(leg1_http.close) + leg1_app = self._sni_app( + credential, azure_region=azure_region, http_client=leg1_http) + leg1, leg1_requests = self._acquire_recorded( + leg1_app, self._FIC_EXCHANGE_SCOPE, binding, pop=True, + http_client=leg1_http) + self._assert_token_result( + leg1, self._FIC_EXCHANGE_SCOPE, "mtls_pop", binding) + first_request = self._assert_token_request( + leg1_requests, self._FIC_EXCHANGE_SCOPE, "client_credentials", + region=azure_region) + self.assertEqual("mtls_pop", first_request["token_type"]) + self.assertEqual(200, first_request["status_code"]) + + provider_calls = [] + + def assertion_provider(): + provider_calls.append(True) + return leg1["access_token"] + + leg2_http = RecordingPolicyHttpClient( + expected_binding=binding, + expected_assertion=leg1["access_token"]) if custom else None + if custom: + self.addCleanup(leg2_http.close) + leg2_app = self._sni_app( + azure_region=azure_region, http_client=leg2_http, + credential={ + "client_assertion": assertion_provider, + "mtls_binding_certificate": credential}) + result, leg2_requests = self._acquire_recorded( + leg2_app, scope, binding, pop=mtls_proof_of_possession, + http_client=leg2_http, expected_assertion=leg1["access_token"]) + self.assertTrue(provider_calls, "The assertion provider must actually be invoked.") + self._assert_token_result( + result, scope, "mtls_pop" if mtls_proof_of_possession else "Bearer", + binding) + self._assert_audience(result, scope) + second_request = self._assert_token_request( + leg2_requests, scope, "client_credentials", region=azure_region, + assertion_type=JWT_POP) + self.assertTrue( + second_request["assertion_matches"], + "The actual jwt-pop POST must carry the leg-1 assertion unchanged.") + self.assertEqual( + "mtls_pop" if mtls_proof_of_possession else None, + second_request["token_type"]) + self.assertEqual(200, second_request["status_code"]) + if custom: + children = [ + self._assert_factory_used(owner, binding) + for owner in (leg1_http, leg2_http)] + self.assertIsNot(children[0], children[1]) + for owner, child in zip((leg1_http, leg2_http), children): + owner.close() + self.assertTrue(owner._closed and child._closed) + return leg1["binding_certificate"].thumbprint_sha256, result def test_credential_fic_output_bearer(self): - _, leg2_bearer = self._acquire_fic_token( - self._FIC_BEARER_SCOPE, azure_region="westus3") - self.assertIn("access_token", leg2_bearer, "Leg 2 (Bearer) failed: %s" % leg2_bearer) - self.assertEqual("Bearer", leg2_bearer.get("token_type")) - self.assertNotIn("binding_certificate", leg2_bearer) + self._acquire_fic_token(self._FIC_BEARER_SCOPE, azure_region="westus3") def test_credential_fic_output_pop(self): - leg1_thumbprint, leg2_pop = self._acquire_fic_token( + _, result = self._acquire_fic_token( self._MTLS_POP_SCOPE, azure_region=False, mtls_proof_of_possession=True) - self.assertIn("access_token", leg2_pop, "Leg 2 (PoP) failed: %s" % leg2_pop) - self.assertEqual("mtls_pop", leg2_pop.get("token_type")) - # The final PoP token must be bound to the leg-1 certificate thumbprint - cnf = self._cnf_x5t_s256(leg2_pop["access_token"]) - if cnf: - self.assertEqual(leg1_thumbprint, cnf, - "Final token cnf must be bound to the leg-1 cert thumbprint") - pop_binding = leg2_pop.get("binding_certificate") - self.assertIsInstance(pop_binding, msal.MtlsBindingContext) - self.assertEqual( - leg1_thumbprint, pop_binding.get("thumbprint_sha256"), - "Final binding context must carry the leg-1 certificate") self._call_graph_with_mtls_pop_token( - leg2_pop["access_token"], pop_binding) + result["access_token"], result["binding_certificate"]) + def test_credential_fic_custom_http_output_bearer(self): + self._acquire_fic_token( + self._FIC_BEARER_SCOPE, azure_region="westus3", custom=True) -class FicScenarioTestCase(unittest.TestCase): - """Exercise the FIC integration test's failure gates without lab access.""" - - def setUp(self): - self.config = { - "LAB_APP_CLIENT_CERT_PFX_PATH": "configured.pfx", - } - self.leg1 = Mock() - self.leg1.acquire_token_for_client.return_value = { - "access_token": "leg1-token", "token_type": "mtls_pop", - "binding_certificate": {"thumbprint_sha256": "cert-thumbprint"}, - } - self.pop_binding = Mock(spec=msal.MtlsBindingContext) - self.pop_binding.get.return_value = "cert-thumbprint" - self.leg2 = Mock() - self.leg2.acquire_token_for_client.return_value = { - "access_token": "opaque-pop-token", - "token_type": "mtls_pop", - "binding_certificate": self.pop_binding, - } - self.scenario = FicTwoLegTestCase("test_credential_fic_output_pop") - self.cert = { - "private_key_pfx_path": "configured.pfx", "public_certificate": True} - self.factory = Mock(side_effect=[self.leg1, self.leg2]) - patchers = ( - patch.dict(os.environ, self.config, clear=True), - patch("tests.lab_config.open", mock_open(read_data=b"unit-test-pfx")), - patch( - "tests.lab_config.pkcs12.load_key_and_certificates", - return_value=(Mock(), Mock(), [])), - patch("msal.ConfidentialClientApplication", self.factory), - patch.object(self.scenario, "_call_graph_with_mtls_pop_token"), - ) - for patcher in patchers: - patcher.start() - self.addCleanup(patcher.stop) - - def test_missing_certificate_fails_before_acquisition(self): - for method in ( - self.scenario.test_credential_fic_output_pop, - self.scenario.test_credential_fic_output_bearer): - for value in ("", "$(LAB_APP_CLIENT_CERT_PFX_PATH)"): - with self.subTest(method=method.__name__, value=value): - with patch.dict( - os.environ, {"LAB_APP_CLIENT_CERT_PFX_PATH": value}): - with self.assertRaisesRegex( - EnvironmentError, "LAB_APP_CLIENT_CERT_PFX_PATH"): - method() - self.factory.assert_not_called() - - def test_missing_certificate_is_a_runner_error_even_on_tagged_releases(self): - with patch.object(E2eTestCase, "__unittest_skip__", True, create=True): - with patch.dict(os.environ, {}, clear=True): - for name in ( - "test_credential_fic_output_pop", - "test_credential_fic_output_bearer"): - with self.subTest(name=name): - result = unittest.TestResult() - FicTwoLegTestCase(name).run(result) - self.assertEqual(1, result.testsRun) - self.assertEqual([], result.skipped) - self.assertEqual(1, len(result.errors)) - self.assertIn( - "LAB_APP_CLIENT_CERT_PFX_PATH", result.errors[0][1]) - self.factory.assert_not_called() - - def test_configured_leg1_errors_fail_instead_of_skipping(self): - for method in ( - self.scenario.test_credential_fic_output_pop, - self.scenario.test_credential_fic_output_bearer): - for error in ("token_type_mismatch", "invalid_client", "invalid_grant"): - with self.subTest(method=method.__name__, error=error): - self.factory.side_effect = [self.leg1, self.leg2] - self.leg1.acquire_token_for_client.return_value = { - "error": error} - with self.assertRaisesRegex(AssertionError, error): - method() - self.leg2.acquire_token_for_client.assert_not_called() - - def test_shared_lab_defaults_and_opaque_token_resource_validation(self): - self.scenario.test_credential_fic_output_pop() - calls = self.factory.call_args_list - self.assertEqual(2, len(calls)) - for app_call in calls: - self.assertEqual( - "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e", app_call.args[0]) - self.assertEqual( - "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c", - app_call.kwargs["authority"]) - self.assertIs(False, app_call.kwargs["azure_region"]) - self.assertEqual(self.cert, calls[0].kwargs["client_credential"]) - credential = calls[1].kwargs["client_credential"] - self.assertIs( - calls[0].kwargs["client_credential"], - credential["mtls_binding_certificate"]) - self.assertEqual("leg1-token", credential["client_assertion"]()) - self.leg1.acquire_token_for_client.assert_called_once_with( - ["api://AzureADTokenExchange/.default"], mtls_proof_of_possession=True) - self.leg2.acquire_token_for_client.assert_called_once_with( - ["https://graph.microsoft.com/.default"], mtls_proof_of_possession=True) - self.assertEqual( - [ - call("opaque-pop-token", self.pop_binding), - ], - self.scenario._call_graph_with_mtls_pop_token.call_args_list) - - def test_bearer_uses_regional_shared_lab_without_downstream_mtls(self): - self.leg2.acquire_token_for_client.return_value = { - "access_token": "bearer-token", "token_type": "Bearer"} - self.scenario.test_credential_fic_output_bearer() - calls = self.factory.call_args_list - self.assertEqual(2, len(calls)) - for app_call in calls: - self.assertEqual( - "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e", app_call.args[0]) - self.assertEqual( - "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c", - app_call.kwargs["authority"]) - self.assertEqual("westus3", app_call.kwargs["azure_region"]) - credential = calls[1].kwargs["client_credential"] - self.assertIs( - calls[0].kwargs["client_credential"], - credential["mtls_binding_certificate"]) - self.assertEqual("leg1-token", credential["client_assertion"]()) - self.leg1.acquire_token_for_client.assert_called_once_with( - ["api://AzureADTokenExchange/.default"], mtls_proof_of_possession=True) - self.leg2.acquire_token_for_client.assert_called_once_with( - ["https://storage.azure.com/.default"], mtls_proof_of_possession=False) - self.scenario._call_graph_with_mtls_pop_token.assert_not_called() - - def test_leg2_errors_fail_instead_of_skipping(self): - for method in ( - self.scenario.test_credential_fic_output_pop, - self.scenario.test_credential_fic_output_bearer): - for error in ("invalid_client", "invalid_grant", "temporarily_unavailable"): - with self.subTest(method=method.__name__, error=error): - self.factory.side_effect = [self.leg1, self.leg2] - self.leg2.acquire_token_for_client.return_value = { - "error": error} - with self.assertRaisesRegex(AssertionError, error): - method() - self.scenario._call_graph_with_mtls_pop_token.assert_not_called() - - def test_bearer_contract_is_checked(self): - for response in ( - {"access_token": "token", "token_type": "mtls_pop"}, - {"access_token": "token", "token_type": "Bearer", - "binding_certificate": {}}): - with self.subTest(response=response): - self.factory.side_effect = [self.leg1, self.leg2] - self.leg2.acquire_token_for_client.return_value = response - with self.assertRaises(AssertionError): - self.scenario.test_credential_fic_output_bearer() - - def test_pop_requires_matching_final_binding_certificate(self): - for binding in (None, {}, Mock(spec=msal.MtlsBindingContext)): - with self.subTest(binding=binding): - self.factory.side_effect = [self.leg1, self.leg2] - self.leg2.acquire_token_for_client.return_value = { - "access_token": "opaque-pop-token", "token_type": "mtls_pop", - "binding_certificate": binding} - with self.assertRaises(AssertionError): - self.scenario.test_credential_fic_output_pop() - self.scenario._call_graph_with_mtls_pop_token.assert_not_called() + def test_credential_fic_custom_http_output_pop(self): + _, result = self._acquire_fic_token( + self._MTLS_POP_SCOPE, azure_region=False, + mtls_proof_of_possession=True, custom=True) + self._call_graph_with_mtls_pop_token( + result["access_token"], result["binding_certificate"]) class DeviceFlowTestCase(E2eTestCase): # A leaf class so it will be run only once diff --git a/tests/test_lab_config.py b/tests/test_lab_config.py index 5ebdb394..9b8a8a60 100644 --- a/tests/test_lab_config.py +++ b/tests/test_lab_config.py @@ -11,6 +11,41 @@ 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_credential_x509_send_certificate_over_mtls_output_bearer", + "test_credential_x509_custom_http_output_pop", + "test_credential_x509_custom_http_send_certificate_over_mtls_output_bearer", + "test_bearer_then_mtls_pop_cache_isolated", + "test_mtls_pop_then_bearer_cache_isolated", + "test_send_certificate_over_mtls_on_behalf_of_request_capture", + "test_send_certificate_over_mtls_refresh_token_request_capture", + "test_send_certificate_over_mtls_auth_code_request_capture", + "test_without_send_certificate_over_mtls_on_behalf_of_regular_endpoint", + "test_custom_http_send_certificate_over_mtls_on_behalf_of_request_capture", + "test_custom_http_send_certificate_over_mtls_refresh_token_request_capture", + "test_custom_http_send_certificate_over_mtls_auth_code_request_capture", + "test_custom_http_without_send_certificate_over_mtls_on_behalf_of_regular_endpoint", + }, + test_e2e.FicTwoLegTestCase: { + "test_credential_fic_output_pop", + "test_credential_fic_output_bearer", + "test_credential_fic_custom_http_output_pop", + "test_credential_fic_custom_http_output_bearer", + }, + } + + 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 +92,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(5, result.testsRun) - self.assertEqual(5, len(result.errors)) + self.assertEqual(21, result.testsRun) + self.assertEqual(21, len(result.errors)) self.assertEqual([], result.skipped) self.assertEqual([], result.failures) for _, traceback in result.errors: @@ -69,6 +105,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..1cf3fbd0 --- /dev/null +++ b/tests/test_mtls_e2e_helpers.py @@ -0,0 +1,435 @@ +"""Offline guard/recorder checks, not evidence of live E2E acquisitions.""" +import base64 +from contextlib import nullcontext +import json +import os +import time +import unittest +from unittest.mock import Mock, patch + +import jwt +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, JWT_POP, RecordingPolicyHttpClient, + 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 + + +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_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_user_capture_requires_invalid_grant_not_success_or_credential_failure(self): + form = self._signed_form() + form.update( + grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion="unit-user-assertion", requested_token_use="on_behalf_of") + del form["refresh_token"] + app = Mock() + for status, payload in ( + (200, {"access_token": "must-not-be-accepted"}), + (400, {"error": "invalid_client", "error_codes": [700027]}), + (503, {"error": "invalid_grant"})): + response = self._response(form=form, status=status, payload=payload) + summary = token_request_summary( + response, self.binding, requests.adapters.HTTPAdapter()) + summary["tls_adapter"] = "_MtlsHTTPAdapter" + app.acquire_token_on_behalf_of.return_value = payload + with patch("tests.lab_config.get_client_certificate", + return_value=_CREDENTIAL), patch( + "msal.ConfidentialClientApplication", return_value=app), patch( + "tests.test_e2e.record_token_requests", + return_value=nullcontext([summary])): + with self.assertRaises(AssertionError): + self.scenario.test_send_certificate_over_mtls_on_behalf_of_request_capture() + self.scenario.doCleanups() + + def test_jwt_pop_capture_requires_the_exact_leg_one_assertion(self): + form = dict( + self._signed_form(), client_assertion="unit-fic-assertion", + client_assertion_type=JWT_POP) + for expected, matches in (("unit-fic-assertion", True), ("different", False)): + summary = token_request_summary( + self._response(form=form), self.binding, + requests.adapters.HTTPAdapter(), expected_assertion=expected) + self.assertEqual(matches, summary["assertion_matches"]) + self.assertFalse("unit-fic-assertion" in repr(summary)) + + 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) + + def test_custom_recorder_retains_sample_pipeline_and_owner_cleanup(self): + def send(adapter, request, **kwargs): + response = self._response() + response.request = request + return response + + with RecordingPolicyHttpClient(expected_binding=self.binding) as owner: + child = owner.create_mtls_client(self.binding) + with patch.object(requests.adapters.HTTPAdapter, "send", + autospec=True, side_effect=send): + child.post(_ENDPOINT, data=self._signed_form()) + self.assertEqual(1, child.response_count) + self.assertEqual(1, len(child.token_requests)) + self.assertEqual( + "custom-policy", child.token_requests[0]["middleware"]) + self.assertEqual( + "_MtlsHTTPAdapter", child.token_requests[0]["tls_adapter"]) + self.assertEqual([], owner.token_requests) + self.assertFalse(owner._closed or child._closed) + self.assertTrue(owner._closed and child._closed) + with self.assertRaisesRegex(RuntimeError, "closed"): + child.post(_ENDPOINT) + + +class TestFicScenarioGuards(CertificateFixture): + def setUp(self): + self.leg1 = Mock() + self.leg2 = Mock() + self.leg1.acquire_token_for_client.return_value = { + "access_token": _jwt({ + "aud": "api://AzureADTokenExchange", + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}}), + "token_type": "mtls_pop", "binding_certificate": self.binding, + } + self.leg2.acquire_token_for_client.return_value = { + "access_token": _jwt({ + "aud": "https://graph.microsoft.com", + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}}), + "token_type": "mtls_pop", "binding_certificate": self.binding, + } + self.factory = Mock(side_effect=[self.leg1, self.leg2]) + self.scenario = test_e2e.FicTwoLegTestCase("test_credential_fic_output_pop") + self.invoke_provider = True + self.omit_final_record = False + self.final_record_changes = {} + patchers = ( + patch("tests.lab_config.get_client_certificate", return_value=_CREDENTIAL), + patch("msal.ConfidentialClientApplication", self.factory), + patch.object(self.scenario, "_acquire_recorded", + side_effect=self._recorded), + patch.object(self.scenario, "_call_graph_with_mtls_pop_token"), + ) + for patcher in patchers: + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(self.scenario.doCleanups) + + def _recorded(self, app, scope, binding, pop=False, + http_client=None, expected_assertion=None): + config = self.factory.call_args.kwargs + final = app is self.leg2 + assertion = None + if final and self.invoke_provider: + assertion = config["client_credential"]["client_assertion"]() + result = app.acquire_token_for_client(scope, mtls_proof_of_possession=pop) + region = config["azure_region"] + record = { + "url": _ENDPOINT.replace( + "://mtlsauth.", "://{}.mtlsauth.".format(region)) if region else _ENDPOINT, + "fields": frozenset( + ("scope", "client_id", "grant_type") + + (("client_assertion", "client_assertion_type") if final else ()) + + (("token_type",) if pop else ())), + "grant_type": "client_credentials", "scope": frozenset(scope), + "client_assertion_type": JWT_POP if final else None, + "client_assertion_present": final, "signed_client_assertion": False, + "assertion_matches": assertion == expected_assertion, + "token_type": "mtls_pop" if pop else None, + "status_code": 200, "tls_adapter": "_MtlsHTTPAdapter", + } + if final: + record.update(self.final_record_changes) + return result, [] if final and self.omit_final_record else [record] + + def test_global_pairing_provider_wire_type_and_resource_validation(self): + self.scenario.test_credential_fic_output_pop() + calls = self.factory.call_args_list + self.assertEqual(2, len(calls)) + for app_call in calls: + self.assertEqual(self.scenario._SNI_ALLOWLISTED_CLIENT_ID, app_call.args[0]) + self.assertEqual( + self.scenario._SNI_ALLOWLISTED_AUTHORITY, app_call.kwargs["authority"]) + self.assertIs(False, app_call.kwargs["azure_region"]) + self.assertIs( + calls[0].kwargs["client_credential"], + calls[1].kwargs["client_credential"]["mtls_binding_certificate"]) + self.leg1.acquire_token_for_client.assert_called_once_with( + ["api://AzureADTokenExchange/.default"], mtls_proof_of_possession=True) + self.leg2.acquire_token_for_client.assert_called_once_with( + ["https://graph.microsoft.com/.default"], mtls_proof_of_possession=True) + self.scenario._call_graph_with_mtls_pop_token.assert_called_once() + + def test_regional_bearer_has_storage_audience_and_no_resource_mtls(self): + self.leg2.acquire_token_for_client.return_value = { + "access_token": _jwt({"aud": "https://storage.azure.com"}), + "token_type": "Bearer"} + self.scenario.test_credential_fic_output_bearer() + self.assertTrue(all( + call.kwargs["azure_region"] == "westus3" + for call in self.factory.call_args_list)) + self.leg2.acquire_token_for_client.assert_called_once_with( + ["https://storage.azure.com/.default"], mtls_proof_of_possession=False) + self.scenario._call_graph_with_mtls_pop_token.assert_not_called() + + def test_leg_one_errors_fail_instead_of_skipping(self): + for method in ( + self.scenario.test_credential_fic_output_pop, + self.scenario.test_credential_fic_output_bearer): + for error in ("token_type_mismatch", "invalid_client", "invalid_grant"): + with self.subTest(error=error, method=method.__name__): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg1.acquire_token_for_client.return_value = {"error": error} + with self.assertRaisesRegex(AssertionError, error): + method() + self.leg2.acquire_token_for_client.assert_not_called() + + def test_leg_two_errors_fail_instead_of_skipping(self): + for method in ( + self.scenario.test_credential_fic_output_pop, + self.scenario.test_credential_fic_output_bearer): + for error in ("invalid_client", "invalid_grant", "temporarily_unavailable"): + with self.subTest(error=error, method=method.__name__): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg2.acquire_token_for_client.return_value = {"error": error} + with self.assertRaisesRegex(AssertionError, error): + method() + self.scenario._call_graph_with_mtls_pop_token.assert_not_called() + + def test_opaque_leg_one_cannot_bypass_binding_checks(self): + self.leg1.acquire_token_for_client.return_value["access_token"] = "opaque" + with self.assertRaises(AssertionError): + self.scenario.test_credential_fic_output_pop() + self.leg2.acquire_token_for_client.assert_not_called() + + def test_final_pop_requires_type_binding_cnf_and_audience(self): + original = self.leg2.acquire_token_for_client.return_value + for changes in ( + {"token_type": "Bearer"}, {"binding_certificate": None}, + {"binding_certificate": {}}, {"access_token": "opaque"}, + {"access_token": _jwt({ + "aud": "https://graph.microsoft.com", + "cnf": {"x5t#S256": "different-certificate"}})}, + {"access_token": _jwt({ + "aud": "https://wrong.example", + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}})}): + with self.subTest(fields=tuple(changes)): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg2.acquire_token_for_client.return_value = dict(original, **changes) + with self.assertRaises(AssertionError): + self.scenario.test_credential_fic_output_pop() + self.scenario._call_graph_with_mtls_pop_token.assert_not_called() + + def test_final_bearer_rejects_bound_or_wrong_audience_results(self): + for result in ( + {"access_token": "unit-token", "token_type": "mtls_pop"}, + {"access_token": _jwt({"aud": "https://storage.azure.com"}), + "token_type": "Bearer", "binding_certificate": {}}, + {"access_token": _jwt({"aud": "https://wrong.example"}), + "token_type": "Bearer"}): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg2.acquire_token_for_client.return_value = result + with self.assertRaises(AssertionError): + self.scenario.test_credential_fic_output_bearer() + self.scenario._call_graph_with_mtls_pop_token.assert_not_called() + + def test_fic_requires_provider_invocation_and_actual_wire_evidence(self): + for invoke, omit, changes in ( + (False, False, {}), (True, True, {}), + (True, False, {"client_assertion_type": JWT_BEARER}), + (True, False, {"assertion_matches": False})): + self.factory.side_effect = [self.leg1, self.leg2] + self.invoke_provider, self.omit_final_record = invoke, omit + self.final_record_changes = changes + with self.assertRaises(AssertionError): + self.scenario.test_credential_fic_output_pop() + self.scenario._call_graph_with_mtls_pop_token.assert_not_called() From fea35cf4e541377f5e6efbdd5016c965a8c68380 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 17:32:30 -0400 Subject: [PATCH 43/46] Keep mTLS sample diagnostics safe and pin fixture TLS Apply the explicitly approved sample logging safety fixes without emitting dynamic token-result fields. Add output guards and require TLS 1.2 or newer in the loopback fixture; retain the CodeQL gate unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...idential_client_mtls_http_client_sample.py | 4 +- sample/confidential_client_mtls_pop_sample.py | 2 +- tests/test_mtls_http_client.py | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/sample/confidential_client_mtls_http_client_sample.py b/sample/confidential_client_mtls_http_client_sample.py index 233fef29..d1a3dc62 100644 --- a/sample/confidential_client_mtls_http_client_sample.py +++ b/sample/confidential_client_mtls_http_client_sample.py @@ -107,7 +107,9 @@ def main(): os.environ["SCOPE"].split(), mtls_proof_of_possession=True) print("Acquired mTLS-PoP token:", result.get("token_type") == "mtls_pop") if "error" in result: - print("Error code:", result["error"]) + print( + "Token acquisition failed; inspect result['error'] / " + "result['error_description'] locally to diagnose.") if __name__ == "__main__": diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py index b4fb17d6..9f2950da 100644 --- a/sample/confidential_client_mtls_pop_sample.py +++ b/sample/confidential_client_mtls_pop_sample.py @@ -83,7 +83,7 @@ def _print_result_summary(result, header): print(header) if "access_token" in result: print(" acquired: yes") - print(" token_type: ", result.get("token_type")) + 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: diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py index 098812f8..b717c4aa 100644 --- a/tests/test_mtls_http_client.py +++ b/tests/test_mtls_http_client.py @@ -1,4 +1,5 @@ import ipaddress +import io import json import os import select @@ -9,6 +10,7 @@ import time import unittest from concurrent.futures import ThreadPoolExecutor +from contextlib import redirect_stdout from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from types import SimpleNamespace @@ -40,6 +42,40 @@ _ORDINARY_PATH = "/tenant/oauth2/v2.0/ordinary" +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()) + + def test_custom_http_sample_reports_failure_without_dynamic_error_values(self): + from sample.confidential_client_mtls_http_client_sample import main + + sensitive = "sensitive-unit-result-value" + app = Mock() + app.acquire_token_for_client.return_value = { + "token_type": sensitive, "error": sensitive, "error_description": sensitive} + environment = { + "PFX_PATH": _CREDENTIAL["private_key_pfx_path"], + "PFX_PASSPHRASE": _CREDENTIAL["passphrase"], + "CLIENT_ID": "sample-client", "AUTHORITY": _AUTHORITY, + "SCOPE": "https://vault.azure.net/.default", + } + with patch.dict(os.environ, environment), patch( + "msal.ConfidentialClientApplication", return_value=app), redirect_stdout( + io.StringIO()) as output: + main() + self.assertNotIn(sensitive, output.getvalue()) + self.assertIn("Token acquisition failed", output.getvalue()) + + @patch("msal.authority.tenant_discovery", return_value=_METADATA) class TestMtlsHttpClientFactory(unittest.TestCase): def setUp(self): @@ -675,6 +711,7 @@ def do_POST(self): self.wfile.write(payload) context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 context.load_cert_chain(server_path) context.load_verify_locations(cafile=self.ca_path) context.verify_mode = ssl.CERT_OPTIONAL From 7f98208e2387a72722ec66167b41cc73c8ef519c Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 17:32:30 -0400 Subject: [PATCH 44/46] Match live mTLS service representations and rejection codes Require the observed OBO invalid_request/AADSTS50027 rejection. Accept verified resource application-ID audience aliases and treat exchange-scope FIC assertions as opaque as the pinned references do, preserving strict resource JWT binding and real redemption assertions. Guard sensitive helper arguments from pytest tracebacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/README.md | 16 ++++++++- tests/mtls_e2e_helpers.py | 3 ++ tests/test_e2e.py | 30 +++++++++++------ tests/test_mtls_e2e_helpers.py | 61 +++++++++++++++++++++++++++++----- 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/tests/README.md b/tests/README.md index 39ac421a..31ee4d69 100644 --- a/tests/README.md +++ b/tests/README.md @@ -107,6 +107,20 @@ grant-specific fields, and absence of PoP request markers. They do not claim successful user-token issuance. A flag-off OBO control must use the ordinary endpoint. Default and custom-pipeline variants run independently; a flag-off custom client must not invoke the mTLS factory. +The malformed OBO assertion specifically requires HTTP 400, +`invalid_request`, and AADSTS50027; refresh/auth-code inputs require +HTTP 400 and `invalid_grant`, never a credential or transport failure. + +Graph and Key Vault resource PoP tokens require readable JWTs and matching +`cnf` bindings, without a best-effort parsing fallback. Resource audiences can +use their verified URI or well-known application-ID alias: Key Vault +`cfa8b339-82a2-471a-a3c9-0fc0be7a4093` and Storage +`e406a681-f3d4-42a8-90b6-c2b029497af1`. +FIC's exchange-scope leg-1 value is instead an opaque federated assertion, +as in the pinned references, not a resource JWT contract. Its requested scope, +PoP type, returned certificate, exact unchanged `jwt-pop` handoff, and real +leg-2 redemption are required; final resource binding/audience checks remain +strict. All 21 required live methods (17 certificate and four FIC) run without skips, including tagged releases. Missing configuration, credential failures, and @@ -156,7 +170,7 @@ from similarly named scenarios: | Go `TestSendCertificateOverMtls_ClientCredential_Live`; .NET `Credential_X509_Output_Bearer_TestAsync` | `C.test_credential_x509_send_certificate_over_mtls_output_bearer` | Built-in real mTLS POST, westus3 Key Vault Bearer, signed client assertion, no PoP markers/output binding, cached repeat. | | Go `TestTwoLegFICMtlsPoP_SNI`; .NET `Credential_Fic_Output_Pop_TestAsync` / `RunTwoLegS2sFicBothLegsPopAsync` | `F.test_credential_fic_output_pop`, `F.test_credential_fic_custom_http_output_pop` | Global exchange leg 1 to Graph leg 2, provider invoked, exact leg-1 assertion sent as `jwt-pop`, certificate continuity, final audience/type/`cnf`, resource HTTP 200. | | Go `TestTwoLegFICMtlsBearer_SNI`; .NET `Credential_Fic_Output_Bearer_TestAsync` | `F.test_credential_fic_output_bearer`, `F.test_credential_fic_custom_http_output_bearer` | westus3 exchange to Storage, provider and actual `jwt-pop` wire shape, certificate continuity, Storage Bearer audience, no final binding or downstream mTLS resource call. | -| Go `TestSendCertificateOverMtls_OnBehalfOf_RequestCapture`; .NET `OboFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet` | `C.test_send_certificate_over_mtls_on_behalf_of_request_capture`, `C.test_custom_http_send_certificate_over_mtls_on_behalf_of_request_capture` | Live invalid-grant rejection over real mTLS; OBO grant and `requested_token_use`, signed `jwt-bearer`, mtlsauth URL, no PoP markers. | +| Go `TestSendCertificateOverMtls_OnBehalfOf_RequestCapture`; .NET `OboFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet` | `C.test_send_certificate_over_mtls_on_behalf_of_request_capture`, `C.test_custom_http_send_certificate_over_mtls_on_behalf_of_request_capture` | Live HTTP 400 / `invalid_request` / AADSTS50027 over real mTLS; OBO grant and `requested_token_use`, signed `jwt-bearer`, mtlsauth URL, no PoP markers. | | .NET `RefreshTokenFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet` | `C.test_send_certificate_over_mtls_refresh_token_request_capture`, `C.test_custom_http_send_certificate_over_mtls_refresh_token_request_capture` | Live invalid-grant rejection, actual refresh-token grant and signed assertion over the real selected mTLS pipeline. | | Go `TestSendCertificateOverMtls_AuthCode_RequestCapture`; .NET `AuthCodeFlow_WithSendCertificateOverMtls_BothMtlsConditionsMetAsync` | `C.test_send_certificate_over_mtls_auth_code_request_capture`, `C.test_custom_http_send_certificate_over_mtls_auth_code_request_capture` | Live invalid-grant rejection, authorization-code/redirect fields, signed assertion, real mTLS endpoint, no PoP markers. | | .NET `OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointAsync` | `C.test_without_send_certificate_over_mtls_on_behalf_of_regular_endpoint`, `C.test_custom_http_without_send_certificate_over_mtls_on_behalf_of_regular_endpoint` | Live rejection capture at the ordinary endpoint with signed assertion; custom control creates no certificate-scoped child. | diff --git a/tests/mtls_e2e_helpers.py b/tests/mtls_e2e_helpers.py index ff8e7e85..57c0d0fe 100644 --- a/tests/mtls_e2e_helpers.py +++ b/tests/mtls_e2e_helpers.py @@ -31,6 +31,7 @@ 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: @@ -71,6 +72,7 @@ def unrelated_binding(): 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: @@ -95,6 +97,7 @@ def _verify_client_assertion(assertion, binding, endpoint, client_id): 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" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index f84f4d63..ec935779 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -357,13 +357,15 @@ class MtlsPopTestCase(E2eTestCase): _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"}, - "api://AzureADTokenExchange/.default": {"api://AzureADTokenExchange"}, - "https://storage.azure.com/.default": {"https://storage.azure.com"}, + "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"]: @@ -386,6 +388,7 @@ def _result_summary(result): error, codes) def _assert_audience(self, result, scope): + __tracebackhide__ = True audience = jwt_claims(result["access_token"]).get("aud") self.assertTrue( isinstance(audience, str) @@ -393,6 +396,7 @@ def _assert_audience(self, result, scope): "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")), @@ -407,13 +411,15 @@ def _assert_token_result(self, result, scope, token_type, expected_binding=None) self.assertEqual(expected_binding.x5c, binding.x5c) self.assertEqual( expected_binding.thumbprint_sha256, binding.thumbprint_sha256) - self.assertEqual( - binding.thumbprint_sha256, - self._cnf_x5t_s256(result["access_token"])) self.assertFalse( "PRIVATE KEY" in repr(binding), "The binding context repr must not expose key material.") - self._assert_audience(result, scope) + # 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, @@ -801,10 +807,14 @@ def redeem(): self.assertEqual("on_behalf_of", request["requested_token_use"]) if field == "code": self.assertIn("redirect_uri", request["fields"]) - diagnostic = "Expected an invalid-grant service rejection: {!r}".format(request) + expected_error = "invalid_request" if field == "assertion" else "invalid_grant" + diagnostic = "Expected the malformed-grant service rejection: {!r}".format(request) self.assertEqual(400, request["status_code"], diagnostic) - self.assertEqual("invalid_grant", request["error"], diagnostic) - self.assertEqual("invalid_grant", result.get("error"), diagnostic) + self.assertEqual(expected_error, request["error"], diagnostic) + self.assertEqual(expected_error, result.get("error"), diagnostic) + if field == "assertion": + self.assertEqual((50027,), request["error_codes"], diagnostic) + self.assertEqual([50027], result.get("error_codes"), diagnostic) self.assertFalse( "access_token" in result, "Intentionally invalid input must not acquire a token.") diff --git a/tests/test_mtls_e2e_helpers.py b/tests/test_mtls_e2e_helpers.py index 1cf3fbd0..5a174868 100644 --- a/tests/test_mtls_e2e_helpers.py +++ b/tests/test_mtls_e2e_helpers.py @@ -8,6 +8,7 @@ from unittest.mock import Mock, patch import jwt +import pytest import requests from msal.application import _load_mtls_cert_material @@ -101,6 +102,40 @@ def test_pop_checks_the_jwt_and_returned_certificate_binding(self): 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) + rendered = str(failure.getrepr( + style="long", showlocals=False, funcargs=True)) + self.assertNotIn(token, rendered) + self.assertNotIn("PRIVATE KEY", rendered) + def test_unrelated_certificate_is_usable_and_not_the_fixture_certificate(self): binding = unrelated_binding() self.assertNotEqual( @@ -184,17 +219,20 @@ def test_regional_ordinary_assertion_uses_the_central_authority_audience(self): self.assertTrue(summary["signed_client_assertion"]) self.assertEqual(regional, summary["url"]) - def test_user_capture_requires_invalid_grant_not_success_or_credential_failure(self): + def test_user_capture_requires_exact_rejection_not_success_or_credential_failure(self): form = self._signed_form() form.update( grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer", assertion="unit-user-assertion", requested_token_use="on_behalf_of") del form["refresh_token"] app = Mock() - for status, payload in ( - (200, {"access_token": "must-not-be-accepted"}), - (400, {"error": "invalid_client", "error_codes": [700027]}), - (503, {"error": "invalid_grant"})): + for status, payload, accepted in ( + (200, {"access_token": "must-not-be-accepted"}, False), + (400, {"error": "invalid_client", "error_codes": [700027]}, False), + (503, {"error": "invalid_grant"}, False), + (400, {"error": "invalid_grant", "error_codes": [50027]}, False), + (400, {"error": "invalid_request", "error_codes": [700027]}, False), + (400, {"error": "invalid_request", "error_codes": [50027]}, True)): response = self._response(form=form, status=status, payload=payload) summary = token_request_summary( response, self.binding, requests.adapters.HTTPAdapter()) @@ -205,7 +243,7 @@ def test_user_capture_requires_invalid_grant_not_success_or_credential_failure(s "msal.ConfidentialClientApplication", return_value=app), patch( "tests.test_e2e.record_token_requests", return_value=nullcontext([summary])): - with self.assertRaises(AssertionError): + with (nullcontext() if accepted else self.assertRaises(AssertionError)): self.scenario.test_send_certificate_over_mtls_on_behalf_of_request_capture() self.scenario.doCleanups() @@ -385,10 +423,15 @@ def test_leg_two_errors_fail_instead_of_skipping(self): method() self.scenario._call_graph_with_mtls_pop_token.assert_not_called() - def test_opaque_leg_one_cannot_bypass_binding_checks(self): + def test_federated_assertion_is_opaque_but_still_requires_certificate_binding(self): self.leg1.acquire_token_for_client.return_value["access_token"] = "opaque" - with self.assertRaises(AssertionError): - self.scenario.test_credential_fic_output_pop() + self.scenario.test_credential_fic_output_pop() + self.leg2.acquire_token_for_client.reset_mock() + for binding in (None, {}, unrelated_binding()): + self.factory.side_effect = [self.leg1, self.leg2] + self.leg1.acquire_token_for_client.return_value["binding_certificate"] = binding + with self.assertRaises(AssertionError): + self.scenario.test_credential_fic_output_pop() self.leg2.acquire_token_for_client.assert_not_called() def test_final_pop_requires_type_binding_cnf_and_audience(self): From 1208c07c93367de613810c438d7a3f6e66aa6339 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 17:58:29 -0400 Subject: [PATCH 45/46] Hide sensitive E2E helper arguments from failure tracebacks Cover cache, FIC, Graph, recorder POST and recorder setup failures with actual pytest argument rendering, including truncated-token checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/README.md | 3 + tests/mtls_e2e_helpers.py | 3 + tests/test_e2e.py | 4 ++ tests/test_mtls_e2e_helpers.py | 107 +++++++++++++++++++++++++++++++-- 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/tests/README.md b/tests/README.md index 31ee4d69..3b9ffdd1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -137,6 +137,9 @@ python -m pytest -q tests\test_lab_config.py tests\test_mtls_e2e_helpers.py test These checks use local fixtures or synthetic responses to verify strict binding, safe recording, failure 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 cache, FIC recording/factory, Graph, and recorder POST/setup helpers for +tokens, client assertions, refresh credentials, and truncated token fragments. The existing custom-HTTP loopback TLS tests remain in `tests\test_mtls_http_client.py`; they complement, not replace, live factory coverage. Recorders retain bounded status/error metadata, never raw forms, diff --git a/tests/mtls_e2e_helpers.py b/tests/mtls_e2e_helpers.py index 57c0d0fe..bb6e0e4e 100644 --- a/tests/mtls_e2e_helpers.py +++ b/tests/mtls_e2e_helpers.py @@ -151,6 +151,7 @@ def token_request_summary( @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 @@ -173,6 +174,7 @@ class RecordingPolicyHttpClient(PolicyHttpClient): def __init__(self, binding_context=None, expected_binding=None, expected_assertion=None, **kwargs): + __tracebackhide__ = True self.binding_context = binding_context self.expected_binding = expected_binding or binding_context self.expected_assertion = expected_assertion @@ -190,6 +192,7 @@ def create_mtls_client(self, binding_context): return child def post(self, url, **kwargs): + __tracebackhide__ = True response = super().post(url, **kwargs) summary = token_request_summary( response, self.expected_binding, diff --git a/tests/test_e2e.py b/tests/test_e2e.py index ec935779..6450d12a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -449,6 +449,7 @@ def _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( @@ -503,6 +504,7 @@ def _assert_factory_used(self, http_client, expected_binding): 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) @@ -516,6 +518,7 @@ def _acquire_recorded( 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, @@ -527,6 +530,7 @@ def _call_graph_with_mtls_pop_token(self, token, binding_certificate): 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( diff --git a/tests/test_mtls_e2e_helpers.py b/tests/test_mtls_e2e_helpers.py index 5a174868..39a577c1 100644 --- a/tests/test_mtls_e2e_helpers.py +++ b/tests/test_mtls_e2e_helpers.py @@ -78,6 +78,21 @@ def setUp(self): self.scenario = test_e2e.CertificateScenariosTestCase( "test_credential_x509_output_pop") + 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.") + def test_cnf_check_rejects_opaque_malformed_and_unbound_tokens(self): for token in ( "opaque", "a.invalid-json.c", _jwt([]), _jwt({}), @@ -131,10 +146,94 @@ 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) - rendered = str(failure.getrepr( - style="long", showlocals=False, funcargs=True)) - self.assertNotIn(token, rendered) - self.assertNotIn("PRIVATE KEY", rendered) + 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_fic_capture_failure_tracebacks_hide_the_expected_assertion(self): + assertion = _jwt({"aud": "api://AzureADTokenExchange"}) + for custom in (False, True): + with self.subTest(custom=custom): + app = Mock() + client = RecordingPolicyHttpClient( + expected_binding=self.binding, + expected_assertion=assertion) if custom else None + if custom: + self.addCleanup(client.close) + app.acquire_token_for_client.return_value = {"access_token": assertion} + else: + app.acquire_token_for_client.side_effect = AssertionError( + "Unit acquisition failed before receiving a response.") + with pytest.raises(AssertionError) as failure: + self.scenario._acquire_recorded( + app, self.scenario._MTLS_POP_SCOPE, self.binding, + pop=True, http_client=client, expected_assertion=assertion) + self._assert_private_traceback(failure, assertion) + + 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_custom_recorder_failure_traceback_hides_posted_credentials(self): + form = self._signed_form() + + def send(adapter, request, **kwargs): + response = self._response(payload=[]) + response.request = request + return response + + with RecordingPolicyHttpClient(expected_binding=self.binding) as owner: + child = owner.create_mtls_client(self.binding) + with patch.object(requests.adapters.HTTPAdapter, "send", + autospec=True, side_effect=send): + with pytest.raises(AssertionError) as failure: + child.post(_ENDPOINT, data=form) + self._assert_private_traceback( + failure, form["client_assertion"], form["refresh_token"]) + + def test_recorder_setup_failure_traceback_hides_the_expected_assertion(self): + assertion = _jwt({"aud": "api://AzureADTokenExchange"}) + with pytest.raises(TypeError) as failure: + RecordingPolicyHttpClient( + expected_assertion=assertion, unexpected_option=True) + self._assert_private_traceback(failure, assertion) def test_unrelated_certificate_is_usable_and_not_the_fixture_certificate(self): binding = unrelated_binding() From 0cd290d14ed194a493bf2f3653cf795880bcdc10 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 23 Oct 2026 18:24:53 -0400 Subject: [PATCH 46/46] Index access-token identities for bounded cache writes Reconcile storage locations once per loaded cache or replaced bucket, maintain the transient index on writes and removals, and preserve legacy renewal and serialized formats. Add deterministic bucket-visit and identity-operation regressions, retain compatibility and persistence coverage, and exercise reload and shared SSH-slot replacement. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- msal/token_cache.py | 68 ++++++++++++---- tests/README.md | 5 ++ tests/test_token_cache.py | 165 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 16 deletions(-) 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 3b9ffdd1..bba96da9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -134,6 +134,11 @@ Offline recorder/guard tests are separate from live evidence: python -m pytest -q tests\test_lab_config.py tests\test_mtls_e2e_helpers.py tests\test_mtls_http_client.py ``` +Cache compatibility and write-scaling regressions run with +`python -m pytest -q tests\test_token_cache.py`. They 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. + These checks use local fixtures or synthetic responses to verify strict binding, safe recording, failure gates, the exact required-method inventory, and real unittest-runner failure rather than skip when credentials are missing. 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