Skip to content

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

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

Add SNI certificate support over mTLS (Bearer-over-mTLS)#947
Robbie-Microsoft wants to merge 6 commits into
rginsburg/sni-mtls-popfrom
rginsburg/bearer-over-mtls

Conversation

@Robbie-Microsoft

Copy link
Copy Markdown
Contributor

Bearer-over-mTLS (SN/I cert on the handshake -> plain Bearer)

App-level opt-in client_credential={..., "send_certificate_over_mtls": True}.
When set, MSAL presents the SN/I certificate on the TLS handshake to the
[region.]mtlsauth.microsoft.com endpoint and signs a private_key_jwt
client_assertion (x5c forced), but requests a plain Bearer token. The
certificate authenticates the transport; the token is not bound to it.

Distinct from:

  • mtls_pop (mtls_proof_of_possession=True): binds the token
    (token_type=mtls_pop) and fences the cache by key_id. A per-request
    mtls_proof_of_possession=True always wins over this app-level flag.
  • the classic private_key_jwt path: never puts the cert on the TLS handshake
    (goes to login.*).

Wire contract (flag set AND request not mtls_pop)

  • Endpoint: global https://mtlsauth.microsoft.com/{tenant}/oauth2/v2.0/token,
    or regional https://{region}.mtlsauth.microsoft.com/....
  • Body: normal grant params + client_assertion (private_key_jwt) +
    client_assertion_type=...jwt-bearer, with x5c forced on the assertion
    header. NO req_cnf, NO key_id, NO token_type=mtls_pop.
  • token_type is Bearer; the cache entry is plain (not key_id-fenced).

Coverage

Ports MSAL.NET CertificateOptions.SendCertificateOverMtls. This PR lands the
client-credentials leg plus config/builder validation. OBO / refresh /
auth-code + E2E follow.

Targets rginsburg/sni-mtls-pop (sibling to the FIC PR). Draft / WIP.

Robbie-Microsoft and others added 6 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>
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.

1 participant