Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 111 additions & 5 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,14 @@ certificate credential and pass ``mtls_proof_of_possession=True`` to
Notes and requirements:

* The ``authority`` must be **tenanted** (not ``/common`` or ``/organizations``).
* MSAL must own the TLS transport, so a custom ``http_client`` is not supported
together with ``mtls_proof_of_possession=True``.
Per-request ``post`` and ``cert`` overrides are also rejected.
The owned mTLS token transport never follows redirects; HTTP 3xx responses
* 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.
* mTLS transport and ``binding.create_requests_session()`` require
* 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.
Expand Down Expand Up @@ -253,6 +255,110 @@ MSAL version or clear the entire persisted cache. An older version's
.. autoclass:: msal.MtlsBindingContext
:members:

Custom HTTP pipelines for mTLS token requests
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

An application that does not supply ``http_client`` needs no new configuration.
MSAL owns its certificate-bearing Requests transport, separate from discovery
and ordinary token traffic.

A custom HTTP client opts in by implementing this additional method::

def create_mtls_client(self, binding_context):
# binding_context is an MSAL-created msal.MtlsBindingContext.
# Return a NEW pipeline with the same middleware and network policy,
# but isolated certificate-bearing connections.
...

There is no marker class to inherit and no additional constructor parameter.
The ordinary client's ``get`` and ``post`` still handle discovery and non-mTLS
requests. On the first mTLS token network request, MSAL calls the factory with
the application's selected certificate. The returned object must have a
callable ``post(url, **kwargs)`` using the normal MSAL HTTP response contract
(``status_code``, ``text``, optional ``headers``, and ``raise_for_status()``).
It must accept and honor ``allow_redirects=False``. Factory and request
exceptions propagate; MSAL never falls back to the ordinary client.

For Requests-based pipelines, mount
``binding_context.create_requests_adapter(...)`` on a **new** session. This
helper accepts ``HTTPAdapter`` options for retry and pooling policy, retains
MSAL's Requests-version compatibility handling, and selects separate
certificate-bearing TLS contexts and pools using the effective Requests
``verify`` value. Session/request CA paths, ``REQUESTS_CA_BUNDLE`` and
``CURL_CA_BUNDLE`` (when ``session.trust_env`` allows them), and HTTPS proxies
retain their Requests semantics. For other HTTP libraries,
``binding_context.create_ssl_context(verify=...)`` creates the certificate-bearing
``ssl.SSLContext``; the implementer must resolve its own library's effective
server verification and proxy settings before using it.

For example, inside a Requests-based factory::

session = MyPolicySession() # A fresh session retaining your middleware
session.verify = self.ca_bundle
session.trust_env = self.trust_env
session.proxies.update(self.proxies)
session.mount("https://", binding_context.create_requests_adapter(
max_retries=self.retries,
pool_connections=self.pool_connections,
pool_maxsize=self.pool_maxsize,
pool_block=True,
))
# Set the pipeline's timeout, hooks and other policy as for ordinary traffic.
# Keep session alive until all applications using this factory have finished.
return session

The runnable ``sample/confidential_client_mtls_http_client_sample.py`` shows a
factory that retains response middleware, a header policy, CA/proxy/environment
settings, timeouts and pooling, and closes its derived pipelines explicitly.
It uses the same certificate and environment configuration as the default
mTLS-PoP sample.

Trust, isolation and lifetime contract
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

* Return a fresh pipeline for every factory call. Do not mutate or return the
ordinary client, its shared transport, or a previously returned pipeline.
Never share certificate-bearing connection pools, adapters, mutable TLS
contexts or TLS session caches with another certificate or ordinary traffic.
Shared immutable policy is fine.
* Preserve all required middleware and networking policy explicitly: MSAL cannot
clone an opaque client. Constructor ``verify``, ``proxies`` and ``timeout``
options still do not override a custom HTTP client's settings.
* MSAL serializes factory creation within each application and caches one
returned pipeline for that application's certificate. Global and regional
token endpoints reuse it. Separate applications invoke the factory separately,
even when they share an HTTP factory or caches. The factory must be safe to
call concurrently from different applications, and its returned pipelines
must support the application's concurrency requirements.
* Certificate credentials are fixed for the application's lifetime. To rotate,
create a new application and pipeline, stop in-flight work using the old
application, then release its pipelines. Do not mutate a supplied binding
context or a TLS context already used by a connection pool.
* The custom factory owns and must retain/close every client it creates,
including partial constructions when its own code raises. MSAL does not
close caller-owned clients or the ordinary pipeline. Close them only after
all applications and in-flight requests using them have finished.
* MSAL rejects a missing/non-callable factory, a return value without callable
``post``, returning the ordinary client itself, and returning a client already
assigned to another live application. This identity check does not keep
applications alive or require custom clients to support weak references.
MSAL restricts its token calls to the configured HTTPS endpoint, passes
``allow_redirects=False``,
and rejects redirect responses or observable redirect history/final-endpoint
changes (excluding query parameters). These checks cannot undo a redirect
already followed by a faulty custom implementation.
* This is a **trusted implementation contract**, not proof that arbitrary code
uses the supplied certificate. MSAL cannot inspect nested transports, detect
all shared pools (or all reuse after an application is collected), enforce an
opaque client's verification/proxy policy, or prevent custom middleware from
ignoring TLS or redirect instructions. The implementer is responsible for
those behaviors; do not opt in unless the complete pipeline honors them.

