Add SNI certificate support over mTLS Proof-of-Possession#938
Draft
Robbie-Microsoft wants to merge 1 commit into
Draft
Add SNI certificate support over mTLS Proof-of-Possession#938Robbie-Microsoft wants to merge 1 commit into
Robbie-Microsoft wants to merge 1 commit into
Conversation
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 - Support FIC two-leg exchange over mTLS PoP via the client_credential mtls_binding_certificate sub-key (jwt-pop assertion over mTLS) - Isolate mtls_pop tokens in cache via key_id (Bearer cache unchanged) - Add token-type telemetry, docs, and a confidential-client mTLS PoP sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| ) | ||
|
|
||
| if "access_token" in result: | ||
| print("token_type:", result["token_type"]) # -> "mtls_pop" |
Comment on lines
+73
to
+74
| print("binding_certificate:", json.dumps( | ||
| result.get("binding_certificate"), indent=2)) |
| # Public binding material only (never the private key): | ||
| print("binding_certificate:", json.dumps( | ||
| result.get("binding_certificate"), indent=2)) | ||
| print("token_source:", result.get("token_source")) |
| result.get("binding_certificate"), indent=2)) | ||
| print("token_source:", result.get("token_source")) | ||
| else: | ||
| print("Token acquisition failed:", result.get("error"), |
| print("token_source:", result.get("token_source")) | ||
| else: | ||
| print("Token acquisition failed:", result.get("error"), | ||
| result.get("error_description")) |
| leg1 = leg1_app.acquire_token_for_client( | ||
| [exchange_scope], mtls_proof_of_possession=True) | ||
| if "access_token" not in leg1: | ||
| print("Leg 1 failed:", leg1.get("error_description")) |
| leg2 = leg2_app.acquire_token_for_client( | ||
| os.environ["SCOPE"].split(), mtls_proof_of_possession=True) | ||
| if "access_token" in leg2: | ||
| print("Leg 2 token_type:", leg2["token_type"]) # -> "mtls_pop" |
| if "access_token" in leg2: | ||
| print("Leg 2 token_type:", leg2["token_type"]) # -> "mtls_pop" | ||
| else: | ||
| print("Leg 2 failed:", leg2.get("error_description")) |
Contributor
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.
Comment on lines
+180
to
+191
| context = ssl.create_default_context() # Verifies the server (ESTS) as usual | ||
| fd, path = tempfile.mkstemp(suffix=".pem") # Owner-only (0600) by default | ||
| try: | ||
| os.write(fd, key_pem + b"\n" + cert_pem) | ||
| os.close(fd) # Close before load_cert_chain reads it (required on Windows) | ||
| 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 |
Comment on lines
169
to
171
| ] + ([ext_cache_key] if ext_cache_key else []) | ||
| + ([key_id] if key_id else []) # ATs of different key_id coexist | ||
| ).lower(), |
Comment on lines
+2843
to
+2847
| "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.") |
Comment on lines
+2849
to
+2852
| raise ValueError( | ||
| "mtls_proof_of_possession=True requires a tenanted authority. " | ||
| "Use a specific tenant id or domain instead of " | ||
| "/common or /organizations.") |
Comment on lines
+1055
to
+1059
| 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.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds support for using a Subject Name + Issuer (SN/I) certificate as the first-leg credential over mTLS Proof-of-Possession (PoP). A confidential-client app configured with an SN/I certificate can now obtain an mTLS-bound PoP access token from Microsoft Entra (ESTS), where that same certificate is used as the client TLS certificate in the mutual-TLS handshake to the token endpoint.
This closes the gap between the existing SN/I + Bearer flow (cert signs a
private_key_jwtassertion) and the new SN/I + mTLS PoP flow (cert is the TLS client cert, ESTS returnstoken_type=mtls_popbound viacnf/x5t#S256). The credential is the same; only the mechanism changes (assertion-signer to TLS client cert). Wire behavior mirrors the shipped MSAL.NET implementation.Public API
Vanilla SN/I -> mTLS PoP:
FIC two-leg over mTLS PoP -- the leg-2 client presents the leg-1 (cert-bound) token as a
jwt-popassertion over the same cert's mTLS connection:What changed
Core
msal/mtls.py(new) -- mTLS client-cert transport: token-endpoint transform,SSLContextbuilt from the cert material, requests adapter injection, plus sovereign-cloud and known-host guardrails.msal/application.py--mtls_proof_of_possessionkwarg onacquire_token_for_client; cert-material plumbing;_MtlsClient; mTLS client selection for cert-bound PoP and every FIC leg-2 request;binding_certificatein the result; fail-fast guards (tenanted authority, customhttp_client, missing cert).msal/token_cache.py-- mtls_pop tokens isolated bykey_id(=x5t#S256). Bearer cache keys are byte-for-byte unchanged; a Bearer lookup never returns a key-bound token.msal/telemetry.py-- token-type telemetry (mtls_pop->6); Bearer telemetry unchanged.msal/oauth2cli/oauth2.py--jwt-popclient-assertion-type constant.msal/sku.py-- version bump1.37.0->1.38.0.Backward compatibility -- the existing SN/I + Bearer (assertion / x5c) flow is untouched; the same certificate can be used either way.
Tests
tests/test_mtls_transport.py(new, 14) -- endpoint transform, sovereign guardrail, SSLContext build + temp-file cleanup, adapter injection, lazy session.tests/test_application.py(+10) -- vanilla request/result, cache hit, backward-compat Bearer, regional, FIC leg-2 (PoP and Bearer-over-mTLS), and validation fail-fasts (secret+flag, string-assertion+flag, custom-http-client, untenanted/common).tests/test_token_cache.py-- Bearer + mtls_pop coexistence / isolation.tests/test_e2e.py(+3) -- E2E-1 vanilla, E2E-2 regional, E2E-3 FIC two-leg. They run in the existing ADO E2E stage using the non-CNG lab cert, and self-skip when lab creds are absent.Verification: full unit suite 340 passed, 11 skipped (no regressions); 25 mTLS-focused tests green.
Docs / samples
docs/index.rst-- new "mTLS Proof-of-Possession (SN/I certificate)" section (SHR-PoP vs mTLS-PoP, tenanted-authority/region requirements).acquire_token_for_clientmtls_proof_of_possessionkwarg +binding_certificateresult;client_credentialmtls_binding_certificatesub-key for FIC leg 2.sample/confidential_client_mtls_pop_sample.py(new) -- vanilla + FIC examples.Notes / open questions
http_client); both are enforced with clearValueErrors.msal/mtls.pyfor easy lifting later.