Skip to content

Add SNI certificate support over mTLS (Bearer-over-mTLS) - #947

Open
Robbie-Microsoft wants to merge 12 commits into
rginsburg/sni-mtls-popfrom
rginsburg/bearer-over-mtls
Open

Robbie-Microsoft wants to merge 12 commits into
rginsburg/sni-mtls-popfrom
rginsburg/bearer-over-mtls

Conversation

@Robbie-Microsoft

@Robbie-Microsoft Robbie-Microsoft commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds Bearer-over-mTLS support, equivalent to MSAL.NET's CertificateOptions.SendCertificateOverMtls.

Set send_certificate_over_mtls=True on an SN/I certificate credential to make MSAL present that certificate during the TLS handshake with the regional or global mtlsauth.microsoft.com token endpoint. MSAL also signs the private_key_jwt client assertion and forces the public certificate chain into its x5c header.

The resulting access token is an ordinary, non-certificate-bound Bearer token:

  • The request does not contain req_cnf, key_id, or token_type=mtls_pop.
  • The result does not contain binding_certificate.
  • The cache entry is not fenced by the certificate thumbprint.
  • The certificate is used to authenticate the token-endpoint connection, not a downstream resource connection.

This remains distinct from mtls_proof_of_possession=True, which requests a certificate-bound mtls_pop token, fences its cache entry by key_id, and returns binding_certificate. Per-request mTLS PoP takes precedence when both options are configured.

Developer experience

credential = {
    "private_key": PRIVATE_KEY_PEM,
    "thumbprint": THUMBPRINT,
    "public_certificate": CERTIFICATE_CHAIN_PEM,
    "send_certificate_over_mtls": True,
}

app = msal.ConfidentialClientApplication(
    CLIENT_ID,
    authority=AUTHORITY,
    client_credential=credential,
    azure_region="westus3",
)

result = app.acquire_token_for_client([
    "https://vault.azure.net/.default",
])

assert result["token_type"] == "Bearer"
assert "binding_certificate" not in result

MSAL owns the certificate-bearing token transport for this flow. Custom application HTTP clients and per-request post overrides are rejected rather than silently bypassing mTLS.

Coverage

  • Client credentials, OBO, authorization-code, refresh-token, device-code, username/password, and silent/cache paths.
  • Global and regional mtlsauth.microsoft.com endpoint routing.
  • Forced x5c assertion headers and PEM/PFX certificate-chain ordering.
  • Proxy, Requests connection-pool, environment/session/request CA-bundle, and certificate-isolation behavior.
  • Cache reuse as a normal Bearer token without a returned binding context.
  • Live E2E acquisition using the Key Vault scope and westus3; no downstream mTLS resource call is made because the final token is not certificate-bound.

Validation

248 passed, 47 subtests passed

Targets rginsburg/sni-mtls-pop.

Robbie-Microsoft and others added 9 commits July 31, 2026 16:11
Introduce an app-level opt-in `send_certificate_over_mtls` on the
confidential-client `client_credential` dict. When set, MSAL presents the
SN/I certificate on the TLS handshake to the [region.]mtlsauth.* endpoint and
signs a private_key_jwt client_assertion (x5c forced), but requests a plain
Bearer token (no token_type=mtls_pop / no key_id / no req_cnf). The result is
an ordinary, non-cert-bound Bearer access token.

- Validate the flag at construction: it requires a certificate credential,
  else ValueError naming the flag.
- Add _get_mtls_bearer_client(): reuses the Task-1 mTLS transport
  (msal/mtls.py) + the login.*->[region.]mtlsauth.* endpoint transform, adds a
  signed x5c-forced assertion, and caches a plain (non-key_id-fenced) token.
- Route acquire_token_for_client() to it when the flag is set and the request
  is not mtls_pop. A per-request mtls_proof_of_possession=True always wins.
- Tests (RED-first) in TestSendCertificateOverMtls: config/builder (default
  false, stored true, flag + non-cert credential raises) and CC behavior
  (global/regional mtlsauth endpoint, client_assertion + jwt-bearer, x5c
  forced, no PoP wire markers, plain-Bearer result, mtls_pop precedence).

Ports MSAL.NET CertificateOptions.SendCertificateOverMtls (client-credentials
leg). OBO/refresh/auth-code coverage follows.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Extend the app-level send_certificate_over_mtls opt-in beyond
client-credentials to every confidential-client user flow: on-behalf-of,
refresh-token, and authorization-code.

Add _client_for_confidential_request(): when the flag is set it returns the
Bearer-over-mTLS client (SN/I cert on the TLS handshake, routed to the
[region.]mtlsauth.microsoft.com endpoint, private_key_jwt client_assertion
with the x5c chain forced, plain Bearer token back), otherwise the regular
client. Swap it in at the three user-flow call sites. Client-credentials
routing stays in _acquire_token_for_client, where a per-request
mtls_proof_of_possession still wins over the app-level flag.

Tests (tests/test_application.py, TestSendCertificateOverMtls): OBO / refresh
/ auth-code with the flag hit the global mtlsauth endpoint, carry
client_assertion + client_assertion_type=jwt-bearer with no key_id/req_cnf,
and use the correct grant (on_behalf_of / refresh_token / authorization_code);
OBO with the flag + a configured region hits the regional mtlsauth host; OBO
without the flag stays on the regular login endpoint (negative control).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Guard the .NET MtlsBearerUserFlowTests second-call regression, adapted to
msal-python's cache seam: a 2nd Bearer-over-mTLS client-credentials call is
served from cache with no 2nd network round-trip and no crash on
region/instance-metadata resolution, returning the cached plain Bearer AT
(token_type=Bearer, NOT cert-bound).