The factory receives a process-local certificate capability, not a successful
token result. ``binding_certificate`` remains present only on a final
``mtls_pop`` result, never on an ordinary Bearer result. This extension does not
change downstream resource TLS or the requirements for mTLS PoP.


Exceptions
----------
Expand Down
158 changes: 85 additions & 73 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,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
Expand Down Expand Up @@ -886,11 +903,8 @@ def get_client_assertion():
'Invalid exclude_scopes={}. You can not opt out "openid" scope'.format(
repr(exclude_scopes)))

# Retained for the mTLS PoP transport, which needs its own dedicated
# requests.Session carrying the client certificate (see msal/mtls.py).
# A user-supplied http_client cannot perform the mTLS handshake, so
# requesting mtls_proof_of_possession=True with one fails fast.
self._http_client_is_custom = bool(http_client)
# A custom pipeline must explicitly opt in to certificate-aware TLS.
self._custom_http_client = http_client if http_client else None
self._http_client_config = {
"verify": verify, "proxies": proxies, "timeout": timeout}
self._http_cache = http_cache # Reused by the lazily-built mTLS client
Expand Down Expand Up @@ -974,6 +988,7 @@ def get_client_assertion():
self._mtls_pop_cert_material = None # Cached parse of the cert below
self._mtls_client = None # Lazily built global mTLS client
self._mtls_regional_client = None # Lazily built regional mTLS client
self._mtls_http_client = None # Shared by this certificate's endpoints
self._mtls_lock = Lock()
if isinstance(client_credential, dict) and not client_credential.get(
"client_assertion") and (
Expand Down Expand Up @@ -1173,24 +1188,26 @@ def _get_mtls_pop_cert(self):
self._mtls_cert_credential)
return self._mtls_pop_cert_material

def _get_mtls_http_client_factory(self):
if self._custom_http_client is None:
return None
factory = getattr(self._custom_http_client, "create_mtls_client", None)
if not callable(factory):
raise ValueError(
"mTLS requires a custom http_client to implement "
"create_mtls_client(binding_context). Omit http_client to use "
"MSAL's built-in mTLS transport.")
return factory

