feat: extend mTLS bearer transport (SendCertificateOverMtls) to OBO, refresh_token, and auth_code flows#6009
feat: extend mTLS bearer transport (SendCertificateOverMtls) to OBO, refresh_token, and auth_code flows#6009Robbie-Microsoft wants to merge 23 commits into
Conversation
…de flows When SendCertificateOverMtls=true, MSAL previously only routed AcquireTokenForClient to the mTLS endpoint (mtlsauth.microsoft.com) and suppressed client_assertion from the POST body. User flows (OBO, refresh_token, auth_code) fell through to the regular login endpoint with a client_assertion JWT. This change extends the feature to all three user flows by calling TryInitMtlsPopParametersAsync in each executor path, mirroring the existing AcquireTokenForClient behaviour. Changes: - ConfidentialClientExecutor: add TryInitMtlsPopParametersAsync to OBO and auth_code executor paths - ClientApplicationBaseExecutor: add TryInitMtlsPopParametersAsync to the refresh_token (IByRefreshToken) executor path - RegionAndMtlsDiscoveryProvider: attempt region discovery for mTLS-enabled user flows when the app has configured WithAzureRegion, so regional mTLS endpoints (e.g. eastus.mtlsauth.microsoft.com) are used for OBO/RT - TokenCache: use OriginalAuthority for cache alias resolution so that mTLS-transformed (mtlsauth.*) endpoints do not propagate into cache lookups Tests: - MtlsBearerUserFlowTests.cs: 4 unit tests (OBO global/regional mTLS, RT global mTLS, regression for non-mTLS cert credential) - MtlsTransportUserFlowTests.cs: updated integration tests asserting both mTLS transport conditions (mtlsauth endpoint + no client_assertion) for OBO and RT Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Extends mTLS bearer transport behavior (SendCertificateOverMtls) beyond client_credentials to OBO, refresh_token, and auth_code flows, ensuring mTLS endpoint routing and suppressing client_assertion where appropriate.
Changes:
- Initialize mTLS/PoP parameters for auth_code, OBO, and refresh_token executor paths.
- Enable region discovery for mTLS-enabled user flows when AzureRegion is configured (regional mtlsauth endpoints).
- Prevent mTLS-transformed authorities from leaking into token cache alias resolution by using OriginalAuthority.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs | Adds unit coverage validating mtlsauth routing + client_assertion suppression for OBO/RT, plus a non-mTLS regression case |
| tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs | Adds/updates integration coverage for mTLS transport factory usage and asserts endpoint/body conditions for OBO/RT/client_credentials |
| src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs | Uses OriginalAuthority for alias resolution to avoid mtlsauth host affecting cache lookups |
| src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs | Attempts region discovery for mTLS user flows when AzureRegion is configured |
| src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs | Calls TryInitMtlsPopParametersAsync for auth_code and OBO paths |
| src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs | Calls TryInitMtlsPopParametersAsync for refresh_token path |
CertHelper.GetOrCreateTestCert() returns a static cached instance. Calling Dispose() in ClassCleanup poisons the cache, causing MtlsPopTests.ClassInitialize to receive a disposed X509Certificate2 (m_safeCertContext is an invalid handle) when it runs alphabetically after MtlsBearerUserFlowTests. CertHelper owns the certificate lifetime; test classes must not dispose certs obtained from it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Clear MSAL_FORCE_REGION in regional unit test for defensive isolation - Clarify assertion messages to specify GetHttpClient(X509Certificate2) overload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix 'requestwith' typo in XML doc - Clarify that ExpectedPostData checks client_assertion_type (not the client_assertion value itself, which is a dynamically generated JWT) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Flow test, no Console.WriteLine - MtlsTransportUserFlowTests: replace secret-based OBO/RT factory tests with cert+SendCertificateOverMtls=true (OboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync, RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsync), making them true mTLS integration tests that assert on both the mTLS endpoint and factory invocation - Remove SilentFlow_WithMtlsTransportFactory_UsesRefreshTokenOverMtlsAsync: it attached IMsalMtlsHttpClientFactory to a PCA (public client), which does not perform cert-based client authentication; the test did not exercise the feature being changed - Remove _oboClientSecret, _keyVault, and secret-based TestInitialize; credentials are now the lab cert via SendCertificateOverMtls across all tests - Remove all Console.WriteLine calls; diagnostic context is embedded in Assert messages - MtlsBearerUserFlowTests: rename regional unit test to UserFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMtlsEndpointAsync and clarify in XML doc that it is a general-purpose regional routing test (shared code path across all user flows), not an OBO-specific test
…est matrix Adds the missing (OBO × client_secret) cell to the 2x2 grant/auth-mechanism matrix: | grant | auth mechanism | expected | |-------------------|----------------------|----------| | client_credentials| mTLS (no assertion) | PASS | | client_credentials| client_secret | PASS | | OBO | mTLS (no assertion) | FAIL* | | OBO | client_secret | PASS ← new | * Fails with AADSTS51000: MtlsClientAuth is/are disabled on AppWebApi. The baseline test proves OBO itself works; the mTLS failure is app-config only. Also updates OboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync XML doc to cross-reference the 2x2 matrix and clarify expected failure reason. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bug #1 (cache crash on 2nd mTLS call): - TokenCache.ITokenCacheInternal.cs: FilterTokensByEnvironmentAsync and FindRefreshTokenAsync used requestParams.AuthorityInfo for alias resolution. After ResolveAuthorityAsync(), AuthorityInfo.Host is 'mtlsauth.microsoft.com', which causes RegionAndMtlsDiscoveryProvider to throw MtlsPopNotSupportedForEnvironment. - Fix: use requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo (same pattern already applied to GetTenantProfilesAsync in this file). - Added regression test: OboFlow_WithSendCertificateOverMtls_SecondCallDoesNotCrashAsync Bug #2 (AcquireTokenSilent does not route RT redemption to mTLS endpoint): - ClientApplicationBaseExecutor.cs: the AcquireTokenSilentParameters overload never called TryInitMtlsPopParametersAsync, so IsMtlsRequested=false and RT redemption went to login.microsoftonline.com instead of mtlsauth.microsoft.com. - Fix: add TryInitMtlsPopParametersAsync call before CreateRequestContextAndLogVersionInfo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace 'contact the ESTS team' with customer-facing preview notice. Feature is in preview; AAD-side enablement is required but no self-serve portal exists yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt:6
- This PR also removes the unshipped public APIs
AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(...)and.WithReservedScopes(...), which is a significant surface-area change not mentioned in the PR description (the description focuses on extending mTLS bearer transport). Please either document this removal in the PR description (with rationale) or split it into a separate PR to keep the change set focused.
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3
Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEvaluationAsync(Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
Per Bogdan+Qi direction: for the preview drop, all flows send client_assertion in the POST body even when SendCertificateOverMtls=true. The cert authenticates at the TLS layer AND the body carries the assertion. North Star (cert-only for S2S) is deferred pending ESTS changes. - CredentialMaterialResolver: remove MtlsCertificate from Mtls mode condition; mTLS bearer no longer suppresses client_assertion for any flow - Unit tests: rename 2 tests (drop 'NoClientAssertion'), flip all 3 to assert client_assertion IS present in body - Integration tests: flip Condition 2 in all 4 BothMtlsConditionsMet tests (OBO, RT, auth_code, S2S) to assert client_assertion IS present Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- TokenCache: reword OriginalAuthority comment to clarify that tokens can be cached under mtlsauth.* but alias resolution must use login.* to avoid MtlsPopNotSupportedForEnvironment from the discovery provider - MtlsBearerUserFlowTests: remove unused 'using Microsoft.Identity.Client.Utils' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (3)
tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs:135
- Same issue as above:
client_assertionis compared for exact equality byMockHttpMessageHandler. Asserting it equals "placeholder" is brittle/incorrect because MSAL will send a dynamically generated JWT.
ExpectedPostData = new Dictionary<string, string>
{
{ OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer },
{ OAuth2Parameter.ClientAssertion, "placeholder" }
}
tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs:194
- Same issue as above for refresh_token redemption:
MockHttpMessageHandlerwill compareclient_assertionexactly, so expecting "placeholder" will fail. Assert presence/non-empty instead of an exact value.
ExpectedPostData = new Dictionary<string, string>
{
{ OAuth2Parameter.ClientId, TestConstants.ClientId },
{ OAuth2Parameter.GrantType, OAuth2GrantType.RefreshToken },
{ OAuth2Parameter.RefreshToken, fakeRefreshToken },
{ OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer },
{ OAuth2Parameter.ClientAssertion, "placeholder" }
},
tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs:168
- This comment states "No client_assertion is sent in the body", which contradicts the test intent/other assertions in this file (mTLS transport preview still includes
client_assertionin the body). Update the comment to avoid misleading future maintainers.
// Build CCA with SendCertificateOverMtls=true: the cert authenticates at the TLS layer
// and the factory provides the mTLS connection. No client_assertion is sent in the body.
// NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory.
var cca = ConfidentialClientApplicationBuilder
- MtlsBearerUserFlowTests: replace 'placeholder' in ExpectedPostData for client_assertion with AdditionalRequestValidation + StringAssert.Contains (handler does exact value match; MSAL generates a signed JWT not 'placeholder') - MtlsTransportUserFlowTests: update stale inline comments that said 'no client_assertion sent' to reflect preview behavior (cert at TLS + assertion) - docs/mtls-bearer-transport.md: update description and verification section to match implemented preview behavior (assertion IS in body) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…om PR #6014 Restores AcquireTokenCommonParameters.SendOfflineAccessScope and all 6 PublicAPI.Unshipped.txt entries for WithCachePartitionKey and WithReservedScopes that were lost due to a prior merge conflict resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…RequestParameters Restores the offline_access scope filtering logic in TokenClient.SendTokenRequestAsync and the SendOfflineAccessScope property on AuthenticationRequestParameters, both originally added in PR #6014 but lost in a prior merge conflict resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Lock LastCapturedUrl/LastCapturedBody reads in RecordingMtlsHttpClientFactory to prevent InvalidOperationException from concurrent MSAL HTTP calls - Add ConfigureAwait(false) to sync-over-async ReadAsStringAsync calls in recording handler (integration tests) and AdditionalRequestValidation lambdas (unit tests) to eliminate deadlock risk - Reword 'Windows only' limitation in docs to clarify it is the integration test setup that is Windows-only, not the feature itself Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…citBearerOverMtlsAsync Case 2 handles the TokenBindingCertificate pattern where the assertion delegate signals mTLS intent by returning a non-null cert. This is a separate opt-in mechanism from SendCertificateOverMtls (Case 1) and must not require it. The SendCertificateOverMtls guard was too broad and broke: - BearerClientAssertion_WithPoPDelegate_Works - BearerClientAssertion_WithPoPDelegate_CanReturnDifferentPairs... - ClientAssertion_NotCalledWhenTokenFromCacheAsync - WithMtlsAssertion_NoRegion_UsesGlobalEndpointAsync Full suite: 2063 passed, 0 failed (up from 2059 passed, 4 failed). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…itializer The comment previously stated 'called once per request' which was inaccurate. Case 2 calls GetAssertionAsync once on every request (including cache hits) to check for TokenBindingCertificate; GetCredentialMaterialAsync calls it a second time on network requests to produce the JWT assertion. Both calls are intentional. Delegates are expected to be cheap (return a pre-generated/cached assertion). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…p compatibility When SendCertificateOverMtls=true, the client_assertion JWT is sent in the body alongside the TLS certificate. For apps configured with SNI (Subject Name Issuer), AAD cannot validate the JWT by cert thumbprint alone - it requires the x5c chain in the JWT header to correlate the assertion with the SNI-registered certificate. Without this fix, Sni_Over_Mtls_Gets_Bearer_Token_Successfully_TestAsync and ClientCredentials_WithSendCertificateOverMtls_BothMtlsConditionsMet fail with: AADSTS700027: The certificate with identifier used to sign the client assertion is not registered on application. SNI may be configured on the app. Please ensure that client assertion is being sent with the x5c claim. The fix: in CredentialMaterialResolver.BuildContext, OR SendX5C with CertificateOptions.SendCertificateOverMtls so x5c is automatically included in the JWT whenever bearer-over-mTLS transport is used. Unit tests: 2063 passed, 0 failed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… pending lab config Both tests require mTLS to be enabled on lab apps in ID4SLAB1 by Bogdan/Qi: - OboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync: AADSTS700027 on AppWebApi (23c64cd8) - RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsync: AADSTS392189 on AppS2S [Ignore] attributes include error codes and instructions to remove once unblocked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add ClientId, GrantType=JwtBearer, and RequestedTokenUse=OnBehalfOf to the ExpectedPostData of UserFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMtlsEndpointAsync, matching the coverage already present in OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAsync. This makes the regional routing test a stronger regression check — it now verifies the full OBO POST body shape, not just the assertion type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
When
SendCertificateOverMtls=true, MSAL previously only applied mTLS bearer transport forAcquireTokenForClient. User flows (OBO, refresh_token, auth_code) continued to use the regularlogin.microsoftonline.comendpoint with aclient_assertionJWT in the POST body.This PR extends mTLS bearer transport to all three user flows so they behave consistently with
client_credentialswhen mTLS transport is configured.Root Cause
TryInitMtlsPopParametersAsync(which setsMtlsCertificateon the request parameters, triggering mTLS endpoint routing) was only called inConfidentialClientExecutor.ExecuteAsyncforAcquireTokenForClientParameters. The OBO, auth_code, and RT executor paths skipped it entirely.Changes
Production
ConfidentialClientExecutor.csTryInitMtlsPopParametersAsyncto OBO and auth_code executor pathsClientApplicationBaseExecutor.csTryInitMtlsPopParametersAsyncto the refresh_token (IByRefreshToken) andAcquireTokenSilentpathsRegionAndMtlsDiscoveryProvider.csWithAzureRegionis configured, so regional endpoints (e.g.eastus.mtlsauth.microsoft.com) are usedTokenCache.ITokenCacheInternal.csOriginalAuthorityfor cache alias resolution so mTLS-transformed (mtlsauth.*) endpoints do not crash on second callMtlsPopParametersInitializer.csIClientSignedAssertionProvider.GetAssertionAsync) is not guarded bySendCertificateOverMtls— it is a separate opt-in where the delegate signals mTLS intent by returning a non-nullTokenBindingCertificate. This is independent of Case 1 (SendCertificateOverMtls+ cert-based credential).CredentialMaterialResolver.csMode=Mtlsonly whenIsMtlsPopRequested(explicit PoP);Mode=OAuthfor all bearer transport cases soclient_assertionis included in the body for every flow. Also auto-enablesSendX5C=truewhenSendCertificateOverMtls=true— required for SNI-registered apps to validate the assertion JWT via the x5c chain.AcquireTokenParameterBuilderExtensions.cs,AcquireTokenCommonParameters.cs,TokenClient.cs,AuthenticationRequestParameters.cs,PublicAPI.Unshipped.txt(×6)WithCachePartitionKey,WithReservedScopes, andSendOfflineAccessScopelost during a prior merge conflict resolution (from PR #6014 /022dcde32)Tests
MtlsBearerUserFlowTests.cs(new)grant_typeandrequested_token_use), regression for non-mTLS cert credential, regression for second-call cache crashMtlsTransportUserFlowTests.cs(new)OboFlow/RefreshTokenFlow/AuthCodeFlow/ClientCredentials_WithSendCertificateOverMtls_BothMtlsConditionsMet,OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointAsync.OboFlow/RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsyncare[Ignore]'d pending lab config (see Testing section).Docs
docs/mtls-bearer-transport.md(new)IMsalMtlsHttpClientFactoryimplementation example, supported flows, how to verify, AAD allowlisting requirementDesign: Preview Behavior
For this preview drop, all flows send
client_assertionin the POST body and present the cert at the TLS layer whenSendCertificateOverMtls=true. This matches what ESTS supports today across all grant types.client_assertionin bodyAcquireTokenForClient(S2S)mtlsauth.microsoft.commtlsauth.microsoft.commtlsauth.microsoft.commtlsauth.microsoft.com"Cert-only" (no
client_assertion) is a future ESTS change, deferred post-preview.Bug Fixes (found during review)
Bug 1 - Cache crash on second mTLS call:
TokenCache.ITokenCacheInternal.csusedrequestParams.AuthorityInfo(a live property that returns the mTLS-transformedmtlsauth.*host afterResolveAuthorityAsync) for cache alias resolution.RegionAndMtlsDiscoveryProviderthrowsMtlsPopNotSupportedForEnvironmentfor non-login.*hosts. First call was safe (empty cache, early return); second call had cached entries and crashed. Fixed by usingrequestParams.AuthorityManager.OriginalAuthority.AuthorityInfo.Bug 2 -
AcquireTokenSilentnot routing to mTLS endpoint:ClientApplicationBaseExecutor.ExecuteAsync(AcquireTokenSilentParameters)never calledTryInitMtlsPopParametersAsync, so silent refresh-token redemptions always hitlogin.microsoftonline.comeven whenSendCertificateOverMtls=true.Bug 3 - SNI regression: missing x5c in
client_assertion:Changing
CredentialMaterialResolver.cstoMode=OAuthfor all non-PoP requests causedCertificateAndClaimsClientCredentialto start sending aclient_assertionJWT where previously none was sent. For SNI-registered apps, AAD requires the x5c chain in the JWT header to validate the assertion. Without x5c,AADSTS700027is returned. Fixed by auto-enablingSendX5C=truewhenSendCertificateOverMtls=true.Bug 4 -
TokenBindingCertificateunit tests broken by overly-broad guard:An earlier review comment suggested guarding Case 2 in
MtlsPopParametersInitializerwithSendCertificateOverMtls == true. This brokeBearerClientAssertion_WithPoPDelegate_Worksand 3 related tests because Case 2 is a separate opt-in mechanism that must fire regardless ofSendCertificateOverMtls. The guard was removed.Testing
Unit Tests (net8.0): 2,063 passed, 0 failed
Integration Tests:
Sni_Over_Mtls_Gets_Bearer_Token_Successfully_TestAsync— passes (pre-existing test, fixed by Bug 3 fix above)ClientCredentials_WithSendCertificateOverMtls_BothMtlsConditionsMet— passes (MSI-allowlisted app,163ffef9)OboFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet— passes (recording factory)RefreshTokenFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet— passes (recording factory)AuthCodeFlow_WithSendCertificateOverMtls_BothMtlsConditionsMetAsync— passes (fake auth code, recording factory)OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointAsync— passesOboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync—[Ignore]pending lab config:AppWebApi(23c64cd8) mTLS not yet enabled in ID4SLAB1 (AADSTS700027). Remove[Ignore]once Bogdan/Qi enable mTLS client auth.RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsync—[Ignore]pending lab config:AppS2SmTLS endpoint not yet configured for this scenario (AADSTS392189). Remove[Ignore]once Bogdan/Qi enable mTLS client auth.Existing
MtlsPopTestssuite: 69/69 pass.