Add SNI certificate support over mTLS Proof-of-Possession#938
Add SNI certificate support over mTLS Proof-of-Possession#938Robbie-Microsoft wants to merge 6 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? |
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.