def _get_mtls_client(self, central_authority):
"""Lazily build (and cache) the mTLS PoP client for ``central_authority``.

The token endpoint host is transformed ``login.* -> [region.]mtlsauth.*``
(region honored when configured, global otherwise), and the client
presents the configured certificate over mutual-TLS.
"""
if self._http_client_is_custom:
raise ValueError(
"mtls_proof_of_possession=True is not supported with a custom "
"http_client, because MSAL must own the TLS transport to present "
"the client certificate in the mutual-TLS handshake. Omit the "
"http_client argument to use MSAL's built-in mTLS transport.")
factory = self._get_mtls_http_client_factory()
region = self._compute_region_to_use()
with self._mtls_lock:
cached = self._mtls_regional_client if region else self._mtls_client
if cached is not None:
return cached
cert = self._get_mtls_pop_cert() # May raise ValueError (no cert configured)
token_endpoint = mtls.transform_token_endpoint( # login.* -> mtlsauth.*
central_authority.token_endpoint, central_authority.instance, region)
Expand All @@ -1201,44 +1218,53 @@ def _get_mtls_client(self, central_authority):
"device_authorization_endpoint":
central_authority.device_authorization_endpoint,
}
http_client = ThrottledHttpClient(
mtls._MtlsHttpClient(
cert["cert_pem"], cert["private_key_pem"],
**self._http_client_config),
# The TLS credential is absent from HTTP cache keys.
http_cache={},
default_throttle_time=5,
)
# Vanilla SN/I: the TLS certificate alone authenticates the client, so
# this mTLS path sends NO signed client_assertion. (The classic, non-mTLS
# certificate path still signs a private_key_jwt client_assertion; only
# mTLS PoP replaces that assertion with the mutual-TLS handshake.)
client = _MtlsClient(
configuration,
self.client_id,
http_client=http_client,
default_headers=self._default_client_headers(),
default_body={"client_info": 1},
# Cache under the ORIGINAL login.* host, never the mtlsauth.* host,
# so mtls_pop ATs share the environment with the rest of the app.
on_obtaining_tokens=lambda event: self.token_cache.add(dict(
event, environment=central_authority.instance)),
on_removing_rt=self.token_cache.remove_rt,
on_updating_rt=self.token_cache.update_rt)
with self._mtls_lock:
# Double-check under the lock: another thread may have built and
# cached a client while we were building ours (we released the lock
# to build). If so, keep the already-cached instance and let ours be
# discarded, so every caller shares one client instead of orphaning
# a redundant build.
cached = self._mtls_regional_client if region else self._mtls_client
if cached is not None:
return cached
if self._mtls_http_client is None:
if factory is None:
http_client = mtls._MtlsHttpClient(
cert["cert_pem"], cert["private_key_pem"],
**self._http_client_config)
else:
binding = mtls.MtlsBindingContext(
cert["cert_pem"], cert["private_key_pem"],
cert["x5c"], cert["key_id"])
http_client = factory(binding)
if (http_client is self._custom_http_client
or http_client is self.http_client
or http_client is self.http_client.http_client):
raise ValueError(
"create_mtls_client must return a new, dedicated "
"client, not the ordinary http_client.")
if not callable(getattr(http_client, "post", None)):
raise TypeError(
"create_mtls_client must return a client with "
"a callable post(url, **kwargs).")
mtls._claim_mtls_http_client(http_client, self)
self._mtls_http_client = http_client
http_client = ThrottledHttpClient(
mtls._MtlsTokenHttpClient(self._mtls_http_client, token_endpoint),
# The TLS credential is absent from HTTP cache keys.
http_cache={},
default_throttle_time=5,
)
# The TLS certificate authenticates this path; no client assertion.
client = _MtlsClient(
configuration,
self.client_id,
http_client=http_client,
default_headers=self._default_client_headers(),
default_body={"client_info": 1},
on_obtaining_tokens=lambda event: self.token_cache.add(dict(
event, environment=central_authority.instance)),
on_removing_rt=self.token_cache.remove_rt,
on_updating_rt=self.token_cache.update_rt)
if region:
if self._mtls_regional_client is None:
self._mtls_regional_client = client
client = self._mtls_regional_client
self._mtls_regional_client = client
else:
if self._mtls_client is None:
self._mtls_client = client
client = self._mtls_client
self._mtls_client = client
return client

def _build_client(self, client_credential, authority, skip_regional_client=False):
Expand Down Expand Up @@ -2979,12 +3005,13 @@ def acquire_token_for_client(
Requirements: the app must be configured with a certificate
credential (``private_key_pfx_path``, or ``private_key`` +
``public_certificate``); the ``authority`` must be tenanted (not
``/common`` or ``/organizations``); and MSAL's built-in HTTP
transport must be in use (a custom ``http_client`` cannot perform
the mTLS handshake). Any of these unmet raises ``ValueError``.
A ``post`` transport override is also rejected. If the provider
returns another token type, the result is ``token_type_mismatch``
and the rejected response is not cached.
``/common`` or ``/organizations``); and a custom ``http_client``
must implement ``create_mtls_client(binding_context)`` as described
in :py:class:`msal.ClientApplication`. Any of these unmet raises
``ValueError``. The default transport needs no extra configuration.
``post`` and ``cert`` overrides and redirects are rejected. If the
provider returns another token type, the result is
``token_type_mismatch`` and the rejected response is not cached.

On success the result also contains an
:py:class:`msal.MtlsBindingContext` as ``binding_certificate``. It
Expand Down Expand Up @@ -3048,23 +3075,8 @@ def acquire_token_for_client(
kwargs["data"] = kwargs.get("data", {})
_stash_client_claims(forwarded_client_claims, kwargs["data"])
if mtls_proof_of_possession:
if kwargs.get("cert") is not None:
raise ValueError(
"mtls_proof_of_possession=True does not support a cert "
"override. MSAL must present the configured binding certificate.")
if "post" in kwargs:
raise ValueError(
"mtls_proof_of_possession=True does not support a post "
"override. Omit post to use MSAL's built-in mTLS transport.")
# An mTLS transport is required to present the certificate for a
# cert-bound PoP request.
if self._http_client_is_custom:
raise ValueError(
"mtls_proof_of_possession=True is not supported with a "
"custom http_client, because MSAL must own the TLS transport "
"to present the client certificate in the mutual-TLS "
"handshake. Omit the http_client argument to use MSAL's "
"built-in mTLS transport.")
mtls._validate_mtls_request(kwargs)
self._get_mtls_http_client_factory()
if self.authority.tenant.lower() in ("common", "organizations"):
raise ValueError(
"mtls_proof_of_possession=True requires a tenanted authority. "
Expand Down
Loading
Loading