Add SNI certificate support over mTLS Proof-of-Possession - #938
Add SNI certificate support over mTLS Proof-of-Possession#938Robbie-Microsoft wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds mTLS Proof-of-Possession support for confidential clients using SN/I certificates, including mTLS-bound token acquisition, transport handling, telemetry updates, and cache isolation so Bearer and key-bound tokens can safely coexist.
Changes:
- Introduces mTLS transport + endpoint transformation/guardrails for mTLS PoP token requests.
- Adds
mtls_proof_of_possessionsupport toacquire_token_for_client(), including FIC leg-2 over mTLS behavior and public binding material in results. - Updates token cache + telemetry to properly isolate and classify
mtls_poptokens; adds unit/E2E tests, docs, and a sample.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_token_cache.py | Adds coverage ensuring Bearer and mtls_pop ATs coexist and don’t cross-match. |
| tests/test_optional_thumbprint.py | Updates certificate mock shape to include PEM material. |
| tests/test_mtls_transport.py | New tests for host transform/guardrails, SSLContext creation, adapter injection, and lazy session creation. |
| tests/test_e2e.py | Adds E2E scenarios for SN/I over mTLS PoP and FIC two-leg over mTLS. |
| tests/test_application.py | Adds unit tests validating request wiring, cache hits, backward compat, regional routing, FIC leg-2, and guardrails. |
| sample/confidential_client_mtls_pop_sample.py | New sample demonstrating vanilla mTLS PoP and optional FIC two-leg flow. |
| msal/token_cache.py | Adds key_id support to AT cache keys and prevents unkeyed queries from matching keyed entries. |
| msal/telemetry.py | Adds token-type mapping for mtls_pop and emits the platform config field when needed. |
| msal/sku.py | Bumps version to 1.38.0. |
| msal/oauth2cli/oauth2.py | Adds jwt-pop client assertion type constant. |
| msal/mtls.py | New module implementing mtlsauth host mapping/guardrails and a requests transport that presents a client cert. |
| msal/application.py | Adds cert-material plumbing, _MtlsClient, mTLS client selection, cache binding via key_id, and public binding result. |
| docs/index.rst | Documents new mTLS PoP feature, requirements, and usage patterns. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
9b42d19 to
5339d9e
Compare
Allow a confidential-client app configured with a Subject Name + Issuer (SN/I) certificate to obtain an mTLS-bound PoP access token from Entra ID, using the same certificate as the client TLS certificate in the mutual-TLS handshake to the token endpoint (token_type=mtls_pop, cnf/x5t#S256 binding). - Add mtls_proof_of_possession kwarg to acquire_token_for_client, returning a binding_certificate (public x5c + sha256 thumbprint) on success - Add mTLS client-cert transport (msal/mtls.py) with endpoint transform and sovereign-cloud / tenanted-authority / custom-http-client guardrails - Isolate mtls_pop tokens in cache via key_id (Bearer unchanged) - Add token-type telemetry, docs, and a confidential-client mTLS PoP sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
5339d9e to
dd4f13a
Compare
The two SN/I-over-mTLS-PoP e2e tests used the generic lab app (LAB_APP_CLIENT_ID) with the microsoft.onmicrosoft.com authority. That combination works for Bearer SN/I but is not ESTS allow-listed for mTLS PoP, so the token request fails with AADSTS700025. Point both tests at the SN/I-allow-listed app 163ffef9-a313-45b4-ab2f-c7e2f5e0e23e in the MSI-team tenant, mirroring the MSAL .NET/Java e2e config. The lab SN/I cert and the MS Graph resource are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Gate the ext_cache_key and key_id cache-isolation filters in TokenCache.search() on a non-empty target. Scoped token retrieval keeps the mtls_pop / FMI isolation intact, but broad target-less searches (used by _sign_out and remove_account) now enumerate key-bound access tokens so they are removed from the cache instead of being left behind. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ion build Harden the mTLS Proof-of-Possession path with three fixes surfaced by review. Fail closed on a token_type downgrade. A cert-bound request is driven by our request flag, not the response, so if ESTS answers an mtls_pop request with a non-cert-bound token we previously still attached binding_certificate and cached it as bound. Now acquire_token_for_client returns token_type_mismatch when the response token_type is not "mtls_pop", and _MtlsClient only re-injects key_id (the cache binding) when the response is actually mtls_pop. A downgraded token therefore caches as an ordinary token that the cert-bound silent query can never match, so the flow re-fetches and fails closed every time. Mirrors MSAL .NET/Go. Honor verify in the mTLS transport. A custom ssl_context bypasses requests' own verify handling, so verify was silently ignored: verify=False raised a cryptic ValueError and the default used the system store instead of certifi. The context builder now matches requests' semantics (certifi default, CA file/dir path, or disabled) so verify behaves as documented. Serialize first session build. Wrap the lazy session construction in a double-checked lock so a cold-start burst on a multi-threaded confidential client no longer builds N transports (each writing the private key to a temp file) and orphans all but one. Also drop stray blank lines that had crept into the acquire_token_for_client mTLS setup block. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two review fixes for the mTLS PoP work. Cache isolation (empty-scopes leak): the key_id and ext_cache_key isolation filters in TokenCache.search() were gated on `target`, so an empty-scopes for-use lookup (e.g. acquire_token_for_client([])) was target-less and bypassed isolation -- returning a key-bound (mtls_pop) or FMI-bound AT the caller never asked for. Decouple "removal intent" from "target-less" via a keyword-only for_removal flag: isolation is now unconditional for for-use lookups and only the two removal callers (_sign_out, remove_tokens_for_client) opt out with for_removal=True to enumerate and delete those ATs. Mirrors MSAL .NET, where token-type isolation is never gated on scope state. mTLS transport verify=None: _MtlsHttpClient forced CERT_NONE onto a verifying ssl_context when verify was None, raising ValueError. Normalize None to True (the safe default for a security library, verifying via certifi) so session.verify and the ssl_context agree. Adds regression tests for both empty-scope leak paths (key-bound and ext-bound) and updates the removal-enumeration test to opt in via for_removal=True. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Four small, low-risk fixes from the code review: - application.py `_private_key_to_unencrypted_pem`: pass an explicit `backend=default_backend()` to `load_pem_private_key` (required on the supported `cryptography` floor, 2.5) and wrap the call so a bad or encrypted-without-passphrase key surfaces a clear, actionable ValueError instead of a cryptic low-level error. Mirrors the sibling `_load_private_key_from_pem_str`. - token_cache.py: coerce `key_id` to a str via a new `_key_id_to_str` helper before building the AccessToken cache key, so a non-str key_id never raises TypeError at the `"-" + key_id` concatenation. The normal ASCII-str path is byte-identical, so existing mtls_pop cache entries are unaffected. Defensive only: current callers already pass an ASCII str. - test_application.py: hoist `import os` to the top of the module and drop the mid-file `import os as _os` alias. Adds regression tests: bytes/other key_id coercion (test_token_cache.py) and the clearer private-key load errors (test_mtls_transport.py). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| "login.microsoftonline.com", | ||
| "login.microsoft.com", | ||
| "login.windows.net", | ||
| "sts.windows.net", |
There was a problem hiding this comment.
Go's PR might not accept sts.windows.net, I think there is a gate for "login.*". can you verify if this is intended or not?
|
@Robbie-Microsoft Thank you for this important PR. It will significantly improve security, and we're looking forward to seeing it merged and included in an upcoming release. @Robbie-Microsoft, @4gust Would you happen to have an estimated timeline for when this PR might be merged? |
gladjohn
left a comment
There was a problem hiding this comment.
Thanks for putting this together. Before this moves forward, I have some concerns that make the feature feel incomplete from my perspective.
1. Missing the flow I actually need: Bearer over mTLS
This PR delivers mtls_pop (proof-of-possession) tokens, but it does not cover Bearer tokens obtained over an mTLS connection using bound credentials.
Presenting the certificate as the TLS client certificate should be usable to obtain a Bearer access token, not only a PoP-bound one. As currently designed, the moment the certificate is used as the TLS binding, the result is forced to mtls_pop, and the cache explicitly prevents a Bearer lookup from ever returning a key-bound token.
That closes off the exact scenario I'm after.
Can we clarify whether Bearer-over-mTLS is in scope, and if not, why it was excluded?
2. No spec, design, or task to anchor the work
I can't find a linked spec, design doc, or tracking issue for this change. Without that, it's hard to review the intended scope, the token-type decision matrix (Bearer vs. mtls_pop vs. SN/I+Bearer), the caching/isolation model, and the sovereign cloud fencing.
Could you link the design and the work item(s) this implements? That would also make it clear which scenarios are intentionally deferred versus simply missing.
3. This isn't a 1:1 port of the MSAL.NET feature
The description says the wire behavior "mirrors MSAL.NET," but the surfaced capabilities don't appear to be a 1:1 parity port. MSAL.NET's mTLS support covers more than just the mtls_pop path implemented here.
If we're claiming parity, I'd like to see a comparison of what MSAL.NET exposes versus what this PR exposes, with any intentional gaps called out explicitly. Otherwise, we risk shipping a partial subset under the impression it matches .NET.
Ask
Could you:
- Link the spec, design, and tracking issue.
- Confirm whether Bearer-over-mTLS is planned (in this PR, a follow-up, or intentionally out of scope).
- Provide a feature parity matrix against MSAL.NET.
As it stands, I don't think this is complete enough to merge for my use case.
Port the msal-dotnet PR #6100 rigor to the SN/I-over-mTLS-PoP e2e tests: - Add a reusable _call_graph_with_mtls_pop_token(token, cert) helper that presents the acquired token to the mTLS Graph host (mtlstb.graph.microsoft.com) over a TLS channel bound by the SAME certificate and asserts HTTP 200. Acquiring a token is not proof it works; a 401/403 (binding lost / wrong scheme) is a regression. The helper reuses msal.mtls._create_ssl_context/_make_mtls_adapter and is shared with the FIC e2e (follow-up PR). - Rename the two PoP tests to the cross-SDK Credential_<X509|Fic>_Output_<Pop| Bearer> matrix (snake_case): test_credential_x509_output_pop (+_regional), both now call the resource-call helper. - Add test_credential_x509_output_bearer: the same SN/I cert WITHOUT the flag must still authenticate via a signed client_assertion and yield an ordinary token (no binding_certificate) - the client_assertion is dropped only on the mTLS path. - Regional test: skip only on temporarily_unavailable. invalid_request is a 400 config error (a real regression), so it now fails loudly instead of being masked as "endpoint not reachable". - Docs/sample/code comments: azure_region is optional (global mtlsauth fallback); clarify the classic cert path still signs a private_key_jwt client_assertion while only mTLS PoP omits it; tighten the sample's log-safety note (binding_certificate x5c is public, not a secret). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per cross-SDK review feedback: make the Bearer contrast explicit. On the non-mTLS path MSAL Python omits binding_certificate entirely, so assert both that the key is absent and that a lookup yields None - proving the cert binding is genuinely gone, not merely that the mtls_proof_of_possession flag was skipped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per cross-SDK review: document that the FIC two-leg e2e (PR #939) reuses this helper as-is, so it is not inlined later. Explains why it takes an explicit cert_credential instead of reading self.app's cert. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
msal/token_cache.py:328
- The key_id isolation gate has the same issue as ext_cache_key:
"key_id" not in (query or {})only checks presence of the key, so a query that includeskey_id=Nonewill bypass isolation and may match key-bound tokens (e.g., mtls_pop) even though the caller effectively did not provide a key_id. This can cause a Bearer-style lookup to “leak” a bound token if callers build query dicts withkey_idset to a default None.
if (credential_type == self.CredentialType.ACCESS_TOKEN
and not for_removal
and "key_id" in entry
and "key_id" not in (query or {})
):
msal/token_cache.py:316
- In the ext_cache_key isolation gate, the check
"ext_cache_key" not in (query or {})only tests key presence, not whether the caller actually provided a non-empty ext_cache_key. A query that includesext_cache_key=None(or other falsy value) will bypass this isolation and can unintentionally match ext-bound (FMI) tokens, contradicting the docstring guarantee that queries “without one” must not match ext-bound entries.
if (credential_type == self.CredentialType.ACCESS_TOKEN
and not for_removal
and "ext_cache_key" in entry
and "ext_cache_key" not in (query or {})
):
| 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.") |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
msal/token_cache.py:26
_key_id_to_str()is intended to be defensive (“building the cache key never raises”), butkey_id.decode('ascii')can still raiseUnicodeDecodeErrorif a caller provides non-ASCII bytes (e.g., raw digest bytes). That would still crash cache key construction.
Consider catching decode failures and falling back to an ASCII-safe representation (e.g., base64url) so any bytes value stays non-throwing.
def _key_id_to_str(key_id):
"""Coerce a key_id (base64url x5t#S256) into a str for the AT cache key.
It is normally an ASCII str, but tolerate bytes (decoded as ASCII) or any
other type so that building the cache key never raises ``TypeError``.
"""
if isinstance(key_id, bytes):
return key_id.decode("ascii")
return key_id if isinstance(key_id, str) else str(key_id)
Align with the region-split rule shared across the sibling SDKs (merged in
MSAL .NET, matched by Java/JS/Go): the regional slice intermittently
downgrades an mtls_pop request to Bearer by returning a Bearer *token* (not a
temporarily_unavailable error), so a regional PoP cell either fails its
token_type assert or forces us to mask a real downgrade - both wrong.
- DELETE test_credential_x509_output_pop_regional. The {region}.mtlsauth host
rewrite stays covered by the mtls transport/endpoint unit tests, so no unit
coverage is lost.
- Make test_credential_x509_output_bearer REGIONAL (azure_region="westus3").
A Bearer token_type is deterministic, so this safely RETAINS live
regional-endpoint E2E coverage. Comment explains region is correct here but
forbidden on the PoP cell.
- test_credential_x509_output_pop stays GLOBAL (Graph scope, resource-call
200, cache-hit) - unchanged.
Result mirrors MSAL .NET: pop (global) / bearer (regional westus3).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
Adds support for using an SN/I (Subject Name + Issuer) certificate as the client credential over mTLS Proof-of-Possession. A confidential client configured with an SN/I cert can obtain an mTLS-bound
mtls_popaccess token from Entra (ESTS), using that same certificate as the client TLS cert in the handshake to the token endpoint. The credential is identical to the existing SN/I + Bearer flow — only the mechanism changes (assertion-signer → TLS client cert). Wire behavior mirrors MSAL.NET.Usage
What changed
msal/mtls.py(new) — mTLS transport: token-endpoint host transform,SSLContextfrom the cert material, requests-adapter injection, sovereign/known-host guardrails.msal/application.py—mtls_proof_of_possessionkwarg onacquire_token_for_client; cert-material plumbing;_MtlsClient;binding_certificatein the result; fail-fast guards (tenanted authority, customhttp_client, missing cert).msal/token_cache.py—mtls_poptokens isolated bykey_id(x5t#S256); Bearer keys stay byte-for-byte unchanged and a Bearer lookup never returns a key-bound token; sign-out/removal still purge key-bound ATs.msal/{region,telemetry,sku}.py— regional mtls-endpoint plumbing; token-type telemetry (mtls_pop→6); version →1.38.0.private_key_jwt) flow is untouched; the same cert works either way.Tests & docs
tests/test_mtls_transport.py(new, 17) plus additions totest_application.py,test_token_cache.py,test_region.py, and two self-skipping E2E cases intest_e2e.py. Full unit suite passes; no regressions.docs/index.rstgains an "mTLS Proof-of-Possession (SN/I certificate)" section; newsample/confidential_client_mtls_pop_sample.py.Notes
http_client) — both enforced with clearValueErrors.msal/mtls.pyfor easy lifting later.