Skip to content

Add SNI certificate support over mTLS Proof-of-Possession#938

Open
Robbie-Microsoft wants to merge 6 commits into
devfrom
rginsburg/sni-mtls-pop
Open

Add SNI certificate support over mTLS Proof-of-Possession#938
Robbie-Microsoft wants to merge 6 commits into
devfrom
rginsburg/sni-mtls-pop

Conversation

@Robbie-Microsoft

@Robbie-Microsoft Robbie-Microsoft commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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_pop access 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.

The optional two-leg FIC exchange over mTLS PoP is a stacked follow-up: #939.

Usage

app = msal.ConfidentialClientApplication(
    client_id,
    authority="https://login.microsoftonline.com/<tenant-id>",   # must be tenanted
    client_credential={"private_key_pfx_path": "sni.pfx", "public_certificate": True},
    # azure_region="westus3",   # optional; omit for the global mtls endpoint
)
result = app.acquire_token_for_client(
    ["https://graph.microsoft.com/.default"], mtls_proof_of_possession=True)
# result["token_type"] == "mtls_pop"
# result["binding_certificate"] == {"x5c": [...], "thumbprint_sha256": "..."}  # public only

What changed

  • msal/mtls.py (new) — mTLS transport: token-endpoint host transform, SSLContext from the cert material, requests-adapter injection, sovereign/known-host guardrails.
  • msal/application.pymtls_proof_of_possession kwarg on acquire_token_for_client; cert-material plumbing; _MtlsClient; binding_certificate in the result; fail-fast guards (tenanted authority, custom http_client, missing cert).
  • msal/token_cache.pymtls_pop tokens isolated by key_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_pop6); version → 1.38.0.
  • Backward compatible — the existing SN/I + Bearer (private_key_jwt) flow is untouched; the same cert works either way.

Tests & docs

  • tests/test_mtls_transport.py (new, 17) plus additions to test_application.py, test_token_cache.py, test_region.py, and two self-skipping E2E cases in test_e2e.py. Full unit suite passes; no regressions.
  • docs/index.rst gains an "mTLS Proof-of-Possession (SN/I certificate)" section; new sample/confidential_client_mtls_pop_sample.py.

Notes

  • mTLS PoP requires a tenanted authority and MSAL's built-in transport (no custom http_client) — both enforced with clear ValueErrors.
  • Targets public + Azure Government (Arlington); other sovereign clouds are fenced off in msal/mtls.py for easy lifting later.

Copilot AI review requested due to automatic review settings July 1, 2026 19:41
Comment thread sample/confidential_client_mtls_pop_sample.py Fixed
Comment thread sample/confidential_client_mtls_pop_sample.py Fixed
Comment thread sample/confidential_client_mtls_pop_sample.py Fixed
Comment thread sample/confidential_client_mtls_pop_sample.py Fixed
Comment thread sample/confidential_client_mtls_pop_sample.py Fixed
Comment thread sample/confidential_client_mtls_pop_sample.py Fixed
Comment thread sample/confidential_client_mtls_pop_sample.py Fixed
Comment thread sample/confidential_client_mtls_pop_sample.py Fixed
@Robbie-Microsoft Robbie-Microsoft marked this pull request as ready for review July 1, 2026 19:44
@Robbie-Microsoft Robbie-Microsoft requested a review from a team as a code owner July 1, 2026 19:44

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.

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_possession support to acquire_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_pop tokens; 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 thread msal/mtls.py Outdated
Comment thread msal/token_cache.py Outdated
Comment thread msal/application.py Outdated
Comment thread msal/application.py Outdated
Comment thread msal/application.py

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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comment thread msal/application.py
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>
@Robbie-Microsoft Robbie-Microsoft force-pushed the rginsburg/sni-mtls-pop branch from 5339d9e to dd4f13a Compare July 7, 2026 22:31
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>
Copilot AI review requested due to automatic review settings July 8, 2026 00:04

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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comment thread msal/token_cache.py
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>
Copilot AI review requested due to automatic review settings July 8, 2026 02:26

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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread msal/mtls.py
Comment thread msal/token_cache.py Outdated
Comment thread tests/test_application.py Outdated
…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>
Copilot AI review requested due to automatic review settings July 8, 2026 20:39

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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comment thread msal/application.py Outdated
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>
Copilot AI review requested due to automatic review settings July 8, 2026 21:51

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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comment thread msal/application.py Outdated

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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

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>
Copilot AI review requested due to automatic review settings July 8, 2026 22:51

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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comment thread msal/telemetry.py
Comment on lines +19 to +21
_TELEMETRY_TOKEN_TYPES = {
"mtls_pop": 6, # mTLS-bound Proof-of-Possession
}
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.

3 participants