In .NET the 2nd call crashes because the AT is cached under the mtlsauth.*
host and instance-metadata discovery rejects that host. msal-python
structurally avoids this by decoupling the cache/metadata authority (login.*,
where the plain Bearer AT lives) from the transport endpoint (mtlsauth.*): the
2nd lookup resolves login.* -- the normal, always-safe path -- so the cached
Bearer AT is returned without feeding the mtlsauth.* host into discovery.

Only client-credentials auto-serves from cache in msal-python (OBO, refresh,
and auth-code have no silent-first lookup), so CC is the flow that exercises
this regression.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover the send_certificate_over_mtls feature in tests/test_e2e.py, mirroring
MSAL .NET's live + capture split and reusing the SNI allow-listed app/authority
constants already in PublicCloudScenariosTestCase.

- test_credential_x509_send_certificate_over_mtls_output_bearer: LIVE
  client-credentials acquisition (allow-listed app, westus3, Key Vault resource)
  that presents the SN/I cert on the TLS handshake to the mTLS endpoint and
  asserts a PLAIN Bearer token back (token_type=Bearer, binding_certificate
  absent) plus a normal flagless cache hit -- the caller-visible proof that the
  AT is an ordinary Bearer entry, not a key-id-fenced mtls_pop entry.
- test_send_certificate_over_mtls_user_flows_capture_request: request-capture
  for OBO, refresh-token, and auth-code over the real lab SN/I certificate --
  asserts each builds the mtlsauth endpoint + a private_key_jwt client_assertion
  + the flow's own grant with none of the mtls_pop markers, without needing
  those (non-mTLS-enabled) apps allow-listed.
- test_send_certificate_over_mtls_user_flows_live_acquire: skip-gated live
  OBO/refresh placeholder documenting the pending server-side app
  mTLS-enablement (AADSTS700027 / AADSTS392189), mirroring MSAL .NET's [Ignore].

All three are gated (lab cert / explicit skip), collect cleanly, and skip
locally; the live and capture cells run in the lab pipeline.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add a docstring admonition to the client_credential parameter describing the
Bearer-over-mTLS opt-in: set send_certificate_over_mtls=True on the certificate
credential dictionary to present the cert on the mTLS handshake and receive a
plain (non-cert-bound) Bearer token. Notes that it covers all four confidential
flows, defaults to False, requires a certificate credential, and that a
per-request mtls_proof_of_possession=True always takes precedence.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add a negative assertion to test_cc_second_call_is_bearer_cache_hit that the
cached plain Bearer AT is stored under the ORIGINAL login.* host and its
environment never contains "mtlsauth". This is the assertion that would fail if
the cache seam were ever switched to the .NET-style mtlsauth.* environment
(Option A), which would silently break msal-python's login.*-keyed cache lookup
and defeat the 2nd-call cache hit. Also assert result1 == result2 (the same
cached AT is returned) alongside the existing exactly-one-network-POST guard.

Mirrors the cross-SDK finding that a 2nd-call cache test can pass without
actually proving the cache environment; this makes the env dependence explicit
and deterministic.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Robbie-Microsoft
Robbie-Microsoft marked this pull request as ready for review October 22, 2026 12:55
@Robbie-Microsoft
Robbie-Microsoft requested a review from a team as a code owner October 22, 2026 12:55
@Robbie-Microsoft
Robbie-Microsoft requested a balanced review from Copilot October 22, 2026 12:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved transport and certificate-chain issues, combined with authentication and persistent-cache changes, require fixes and final human review.

2 open issues
What changed in this PR

Adds opt-in Bearer-over-mTLS authentication to MSAL’s confidential-client flows, distinct from certificate-bound mTLS PoP tokens.

Changes:

  • Adds certificate validation, forced x5c assertions, and mTLS routing for client-credentials and user flows.
  • Updates certificate-chain handling, TLS trust configuration, and bound-token cache isolation.
  • Expands regression and live-test coverage; live user-flow acquisition remains gated.
File Description
tests/​test_token_cache.py Tests key normalization, collision prevention, and legacy-entry handling.
tests/​test_mtls_transport.py Tests certificate chains, TLS handshakes, and CA-bundle handling.
tests/​test_e2e.py Adds live client-credentials coverage and user-flow request capture.
tests/​test_application.py Tests routing, assertions, cache behavior, and PoP safeguards.
msal/​token_cache.py Hardens bound-token keys, lookup isolation, and legacy removal.
msal/​mtls.py Restricts supported hosts and selects sessions by TLS verification settings.
msal/​application.py Implements Bearer-over-mTLS routing and certificate-chain support.
docs/​index.rst Documents PoP transport restrictions and rejected-response handling.

Review effort: Balanced

Comment thread msal/application.py
Comment thread msal/application.py Outdated
Robbie-Microsoft and others added 2 commits October 22, 2026 09:56
Reject post overrides centrally for Bearer-over-mTLS grants and capture requests through the mTLS transport in tests. Build ordered PFX issuer chains using signature verification, excluding unrelated and duplicate certificates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Return the binding context for Bearer-over-mTLS results and use it in the public sample and documentation.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants