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/docs/index.rst b/docs/index.rst index 1c5d79bc..1b9689bb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -160,6 +160,229 @@ 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" + 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: + +* The ``authority`` must be **tenanted** (not ``/common`` or ``/organizations``). +* 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. + The built-in mTLS token transport never follows redirects; HTTP 3xx responses + raise ``requests.exceptions.HTTPError`` and are not cached as tokens. +* MSAL's built-in 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**: 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). +* 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. +* ``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. + 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. +* For a Federated Identity Credential (FIC) exchange, configure the leg-2 client + 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 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. + 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``. +* To request Bearer-over-mTLS instead, set + ``send_certificate_over_mtls=True`` in the certificate credential and omit + ``mtls_proof_of_possession``. The resulting token uses the ``Bearer`` scheme, + is not certificate-bound, and does not include ``binding_certificate``. + +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: + +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. + +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 +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 ---------- These are exceptions that MSAL Python may raise. 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 1aa14c28..1ad10a91 100644 --- a/msal/application.py +++ b/msal/application.py @@ -5,6 +5,7 @@ import platform 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 @@ -26,6 +27,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 @@ -122,19 +124,73 @@ 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): +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 = _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) + 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 def _load_private_key_from_pem_str(private_key_pem_str, passphrase_bytes): @@ -147,6 +203,89 @@ 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 + from cryptography.hazmat.backends import default_backend + if isinstance(private_key, (str, 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( + 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 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 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. + """ + 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, + include_chain=True) + 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 + 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 + 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] = "********" @@ -247,6 +386,75 @@ def obtain_token_by_username_password(self, username, password, **kwargs): username, password, headers=headers, **kwargs) +class _MtlsBearerClient(_ClientWithCcsRoutingInfo): + def _obtain_token(self, *args, **kwargs): + for option in ("post", "cert"): + if option in kwargs: + raise ValueError( + "send_certificate_over_mtls=True does not support a {} " + "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) + + +def _validate_mtls_response(response, expected_token_type="mtls_pop"): + if (response and "access_token" in response + 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 {!r}.".format(response.get("token_type"), expected_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). + + 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 + expected_token_type = (data.get("token_type") or "Bearer").lower() + if key_id: + outer = kwargs.pop("on_obtaining_tokens", None) + + 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 + # 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) + + 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_response(response, expected_token_type) + + def _msal_extension_check(): # Can't run this in module or class level otherwise you'll get circular import error try: @@ -420,6 +628,46 @@ 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) + + 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. + 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": get_leg1_assertion, + "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 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. + .. admonition:: Supporting reading client certificates from PFX files This usage will automatically use SHA-256 thumbprint of the certificate. @@ -443,6 +691,41 @@ 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_auth_code_flow`, + :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`` 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. + :type client_credential: Union[dict, str, None] :param dict client_claims: @@ -501,6 +784,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 @@ -700,6 +1000,11 @@ def get_client_assertion(): 'Invalid exclude_scopes={}. You can not opt out "openid" scope'.format( repr(exclude_scopes))) + # 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 if http_client: self.http_client = http_client else: @@ -767,8 +1072,74 @@ 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 + # 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 + # 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_clients = {} # Keyed by authority and resolved region + self._mtls_http_client = None # Shared by this certificate's endpoints + self._mtls_lock = Lock() + 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 = 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 + self.client, self._regional_client = self._build_client( client_credential, self.authority) + + # 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 + 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 " + "'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)): @@ -869,12 +1240,23 @@ 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_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 @@ -896,6 +1278,12 @@ 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))) + self._region_to_use = region_to_use + self._region_resolved = True + 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 ( @@ -914,6 +1302,229 @@ 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 requires this confidential client " + "to be configured with a certificate credential (a " + "'private_key_pfx_path', or a 'private_key' plus " + "'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( + 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_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``. + + 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. For a FIC leg-2 + credential (``client_assertion`` + ``mtls_binding_certificate``) the + assertion is sent with ``client_assertion_type = ...:jwt-pop``. + """ + factory = self._get_mtls_http_client_factory() + region = self._compute_region_to_use() + 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, + } + 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, 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_assertion = None + client_assertion_type = None + with self._mtls_lock: + cached = self._mtls_regional_client if region else self._mtls_client + if cached is not None: + return cached + http_client = ThrottledHttpClient( + 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, + ) + 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, + 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: + self._mtls_regional_client = client + else: + self._mtls_client = 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 _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. + """ + factory = self._get_mtls_http_client_factory() + region = self._compute_region_to_use() + cache_key = ( + central_authority.instance, central_authority.token_endpoint, region) + with self._mtls_lock: + 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) + 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, + } + 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 + # 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 = _MtlsBearerClient( + 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. + client = self._mtls_bearer_clients.setdefault(cache_key, client) + return client + + def _client_for_confidential_request(self): + """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: + 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 @@ -940,7 +1551,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: @@ -963,7 +1574,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( @@ -1279,10 +1890,11 @@ 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) - 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, @@ -1357,6 +1969,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 @@ -1372,7 +1985,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(), @@ -1513,6 +2126,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. @@ -1580,6 +2194,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 if forwarded_client_claims is not None: @@ -1649,6 +2264,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 if forwarded_client_claims is not None: @@ -1741,6 +2357,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 @@ -1910,9 +2527,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) @@ -1996,11 +2616,12 @@ 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, 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(), @@ -2050,6 +2671,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: @@ -2090,7 +2712,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, @@ -2132,9 +2754,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, @@ -2609,7 +3232,10 @@ 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, forwarded_client_claims=None, **kwargs): + def acquire_token_for_client( + self, scopes, claims_challenge=None, fmi_path=None, + forwarded_client_claims=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, @@ -2633,6 +3259,54 @@ 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``) - 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 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. + FIC exchanges only support final ``Bearer`` or ``mtls_pop`` tokens; + 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`` + and the rejected response is not cached. + + 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:: + + result = cca.acquire_token_for_client( + ["https://graph.microsoft.com/.default"], + mtls_proof_of_possession=True, + ) + :param str forwarded_client_claims: Optional. A JSON string of *client-originated* claims to include in the token request. Unlike ``claims_challenge`` (server-issued, which @@ -2654,6 +3328,19 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, fmi_path=None, 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 (requested_token_type or "").lower() == "mtls_pop"): + 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( @@ -2667,8 +3354,54 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, fmi_path=None, # and stripped from the wire body by the oauth2 layer. kwargs["data"] = kwargs.get("data", {}) _stash_client_claims(forwarded_client_claims, kwargs["data"]) - return _clean_up(self._acquire_token_silent_with_error( + if mtls_proof_of_possession or self._mtls_is_fic_leg2: + mtls._validate_mtls_request(kwargs) + self._get_mtls_http_client_factory() + # 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.authority.tenant.lower() in ("common", "organizations"): + raise ValueError( + "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() + data = dict(kwargs.get("data") or {}) + 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): + # 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, 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_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. + 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( self, @@ -2682,10 +3415,23 @@ 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 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) + 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", {}) claims = _merge_claims_challenge_and_capabilities( self._client_capabilities, claims_challenge) @@ -2714,7 +3460,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 @@ -2756,12 +3502,13 @@ 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) _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: @@ -2828,6 +3575,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") @@ -2848,7 +3596,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 new file mode 100644 index 00000000..3a1ea06e --- /dev/null +++ b/msal/mtls.py @@ -0,0 +1,490 @@ +"""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 +import os +import threading +import weakref +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 + from urlparse import urlparse, urlunparse + + +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 +# 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", + ]) + +# ───────────────────────────────────────────────────────────────────────────── +# SOVEREIGN GUARDRAIL - single override point for mTLS cloud availability. +# +# 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.", + } + + +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. + """ + 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 (including legacy login.windows.net) + # all normalize to the single global mTLS host. + base = _PUBLIC_MTLS_HOST + elif instance == "login.microsoftonline.us": + base = "mtlsauth.microsoftonline.us" + else: + raise ValueError( + "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 + + +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 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 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. + """ + + __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_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. + """ + _require_mtls_dependencies() + 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. + + The caller owns the returned session and should close it, preferably by + using it as a context manager. + """ + import requests + _require_mtls_dependencies() + effective_verify = _normalize_verify(verify) + session = requests.Session() + session.verify = effective_verify + if proxies: + session.proxies.update(proxies) + session.mount("https://", self.create_requests_adapter()) + 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. + + 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. + """ + 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 + self._key_pem = key_pem + # 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 = _normalize_verify(verify) + self._proxies = proxies + self._timeout = timeout + self._session = None + self._sessions = {} + self._session_lock = threading.Lock() + + 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 = _normalize_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, 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): + 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 "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. + 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: + for session in self._sessions.values(): + session.close() + + +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(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 + + +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 + + class _MtlsHTTPAdapter(HTTPAdapter): + def __init__(self, ssl_context): + self._ssl_context = ssl_context + self._adapters = {} + self._adapter_lock = threading.Lock() + 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.") + 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( + ssl_context_factory(verify), **adapter_kwargs) + 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) + + 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) + + +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 + ``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 + verify = _normalize_verify(verify) + 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 + 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 os + import tempfile + 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 + # 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/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py index 5b38519c..2a49c8ba 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/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 128bf7ca..d3f8609e 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -13,6 +13,53 @@ 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): + """Normalize a key_id to text for cache storage and lookup.""" + if isinstance(key_id, bytes): + 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, token_type=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 (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 # cache key hash. Everything else in data IS included, because those are extra @@ -211,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. @@ -228,22 +277,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, + key_id=None, # Separates mTLS certificate identities **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 "", - #key_id or "", # So ATs of different key_id can coexist - ] + ([ext_cache_key] if ext_cache_key else []) - ).lower(), + _make_access_token_key( + home_account_id, environment, client_id, realm, target, + 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: @@ -272,10 +312,12 @@ 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, + 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, @@ -283,6 +325,8 @@ def _get_access_token( realm=realm, target=" ".join(target), ext_cache_key=ext_cache_key, + key_id=key_id, + token_type=token_type, ), default=default) @@ -304,22 +348,32 @@ 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) - 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. + + 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) @@ -329,14 +383,16 @@ 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")) - if preferred_result and self._is_matching( - preferred_result, query, - # Needs no target_set here because it is satisfied by dict key + ext_cache_key=query.get("ext_cache_key"), + 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 not preferred_result.get("ext_cache_key") + or query.get("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) @@ -345,7 +401,13 @@ def search(self, credential_type, target=None, query=None, *, now=None): # O(n) # 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 @@ -357,9 +419,13 @@ 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. + # 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 "ext_cache_key" in entry - and "ext_cache_key" not in (query or {}) + and not for_removal + and entry.get("ext_cache_key") + and not (query or {}).get("ext_cache_key") ): continue yield entry @@ -541,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. @@ -551,13 +643,47 @@ 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: + is_at = credential_type == self.CredentialType.ACCESS_TOKEN + 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 - 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 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. + 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: + self._remove_access_token_entry(bucket, key) + else: + # Persisted caches may still use an older AT key format. + for stored_bucket, stored_key in tuple( + index.get(_access_token_identity(old_entry), ())): + if self._cache[stored_bucket].get(stored_key) == old_entry: + self._remove_access_token_entry(stored_bucket, stored_key) else: # Remove old_entry - self._cache.setdefault(credential_type, {}).pop(key, None) + 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 @@ -629,10 +755,37 @@ 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 + 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)} + # 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) def serialize(self): # type: () -> str @@ -640,4 +793,3 @@ def serialize(self): with self._lock: self.has_state_changed = False return json.dumps(self._cache, indent=4) - 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..d1a3dc62 --- /dev/null +++ b/sample/confidential_client_mtls_http_client_sample.py @@ -0,0 +1,116 @@ +"""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( + "Token acquisition failed; inspect result['error'] / " + "result['error_description'] locally to diagnose.") + + +if __name__ == "__main__": + main() diff --git a/sample/confidential_client_mtls_pop_sample.py b/sample/confidential_client_mtls_pop_sample.py new file mode 100644 index 00000000..9f2950da --- /dev/null +++ b/sample/confidential_client_mtls_pop_sample.py @@ -0,0 +1,228 @@ +""" +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"``). + +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. +* 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 + RESOURCE_URL optional mTLS resource URL to call with the acquired token + AZURE_REGION (optional) e.g. westus3; omission honors MSAL_FORCE_REGION + +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 +""" + +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``. + + The sensitive part of ``result`` is the ``access_token`` (a PoP/bearer + 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 + 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. + """ + 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 _call_resource(result): + resource_url = os.getenv("RESOURCE_URL") + if not resource_url or "access_token" not in result: + return + binding = result["binding_certificate"] + with binding.create_requests_session() as session: + response = session.get( + resource_url, + headers={ + "Authorization": + "{} {}".format( + result["token_type"], + result["access_token"], + ), + }, + timeout=30, + ) + response.raise_for_status() + print(" resource call status: ", response.status_code) + + +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(), + # The default transport handles mTLS automatically. For a custom + # pipeline, see confidential_client_mtls_http_client_sample.py. + azure_region=os.getenv("AZURE_REGION"), # None honors MSAL_FORCE_REGION + ) + + 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:") + _call_resource(result) + return result + + +def bearer_over_mtls(): + """Acquire an ordinary Bearer token while authenticating over mTLS.""" + credential = _build_cert_credential() + credential["send_certificate_over_mtls"] = True + app = msal.ConfidentialClientApplication( + os.environ["CLIENT_ID"], + authority=os.environ["AUTHORITY"], + client_credential=credential, + azure_region=os.getenv("AZURE_REGION"), + ) + result = app.acquire_token_for_client(os.environ["SCOPE"].split()) + _print_result_summary(result, "SN/I -> Bearer-over-mTLS result:") + 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. Requesting PoP produces ``mtls_pop`` and a + binding context; omitting PoP produces Bearer without one. + """ + 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") + + 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.environ["LEG2_CLIENT_ID"], + authority=os.environ["LEG2_AUTHORITY"], + client_credential={ + "client_assertion": get_leg1_assertion, + "mtls_binding_certificate": cert, + }, + azure_region=os.getenv("AZURE_REGION"), + ) + + # 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:") + resource_url = os.getenv("RESOURCE_URL") + 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, + headers={ + "Authorization": + "{} {}".format( + leg2["token_type"], + leg2["access_token"], + ), + }, + timeout=30, + ) + response.raise_for_status() + print(" resource call status: ", response.status_code) + return leg2 + + +if __name__ == "__main__": + print("=== Vanilla SN/I -> mTLS PoP ===") + vanilla_sni_mtls_pop() + print("\n=== SN/I -> Bearer-over-mTLS ===") + bearer_over_mtls() + + if os.getenv("RUN_FIC_SAMPLE"): + print("\n=== FIC two-leg over mTLS PoP ===") + fic_two_leg_over_mtls_pop() diff --git a/tests/README.md b/tests/README.md index 0b044a60..bba96da9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,6 +33,38 @@ 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. + +### 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. + +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 +``` ## 4) Run unit/integration tests @@ -45,9 +77,131 @@ 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 +``` + +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. +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 +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 ``` +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. +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, +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 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. | +| .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 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/mtls_e2e_helpers.py b/tests/mtls_e2e_helpers.py new file mode 100644 index 00000000..bb6e0e4e --- /dev/null +++ b/tests/mtls_e2e_helpers.py @@ -0,0 +1,203 @@ +"""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): + __tracebackhide__ = True + if not isinstance(token, str) or len(token.split(".")) != 3: + raise AssertionError("Expected a compact JWT with three segments.") + try: + claims = json.loads(decode_part(token.split(".")[1])) + except (ValueError, TypeError): + raise AssertionError("The JWT payload is not valid JSON.") from None + if not isinstance(claims, dict): + raise AssertionError("The JWT payload must be an object.") + return claims + + +def binding_from_credential(credential): + material = _load_mtls_cert_material(credential) + return msal.MtlsBindingContext( + material["cert_pem"], material["private_key_pem"], + material["x5c"], material["key_id"]) + + +def unrelated_binding(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "msal-python-unrelated-client")]) + now = datetime.now(timezone.utc) + certificate = (x509.CertificateBuilder() + .subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(hours=1)) + .not_valid_after(now + timedelta(hours=1)) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), False) + .sign(key, hashes.SHA256())) + return binding_from_credential({ + "private_key": key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()).decode("ascii"), + "public_certificate": certificate.public_bytes( + serialization.Encoding.PEM).decode("ascii"), + }) + + +def _verify_client_assertion(assertion, binding, endpoint, client_id): + __tracebackhide__ = True + if binding is None: + raise AssertionError("A certificate is required to verify the client assertion.") + try: + header = jwt.get_unverified_header(assertion) + certificate = x509.load_der_x509_certificate( + base64.b64decode(binding.x5c[0])) + claims = jwt.decode( + assertion, certificate.public_key(), algorithms=["PS256"], + audience=endpoint, issuer=client_id) + except (jwt.InvalidTokenError, ValueError, TypeError, IndexError): + raise AssertionError( + "The signed client assertion failed certificate/signature/audience validation." + ) from None + chain = header.get("x5c") + if (not isinstance(chain, list) or not chain or chain[0] != binding.x5c[0] + or claims.get("sub") != client_id + or not all(claims.get(field) for field in ("exp", "iat", "jti"))): + raise AssertionError( + "The client assertion must identify the configured certificate and client.") + + +def token_request_summary( + response, binding, adapter, expected_assertion=None, assertion_audience=None): + """Retain only safe metadata, never tokens, assertions, or raw POST bodies.""" + __tracebackhide__ = True + request = response.request + endpoint = urlparse(request.url)._replace(query="", fragment="").geturl() + if (request.method != "POST" + or not urlparse(endpoint).path.endswith("/oauth2/v2.0/token")): + return None + try: + raw = request.body.decode("utf-8") if isinstance( + request.body, bytes) else request.body + form = parse_qs(raw, keep_blank_values=True, strict_parsing=True) + payload = json.loads(response.text) + except (ValueError, TypeError, AttributeError): + raise AssertionError("The real token exchange did not carry a valid OAuth form/JSON response.") from None + if not isinstance(payload, dict): + raise AssertionError("The real token endpoint did not return an OAuth JSON object.") + error = payload.get("error") + if error is not None and ( + not isinstance(error, str) or not re.fullmatch(r"[a-z_]{1,64}", error)): + raise AssertionError("The token endpoint returned an invalid OAuth error code.") + codes = payload.get("error_codes", []) + if not isinstance(codes, list) or any(not isinstance(code, int) for code in codes): + raise AssertionError("OAuth error_codes must be a list of integers.") + fields = {key: values[0] for key, values in form.items()} + assertion = fields.get("client_assertion") + assertion_type = fields.get("client_assertion_type") + signed = False + if assertion and assertion_type == JWT_BEARER: + _verify_client_assertion( + assertion, binding, assertion_audience or endpoint, + fields.get("client_id")) + signed = True + return { + "url": endpoint, + "fields": frozenset(fields), + "grant_type": fields.get("grant_type"), + "requested_token_use": fields.get("requested_token_use"), + "scope": frozenset(fields.get("scope", "").split()), + "token_type": fields.get("token_type"), + "client_assertion_type": assertion_type, + "client_assertion_present": bool(assertion), + "signed_client_assertion": signed, + "assertion_matches": ( + expected_assertion is not None and assertion == expected_assertion), + "status_code": response.status_code, + "error": error, + "error_codes": tuple(codes[:8]), + "tls_adapter": type(adapter).__name__, + "middleware": request.headers.get("X-Msal-Pipeline"), + } + + +@contextmanager +def record_token_requests(binding, expected_assertion=None, assertion_audience=None): + """Delegate Session.send unchanged, including MSAL's built-in TLS adapter.""" + __tracebackhide__ = True + records = [] + send = requests.Session.send + + def recording_send(session, request, **kwargs): + response = send(session, request, **kwargs) + summary = token_request_summary( + response, binding, session.get_adapter(request.url), + expected_assertion=expected_assertion, + assertion_audience=assertion_audience) + if summary is not None: + records.append(summary) + return response + + with patch.object(requests.Session, "send", new=recording_send): + yield records + + +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): + __tracebackhide__ = True + 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): + __tracebackhide__ = True + 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_application.py b/tests/test_application.py index 7e5a64dd..8ad77fea 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -4,6 +4,8 @@ import json import logging import os +import pickle +import runpy import sys import time import warnings @@ -1575,6 +1577,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( @@ -2243,4 +2273,1117 @@ 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") + + +_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" +_JWT_POP = "urn:ietf:params:oauth:client-assertion-type:jwt-pop" + + +@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_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) + 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( + 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 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.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( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post([])): + app.acquire_token_for_client(["s1"], mtls_proof_of_possession=True) + 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.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): + 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([], 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) + 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) + 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_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 no persistence of the rejected response. + app = ConfidentialClientApplication( + "cid", client_credential=_MTLS_CERT_CRED, authority=self._AUTHORITY) + 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.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 = [ + at for at in app.token_cache.search( + 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]) + self.assertIn( + "token_type_mismatch", captured[0]["headers"][CLIENT_LAST_TELEMETRY]) + + 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_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) + app._get_mtls_client(app.authority) + self.addCleanup(app._mtls_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) + 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, + azure_region="westus3") + 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") + + 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 = [] + 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] + 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")) + self.assertIsInstance( + result.get("binding_certificate"), msal.MtlsBindingContext) + + 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 = [] + with patch("msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post( + captured, token_type="Bearer")): + result = app.acquire_token_for_client(["s1"]) + 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_fic_leg2_rejects_post_override(self): + app = ConfidentialClientApplication( + "cid", authority=self._AUTHORITY, + client_credential={ + "client_assertion": "LEG1_TOKEN", + "mtls_binding_certificate": _MTLS_CERT_CRED}) + with self.assertRaisesRegex(ValueError, "post override"): + 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.assertRaisesRegex( + ValueError, "also requires a client_assertion"): + ConfidentialClientApplication( + "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}) + with patch( + "msal.mtls._MtlsHttpClient.post", + side_effect=self._capturing_post(captured)): + 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]) + 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 + # 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") + # 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( + "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, + 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) + + +_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)) + + 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( + "cid", authority=self._AUTHORITY, client_credential=dict( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + 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( + 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 = [] + 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") + + 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 = [] + 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") + + 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 = [] + 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 + 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 = [] + 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"], + "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 = [] + 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") + + 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 = [] + 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"], + "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_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( + _MTLS_CERT_CRED, send_certificate_over_mtls=True)) + 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"], + "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_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)) + with patch("msal.mtls._MtlsHttpClient.post", + return_value=MinimalResponse( + status_code=200, text=_build_user_token_response( + 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 = [] + + 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). + 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") + + 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)) + 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( + ["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) + + 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", "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_e2e.py b/tests/test_e2e.py index 5b84a42e..6450d12a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,5 +1,9 @@ -"""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. + +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 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 @@ -7,6 +11,7 @@ except: pass import base64 +from contextlib import nullcontext import logging import os import json @@ -30,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__) @@ -338,7 +347,206 @@ 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"] + _KEY_VAULT_SCOPE = ["https://vault.azure.net/.default"] + # Only the mTLS Graph host validates the certificate binding on the channel. + _MTLS_GRAPH_RESOURCE = "https://mtlstb.graph.microsoft.com/v1.0/applications?$top=1" + _SNI_ALLOWLISTED_CLIENT_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e" + _SNI_ALLOWLISTED_AUTHORITY = "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c" + _TOKEN_AUDIENCES = { + "https://graph.microsoft.com/.default": { + "https://graph.microsoft.com", "00000003-0000-0000-c000-000000000000"}, + "https://vault.azure.net/.default": { + "https://vault.azure.net", "cfa8b339-82a2-471a-a3c9-0fc0be7a4093"}, + "https://storage.azure.com/.default": { + "https://storage.azure.com", "e406a681-f3d4-42a8-90b6-c2b029497af1"}, + } + + @staticmethod + def _cnf_x5t_s256(access_token): + __tracebackhide__ = True + cnf = jwt_claims(access_token).get("cnf") + if not isinstance(cnf, dict) or not isinstance( + cnf.get("x5t#S256"), str) or not cnf["x5t#S256"]: + raise AssertionError("The PoP JWT must carry cnf['x5t#S256'].") + return cnf["x5t#S256"] + + @staticmethod + def _result_summary(result): + error = result.get("error") + if error is not None and (not isinstance(error, str) or error not in { + "invalid_client", "invalid_grant", "invalid_request", + "invalid_scope", "unauthorized_client", "unsupported_grant_type", + "temporarily_unavailable", "server_error", "interaction_required", + "token_type_mismatch"}): + error = "" + codes = result.get("error_codes") + codes = [code for code in codes[:8] if isinstance(code, int)] if isinstance( + codes, list) else [] + return "error={!r}, error_codes={!r}".format( + error, codes) + + def _assert_audience(self, result, scope): + __tracebackhide__ = True + audience = jwt_claims(result["access_token"]).get("aud") + self.assertTrue( + isinstance(audience, str) + and audience.rstrip("/") in self._TOKEN_AUDIENCES[scope[0]], + "The token audience must identify the requested resource.") + + def _assert_token_result(self, result, scope, token_type, expected_binding=None): + __tracebackhide__ = True + self.assertTrue(isinstance(result, dict), "MSAL must return a token result.") + self.assertTrue( + bool(result.get("access_token")), + "Token acquisition failed: " + self._result_summary(result)) + self.assertEqual(token_type, result.get("token_type")) + if token_type == "mtls_pop": + binding = result.get("binding_certificate") + self.assertTrue( + isinstance(binding, msal.MtlsBindingContext), + "PoP must return a usable binding context.") + self.assertTrue(binding.x5c and binding.thumbprint_sha256) + self.assertEqual(expected_binding.x5c, binding.x5c) + self.assertEqual( + expected_binding.thumbprint_sha256, binding.thumbprint_sha256) + self.assertFalse( + "PRIVATE KEY" in repr(binding), + "The binding context repr must not expose key material.") + # Reference FIC assertions are opaque; resource PoP JWTs require cnf. + if scope != ["api://AzureADTokenExchange/.default"]: + self.assertEqual( + binding.thumbprint_sha256, + self._cnf_x5t_s256(result["access_token"])) + self._assert_audience(result, scope) + else: + self.assertFalse( + "binding_certificate" in result, + "An ordinary Bearer result must not expose certificate binding.") + + @staticmethod + def _close_default_http_clients(app): + app.http_client.close() + if app._mtls_http_client is not None: + app._mtls_http_client.close() + + def _sni_app(self, credential, azure_region=False, http_client=None): + app = msal.ConfidentialClientApplication( + self._SNI_ALLOWLISTED_CLIENT_ID, + authority=self._SNI_ALLOWLISTED_AUTHORITY, + client_credential=credential, azure_region=azure_region, + http_client=http_client, timeout=30) + if http_client is None: + self.addCleanup(self._close_default_http_clients, app) + return app + + @staticmethod + def _certificate(): + from tests.lab_config import get_client_certificate + + credential = get_client_certificate() + return credential, binding_from_credential(credential) + + def _assert_cached_result(self, app, scope, original, pop=False): + __tracebackhide__ = True + cached = app.acquire_token_for_client( + scope, mtls_proof_of_possession=pop) + self._assert_token_result( + cached, scope, original["token_type"], + expected_binding=original.get("binding_certificate")) + self.assertEqual(app._TOKEN_SOURCE_CACHE, cached.get(app._TOKEN_SOURCE)) + self.assertTrue( + original["access_token"] == cached["access_token"], + "The cached token must be the originally acquired token.") + + def _assert_token_request( + self, records, scope, grant, region=False, assertion_type=None, + regular_endpoint=False): + self.assertEqual(1, len(records), "Exactly one real token POST is required.") + request = records[0] + host = ("login.microsoftonline.com" if regular_endpoint + else ("{}.".format(region) if region else "") + "mtlsauth.microsoft.com") + tenant = urlparse(self._SNI_ALLOWLISTED_AUTHORITY).path.strip("/") + self.assertEqual( + "https://{}/{}/oauth2/v2.0/token".format(host, tenant), + request["url"]) + self.assertEqual(grant, request["grant_type"]) + self.assertTrue(set(scope) <= request["scope"]) + self.assertEqual(assertion_type, request["client_assertion_type"]) + self.assertEqual( + assertion_type is not None, request["client_assertion_present"]) + self.assertEqual( + "HTTPAdapter" if regular_endpoint else "_MtlsHTTPAdapter", + request["tls_adapter"]) + for field in ("req_cnf", "key_id"): + self.assertNotIn(field, request["fields"]) + if assertion_type == JWT_BEARER: + self.assertTrue( + request["signed_client_assertion"], + "The private_key_jwt must verify against the configured certificate.") + return request + + def _assert_factory_used(self, http_client, expected_binding): + self.assertEqual(1, len(http_client._children)) + child = http_client._children[0] + self.assertIsNot(http_client, child) + self.assertEqual( + expected_binding.thumbprint_sha256, + child.binding_context.thumbprint_sha256) + self.assertEqual([], http_client.token_requests) + self.assertEqual(1, child.response_count) + self.assertEqual(1, len(child.token_requests)) + self.assertEqual("custom-policy", child.token_requests[0]["middleware"]) + self.assertFalse(http_client._closed or child._closed) + return child + + def _acquire_recorded( + self, app, scope, binding, pop=False, http_client=None, + expected_assertion=None): + __tracebackhide__ = True + if http_client is not None: + result = app.acquire_token_for_client( + scope, mtls_proof_of_possession=pop) + records = self._assert_factory_used( + http_client, binding).token_requests + else: + with record_token_requests( + binding, expected_assertion=expected_assertion) as records: + result = app.acquire_token_for_client( + scope, mtls_proof_of_possession=pop) + return result, records + + def _call_graph_with_mtls_pop_token(self, token, binding_certificate): + __tracebackhide__ = True + with binding_certificate.create_requests_session() as session: + resp = session.get( + self._MTLS_GRAPH_RESOURCE, + headers={"Authorization": "mtls_pop " + token}, + timeout=30, allow_redirects=False) + self.assertEqual( + 200, resp.status_code, + "The real Graph resource must accept the bound PoP token.") + return resp + + def _assert_graph_rejects(self, token, scheme, binding, marker): + __tracebackhide__ = True + with (binding.create_requests_session() if binding is not None + else requests.Session()) as session: + response = session.get( + self._MTLS_GRAPH_RESOURCE, + headers={"Authorization": scheme + " " + token}, + timeout=30, allow_redirects=False) + observed = tuple( + candidate for candidate in GRAPH_REJECTION_MARKERS + if candidate in response.text) + diagnostic = "HTTP {}; binding markers={!r}".format( + response.status_code, observed) + self.assertIn(response.status_code, (401, 403), diagnostic) + self.assertEqual((marker,), observed, diagnostic) + + +class PublicCloudScenariosTestCase(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. @@ -426,20 +634,312 @@ def test_client_secret(self): self.assertIn('access_token', result) self.assertCacheWorksForApp(result, scope) - 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( +class CertificateScenariosTestCase(MtlsPopTestCase): + # Required unattended scenarios must also run during tagged releases. + __unittest_skip__ = False + + def test_subject_name_issuer_authentication(self): + 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) + 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 test_credential_x509_output_pop(self): + 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): + 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): + self._acquire_x509( + self._KEY_VAULT_SCOPE, send=True, region="westus3") + + 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( + 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"]) + 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(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.") + + 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): + __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 _acquire_fic_token( + 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): + self._acquire_fic_token(self._FIC_BEARER_SCOPE, azure_region="westus3") + + def test_credential_fic_output_pop(self): + _, result = self._acquire_fic_token( + self._MTLS_POP_SCOPE, azure_region=False, + mtls_proof_of_possession=True) + self._call_graph_with_mtls_pop_token( + 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) + + 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 @classmethod diff --git a/tests/test_lab_config.py b/tests/test_lab_config.py new file mode 100644 index 00000000..9b8a8a60 --- /dev/null +++ b/tests/test_lab_config.py @@ -0,0 +1,111 @@ +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): + _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): + 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, create=True), patch( + "tests.test_e2e.msal.ConfidentialClientApplication") as application: + suite = unittest.TestSuite( + unittest.defaultTestLoader.loadTestsFromTestCase(case) + for case in self._REQUIRED_SCENARIOS) + result = unittest.TextTestRunner(stream=io.StringIO()).run(suite) + self.assertEqual(21, result.testsRun) + self.assertEqual(21, 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): + 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..39a577c1 --- /dev/null +++ b/tests/test_mtls_e2e_helpers.py @@ -0,0 +1,577 @@ +"""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 pytest +import requests + +from msal.application import _load_mtls_cert_material +from tests import test_e2e +from tests.mtls_e2e_helpers import ( + GRAPH_REJECTION_MARKERS, JWT_BEARER, 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 _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({}), + _jwt({"cnf": {}}), _jwt({"cnf": {"x5t#S256": ""}})): + with self.subTest(token_shape=token.count(".")): + with self.assertRaises(AssertionError): + self.scenario._cnf_x5t_s256(token) + + def test_pop_checks_the_jwt_and_returned_certificate_binding(self): + result = { + "token_type": "mtls_pop", "binding_certificate": self.binding, + "access_token": _jwt({ + "aud": "00000003-0000-0000-c000-000000000000", + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}}), + } + self.scenario._assert_token_result( + result, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + wrong = dict(result, access_token=_jwt({ + "aud": "https://graph.microsoft.com", + "cnf": {"x5t#S256": "different-certificate"}})) + with self.assertRaises(AssertionError): + self.scenario._assert_token_result( + wrong, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + + def test_key_vault_accepts_its_verified_application_id_not_another_resource(self): + for audience, accepted in ( + ("https://vault.azure.net", True), + ("cfa8b339-82a2-471a-a3c9-0fc0be7a4093", True), + ("00000003-0000-0000-c000-000000000000", False)): + result = { + "token_type": "mtls_pop", "binding_certificate": self.binding, + "access_token": _jwt({ + "aud": audience, + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}}), + } + with self.subTest(audience=audience): + with (nullcontext() if accepted else self.assertRaises(AssertionError)): + self.scenario._assert_token_result( + result, self.scenario._KEY_VAULT_SCOPE, "mtls_pop", self.binding) + + def test_failure_tracebacks_do_not_expose_token_arguments(self): + for token in ( + "opaque-" + self.binding.thumbprint_sha256, + _jwt({"aud": "https://wrong.example", "cnf": {}}), + _jwt({"aud": "https://wrong.example", "cnf": { + "x5t#S256": self.binding.thumbprint_sha256}})): + result = { + "access_token": token, "token_type": "mtls_pop", + "binding_certificate": self.binding, + } + with pytest.raises(AssertionError) as failure: + self.scenario._assert_token_result( + result, self.scenario._MTLS_POP_SCOPE, "mtls_pop", self.binding) + self._assert_private_traceback(failure, token) + + def test_cached_result_failure_traceback_hides_original_token(self): + claims = { + "aud": "https://graph.microsoft.com", + "cnf": {"x5t#S256": self.binding.thumbprint_sha256}, + } + token = _jwt(dict(claims, nonce="original")) + cached_token = _jwt(dict(claims, nonce="cached")) + original = { + "access_token": token, "token_type": "mtls_pop", + "binding_certificate": self.binding, + } + app = Mock(_TOKEN_SOURCE_CACHE="cache", _TOKEN_SOURCE="token_source") + app.acquire_token_for_client.return_value = dict( + original, access_token=cached_token, token_source="cache") + with pytest.raises(AssertionError) as failure: + self.scenario._assert_cached_result( + app, self.scenario._MTLS_POP_SCOPE, original, pop=True) + self._assert_private_traceback(failure, token, cached_token) + + def test_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() + 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_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, 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()) + 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 (nullcontext() if accepted else 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_federated_assertion_is_opaque_but_still_requires_certificate_binding(self): + self.leg1.acquire_token_for_client.return_value["access_token"] = "opaque" + 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): + 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() diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py new file mode 100644 index 00000000..b717c4aa --- /dev/null +++ b/tests/test_mtls_http_client.py @@ -0,0 +1,959 @@ +import ipaddress +import io +import json +import os +import select +import socket +import ssl +import tempfile +import threading +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 +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" + + +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): + 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, client_credential=None, **kwargs): + return msal.ConfidentialClientApplication( + "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"}) + 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_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 + 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_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"): + 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 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]): + 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 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))) + self.end_headers() + 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 + 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"]) + error_type = (ValueError if app is custom + else requests.exceptions.HTTPError) + error_message = ("must not redirect" if app is custom + else "does not support HTTP redirects") + with self.assertRaisesRegex(error_type, error_message): + 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_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 = [] + + 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() diff --git a/tests/test_mtls_transport.py b/tests/test_mtls_transport.py new file mode 100644 index 00000000..8e0d7f66 --- /dev/null +++ b/tests/test_mtls_transport.py @@ -0,0 +1,717 @@ +import base64 +import io +import json +import os +import ssl +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 +from msal.application import ( + _is_certificate_issuer, _load_mtls_cert_material, + _private_key_to_unencrypted_pem, extract_certs) + + +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", + ]: + 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("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): + 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): + """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_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 = {} + # 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_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( + 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() + + 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_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 = ( + 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, + 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"): + factory() + + 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"]) + adapter = Mock() + with 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 + 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_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"}') + + 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 + 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_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 if send_over_mtls else expected_x5c[:1], + 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_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 + tbs_certificate_bytes = self.leaf.tbs_certificate_bytes + signature_algorithm_oid = self.leaf.signature_algorithm_oid + signature_hash_algorithm = self.leaf.signature_hash_algorithm + + 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: + 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) + + 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_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 + + 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", "session_path", "request_path"): + 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(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(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)], + 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.""" + + @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_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 51ba8f00..8baae02c 100644 --- a/tests/test_token_cache.py +++ b/tests/test_token_cache.py @@ -2,11 +2,15 @@ import base64 import copy import json +from pathlib import Path +import subprocess +import tempfile import time import warnings +from unittest.mock import Mock, patch from msal.token_cache import ( - TokenCache, SerializableTokenCache, _compute_ext_cache_key, + TokenCache, SerializableTokenCache, _compute_ext_cache_key, _key_id_to_str, _parse_claims_or_raise, _merge_claims, ) from tests import unittest @@ -34,7 +38,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 = {} @@ -52,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): @@ -218,7 +244,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 +321,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 +381,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 +392,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 +407,229 @@ 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(list(self.cache.search("AccessToken", now=1000))), + 2, """Access tokens are now isolated by key_id, so that a key-bound +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"): + 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_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() + # 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(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( + 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=dict(query, token_type="Bearer"), 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, token_type="mtls_pop"), + }, 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 + # 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( - 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, 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, 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", 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 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. + 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") + # 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)) + secrets = {at["secret"] for at in found} + self.assertIn("mtls_at", secrets, + "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): + # 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", 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=[], + 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. scopes = ["s2", "s1", "s3"] # Not in particular order @@ -426,6 +670,523 @@ 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 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" + 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) + 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): + 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.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( + "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_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 + + 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() @@ -675,6 +1436,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 the shared