Skip to content

Add Bearer-over-mTLS (sendCertificateOverMtls) for confidential clients - #1054

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

Add Bearer-over-mTLS (sendCertificateOverMtls) for confidential clients#1054
Robbie-Microsoft wants to merge 3 commits into
rginsburg/sni-mtls-popfrom
rginsburg/bearer-over-mtls

Conversation

@Robbie-Microsoft

Copy link
Copy Markdown
Contributor

Bearer-over-mTLS (sendCertificateOverMtls)

Ports MSAL.NET's CertificateOptions.SendCertificateOverMtls (.NET PRs #5849 client-credentials, #6009 OBO/refresh/auth-code).

What it is: present the app's SN/I certificate as the client certificate on the mTLS handshake to the token endpoint, route to the mTLS endpoint (mtlsauth.*), and receive a plain Bearer access token. The certificate authenticates the transport; the token is not bound to it.

How it differs from existing surfaces:

  • NOT mTLS PoP (Task 1, Add SN/I certificate support over mTLS Proof-of-Possession (PoP) #1040): that binds the token (token_type=mtls_pop) and fences the cache by certificate thumbprint.
  • NOT the existing SNI + private_key_jwt Bearer path: that signs an assertion to the regular token endpoint; the cert is never on the TLS handshake. Bearer-over-mTLS = cert on the handshake → Bearer from the mTLS endpoint.

API

ConfidentialClientApplication.Builder.sendCertificateOverMtls(boolean) — app-level, defaults false (zero behavior change when unset). build() fails fast with MsalClientException (AuthenticationErrorCode.CERTIFICATE_REQUIRED_FOR_MTLS) if the flag is set without a certificate credential.

Behavior

  • Routes to the mTLS endpoint reusing the Task-1 SNI mTLS transport (no re-architecture).
  • Keeps token_type=Bearer (no token_type=mtls_pop, no req_cnf); forces the x5c chain on the client_assertion (regardless of app.sendX5c()) so ESTS can do SN/I subject+issuer matching over the mTLS channel; produces a plain (not thumbprint-fenced) Bearer cache entry.
  • A per-request .mtlsProofOfPossession() always takes precedence over the app-level flag.
  • Honored by every confidential flow: client credentials, on-behalf-of, refresh token, authorization code (the flag is read off the application, not a request cast).

Tests

  • Unit (BearerOverMtlsTest, 13 cells via mockConstruction(DefaultHttpClient)): config (default / stored / non-cert fail-fast); client credentials (global mtlsauth + Bearer + client_assertion with forced x5c and no mtls_pop/req_cnf; regional; per-request mtls_pop wins; plain-Bearer cache); user flows (OBO / refresh / auth-code route to mtlsauth with the correct grant + forced-x5c assertion; OBO 2nd call served from cache; OBO without the flag uses the regular login endpoint).
  • Integration (MtlsPopIT): live client-credentials Bearer-over-mTLS cell + cache-hit (SN/I-allow-listed app); a skip-gated OBO cell documenting the user-flow allow-listing gap (AADSTS700027 / AADSTS392189). The token-endpoint wire contract is asserted at the unit level because the mTLS DefaultHttpClient is constructed internally (no factory injection point).

PR topology

Child branch off rginsburg/sni-mtls-pop (#1040), targeting the SNI branch — a sibling to the FIC follow-up #1041, not stacked on it. Retarget to dev after #1040 merges. Does not touch the FIC test or its skip-hatch.

Draft until #1040 merges to dev.

Robbie-Microsoft and others added 3 commits July 31, 2026 16:12
Present the app's SN/I certificate as the client certificate on the mTLS
handshake to the token endpoint and receive a plain (unbound) Bearer token,
ported from MSAL.NET's CertificateOptions.SendCertificateOverMtls (.NET PRs
#5849 client-credentials, #6009 OBO/refresh/auth-code).

This is distinct from Task-1 mTLS Proof-of-Possession: mtls_pop binds the token
to the certificate (token_type=mtls_pop, thumbprint-fenced cache); Bearer-over-mTLS
uses the same mTLS transport but returns an ordinary Bearer token that is NOT
bound to the certificate.

- New app-level ConfidentialClientApplication.Builder.sendCertificateOverMtls(boolean)
  (default false); getter on ConfidentialClientApplication + IConfidentialClientApplication
  (mirrors the sendX5c pattern).
- build() fails fast with MsalClientException (CERTIFICATE_REQUIRED_FOR_MTLS) when the
  flag is set without a certificate credential (mirrors .NET InvalidCredentialMaterial).
- Generalize TokenRequestExecutor routing from isMtlsProofOfPossession() to
  usesMtlsTransport() = mtls_pop OR Bearer-over-mTLS. The Bearer branch routes to the
  mTLS endpoint and forces the x5c chain on the client_assertion (regardless of
  app.sendX5c()), but keeps token_type Bearer (no token_type=mtls_pop / req_cnf) and
  produces a plain Bearer cache entry (not thumbprint-fenced). A per-request
  mtlsProofOfPossession() always takes precedence over the app-level flag.
- Honored by every confidential flow (client credentials, on-behalf-of, refresh token,
  authorization code) by reading the flag off the application rather than a request cast.

Tests:
- BearerOverMtlsTest (13 unit cells via mockConstruction(DefaultHttpClient)): config
  (default/stored/non-cert fail-fast); client credentials (global mtlsauth + Bearer +
  client_assertion with forced x5c and no mtls_pop/req_cnf; regional; per-request
  mtls_pop wins; plain-Bearer cache); user flows (OBO/refresh/auth-code route to
  mtlsauth with the correct grant + forced-x5c assertion; OBO 2nd call served from
  cache; OBO without the flag uses the regular login endpoint).
- MtlsPopIT: live Bearer-over-mTLS client-credentials cell + cache-hit (SN/I-allow-listed
  app), and a skip-gated OBO cell documenting the user-flow allow-listing gap
  (AADSTS700027 / AADSTS392189).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…all cache

Lock the Bearer-over-mTLS 2nd-call cache-serve seam deterministically in CI. The
existing OBO 2nd-call test uses instanceDiscovery(false), which skips the metadata
resolution path where .NET's SecondCallDoesNotCrash regression lived. This adds a
variant with instanceDiscovery(true) (seeding the login-host metadata to avoid a
live IMDS call) that asserts the 2nd call is served from cache without crashing and
that the Bearer AT is cached under the login host, never the rewritten mtlsauth host.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per cross-SDK parity review: name the gate (AadInstanceDiscoveryProvider
.shouldUseRegionalEndpoint routes regionally only for ClientCredentialRequest, so
user flows fall back to the global endpoint). The OBO+region cell locks that
Bearer-over-mTLS does not accidentally introduce regional routing on a user flow
msal4j does not support there. Comment-only; behavior unchanged (14/14 green).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@gterminator

Copy link
Copy Markdown
Contributor

Hi,
looks fine.

I have only one problem. We are adding and adding more functionality in our tokenrequestexecutor. This will lead to a massive degeneration of our code in the future when everybody does that.

How about we do a refactoring after this PR and use the composite pattern to have a functionality based class like mTLS that add functionality to an object?

@Robbie-Microsoft How is your opinion on that?

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.

2 participants