From 64e17691539460a6648c1a05c1c824249d7983de Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 14 May 2026 13:28:24 -0400 Subject: [PATCH 01/22] feat: extend mTLS bearer transport to OBO, refresh_token, and auth_code 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> --- .../ClientApplicationBaseExecutor.cs | 3 + .../Executors/ConfidentialClientExecutor.cs | 6 + .../RegionAndMtlsDiscoveryProvider.cs | 10 +- .../TokenCache.ITokenCacheInternal.cs | 8 +- .../MtlsTransportUserFlowTests.cs | 497 ++++++++++++++++++ .../PublicApiTests/MtlsBearerUserFlowTests.cs | 282 ++++++++++ 6 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs create mode 100644 tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs index 0556dd2569..b03f9ccade 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs @@ -47,6 +47,9 @@ public async Task ExecuteAsync( AcquireTokenByRefreshTokenParameters refreshTokenParameters, CancellationToken cancellationToken) { + await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken) + .ConfigureAwait(false); + var requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); if (commonParameters.Scopes == null || !commonParameters.Scopes.Any()) { diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs index 8b1178cf78..cba3c2fe8d 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs @@ -37,6 +37,9 @@ public async Task ExecuteAsync( AcquireTokenByAuthorizationCodeParameters authorizationCodeParameters, CancellationToken cancellationToken) { + await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken) + .ConfigureAwait(false); + RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); AuthenticationRequestParameters requestParams = await _confidentialClientApplication.CreateRequestParametersAsync( @@ -85,6 +88,9 @@ public async Task ExecuteAsync( AcquireTokenOnBehalfOfParameters onBehalfOfParameters, CancellationToken cancellationToken) { + await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken) + .ConfigureAwait(false); + RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); AuthenticationRequestParameters requestParams = await _confidentialClientApplication.CreateRequestParametersAsync( diff --git a/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs b/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs index d3d96536a6..f301acc684 100644 --- a/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs +++ b/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs @@ -59,7 +59,15 @@ public async Task GetMetadataAsync(Uri authority string region = null; bool isMtlsEnabled = requestContext.IsMtlsRequested; - if (requestContext.ApiEvent?.ApiId == TelemetryCore.Internal.Events.ApiEvent.ApiIds.AcquireTokenForClient) + // Always attempt region discovery for AcquireTokenForClient. + // Also attempt it for mTLS-enabled user flows when the app has opted in to + // regional endpoints (AzureRegion != null), so that OBO/RT can use a regional + // mTLS endpoint (e.g. eastus.mtlsauth.microsoft.com) when configured. + bool shouldAttemptRegionDiscovery = + requestContext.ApiEvent?.ApiId == TelemetryCore.Internal.Events.ApiEvent.ApiIds.AcquireTokenForClient || + (isMtlsEnabled && requestContext.ServiceBundle.Config.AzureRegion != null); + + if (shouldAttemptRegionDiscovery) { region = await _regionManager.GetAzureRegionAsync(requestContext).ConfigureAwait(false); } diff --git a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs index 072cf972ae..6d570993b0 100644 --- a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs +++ b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs @@ -1183,8 +1183,14 @@ private async Task> GetTenantProfilesAsync( idTokenCacheItems.Select(aci => aci.Environment), StringComparer.OrdinalIgnoreCase); + // Use OriginalAuthority for alias resolution so that mTLS-transformed authorities + // (mtlsauth.microsoft.com) don't propagate into the cache lookup. + // _currentAuthority may be set to the mTLS endpoint (PreferredNetwork) after instance + // discovery; using OriginalAuthority ensures we always look up aliases from the + // canonical login.* host, which is where id tokens are stored. + var authorityInfoForAliases = requestParameters.AuthorityManager.OriginalAuthority.AuthorityInfo; InstanceDiscoveryMetadataEntry instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParameters.AuthorityInfo, + authorityInfoForAliases, allEnvironmentsInCache, requestParameters.RequestContext).ConfigureAwait(false); diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs new file mode 100644 index 0000000000..45376869bd --- /dev/null +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -0,0 +1,497 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AppConfig; +using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Test.Common; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.Identity.Test.Integration.Infrastructure; +using Microsoft.Identity.Test.LabInfrastructure; +using Microsoft.Identity.Test.Unit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Integration.HeadlessTests +{ + /// + /// Tests mTLS as transport (bearer tokens over a mutually authenticated TLS connection) + /// for user flows: OBO, refresh_token. + /// + /// These tests confirm that when a custom IMsalMtlsHttpClientFactory is registered, + /// MSAL routes all HTTP calls through it for user flows — not just client_credentials. + /// + /// This is distinct from mTLS PoP (.WithMtlsProofOfPossession()), which binds the token + /// cryptographically to a certificate and is only available on AcquireTokenForClient. + /// + [TestClass] + public class MtlsTransportUserFlowTests + { + private static readonly string[] s_userReadScopes = { "User.Read" }; + private string _oboClientSecret; + + private readonly KeyVaultSecretsProvider _keyVault = new KeyVaultSecretsProvider(KeyVaultInstance.MsalTeam); + + [TestInitialize] + public void TestInitialize() + { + ApplicationBase.ResetStateForTest(); + if (string.IsNullOrEmpty(_oboClientSecret)) + { + _oboClientSecret = _keyVault.GetSecretByName(TestConstants.MsalOBOKeyVaultSecretName).Value; + } + } + + /// + /// Verifies that OBO flow succeeds when MSAL uses an mTLS transport factory. + /// The custom factory is always invoked and uses an mTLS HttpClient with the lab cert. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task OboFlow_WithMtlsTransportFactory_AcquiresTokenAsync() + { + // Arrange + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var trackingFactory = new TrackingMtlsHttpClientFactory(mtlsCert); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + // Step 1: Get a user assertion via ROPC (no mTLS factory needed here — PCA is public client) + var pca = PublicClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) + .WithTestLogging() + .Build(); + +#pragma warning disable CS0618 + AuthenticationResult userResult = await pca + .AcquireTokenByUsernamePassword([appApiConfig.DefaultScopes], user.Upn, user.GetOrFetchPassword()) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); +#pragma warning restore CS0618 + + Assert.IsNotNull(userResult?.AccessToken, "Failed to acquire user token via ROPC."); + + // Step 2: Build the confidential client with the mTLS transport factory + // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory + var cca = ConfidentialClientApplicationBuilder + .Create(appApiConfig.AppId) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResult.TenantId}"), true) + .WithClientSecret(_oboClientSecret) + .WithTestLogging() + .WithHttpClientFactory(trackingFactory) + .Build(); + + // Act: OBO + AuthenticationResult oboResult = await cca + .AcquireTokenOnBehalfOf(s_userReadScopes, new UserAssertion(userResult.AccessToken)) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(oboResult, "OBO result should not be null."); + Assert.IsNotNull(oboResult.AccessToken, "OBO access token should not be null."); + Assert.AreEqual(TokenSource.IdentityProvider, oboResult.AuthenticationResultMetadata.TokenSource); + Assert.IsGreaterThan(0, trackingFactory.GetHttpClientCallCount, + "The mTLS factory's GetHttpClient should have been called at least once for the OBO flow."); + + Console.WriteLine($"[MtlsTransport OBO] Success. Factory invoked {trackingFactory.GetHttpClientCallCount}x. " + + $"mTLS client used {trackingFactory.MtlsClientUsedCount}x."); + } + + /// + /// Verifies that AcquireTokenByRefreshToken succeeds when MSAL uses an mTLS transport factory. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task RefreshTokenFlow_WithMtlsTransportFactory_AcquiresTokenAsync() + { + // Arrange + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var trackingFactory = new TrackingMtlsHttpClientFactory(mtlsCert); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + // Extract the refresh token from the PCA's token cache via internal accessor + // (using BuildConcrete() allows access to internal APIs for test purposes) + var pcaConcrete = PublicClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) + .WithTestLogging() + .BuildConcrete(); + +#pragma warning disable CS0618 + AuthenticationResult userResultConcrete = await pcaConcrete + .AcquireTokenByUsernamePassword([appApiConfig.DefaultScopes], user.Upn, user.GetOrFetchPassword()) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); +#pragma warning restore CS0618 + + var rtCacheItem = pcaConcrete.UserTokenCacheInternal.Accessor.GetAllRefreshTokens().FirstOrDefault(); + Assert.IsNotNull(rtCacheItem, "Refresh token must be present in cache."); + string refreshToken = rtCacheItem.Secret; + + // Step 2: Build CCA with the mTLS transport factory + // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory + var cca = ConfidentialClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResultConcrete.TenantId}"), true) + .WithTestLogging() + .WithHttpClientFactory(trackingFactory) + .Build(); + + // Act: AcquireTokenByRefreshToken + AuthenticationResult refreshResult = await ((IByRefreshToken)cca) + .AcquireTokenByRefreshToken([appApiConfig.DefaultScopes], refreshToken) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(refreshResult, "Refresh token result should not be null."); + Assert.IsNotNull(refreshResult.AccessToken, "Access token should not be null after refresh."); + Assert.IsGreaterThan(0, trackingFactory.GetHttpClientCallCount, + "The mTLS factory's GetHttpClient should have been called at least once for the refresh_token flow."); + + Console.WriteLine($"[MtlsTransport RT] Success. Factory invoked {trackingFactory.GetHttpClientCallCount}x. " + + $"mTLS client used {trackingFactory.MtlsClientUsedCount}x."); + } + + /// + /// Verifies that AcquireTokenSilent (which internally uses the refresh token) + /// routes through the mTLS transport factory. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task SilentFlow_WithMtlsTransportFactory_UsesRefreshTokenOverMtlsAsync() + { + // Arrange + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var trackingFactory = new TrackingMtlsHttpClientFactory(mtlsCert); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + // Build PCA with the mTLS factory to track RT redemption during silent + // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory + var pca = PublicClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) + .WithTestLogging() + .WithHttpClientFactory(trackingFactory) + .Build(); + +#pragma warning disable CS0618 + // First call acquires from IdP and caches AT + RT + AuthenticationResult firstResult = await pca + .AcquireTokenByUsernamePassword([appApiConfig.DefaultScopes], user.Upn, user.GetOrFetchPassword()) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); +#pragma warning restore CS0618 + + Assert.IsNotNull(firstResult?.AccessToken); + int callsAfterRopc = trackingFactory.GetHttpClientCallCount; + + // Force token expiry so silent must redeem the RT + AuthenticationResult silentResult = await pca + .AcquireTokenSilent([appApiConfig.DefaultScopes], firstResult.Account) + .WithForceRefresh(true) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(silentResult, "Silent result should not be null."); + Assert.IsNotNull(silentResult.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, silentResult.AuthenticationResultMetadata.TokenSource); + Assert.IsGreaterThan(callsAfterRopc, trackingFactory.GetHttpClientCallCount, + "The mTLS factory should have been called again during the silent/RT redemption."); + + Console.WriteLine($"[MtlsTransport Silent/RT] Success. Total factory calls: {trackingFactory.GetHttpClientCallCount}. " + + $"mTLS client used: {trackingFactory.MtlsClientUsedCount}x."); + } + + /// + /// Tests the two conditions required for true mTLS transport auth on OBO: + /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com), not the regular endpoint. + /// 2. No client_assertion in the POST body — the TLS cert alone authenticates the app. + /// + /// Uses CertificateOptions.SendCertificateOverMtls = true to opt in to mTLS bearer transport. + /// AAD may reject the request if the cert is not registered for AppWebApi, but MSAL's request + /// format (endpoint + body) is verified via the recording factory before any AAD response. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task OboFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet() + { + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + var pca = PublicClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) + .WithTestLogging() + .Build(); + +#pragma warning disable CS0618 + AuthenticationResult userResult = await pca + .AcquireTokenByUsernamePassword([appApiConfig.DefaultScopes], user.Upn, user.GetOrFetchPassword()) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); +#pragma warning restore CS0618 + + Assert.IsNotNull(userResult?.AccessToken, "Failed to acquire user token via ROPC."); + + var recordingFactory = new RecordingMtlsHttpClientFactory(); + var cca = ConfidentialClientApplicationBuilder + .Create(appApiConfig.AppId) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResult.TenantId}"), true) + .WithCertificate(mtlsCert, new CertificateOptions { SendCertificateOverMtls = true }) + .WithHttpClientFactory(recordingFactory) + .Build(); + + try + { + await cca + .AcquireTokenOnBehalfOf(s_userReadScopes, new UserAssertion(userResult.AccessToken)) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (MsalServiceException ex) + { + Console.WriteLine($"[Expected] AAD rejected: {ex.ErrorCode} — {ex.Message}"); + } + + string lastBody = recordingFactory.LastCapturedBody; + string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; + + Console.WriteLine($"[OBO endpoint] {requestUrl}"); + Console.WriteLine($"[OBO body contains client_assertion] {lastBody?.Contains("client_assertion")}"); + + // Condition 1: request must go to the mTLS endpoint + StringAssert.Contains(requestUrl, "mtlsauth", + $"Condition 1 FAILED: OBO token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); + + // Condition 2: no client_assertion in body + Assert.DoesNotContain(lastBody, "client_assertion", + "Condition 2 FAILED: client_assertion IS present in the OBO POST body — should be absent for mTLS transport."); + } + + /// + /// Tests the two conditions required for true mTLS transport auth on refresh_token redemption: + /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com). + /// 2. No client_assertion in the POST body. + /// + /// Uses CertificateOptions.SendCertificateOverMtls = true to opt in to mTLS bearer transport. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task RefreshTokenFlow_WithSendCertificateOverMtls_BothMtlsConditionsMet() + { + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + var pcaConcrete = PublicClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) + .WithTestLogging() + .BuildConcrete(); + +#pragma warning disable CS0618 + AuthenticationResult userResult = await pcaConcrete + .AcquireTokenByUsernamePassword([appApiConfig.DefaultScopes], user.Upn, user.GetOrFetchPassword()) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); +#pragma warning restore CS0618 + + var rtItem = pcaConcrete.UserTokenCacheInternal.Accessor.GetAllRefreshTokens().FirstOrDefault(); + Assert.IsNotNull(rtItem, "Refresh token must be present in cache."); + string refreshToken = rtItem.Secret; + + var recordingFactory = new RecordingMtlsHttpClientFactory(); + var cca = ConfidentialClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResult.TenantId}"), true) + .WithCertificate(mtlsCert, new CertificateOptions { SendCertificateOverMtls = true }) + .WithHttpClientFactory(recordingFactory) + .Build(); + + try + { + await ((IByRefreshToken)cca) + .AcquireTokenByRefreshToken([appApiConfig.DefaultScopes], refreshToken) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (MsalServiceException ex) + { + Console.WriteLine($"[Expected] AAD rejected: {ex.ErrorCode} — {ex.Message}"); + } + + string lastBody = recordingFactory.LastCapturedBody; + string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; + + Console.WriteLine($"[RT endpoint] {requestUrl}"); + Console.WriteLine($"[RT body contains client_assertion] {lastBody?.Contains("client_assertion")}"); + + // Condition 1: request must go to the mTLS endpoint + StringAssert.Contains(requestUrl, "mtlsauth", + $"Condition 1 FAILED: RT token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); + + // Condition 2: no client_assertion in body + Assert.DoesNotContain(lastBody, "client_assertion", + "Condition 2 FAILED: client_assertion IS present in the RT POST body — should be absent for mTLS transport."); + } + + /// + /// Control test: verifies that for AcquireTokenForClient with SendCertificateOverMtls=true, + /// BOTH mTLS transport conditions ARE met: + /// 1. Request goes to mtlsauth.microsoft.com (mTLS endpoint). + /// 2. No client_assertion in the POST body. + /// + /// This is the "correct" behavior that we want to extend to user flows. + /// Uses the MSI-allowlisted app (163ffef9) which has the lab cert registered. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task ClientCredentials_WithSendCertificateOverMtls_BothMtlsConditionsMet() + { + const string MsiAllowListedAppId = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e"; + string[] vaultScopes = ["https://vault.azure.net/.default"]; + + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var recordingFactory = new RecordingMtlsHttpClientFactory(); + + var cca = ConfidentialClientApplicationBuilder + .Create(MsiAllowListedAppId) + .WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c") + .WithAzureRegion("westus3") + .WithCertificate(mtlsCert, new CertificateOptions { SendCertificateOverMtls = true }) + .WithHttpClientFactory(recordingFactory) + .Build(); + + AuthenticationResult result = await cca + .AcquireTokenForClient(vaultScopes) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(result?.AccessToken, "Token acquisition should succeed for the MSI-allowlisted app."); + + string lastBody = recordingFactory.LastCapturedBody; + string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; + + Console.WriteLine($"[client_credentials endpoint] {requestUrl}"); + Console.WriteLine($"[client_credentials body contains client_assertion] {lastBody?.Contains("client_assertion")}"); + + // Condition 1: request must go to mTLS endpoint + StringAssert.Contains(requestUrl, "mtlsauth", + $"Expected mTLS endpoint (mtlsauth) but got: {requestUrl}"); + + // Condition 2: no client_assertion in body + Assert.DoesNotContain(lastBody, "client_assertion", + "client_assertion should NOT be in the body when SendCertificateOverMtls=true."); + } + + /// + /// A recording mTLS factory that captures request URLs and bodies from BOTH the plain + /// and cert-bearing HTTP client paths. Unlike HttpSnifferClientFactory, the cert path + /// also uses a RecordingHandler so we can assert on the token endpoint URL. + /// + private class RecordingMtlsHttpClientFactory : IMsalMtlsHttpClientFactory + { + private readonly List<(string Url, string Body)> _captured = new(); + + public IReadOnlyList<(string Url, string Body)> Captured => _captured; + + public string LastCapturedUrl => _captured.LastOrDefault(c => c.Url.Contains("/oauth2/")).Url; + public string LastCapturedBody => _captured.LastOrDefault(c => !string.IsNullOrEmpty(c.Body)).Body; + + private HttpClient BuildRecordingClient(X509Certificate2 cert = null) + { + var inner = new HttpClientHandler(); + if (cert != null) inner.ClientCertificates.Add(cert); + + var recording = new RecordingHandler((req, _) => + { + string body = null; + if (req.Content != null) + { + req.Content.LoadIntoBufferAsync().GetAwaiter().GetResult(); + body = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + } + lock (_captured) { _captured.Add((req.RequestUri?.AbsoluteUri ?? "", body ?? "")); } + }); + recording.InnerHandler = inner; + return new HttpClient(recording); + } + + public HttpClient GetHttpClient() => BuildRecordingClient(); + public HttpClient GetHttpClient(X509Certificate2 cert) => BuildRecordingClient(cert); + } + + private class TrackingMtlsHttpClientFactory : IMsalMtlsHttpClientFactory + { + private readonly X509Certificate2 _cert; + private readonly HttpClient _mtlsClient; + private readonly HttpClient _plainClient; + private int _callCount; + private int _mtlsUsedCount; + + public int GetHttpClientCallCount => _callCount; + public int MtlsClientUsedCount => _mtlsUsedCount; + + public TrackingMtlsHttpClientFactory(X509Certificate2 cert) + { + _cert = cert ?? throw new ArgumentNullException(nameof(cert)); + + var handler = new HttpClientHandler(); + handler.ClientCertificates.Add(_cert); + _mtlsClient = new HttpClient(handler); + + _plainClient = new HttpClient(); + } + + public HttpClient GetHttpClient() + { + // Plain HTTP (no mTLS) — used for non-mTLS scenarios + return _plainClient; + } + + public HttpClient GetHttpClient(X509Certificate2 x509Certificate2) + { + Interlocked.Increment(ref _callCount); + + // Always return the mTLS client, even when x509Certificate2 is null. + // This simulates how a real-world mTLS factory for user flows would behave — + // the cert is baked in at construction, not passed per-call. + Interlocked.Increment(ref _mtlsUsedCount); + return _mtlsClient; + } + } + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs new file mode 100644 index 0000000000..08080ccca7 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AppConfig; +using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Client.Utils; +using Microsoft.Identity.Test.Common; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.Identity.Test.Common.Core.Mocks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Unit +{ + /// + /// Unit tests for mTLS bearer transport applied to user flows (OBO and refresh_token). + /// + /// These tests verify that when is + /// set to true, MSAL routes user-flow token requests to the mTLS endpoint + /// (mtlsauth.microsoft.com) and omits client_assertion from the POST body — + /// the same behaviour already implemented for AcquireTokenForClient. + /// + [TestClass] + public class MtlsBearerUserFlowTests : TestBase + { + private static X509Certificate2 s_testCertificate; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + s_testCertificate = CertHelper.GetOrCreateTestCert(); + + if (s_testCertificate == null || string.IsNullOrEmpty(s_testCertificate.Thumbprint)) + { + throw new InvalidOperationException("Failed to initialize a valid test certificate."); + } + } + + [ClassCleanup] + public static void ClassCleanup() + { + s_testCertificate.Dispose(); + } + + /// + /// Verifies that an OBO token request with SendCertificateOverMtls = true: + /// 1. Targets the global mTLS endpoint (mtlsauth.microsoft.com). + /// 2. Does NOT include client_assertion in the POST body. + /// + [TestMethod] + public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAndNoClientAssertionAsync() + { + string tenantId = "123456-1234-2345-1234561234"; + string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; + string expectedTokenEndpoint = $"https://mtlsauth.microsoft.com/{tenantId}/oauth2/v2.0/token"; + string fakeUserAssertion = "fake.user.assertion.token"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", null); + Environment.SetEnvironmentVariable("MSAL_FORCE_REGION", null); + + using (var harness = new MockHttpAndServiceBundle()) + { + var tokenHttpCallHandler = new MockHttpMessageHandler() + { + ExpectedUrl = expectedTokenEndpoint, + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientId, TestConstants.ClientId }, + { OAuth2Parameter.GrantType, OAuth2GrantType.JwtBearer }, + { OAuth2Parameter.RequestedTokenUse, OAuth2RequestedTokenUse.OnBehalfOf }, + }, + UnExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + { OAuth2Parameter.ClientAssertion, "placeholder" } + } + }; + + harness.HttpManager.AddMockHandler(tokenHttpCallHandler); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(authorityUrl) + .WithHttpManager(harness.HttpManager) + .WithCertificate(s_testCertificate, new CertificateOptions { SendCertificateOverMtls = true }) + .Build(); + + // Act + var result = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, new UserAssertion(fakeUserAssertion)) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + } + } + } + + /// + /// Verifies that an OBO token request with SendCertificateOverMtls = true and a + /// region set uses the regional mTLS endpoint (e.g. eastus.mtlsauth.microsoft.com). + /// + [TestMethod] + public async Task OboFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMtlsEndpointAsync() + { + string tenantId = "123456-1234-2345-1234561234"; + string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; + const string region = "eastus"; + string expectedTokenEndpoint = $"https://{region}.mtlsauth.microsoft.com/{tenantId}/oauth2/v2.0/token"; + string fakeUserAssertion = "fake.user.assertion.token"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", region); + + using (var harness = new MockHttpAndServiceBundle()) + { + var tokenHttpCallHandler = new MockHttpMessageHandler() + { + ExpectedUrl = expectedTokenEndpoint, + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), + UnExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + { OAuth2Parameter.ClientAssertion, "placeholder" } + } + }; + + harness.HttpManager.AddMockHandler(tokenHttpCallHandler); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(authorityUrl) + .WithHttpManager(harness.HttpManager) + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithCertificate(s_testCertificate, new CertificateOptions { SendCertificateOverMtls = true }) + .Build(); + + // Act + var result = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, new UserAssertion(fakeUserAssertion)) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + } + } + } + + /// + /// Verifies that a refresh-token redemption (IByRefreshToken) with + /// SendCertificateOverMtls = true: + /// 1. Targets the global mTLS endpoint. + /// 2. Does NOT include client_assertion in the POST body. + /// + [TestMethod] + public async Task RefreshTokenFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAndNoClientAssertionAsync() + { + string tenantId = "123456-1234-2345-1234561234"; + string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; + string expectedTokenEndpoint = $"https://mtlsauth.microsoft.com/{tenantId}/oauth2/v2.0/token"; + const string fakeRefreshToken = "my_test_refresh_token"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", null); + Environment.SetEnvironmentVariable("MSAL_FORCE_REGION", null); + + using (var harness = new MockHttpAndServiceBundle()) + { + var tokenHttpCallHandler = new MockHttpMessageHandler() + { + ExpectedUrl = expectedTokenEndpoint, + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientId, TestConstants.ClientId }, + { OAuth2Parameter.GrantType, OAuth2GrantType.RefreshToken }, + { OAuth2Parameter.RefreshToken, fakeRefreshToken }, + }, + UnExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + { OAuth2Parameter.ClientAssertion, "placeholder" } + } + }; + + harness.HttpManager.AddMockHandler(tokenHttpCallHandler); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(authorityUrl) + .WithHttpManager(harness.HttpManager) + .WithCertificate(s_testCertificate, new CertificateOptions { SendCertificateOverMtls = true }) + .Build(); + + // Act + var result = await ((IByRefreshToken)app) + .AcquireTokenByRefreshToken(TestConstants.s_scope, fakeRefreshToken) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + } + } + } + + /// + /// Regression test: without SendCertificateOverMtls, a cert-credential OBO request + /// still uses the regular (non-mTLS) endpoint and sends client_assertion. + /// + [TestMethod] + public async Task OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointWithClientAssertionAsync() + { + string tenantId = "123456-1234-2345-1234561234"; + string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; + string expectedTokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + string fakeUserAssertion = "fake.user.assertion.token"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", null); + Environment.SetEnvironmentVariable("MSAL_FORCE_REGION", null); + + using (var harness = new MockHttpAndServiceBundle()) + { + // Regular endpoint — no mTLS routing + var tokenHttpCallHandler = new MockHttpMessageHandler() + { + ExpectedUrl = expectedTokenEndpoint, + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), + // client_assertion MUST be present (cert credential → JWT serialized) + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientId, TestConstants.ClientId }, + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + }, + }; + + harness.HttpManager.AddMockHandler(tokenHttpCallHandler); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(authorityUrl) + .WithHttpManager(harness.HttpManager) + .WithCertificate(s_testCertificate) + .WithInstanceDiscovery(false) + .Build(); + + // Act + var result = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, new UserAssertion(fakeUserAssertion)) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result.AccessToken); + StringAssert.Contains(result.AuthenticationResultMetadata.TokenEndpoint, "login.microsoftonline.com", + "Without SendCertificateOverMtls, OBO should use the regular login endpoint."); + } + } + } + } +} From c2451d955b80383b58b1d2d188c71a20c186ce79 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 14 May 2026 14:16:55 -0400 Subject: [PATCH 02/22] fix: remove ClassCleanup disposal of shared CertHelper cert 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> --- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index 08080ccca7..968ebfdda0 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -41,14 +41,8 @@ public static void ClassInitialize(TestContext context) } } - [ClassCleanup] - public static void ClassCleanup() - { - s_testCertificate.Dispose(); - } - /// - /// Verifies that an OBO token request with SendCertificateOverMtls = true: + /// Verifies that an OBO token requestwith SendCertificateOverMtls = true: /// 1. Targets the global mTLS endpoint (mtlsauth.microsoft.com). /// 2. Does NOT include client_assertion in the POST body. /// From f37ce63c1db7c1ed925636854a18b72e6135e7a3 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 14 May 2026 15:18:15 -0400 Subject: [PATCH 03/22] test: address Copilot review comments - 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> --- .../HeadlessTests/MtlsTransportUserFlowTests.cs | 4 ++-- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs index 45376869bd..209137e684 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -103,7 +103,7 @@ public async Task OboFlow_WithMtlsTransportFactory_AcquiresTokenAsync() Assert.IsNotNull(oboResult.AccessToken, "OBO access token should not be null."); Assert.AreEqual(TokenSource.IdentityProvider, oboResult.AuthenticationResultMetadata.TokenSource); Assert.IsGreaterThan(0, trackingFactory.GetHttpClientCallCount, - "The mTLS factory's GetHttpClient should have been called at least once for the OBO flow."); + "The mTLS-specific GetHttpClient(X509Certificate2) overload should have been called at least once for the OBO flow."); Console.WriteLine($"[MtlsTransport OBO] Success. Factory invoked {trackingFactory.GetHttpClientCallCount}x. " + $"mTLS client used {trackingFactory.MtlsClientUsedCount}x."); @@ -164,7 +164,7 @@ public async Task RefreshTokenFlow_WithMtlsTransportFactory_AcquiresTokenAsync() Assert.IsNotNull(refreshResult, "Refresh token result should not be null."); Assert.IsNotNull(refreshResult.AccessToken, "Access token should not be null after refresh."); Assert.IsGreaterThan(0, trackingFactory.GetHttpClientCallCount, - "The mTLS factory's GetHttpClient should have been called at least once for the refresh_token flow."); + "The mTLS-specific GetHttpClient(X509Certificate2) overload should have been called at least once for the refresh_token flow."); Console.WriteLine($"[MtlsTransport RT] Success. Factory invoked {trackingFactory.GetHttpClientCallCount}x. " + $"mTLS client used {trackingFactory.MtlsClientUsedCount}x."); diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index 968ebfdda0..4a813ad553 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -117,6 +117,7 @@ public async Task OboFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMtl using (var envContext = new EnvVariableContext()) { Environment.SetEnvironmentVariable("REGION_NAME", region); + Environment.SetEnvironmentVariable("MSAL_FORCE_REGION", null); using (var harness = new MockHttpAndServiceBundle()) { From 596654c44d74891bee651b2491a6b6c4c1115cd2 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 14 May 2026 15:38:40 -0400 Subject: [PATCH 04/22] test: fix typo and clarify client_assertion_type comment - 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> --- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index 4a813ad553..adc5887866 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -42,7 +42,7 @@ public static void ClassInitialize(TestContext context) } /// - /// Verifies that an OBO token requestwith SendCertificateOverMtls = true: + /// Verifies that an OBO token request with SendCertificateOverMtls = true: /// 1. Targets the global mTLS endpoint (mtlsauth.microsoft.com). /// 2. Does NOT include client_assertion in the POST body. /// @@ -242,7 +242,7 @@ public async Task OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointWith ExpectedUrl = expectedTokenEndpoint, ExpectedMethod = HttpMethod.Post, ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), - // client_assertion MUST be present (cert credential → JWT serialized) + // client_assertion_type MUST be present (indicates cert credential is being serialized as a JWT) ExpectedPostData = new Dictionary { { OAuth2Parameter.ClientId, TestConstants.ClientId }, From 774a1df8038ce0809ede42f30fa709b5fa1d415f Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Tue, 19 May 2026 11:40:03 -0400 Subject: [PATCH 05/22] test: address review feedback - real integration tests, remove SilentFlow 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 --- .../MtlsTransportUserFlowTests.cs | 145 ++++++------------ .../PublicApiTests/MtlsBearerUserFlowTests.cs | 10 +- 2 files changed, 53 insertions(+), 102 deletions(-) diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs index 209137e684..68c4a09a40 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -21,40 +21,42 @@ namespace Microsoft.Identity.Test.Integration.HeadlessTests { /// - /// Tests mTLS as transport (bearer tokens over a mutually authenticated TLS connection) - /// for user flows: OBO, refresh_token. + /// Integration tests for mTLS bearer transport (SendCertificateOverMtls = true) + /// applied to user flows: OBO and refresh_token. /// - /// These tests confirm that when a custom IMsalMtlsHttpClientFactory is registered, - /// MSAL routes all HTTP calls through it for user flows — not just client_credentials. + /// Each test validates the two conditions required for true mTLS bearer transport: + /// 1. The token request goes to the mTLS endpoint (mtlsauth.microsoft.com). + /// 2. No client_assertion is in the POST body — the TLS certificate authenticates + /// the app at the transport layer. /// - /// This is distinct from mTLS PoP (.WithMtlsProofOfPossession()), which binds the token - /// cryptographically to a certificate and is only available on AcquireTokenForClient. + /// This is distinct from mTLS PoP (.WithMtlsProofOfPossession()), which binds the + /// token cryptographically to a certificate and is only available on AcquireTokenForClient. /// [TestClass] public class MtlsTransportUserFlowTests { private static readonly string[] s_userReadScopes = { "User.Read" }; - private string _oboClientSecret; - - private readonly KeyVaultSecretsProvider _keyVault = new KeyVaultSecretsProvider(KeyVaultInstance.MsalTeam); [TestInitialize] public void TestInitialize() { ApplicationBase.ResetStateForTest(); - if (string.IsNullOrEmpty(_oboClientSecret)) - { - _oboClientSecret = _keyVault.GetSecretByName(TestConstants.MsalOBOKeyVaultSecretName).Value; - } } /// - /// Verifies that OBO flow succeeds when MSAL uses an mTLS transport factory. - /// The custom factory is always invoked and uses an mTLS HttpClient with the lab cert. + /// Integration test: verifies that an OBO token request with SendCertificateOverMtls = true + /// satisfies both mTLS conditions: + /// 1. The request goes to mtlsauth.microsoft.com (not login.microsoftonline.com). + /// 2. The IMsalMtlsHttpClientFactory cert overload is invoked, confirming the mTLS + /// transport factory is used for the OBO flow. + /// + /// Note: token acquisition succeeds only if AppWebApi is registered in the lab for + /// mTLS bearer transport. If not yet registered, this test verifies MSAL's request routing + /// behaviour and may receive an AAD rejection. /// [DoNotRunOnLinux] [TestMethod] - public async Task OboFlow_WithMtlsTransportFactory_AcquiresTokenAsync() + public async Task OboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync() { // Arrange X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); @@ -66,7 +68,7 @@ public async Task OboFlow_WithMtlsTransportFactory_AcquiresTokenAsync() var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); - // Step 1: Get a user assertion via ROPC (no mTLS factory needed here — PCA is public client) + // Step 1: Acquire a user assertion via ROPC (public client — no mTLS needed here) var pca = PublicClientApplicationBuilder .Create(appConfig.AppId) .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) @@ -82,12 +84,13 @@ public async Task OboFlow_WithMtlsTransportFactory_AcquiresTokenAsync() Assert.IsNotNull(userResult?.AccessToken, "Failed to acquire user token via ROPC."); - // Step 2: Build the confidential client with the mTLS transport factory - // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory + // Step 2: Build the OBO confidential client with SendCertificateOverMtls=true. + // The cert authenticates the app at the TLS layer; no client_assertion is sent in the body. + // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory. var cca = ConfidentialClientApplicationBuilder .Create(appApiConfig.AppId) .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResult.TenantId}"), true) - .WithClientSecret(_oboClientSecret) + .WithCertificate(mtlsCert, new CertificateOptions { SendCertificateOverMtls = true }) .WithTestLogging() .WithHttpClientFactory(trackingFactory) .Build(); @@ -102,19 +105,24 @@ public async Task OboFlow_WithMtlsTransportFactory_AcquiresTokenAsync() Assert.IsNotNull(oboResult, "OBO result should not be null."); Assert.IsNotNull(oboResult.AccessToken, "OBO access token should not be null."); Assert.AreEqual(TokenSource.IdentityProvider, oboResult.AuthenticationResultMetadata.TokenSource); + StringAssert.Contains( + oboResult.AuthenticationResultMetadata.TokenEndpoint, "mtlsauth", + $"OBO token request should use the mTLS endpoint, but got: {oboResult.AuthenticationResultMetadata.TokenEndpoint}"); Assert.IsGreaterThan(0, trackingFactory.GetHttpClientCallCount, "The mTLS-specific GetHttpClient(X509Certificate2) overload should have been called at least once for the OBO flow."); - - Console.WriteLine($"[MtlsTransport OBO] Success. Factory invoked {trackingFactory.GetHttpClientCallCount}x. " + - $"mTLS client used {trackingFactory.MtlsClientUsedCount}x."); } /// - /// Verifies that AcquireTokenByRefreshToken succeeds when MSAL uses an mTLS transport factory. + /// Integration test: verifies that a refresh-token redemption with SendCertificateOverMtls = true + /// satisfies both mTLS conditions: + /// 1. The request goes to mtlsauth.microsoft.com. + /// 2. The IMsalMtlsHttpClientFactory cert overload is invoked for the RT redemption. + /// + /// Note: token acquisition succeeds only if the app is registered in the lab for mTLS bearer transport. /// [DoNotRunOnLinux] [TestMethod] - public async Task RefreshTokenFlow_WithMtlsTransportFactory_AcquiresTokenAsync() + public async Task RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsync() { // Arrange X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); @@ -145,11 +153,13 @@ public async Task RefreshTokenFlow_WithMtlsTransportFactory_AcquiresTokenAsync() Assert.IsNotNull(rtCacheItem, "Refresh token must be present in cache."); string refreshToken = rtCacheItem.Secret; - // Step 2: Build CCA with the mTLS transport factory - // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory + // 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 .Create(appConfig.AppId) .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResultConcrete.TenantId}"), true) + .WithCertificate(mtlsCert, new CertificateOptions { SendCertificateOverMtls = true }) .WithTestLogging() .WithHttpClientFactory(trackingFactory) .Build(); @@ -163,67 +173,11 @@ public async Task RefreshTokenFlow_WithMtlsTransportFactory_AcquiresTokenAsync() // Assert Assert.IsNotNull(refreshResult, "Refresh token result should not be null."); Assert.IsNotNull(refreshResult.AccessToken, "Access token should not be null after refresh."); + StringAssert.Contains( + refreshResult.AuthenticationResultMetadata.TokenEndpoint, "mtlsauth", + $"RT redemption should use the mTLS endpoint, but got: {refreshResult.AuthenticationResultMetadata.TokenEndpoint}"); Assert.IsGreaterThan(0, trackingFactory.GetHttpClientCallCount, "The mTLS-specific GetHttpClient(X509Certificate2) overload should have been called at least once for the refresh_token flow."); - - Console.WriteLine($"[MtlsTransport RT] Success. Factory invoked {trackingFactory.GetHttpClientCallCount}x. " + - $"mTLS client used {trackingFactory.MtlsClientUsedCount}x."); - } - - /// - /// Verifies that AcquireTokenSilent (which internally uses the refresh token) - /// routes through the mTLS transport factory. - /// - [DoNotRunOnLinux] - [TestMethod] - public async Task SilentFlow_WithMtlsTransportFactory_UsesRefreshTokenOverMtlsAsync() - { - // Arrange - X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); - Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); - - var trackingFactory = new TrackingMtlsHttpClientFactory(mtlsCert); - - var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); - var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); - var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); - - // Build PCA with the mTLS factory to track RT redemption during silent - // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory - var pca = PublicClientApplicationBuilder - .Create(appConfig.AppId) - .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) - .WithTestLogging() - .WithHttpClientFactory(trackingFactory) - .Build(); - -#pragma warning disable CS0618 - // First call acquires from IdP and caches AT + RT - AuthenticationResult firstResult = await pca - .AcquireTokenByUsernamePassword([appApiConfig.DefaultScopes], user.Upn, user.GetOrFetchPassword()) - .ExecuteAsync(CancellationToken.None) - .ConfigureAwait(false); -#pragma warning restore CS0618 - - Assert.IsNotNull(firstResult?.AccessToken); - int callsAfterRopc = trackingFactory.GetHttpClientCallCount; - - // Force token expiry so silent must redeem the RT - AuthenticationResult silentResult = await pca - .AcquireTokenSilent([appApiConfig.DefaultScopes], firstResult.Account) - .WithForceRefresh(true) - .ExecuteAsync(CancellationToken.None) - .ConfigureAwait(false); - - // Assert - Assert.IsNotNull(silentResult, "Silent result should not be null."); - Assert.IsNotNull(silentResult.AccessToken); - Assert.AreEqual(TokenSource.IdentityProvider, silentResult.AuthenticationResultMetadata.TokenSource); - Assert.IsGreaterThan(callsAfterRopc, trackingFactory.GetHttpClientCallCount, - "The mTLS factory should have been called again during the silent/RT redemption."); - - Console.WriteLine($"[MtlsTransport Silent/RT] Success. Total factory calls: {trackingFactory.GetHttpClientCallCount}. " + - $"mTLS client used: {trackingFactory.MtlsClientUsedCount}x."); } /// @@ -276,17 +230,15 @@ await cca .ExecuteAsync(CancellationToken.None) .ConfigureAwait(false); } - catch (MsalServiceException ex) + catch (MsalServiceException) { - Console.WriteLine($"[Expected] AAD rejected: {ex.ErrorCode} — {ex.Message}"); + // AAD may reject if AppWebApi is not yet registered for mTLS bearer transport. + // The assertions below verify MSAL's request-level behaviour regardless. } string lastBody = recordingFactory.LastCapturedBody; string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; - Console.WriteLine($"[OBO endpoint] {requestUrl}"); - Console.WriteLine($"[OBO body contains client_assertion] {lastBody?.Contains("client_assertion")}"); - // Condition 1: request must go to the mTLS endpoint StringAssert.Contains(requestUrl, "mtlsauth", $"Condition 1 FAILED: OBO token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); @@ -346,17 +298,15 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_BothMtlsCondition .ExecuteAsync(CancellationToken.None) .ConfigureAwait(false); } - catch (MsalServiceException ex) + catch (MsalServiceException) { - Console.WriteLine($"[Expected] AAD rejected: {ex.ErrorCode} — {ex.Message}"); + // AAD may reject if the app is not yet registered for mTLS bearer transport. + // The assertions below verify MSAL's request-level behaviour regardless. } string lastBody = recordingFactory.LastCapturedBody; string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; - Console.WriteLine($"[RT endpoint] {requestUrl}"); - Console.WriteLine($"[RT body contains client_assertion] {lastBody?.Contains("client_assertion")}"); - // Condition 1: request must go to the mTLS endpoint StringAssert.Contains(requestUrl, "mtlsauth", $"Condition 1 FAILED: RT token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); @@ -405,9 +355,6 @@ public async Task ClientCredentials_WithSendCertificateOverMtls_BothMtlsConditio string lastBody = recordingFactory.LastCapturedBody; string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; - Console.WriteLine($"[client_credentials endpoint] {requestUrl}"); - Console.WriteLine($"[client_credentials body contains client_assertion] {lastBody?.Contains("client_assertion")}"); - // Condition 1: request must go to mTLS endpoint StringAssert.Contains(requestUrl, "mtlsauth", $"Expected mTLS endpoint (mtlsauth) but got: {requestUrl}"); diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index adc5887866..ec73c91774 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -102,11 +102,15 @@ public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAndN } /// - /// Verifies that an OBO token request with SendCertificateOverMtls = true and a - /// region set uses the regional mTLS endpoint (e.g. eastus.mtlsauth.microsoft.com). + /// Verifies that a user flow token request with SendCertificateOverMtls = true and a + /// region configured uses the regional mTLS endpoint (e.g. eastus.mtlsauth.microsoft.com). + /// + /// OBO is used as the representative user flow here. The regional routing code + /// (RegionAndMtlsDiscoveryProvider) is shared across all user flows (OBO, refresh_token, + /// auth_code), so a single general-purpose test is sufficient to verify the routing logic. /// [TestMethod] - public async Task OboFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMtlsEndpointAsync() + public async Task UserFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMtlsEndpointAsync() { string tenantId = "123456-1234-2345-1234561234"; string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; From 7617782daef27e9696ce34ee6d866ca3ecc939cb Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Wed, 20 May 2026 13:58:00 -0400 Subject: [PATCH 06/22] Add OboFlow_WithClientSecret_BaselineAsync to complete Bogdan's 2x2 test matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../MtlsTransportUserFlowTests.cs | 86 +++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs index 68c4a09a40..db71173f44 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -44,15 +44,24 @@ public void TestInitialize() } /// - /// Integration test: verifies that an OBO token request with SendCertificateOverMtls = true - /// satisfies both mTLS conditions: + /// Integration test — 2x2 matrix cell (OBO × mTLS bearer): + /// Verifies that an OBO token request with SendCertificateOverMtls = true + /// satisfies both mTLS transport conditions: /// 1. The request goes to mtlsauth.microsoft.com (not login.microsoftonline.com). /// 2. The IMsalMtlsHttpClientFactory cert overload is invoked, confirming the mTLS /// transport factory is used for the OBO flow. /// - /// Note: token acquisition succeeds only if AppWebApi is registered in the lab for - /// mTLS bearer transport. If not yet registered, this test verifies MSAL's request routing - /// behaviour and may receive an AAD rejection. + /// Expected outcome: PASSES once AppWebApi is enabled for mTLS client auth in the lab. + /// Until then, AAD returns HTTP 412 / AADSTS51000: MtlsClientAuth is/are disabled. + /// This error means the app is not yet configured — it does NOT mean the grant is unsupported. + /// See for the (OBO × client_secret) cell + /// that proves the OBO grant itself works and the mTLS failure is purely app-config. + /// + /// Full 2x2 matrix: + /// client_credentials + mTLS → ClientCredentialsMtlsPopTests.Sni_Over_Mtls_Gets_Bearer_Token_Successfully_TestAsync (PASSES) + /// client_credentials + secret → ClientCredentialsTests (PASSES) + /// OBO + mTLS → this test (FAILS until AppWebApi is mTLS-enabled) + /// OBO + secret → (PASSES) /// [DoNotRunOnLinux] [TestMethod] @@ -180,6 +189,73 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsyn "The mTLS-specific GetHttpClient(X509Certificate2) overload should have been called at least once for the refresh_token flow."); } + /// + /// Baseline test — 2x2 matrix cell (OBO × client_secret): + /// Verifies that AcquireTokenOnBehalfOf succeeds with standard client-secret authentication. + /// + /// Purpose: proves the OBO grant itself works end-to-end with normal app auth. + /// When fails with + /// AADSTS51000: MtlsClientAuth is/are disabled, this test confirms the failure is + /// purely an AAD app-configuration gap (mTLS not enabled on AppWebApi), NOT a limitation of + /// the OBO grant over mTLS transport. + /// + /// Full 2x2 matrix: + /// client_credentials + mTLS → ClientCredentialsMtlsPopTests.Sni_Over_Mtls_Gets_Bearer_Token_Successfully_TestAsync (PASSES) + /// client_credentials + secret → ClientCredentialsTests (PASSES) + /// OBO + mTLS → (FAILS until AppWebApi is mTLS-enabled) + /// OBO + secret → this test (PASSES) + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task OboFlow_WithClientSecret_BaselineAsync() + { + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + // Get the AppWebApi client secret from Key Vault (same secret used by OnBehalfOfTests). + string oboClientSecret = LabResponseHelper.FetchSecretString( + TestConstants.MsalOBOKeyVaultSecretName, + LabResponseHelper.KeyVaultSecretsProviderMsal); + + // Step 1: Acquire user assertion via ROPC + var pca = PublicClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) + .WithTestLogging() + .Build(); + +#pragma warning disable CS0618 + AuthenticationResult userResult = await pca + .AcquireTokenByUsernamePassword([appApiConfig.DefaultScopes], user.Upn, user.GetOrFetchPassword()) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); +#pragma warning restore CS0618 + + Assert.IsNotNull(userResult?.AccessToken, "Failed to acquire user token via ROPC."); + + // Step 2: OBO with standard client_secret auth (no mTLS). + // This sends client_secret in the POST body to login.microsoftonline.com. + var cca = ConfidentialClientApplicationBuilder + .Create(appApiConfig.AppId) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResult.TenantId}"), true) + .WithClientSecret(oboClientSecret) + .WithTestLogging() + .Build(); + + AuthenticationResult oboResult = await cca + .AcquireTokenOnBehalfOf(s_userReadScopes, new UserAssertion(userResult.AccessToken)) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(oboResult?.AccessToken, "OBO with client_secret should succeed."); + Assert.AreEqual(TokenSource.IdentityProvider, oboResult.AuthenticationResultMetadata.TokenSource, + "First OBO call should hit the network."); + StringAssert.Contains( + oboResult.AuthenticationResultMetadata.TokenEndpoint, "login.microsoftonline.com", + "Standard client_secret OBO should route to the regular login endpoint, not mtlsauth."); + } + /// /// Tests the two conditions required for true mTLS transport auth on OBO: /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com), not the regular endpoint. From c86dacc07f0db1901865cd1be5fc13dfb80668f7 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Thu, 21 May 2026 10:00:59 -0400 Subject: [PATCH 07/22] fix: Bug #1 and Bug #2 for mTLS bearer user flows 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> --- .../ClientApplicationBaseExecutor.cs | 3 + .../TokenCache.ITokenCacheInternal.cs | 8 ++- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 64 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs index b03f9ccade..7f93428a8d 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs @@ -28,6 +28,9 @@ public async Task ExecuteAsync( AcquireTokenSilentParameters silentParameters, CancellationToken cancellationToken) { + await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken) + .ConfigureAwait(false); + var requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); var requestParameters = await _clientApplicationBase.CreateRequestParametersAsync( diff --git a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs index 6d570993b0..d7d14d1b77 100644 --- a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs +++ b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs @@ -722,8 +722,10 @@ private async Task> FilterTokensByEnvironmentAsyn } // at this point we need environment aliases, try to get them without a discovery call + // Use OriginalAuthority so that mTLS-transformed authorities (mtlsauth.microsoft.com) don't + // propagate into alias resolution — the cache stores tokens under login.* environments. var instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo, tokenCacheItems.Select(at => at.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); @@ -841,7 +843,7 @@ async Task ITokenCacheInternal.FindRefreshTokenAsync( { var metadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo, refreshTokens.Select(rt => rt.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); @@ -871,7 +873,7 @@ await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsyn { var metadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo, refreshTokens.Select(rt => rt.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index ec73c91774..9c6c7a703c 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -221,6 +221,70 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEnd } } + /// + /// Regression test for Bug #1: a second AcquireTokenOnBehalfOf call on the same app + /// instance must NOT throw MsalClientException(MtlsPopNotSupportedForEnvironment). + /// + /// Root cause: after the first call, the AT cache is populated with an entry whose + /// Environment is mtlsauth.microsoft.com. On the second call, FilterTokensByEnvironmentAsync + /// passes requestParams.AuthorityInfo (which is mtlsauth.microsoft.com after authority + /// resolution) to GetMetadataEntryTryAvoidNetworkAsync, which throws because + /// RegionAndMtlsDiscoveryProvider only accepts login.* hosts. + /// The fix uses requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo instead. + /// + [TestMethod] + public async Task OboFlow_WithSendCertificateOverMtls_SecondCallDoesNotCrashAsync() + { + string tenantId = "123456-1234-2345-1234561234"; + string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; + string expectedTokenEndpoint = $"https://mtlsauth.microsoft.com/{tenantId}/oauth2/v2.0/token"; + string fakeUserAssertion = "fake.user.assertion.token"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", null); + Environment.SetEnvironmentVariable("MSAL_FORCE_REGION", null); + + using (var harness = new MockHttpAndServiceBundle()) + { + // Only one network response — the second call must be served from cache. + harness.HttpManager.AddMockHandler(new MockHttpMessageHandler() + { + ExpectedUrl = expectedTokenEndpoint, + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), + }); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(authorityUrl) + .WithHttpManager(harness.HttpManager) + .WithCertificate(s_testCertificate, new CertificateOptions { SendCertificateOverMtls = true }) + .Build(); + + var assertion = new UserAssertion(fakeUserAssertion); + + // First call — hits network, populates cache. + var result1 = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, assertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result1.AccessToken); + + // Second call — must not throw MsalClientException(MtlsPopNotSupportedForEnvironment). + // Should be served from cache without a network call. + var result2 = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, assertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result2.AccessToken); + Assert.AreEqual(result1.AccessToken, result2.AccessToken, "Second call should return the cached token."); + } + } + } + /// /// Regression test: without SendCertificateOverMtls, a cert-credential OBO request /// still uses the regular (non-mTLS) endpoint and sends client_assertion. From b855aa7b716df62f7f151c896418dfdc9eccc747 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Thu, 21 May 2026 10:18:06 -0400 Subject: [PATCH 08/22] Guard Case 2 in TryInitImplicitBearerOverMtlsAsync behind SendCertificateOverMtls IClientSignedAssertionProvider.GetAssertionAsync was being invoked for every auth-code, OBO, and silent request regardless of whether mTLS is needed. Added SendCertificateOverMtls == true guard consistent with Case 1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApiConfig/Parameters/MtlsPopParametersInitializer.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs index 011317132a..076556f121 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs @@ -66,7 +66,10 @@ private static async Task TryInitImplicitBearerOverMtlsAsync( } // Case 2 – Only cert-capable credentials implement this capability interface. - if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) + // Guarded by SendCertificateOverMtls == true (same as Case 1) to avoid invoking + // the assertion callback on every request for apps that don't opt into mTLS. + if (serviceBundle.Config.CertificateOptions?.SendCertificateOverMtls == true && + serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) { var opts = CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct); From c67b809d479b8811c4ec4481d774bdc338492a7b Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Thu, 21 May 2026 11:24:23 -0400 Subject: [PATCH 09/22] Replace WithClientSecret baseline with negative test; add auth_code x mTLS test; add wiki doc - Replace OboFlow_WithClientSecret_BaselineAsync with OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointAsync: proves that without SendCertificateOverMtls=true the cert goes as client_assertion to login.microsoftonline.com, not as TLS cert to mtlsauth.microsoft.com - Add AuthCodeFlow_WithSendCertificateOverMtls_BothMtlsConditionsMetAsync: headless test using a fake auth code + RecordingMtlsHttpClientFactory to verify request goes to mtlsauth and contains no client_assertion - Add docs/mtls-bearer-transport.md: user-facing wiki page covering opt-in API, IMsalMtlsHttpClientFactory implementation, supported flows, how to verify, AAD allowlisting requirement, and known limitations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/mtls-bearer-transport.md | 117 +++++++++++++++++ .../MtlsTransportUserFlowTests.cs | 121 +++++++++++++----- 2 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 docs/mtls-bearer-transport.md diff --git a/docs/mtls-bearer-transport.md b/docs/mtls-bearer-transport.md new file mode 100644 index 0000000000..4a7c78f175 --- /dev/null +++ b/docs/mtls-bearer-transport.md @@ -0,0 +1,117 @@ +# mTLS Bearer Transport for Confidential Client Applications + +## What is mTLS Bearer Transport? + +By default, MSAL authenticates a confidential client app by signing a JWT (`client_assertion`) with its certificate and including that assertion in the POST body of every token request. + +**mTLS bearer transport** is an alternative: the certificate is presented at the **TLS layer** during the handshake, and no `client_assertion` is sent in the request body. The token returned is still a standard Bearer token. + +This is enabled by the `SendCertificateOverMtls = true` option. When set: +- Token requests are routed to `mtlsauth.microsoft.com` (or a regional mTLS endpoint when `WithAzureRegion` is also configured) +- `client_assertion` is **not** included in the POST body +- The TLS certificate authenticates the app + +## AAD Prerequisite: Allowlisting + +> ⚠️ **Your app must be allowlisted for mTLS client auth on the AAD/ESTS side.** +> +> There is no self-serve portal. Contact the ESTS team to enable mTLS for your app registration. +> +> Without allowlisting, AAD returns `AADSTS51000: MtlsClientAuth is/are disabled`. + +## How to Opt In + +Two steps are required. + +### Step 1 — Configure the credential + +```csharp +var cca = ConfidentialClientApplicationBuilder + .Create(clientId) + .WithAuthority($"https://login.microsoftonline.com/{tenantId}") + .WithCertificate(cert, new CertificateOptions { SendCertificateOverMtls = true }) + .WithHttpClientFactory(new MyMtlsHttpClientFactory(cert)) // see Step 2 + .Build(); +``` + +`SendCertificateOverMtls` requires a certificate-based credential. Passing it with a client secret throws at `Build()` time. + +### Step 2 — Implement `IMsalMtlsHttpClientFactory` + +MSAL calls `GetHttpClient(X509Certificate2)` to obtain an `HttpClient` that presents the client certificate during the TLS handshake. You must provide an implementation via `WithHttpClientFactory`. + +```csharp +public class MyMtlsHttpClientFactory : IMsalMtlsHttpClientFactory +{ + // Reuse HttpClient instances — do NOT create a new one per call (socket exhaustion). + private readonly HttpClient _mtlsClient; + private readonly HttpClient _plainClient; + + public MyMtlsHttpClientFactory(X509Certificate2 cert) + { + var handler = new HttpClientHandler(); + handler.ClientCertificates.Add(cert); + _mtlsClient = new HttpClient(handler); + _plainClient = new HttpClient(); + } + + // Called for mTLS token requests (cert at TLS layer) + public HttpClient GetHttpClient(X509Certificate2 cert) => _mtlsClient; + + // Called for non-mTLS requests (e.g., instance discovery) + public HttpClient GetHttpClient() => _plainClient; +} +``` + +### Acquire a token + +```csharp +// S2S (app-to-app) +AuthenticationResult result = await cca + .AcquireTokenForClient(scopes) + .ExecuteAsync(); + +// On-behalf-of +AuthenticationResult result = await cca + .AcquireTokenOnBehalfOf(scopes, new UserAssertion(userToken)) + .ExecuteAsync(); +``` + +The same `WithCertificate(cert, new CertificateOptions { SendCertificateOverMtls = true })` configuration applies to all supported flows — no per-call change is needed. + +## Supported Flows + +| Flow | MSAL API | +|------|----------| +| App-to-app (S2S / client credentials) | `AcquireTokenForClient` | +| On-behalf-of (OBO) | `AcquireTokenOnBehalfOf` | +| Silent / refresh-token redemption | `AcquireTokenSilent`, `AcquireTokenByRefreshToken` | +| Authorization code | `AcquireTokenByAuthorizationCode` | + +## How to Verify It's Working + +### Option 1 — Check the token endpoint + +```csharp +AuthenticationResult result = await cca.AcquireTokenForClient(scopes).ExecuteAsync(); + +// Should contain "mtlsauth.microsoft.com", not "login.microsoftonline.com" +Console.WriteLine(result.AuthenticationResultMetadata.TokenEndpoint); +``` + +### Option 2 — Intercept the request (unit/integration tests) + +Use a recording `IMsalMtlsHttpClientFactory` (see `RecordingMtlsHttpClientFactory` in `MtlsTransportUserFlowTests.cs`) to capture the outgoing request. Assert: +- URL contains `mtlsauth` +- Body does **not** contain `client_assertion` + +## Known Limitations + +- **Windows only** — the mTLS client certificate stack depends on `System.Net.Security` behavior that is not supported on Linux in the current test configuration. +- **AAD allowlisting required** — no self-serve portal; contact ESTS. +- **Certificate credential required** — `SendCertificateOverMtls = true` is incompatible with client secrets and throws at `Build()` time. + +## Related Docs + +- [sni_mtls_bearer_token_design.md](sni_mtls_bearer_token_design.md) — internal design spec +- [mtlspop_architecture.md](mtlspop_architecture.md) — mTLS PoP architecture (distinct from Bearer transport) diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs index db71173f44..cdc9b3a657 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -190,34 +190,24 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsyn } /// - /// Baseline test — 2x2 matrix cell (OBO × client_secret): - /// Verifies that AcquireTokenOnBehalfOf succeeds with standard client-secret authentication. + /// Negative test: verifies that OBO WITHOUT SendCertificateOverMtls = true does NOT + /// route to the mTLS endpoint. The certificate is presented as a JWT client_assertion + /// in the POST body via the regular login.microsoftonline.com endpoint. /// - /// Purpose: proves the OBO grant itself works end-to-end with normal app auth. - /// When fails with - /// AADSTS51000: MtlsClientAuth is/are disabled, this test confirms the failure is - /// purely an AAD app-configuration gap (mTLS not enabled on AppWebApi), NOT a limitation of - /// the OBO grant over mTLS transport. - /// - /// Full 2x2 matrix: - /// client_credentials + mTLS → ClientCredentialsMtlsPopTests.Sni_Over_Mtls_Gets_Bearer_Token_Successfully_TestAsync (PASSES) - /// client_credentials + secret → ClientCredentialsTests (PASSES) - /// OBO + mTLS → (FAILS until AppWebApi is mTLS-enabled) - /// OBO + secret → this test (PASSES) + /// This is the contrast case for : + /// the opt-in flag is what distinguishes mTLS transport from standard cert-assertion auth. /// [DoNotRunOnLinux] [TestMethod] - public async Task OboFlow_WithClientSecret_BaselineAsync() + public async Task OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointAsync() { + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); - // Get the AppWebApi client secret from Key Vault (same secret used by OnBehalfOfTests). - string oboClientSecret = LabResponseHelper.FetchSecretString( - TestConstants.MsalOBOKeyVaultSecretName, - LabResponseHelper.KeyVaultSecretsProviderMsal); - // Step 1: Acquire user assertion via ROPC var pca = PublicClientApplicationBuilder .Create(appConfig.AppId) @@ -234,26 +224,38 @@ public async Task OboFlow_WithClientSecret_BaselineAsync() Assert.IsNotNull(userResult?.AccessToken, "Failed to acquire user token via ROPC."); - // Step 2: OBO with standard client_secret auth (no mTLS). - // This sends client_secret in the POST body to login.microsoftonline.com. + // Step 2: OBO with cert but WITHOUT SendCertificateOverMtls — cert goes in the body as + // client_assertion, NOT at the TLS layer. Request goes to login.microsoftonline.com. + var recordingFactory = new RecordingMtlsHttpClientFactory(); var cca = ConfidentialClientApplicationBuilder .Create(appApiConfig.AppId) .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResult.TenantId}"), true) - .WithClientSecret(oboClientSecret) - .WithTestLogging() + .WithCertificate(mtlsCert) // no SendCertificateOverMtls + .WithHttpClientFactory(recordingFactory) .Build(); - AuthenticationResult oboResult = await cca - .AcquireTokenOnBehalfOf(s_userReadScopes, new UserAssertion(userResult.AccessToken)) - .ExecuteAsync(CancellationToken.None) - .ConfigureAwait(false); + try + { + await cca + .AcquireTokenOnBehalfOf(s_userReadScopes, new UserAssertion(userResult.AccessToken)) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (MsalServiceException) + { + // AAD may reject for cert/config reasons — we only care about the request format. + } - Assert.IsNotNull(oboResult?.AccessToken, "OBO with client_secret should succeed."); - Assert.AreEqual(TokenSource.IdentityProvider, oboResult.AuthenticationResultMetadata.TokenSource, - "First OBO call should hit the network."); - StringAssert.Contains( - oboResult.AuthenticationResultMetadata.TokenEndpoint, "login.microsoftonline.com", - "Standard client_secret OBO should route to the regular login endpoint, not mtlsauth."); + string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; + string requestBody = recordingFactory.LastCapturedBody ?? ""; + + // Without SendCertificateOverMtls, request must NOT go to mtlsauth + Assert.DoesNotContain(requestUrl, "mtlsauth", + $"OBO without SendCertificateOverMtls should use login.microsoftonline.com, but got: {requestUrl}"); + + // The cert is sent as client_assertion in the body (standard cert auth) + StringAssert.Contains(requestBody, "client_assertion", + "OBO without SendCertificateOverMtls should include client_assertion in the body."); } /// @@ -440,6 +442,59 @@ public async Task ClientCredentials_WithSendCertificateOverMtls_BothMtlsConditio "client_assertion should NOT be in the body when SendCertificateOverMtls=true."); } + /// + /// Tests the two conditions required for mTLS transport on the auth_code flow: + /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com). + /// 2. No client_assertion in the POST body. + /// + /// Uses a fake/expired auth code to trigger the token request without a real browser session. + /// AAD will reject the code, but the assertions verify MSAL's request format before the response. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task AuthCodeFlow_WithSendCertificateOverMtls_BothMtlsConditionsMetAsync() + { + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + var recordingFactory = new RecordingMtlsHttpClientFactory(); + var cca = ConfidentialClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{user.TenantId}"), true) + .WithCertificate(mtlsCert, new CertificateOptions { SendCertificateOverMtls = true }) + .WithRedirectUri("http://localhost") + .WithHttpClientFactory(recordingFactory) + .Build(); + + try + { + // Use a fake auth code to trigger the token request. + // AAD will reject it, but MSAL sends the request first — we capture and assert on it. + await cca + .AcquireTokenByAuthorizationCode(s_userReadScopes, "fake_auth_code_for_mtls_format_test") + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (MsalServiceException) + { + // Expected — AAD rejects the fake code. We assert on the captured request below. + } + + string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; + string requestBody = recordingFactory.LastCapturedBody ?? ""; + + // Condition 1: request must go to the mTLS endpoint + StringAssert.Contains(requestUrl, "mtlsauth", + $"Condition 1 FAILED: auth_code token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); + + // Condition 2: no client_assertion in body + Assert.DoesNotContain(requestBody, "client_assertion", + "Condition 2 FAILED: client_assertion IS present in the auth_code POST body — should be absent for mTLS transport."); + } + /// /// A recording mTLS factory that captures request URLs and bodies from BOTH the plain /// and cert-bearing HTTP client paths. Unlike HttpSnifferClientFactory, the cert path From 51c16c88a0b3d652d49bb729b73a13e5afdf7576 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Thu, 21 May 2026 11:33:23 -0400 Subject: [PATCH 10/22] docs: remove internal ESTS team language from mtls-bearer-transport.md 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> --- docs/mtls-bearer-transport.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/mtls-bearer-transport.md b/docs/mtls-bearer-transport.md index 4a7c78f175..2e990ee38d 100644 --- a/docs/mtls-bearer-transport.md +++ b/docs/mtls-bearer-transport.md @@ -11,13 +11,11 @@ This is enabled by the `SendCertificateOverMtls = true` option. When set: - `client_assertion` is **not** included in the POST body - The TLS certificate authenticates the app -## AAD Prerequisite: Allowlisting +## AAD Prerequisite: App Enablement (Preview) -> ⚠️ **Your app must be allowlisted for mTLS client auth on the AAD/ESTS side.** +> ⚠️ **This feature is in preview. Your app must be enabled for mTLS client auth by Microsoft Entra before token requests will succeed.** > -> There is no self-serve portal. Contact the ESTS team to enable mTLS for your app registration. -> -> Without allowlisting, AAD returns `AADSTS51000: MtlsClientAuth is/are disabled`. +> There is no self-serve portal today. Without enablement, AAD returns `AADSTS51000: MtlsClientAuth is/are disabled`. ## How to Opt In @@ -108,7 +106,7 @@ Use a recording `IMsalMtlsHttpClientFactory` (see `RecordingMtlsHttpClientFactor ## Known Limitations - **Windows only** — the mTLS client certificate stack depends on `System.Net.Security` behavior that is not supported on Linux in the current test configuration. -- **AAD allowlisting required** — no self-serve portal; contact ESTS. +- **AAD-side enablement required (preview)** — there is no self-serve portal today; app enablement requires Microsoft Entra configuration. - **Certificate credential required** — `SendCertificateOverMtls = true` is incompatible with client secrets and throws at `Build()` time. ## Related Docs From 2932f1f8916fa05707c2e5554aae2f40671c5368 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 15:51:16 -0400 Subject: [PATCH 11/22] fix(mtls): send client_assertion on all flows including S2S 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> --- .../CredentialMaterialResolver.cs | 2 +- .../MtlsTransportUserFlowTests.cs | 45 +++++++++---------- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 30 ++++++------- 3 files changed, 36 insertions(+), 41 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index 7a819160c1..d200ae6e18 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -61,7 +61,7 @@ private static CredentialContext BuildContext( { ClientId = requestParams.AppConfig.ClientId, TokenEndpoint = tokenEndpoint, - Mode = requestParams.MtlsCertificate != null || requestParams.IsMtlsPopRequested + Mode = requestParams.IsMtlsPopRequested ? CredentialTransportProtocol.Mtls : CredentialTransportProtocol.OAuth, Claims = requestParams.Claims, diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs index cdc9b3a657..47dd54a3ce 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -22,12 +22,12 @@ namespace Microsoft.Identity.Test.Integration.HeadlessTests { /// /// Integration tests for mTLS bearer transport (SendCertificateOverMtls = true) - /// applied to user flows: OBO and refresh_token. + /// applied to all flows: S2S, OBO, refresh_token, and auth_code. /// - /// Each test validates the two conditions required for true mTLS bearer transport: + /// Each test validates the two conditions required for mTLS bearer transport: /// 1. The token request goes to the mTLS endpoint (mtlsauth.microsoft.com). - /// 2. No client_assertion is in the POST body — the TLS certificate authenticates - /// the app at the transport layer. + /// 2. client_assertion IS in the POST body — cert authenticates at the TLS layer + /// AND the body carries the assertion (required by ESTS for this preview). /// /// This is distinct from mTLS PoP (.WithMtlsProofOfPossession()), which binds the /// token cryptographically to a certificate and is only available on AcquireTokenForClient. @@ -259,9 +259,9 @@ await cca } /// - /// Tests the two conditions required for true mTLS transport auth on OBO: + /// Tests the two conditions required for mTLS transport auth on OBO: /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com), not the regular endpoint. - /// 2. No client_assertion in the POST body — the TLS cert alone authenticates the app. + /// 2. client_assertion IS in the POST body — cert at TLS layer + assertion in body. /// /// Uses CertificateOptions.SendCertificateOverMtls = true to opt in to mTLS bearer transport. /// AAD may reject the request if the cert is not registered for AppWebApi, but MSAL's request @@ -321,15 +321,15 @@ await cca StringAssert.Contains(requestUrl, "mtlsauth", $"Condition 1 FAILED: OBO token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); - // Condition 2: no client_assertion in body - Assert.DoesNotContain(lastBody, "client_assertion", - "Condition 2 FAILED: client_assertion IS present in the OBO POST body — should be absent for mTLS transport."); + // Condition 2: client_assertion must be in body (cert at TLS + assertion in body) + StringAssert.Contains(lastBody, "client_assertion", + "Condition 2 FAILED: client_assertion is NOT present in the OBO POST body — should be present for mTLS transport."); } /// - /// Tests the two conditions required for true mTLS transport auth on refresh_token redemption: + /// Tests the two conditions required for mTLS transport auth on refresh_token redemption: /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com). - /// 2. No client_assertion in the POST body. + /// 2. client_assertion IS in the POST body. /// /// Uses CertificateOptions.SendCertificateOverMtls = true to opt in to mTLS bearer transport. /// @@ -389,18 +389,17 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_BothMtlsCondition StringAssert.Contains(requestUrl, "mtlsauth", $"Condition 1 FAILED: RT token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); - // Condition 2: no client_assertion in body - Assert.DoesNotContain(lastBody, "client_assertion", - "Condition 2 FAILED: client_assertion IS present in the RT POST body — should be absent for mTLS transport."); + // Condition 2: client_assertion must be in body + StringAssert.Contains(lastBody, "client_assertion", + "Condition 2 FAILED: client_assertion is NOT present in the RT POST body — should be present for mTLS transport."); } /// /// Control test: verifies that for AcquireTokenForClient with SendCertificateOverMtls=true, /// BOTH mTLS transport conditions ARE met: /// 1. Request goes to mtlsauth.microsoft.com (mTLS endpoint). - /// 2. No client_assertion in the POST body. + /// 2. client_assertion IS in the POST body (cert at TLS + assertion in body). /// - /// This is the "correct" behavior that we want to extend to user flows. /// Uses the MSI-allowlisted app (163ffef9) which has the lab cert registered. /// [DoNotRunOnLinux] @@ -437,15 +436,15 @@ public async Task ClientCredentials_WithSendCertificateOverMtls_BothMtlsConditio StringAssert.Contains(requestUrl, "mtlsauth", $"Expected mTLS endpoint (mtlsauth) but got: {requestUrl}"); - // Condition 2: no client_assertion in body - Assert.DoesNotContain(lastBody, "client_assertion", - "client_assertion should NOT be in the body when SendCertificateOverMtls=true."); + // Condition 2: client_assertion must be in body + StringAssert.Contains(lastBody, "client_assertion", + "client_assertion should be in the body when SendCertificateOverMtls=true (cert at TLS + assertion in body)."); } /// /// Tests the two conditions required for mTLS transport on the auth_code flow: /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com). - /// 2. No client_assertion in the POST body. + /// 2. client_assertion IS in the POST body (cert at TLS layer + assertion in body). /// /// Uses a fake/expired auth code to trigger the token request without a real browser session. /// AAD will reject the code, but the assertions verify MSAL's request format before the response. @@ -490,9 +489,9 @@ await cca StringAssert.Contains(requestUrl, "mtlsauth", $"Condition 1 FAILED: auth_code token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); - // Condition 2: no client_assertion in body - Assert.DoesNotContain(requestBody, "client_assertion", - "Condition 2 FAILED: client_assertion IS present in the auth_code POST body — should be absent for mTLS transport."); + // Condition 2: client_assertion must be in body + StringAssert.Contains(requestBody, "client_assertion", + "Condition 2 FAILED: client_assertion is NOT present in the auth_code POST body — should be present for mTLS transport."); } /// diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index 9c6c7a703c..362f547e51 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -21,9 +21,10 @@ namespace Microsoft.Identity.Test.Unit /// Unit tests for mTLS bearer transport applied to user flows (OBO and refresh_token). /// /// These tests verify that when is - /// set to true, MSAL routes user-flow token requests to the mTLS endpoint - /// (mtlsauth.microsoft.com) and omits client_assertion from the POST body — - /// the same behaviour already implemented for AcquireTokenForClient. + /// set to true, MSAL routes token requests to the mTLS endpoint + /// (mtlsauth.microsoft.com) and includes client_assertion in the POST body + /// for all flows — the cert authenticates at the TLS layer AND the body carries the assertion. + /// This applies to all flows: S2S, OBO, refresh_token, and auth_code. /// [TestClass] public class MtlsBearerUserFlowTests : TestBase @@ -44,10 +45,10 @@ public static void ClassInitialize(TestContext context) /// /// Verifies that an OBO token request with SendCertificateOverMtls = true: /// 1. Targets the global mTLS endpoint (mtlsauth.microsoft.com). - /// 2. Does NOT include client_assertion in the POST body. + /// 2. Includes client_assertion in the POST body (cert at TLS layer + assertion in body). /// [TestMethod] - public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAndNoClientAssertionAsync() + public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAsync() { string tenantId = "123456-1234-2345-1234561234"; string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; @@ -71,12 +72,9 @@ public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAndN { OAuth2Parameter.ClientId, TestConstants.ClientId }, { OAuth2Parameter.GrantType, OAuth2GrantType.JwtBearer }, { OAuth2Parameter.RequestedTokenUse, OAuth2RequestedTokenUse.OnBehalfOf }, - }, - UnExpectedPostData = new Dictionary - { { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, { OAuth2Parameter.ClientAssertion, "placeholder" } - } + }, }; harness.HttpManager.AddMockHandler(tokenHttpCallHandler); @@ -103,7 +101,8 @@ public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAndN /// /// Verifies that a user flow token request with SendCertificateOverMtls = true and a - /// region configured uses the regional mTLS endpoint (e.g. eastus.mtlsauth.microsoft.com). + /// region configured uses the regional mTLS endpoint (e.g. eastus.mtlsauth.microsoft.com) + /// and includes client_assertion in the POST body. /// /// OBO is used as the representative user flow here. The regional routing code /// (RegionAndMtlsDiscoveryProvider) is shared across all user flows (OBO, refresh_token, @@ -130,7 +129,7 @@ public async Task UserFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMt ExpectedUrl = expectedTokenEndpoint, ExpectedMethod = HttpMethod.Post, ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), - UnExpectedPostData = new Dictionary + ExpectedPostData = new Dictionary { { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, { OAuth2Parameter.ClientAssertion, "placeholder" } @@ -164,10 +163,10 @@ public async Task UserFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMt /// Verifies that a refresh-token redemption (IByRefreshToken) with /// SendCertificateOverMtls = true: /// 1. Targets the global mTLS endpoint. - /// 2. Does NOT include client_assertion in the POST body. + /// 2. Includes client_assertion in the POST body (cert at TLS layer + assertion in body). /// [TestMethod] - public async Task RefreshTokenFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAndNoClientAssertionAsync() + public async Task RefreshTokenFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAsync() { string tenantId = "123456-1234-2345-1234561234"; string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; @@ -191,12 +190,9 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEnd { OAuth2Parameter.ClientId, TestConstants.ClientId }, { OAuth2Parameter.GrantType, OAuth2GrantType.RefreshToken }, { OAuth2Parameter.RefreshToken, fakeRefreshToken }, - }, - UnExpectedPostData = new Dictionary - { { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, { OAuth2Parameter.ClientAssertion, "placeholder" } - } + }, }; harness.HttpManager.AddMockHandler(tokenHttpCallHandler); From 90b2cefe1e9b98ebfb918e0c95afda64714dd3eb Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 15:59:15 -0400 Subject: [PATCH 12/22] fix: address Copilot review comments - 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> --- .../TokenCache.ITokenCacheInternal.cs | 5 ++++- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs index d7d14d1b77..3db2de2141 100644 --- a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs +++ b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs @@ -723,7 +723,10 @@ private async Task> FilterTokensByEnvironmentAsyn // at this point we need environment aliases, try to get them without a discovery call // Use OriginalAuthority so that mTLS-transformed authorities (mtlsauth.microsoft.com) don't - // propagate into alias resolution — the cache stores tokens under login.* environments. + // propagate into alias resolution. After ResolveAuthorityAsync, requestParams.AuthorityInfo + // may reflect the mtlsauth host; passing that to GetMetadataEntryTryAvoidNetworkAsync causes + // RegionAndMtlsDiscoveryProvider to throw MtlsPopNotSupportedForEnvironment. Using the + // original login.* authority ensures alias lookup always succeeds via instance discovery. var instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo, tokenCacheItems.Select(at => at.Environment), // if all environments are known, a network call can be avoided diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index 362f547e51..4067cb2e7f 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -9,7 +9,6 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.AppConfig; using Microsoft.Identity.Client.OAuth2; -using Microsoft.Identity.Client.Utils; using Microsoft.Identity.Test.Common; using Microsoft.Identity.Test.Common.Core.Helpers; using Microsoft.Identity.Test.Common.Core.Mocks; From 1c10e46a3cf155149e71b9716ee2d704d98390ed Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 16:33:39 -0400 Subject: [PATCH 13/22] fix: address Copilot review comments round 2 - 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> --- docs/mtls-bearer-transport.md | 8 +++---- .../MtlsTransportUserFlowTests.cs | 5 ++-- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 24 ++++++++++++++++--- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/mtls-bearer-transport.md b/docs/mtls-bearer-transport.md index 2e990ee38d..deabf4ecf7 100644 --- a/docs/mtls-bearer-transport.md +++ b/docs/mtls-bearer-transport.md @@ -4,12 +4,12 @@ By default, MSAL authenticates a confidential client app by signing a JWT (`client_assertion`) with its certificate and including that assertion in the POST body of every token request. -**mTLS bearer transport** is an alternative: the certificate is presented at the **TLS layer** during the handshake, and no `client_assertion` is sent in the request body. The token returned is still a standard Bearer token. +**mTLS bearer transport** is an alternative: the certificate is presented at the **TLS layer** during the handshake, and a `client_assertion` JWT is also included in the POST body. The token returned is still a standard Bearer token. This is enabled by the `SendCertificateOverMtls = true` option. When set: - Token requests are routed to `mtlsauth.microsoft.com` (or a regional mTLS endpoint when `WithAzureRegion` is also configured) -- `client_assertion` is **not** included in the POST body -- The TLS certificate authenticates the app +- `client_assertion` **is** included in the POST body (ESTS requires it for this preview) +- The TLS certificate also authenticates the app at the transport layer ## AAD Prerequisite: App Enablement (Preview) @@ -101,7 +101,7 @@ Console.WriteLine(result.AuthenticationResultMetadata.TokenEndpoint); Use a recording `IMsalMtlsHttpClientFactory` (see `RecordingMtlsHttpClientFactory` in `MtlsTransportUserFlowTests.cs`) to capture the outgoing request. Assert: - URL contains `mtlsauth` -- Body does **not** contain `client_assertion` +- Body **contains** `client_assertion` (cert at TLS + assertion in body) ## Known Limitations diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs index 47dd54a3ce..7610ab0a18 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -94,7 +94,8 @@ public async Task OboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync() Assert.IsNotNull(userResult?.AccessToken, "Failed to acquire user token via ROPC."); // Step 2: Build the OBO confidential client with SendCertificateOverMtls=true. - // The cert authenticates the app at the TLS layer; no client_assertion is sent in the body. + // The cert authenticates the app at the TLS layer AND client_assertion is sent in the body + // (preview behavior — ESTS requires the assertion for user flows). // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory. var cca = ConfidentialClientApplicationBuilder .Create(appApiConfig.AppId) @@ -163,7 +164,7 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsyn string refreshToken = rtCacheItem.Secret; // 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. + // AND client_assertion is sent in the body (preview behavior). // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory. var cca = ConfidentialClientApplicationBuilder .Create(appConfig.AppId) diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index 4067cb2e7f..419b69afa2 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -72,8 +72,14 @@ public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAsyn { OAuth2Parameter.GrantType, OAuth2GrantType.JwtBearer }, { OAuth2Parameter.RequestedTokenUse, OAuth2RequestedTokenUse.OnBehalfOf }, { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, - { OAuth2Parameter.ClientAssertion, "placeholder" } }, + // client_assertion value is a signed JWT — assert presence only, not value + AdditionalRequestValidation = req => + { + string body = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + StringAssert.Contains(body, "client_assertion=", + "client_assertion must be present in the OBO POST body."); + } }; harness.HttpManager.AddMockHandler(tokenHttpCallHandler); @@ -131,7 +137,13 @@ public async Task UserFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMt ExpectedPostData = new Dictionary { { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, - { OAuth2Parameter.ClientAssertion, "placeholder" } + }, + // client_assertion value is a signed JWT — assert presence only, not value + AdditionalRequestValidation = req => + { + string body = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + StringAssert.Contains(body, "client_assertion=", + "client_assertion must be present in the regional OBO POST body."); } }; @@ -190,8 +202,14 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEnd { OAuth2Parameter.GrantType, OAuth2GrantType.RefreshToken }, { OAuth2Parameter.RefreshToken, fakeRefreshToken }, { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, - { OAuth2Parameter.ClientAssertion, "placeholder" } }, + // client_assertion value is a signed JWT — assert presence only, not value + AdditionalRequestValidation = req => + { + string body = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + StringAssert.Contains(body, "client_assertion=", + "client_assertion must be present in the RT POST body."); + } }; harness.HttpManager.AddMockHandler(tokenHttpCallHandler); From 12b99844a3a361b380aa78401c36b50a6fb4e315 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 16:43:19 -0400 Subject: [PATCH 14/22] fix: restore WithCachePartitionKey and WithReservedScopes APIs from main These APIs were added in PR #6014 (022dcde32) but were accidentally absent from this branch due to shallow clone grafting. Restoring to fix CI build failures in WithReservedScopesTests.cs and WithCachePartitionKeyTests.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AcquireTokenParameterBuilderExtensions.cs | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenParameterBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenParameterBuilderExtensions.cs index 1d318946f8..4726af213e 100644 --- a/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenParameterBuilderExtensions.cs +++ b/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenParameterBuilderExtensions.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.Identity.Client.Advanced { @@ -44,9 +46,64 @@ public static T WithExtraHttpHeaders( this AbstractAcquireTokenParameterBuilder builder, IDictionary extraHttpHeaders) where T : AbstractAcquireTokenParameterBuilder - { + { builder.CommonParameters.ExtraHttpHeaders = extraHttpHeaders; return (T)builder; } + + /// + /// Adds a key-value pair to the token cache key without sending it as a query parameter. + /// Use this to partition cached tokens (e.g., isolating short-lived sessions from regular + /// sessions for the same user). Both AcquireTokenByAuthorizationCode and + /// AcquireTokenSilent must use the same partition key to match cached entries. + /// + /// The builder to chain .With methods. + /// The partition key name. + /// The partition key value. + /// The builder to chain .With methods. + public static T WithCachePartitionKey( + this BaseAbstractAcquireTokenParameterBuilder builder, + string key, + string value) + where T : BaseAbstractAcquireTokenParameterBuilder + { + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (key.Length == 0) + { + throw new ArgumentException("Value cannot be empty.", nameof(key)); + } + + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + builder.CommonParameters.CacheKeyComponents ??= new SortedList>>(); + string capturedValue = value; + builder.CommonParameters.CacheKeyComponents[key] = (CancellationToken _) => Task.FromResult(capturedValue); + return (T)builder; + } + + /// + /// Controls whether MSAL sends the reserved offline_access scope while continuing to + /// send openid and profile. Only applicable to authorization code redemption flows. + /// + /// The builder to chain .With methods. + /// + /// Set to to omit offline_access. Set to + /// to preserve the default MSAL behavior of sending all reserved scopes. + /// + /// The builder to chain .With methods. + public static AcquireTokenByAuthorizationCodeParameterBuilder WithReservedScopes( + this AcquireTokenByAuthorizationCodeParameterBuilder builder, + bool offlineAccessScope) + { + builder.CommonParameters.SendOfflineAccessScope = offlineAccessScope; + return builder; + } } } From 3df65bd9f251ba4eef6072f33c9853ce9d6cdef6 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 16:46:33 -0400 Subject: [PATCH 15/22] fix: restore SendOfflineAccessScope property and PublicAPI entries from 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> --- .../ApiConfig/Parameters/AcquireTokenCommonParameters.cs | 1 + .../PublicApi/net462/PublicAPI.Unshipped.txt | 2 ++ .../PublicApi/net472/PublicAPI.Unshipped.txt | 2 ++ .../PublicApi/net8.0-android/PublicAPI.Unshipped.txt | 2 ++ .../PublicApi/net8.0-ios/PublicAPI.Unshipped.txt | 2 ++ .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 2 ++ .../PublicApi/netstandard2.0/PublicAPI.Unshipped.txt | 2 ++ 7 files changed, 13 insertions(+) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index 1bd046d6ea..13d7137b47 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -39,6 +39,7 @@ internal class AcquireTokenCommonParameters public X509Certificate2 MtlsCertificate { get; internal set; } public List AdditionalCacheParameters { get; set; } public SortedList>> CacheKeyComponents { get; internal set; } + public bool? SendOfflineAccessScope { get; set; } public string FmiPathSuffix { get; internal set; } public string ClientAssertionFmiPath { get; internal set; } public bool IsMtlsPopRequested { get; set; } diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index 1ace583cda..99e725f55b 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -3,3 +3,5 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv 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 +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index 1ace583cda..99e725f55b 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -3,3 +3,5 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv 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 +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index 1ace583cda..99e725f55b 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -3,3 +3,5 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv 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 +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index 1ace583cda..99e725f55b 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -3,3 +3,5 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv 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 +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index 1ace583cda..99e725f55b 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -3,3 +3,5 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv 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 +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index 1ace583cda..99e725f55b 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -3,3 +3,5 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv 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 +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value) -> T +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder From dddbf3d1165197f42f80d5d53a25fa0113233a03 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 16:48:19 -0400 Subject: [PATCH 16/22] fix: restore SendOfflineAccessScope in TokenClient and AuthenticationRequestParameters 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> --- .../Internal/Requests/AuthenticationRequestParameters.cs | 1 + src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index 9eab9f0bc8..1b7fd2a83e 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -118,6 +118,7 @@ public X509Certificate2 MtlsCertificate } public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested; + public bool? SendOfflineAccessScope => _commonParameters.SendOfflineAccessScope; /// /// The certificate resolved and used for client authentication (if certificate-based authentication was used). diff --git a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs index 14b17c72cc..4a98bd6f7a 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs @@ -63,6 +63,12 @@ public async Task SendTokenRequestAsync( string scopes = !string.IsNullOrEmpty(scopeOverride) ? scopeOverride : GetDefaultScopes(_requestParams.Scope); + if (_requestParams.SendOfflineAccessScope is false) + { + scopes = string.Join(" ", scopes.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Where(s => !string.Equals(s, OAuth2Value.ScopeOfflineAccess, StringComparison.OrdinalIgnoreCase))); + } + await AddBodyParamsAndHeadersAsync(additionalBodyParameters, scopes, tokenEndpoint, cancellationToken).ConfigureAwait(false); AddThrottlingHeader(); From e54af78aae010a64f35510194968f3bc1a0d74d1 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 17:05:00 -0400 Subject: [PATCH 17/22] fix: address Copilot review comments round 3 - 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> --- docs/mtls-bearer-transport.md | 2 +- .../HeadlessTests/MtlsTransportUserFlowTests.cs | 8 ++++---- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/mtls-bearer-transport.md b/docs/mtls-bearer-transport.md index deabf4ecf7..649128dc29 100644 --- a/docs/mtls-bearer-transport.md +++ b/docs/mtls-bearer-transport.md @@ -105,7 +105,7 @@ Use a recording `IMsalMtlsHttpClientFactory` (see `RecordingMtlsHttpClientFactor ## Known Limitations -- **Windows only** — the mTLS client certificate stack depends on `System.Net.Security` behavior that is not supported on Linux in the current test configuration. +- **Integration test setup is Windows-only** — the provided integration tests use `[DoNotRunOnLinux]` due to test infrastructure constraints. The mTLS bearer transport feature itself works cross-platform wherever `HttpClientHandler` client certificate authentication is supported. - **AAD-side enablement required (preview)** — there is no self-serve portal today; app enablement requires Microsoft Entra configuration. - **Certificate credential required** — `SendCertificateOverMtls = true` is incompatible with client secrets and throws at `Build()` time. diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs index 7610ab0a18..1f44092dc3 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -506,8 +506,8 @@ private class RecordingMtlsHttpClientFactory : IMsalMtlsHttpClientFactory public IReadOnlyList<(string Url, string Body)> Captured => _captured; - public string LastCapturedUrl => _captured.LastOrDefault(c => c.Url.Contains("/oauth2/")).Url; - public string LastCapturedBody => _captured.LastOrDefault(c => !string.IsNullOrEmpty(c.Body)).Body; + public string LastCapturedUrl { get { lock (_captured) { return _captured.LastOrDefault(c => c.Url.Contains("/oauth2/")).Url; } } } + public string LastCapturedBody { get { lock (_captured) { return _captured.LastOrDefault(c => !string.IsNullOrEmpty(c.Body)).Body; } } } private HttpClient BuildRecordingClient(X509Certificate2 cert = null) { @@ -519,8 +519,8 @@ private HttpClient BuildRecordingClient(X509Certificate2 cert = null) string body = null; if (req.Content != null) { - req.Content.LoadIntoBufferAsync().GetAwaiter().GetResult(); - body = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + req.Content.LoadIntoBufferAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + body = req.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } lock (_captured) { _captured.Add((req.RequestUri?.AbsoluteUri ?? "", body ?? "")); } }); diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index 419b69afa2..7665217c43 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -76,7 +76,7 @@ public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAsyn // client_assertion value is a signed JWT — assert presence only, not value AdditionalRequestValidation = req => { - string body = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + string body = req.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); StringAssert.Contains(body, "client_assertion=", "client_assertion must be present in the OBO POST body."); } @@ -141,7 +141,7 @@ public async Task UserFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMt // client_assertion value is a signed JWT — assert presence only, not value AdditionalRequestValidation = req => { - string body = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + string body = req.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); StringAssert.Contains(body, "client_assertion=", "client_assertion must be present in the regional OBO POST body."); } @@ -206,7 +206,7 @@ public async Task RefreshTokenFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEnd // client_assertion value is a signed JWT — assert presence only, not value AdditionalRequestValidation = req => { - string body = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + string body = req.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); StringAssert.Contains(body, "client_assertion=", "client_assertion must be present in the RT POST body."); } From 26250d22b2e106a4009ad5678493dbee2c660da0 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 17:32:44 -0400 Subject: [PATCH 18/22] fix: remove SendCertificateOverMtls guard from Case 2 in TryInitImplicitBearerOverMtlsAsync 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> --- .../ApiConfig/Parameters/MtlsPopParametersInitializer.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs index 076556f121..e93944ed99 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs @@ -66,10 +66,11 @@ private static async Task TryInitImplicitBearerOverMtlsAsync( } // Case 2 – Only cert-capable credentials implement this capability interface. - // Guarded by SendCertificateOverMtls == true (same as Case 1) to avoid invoking - // the assertion callback on every request for apps that don't opt into mTLS. - if (serviceBundle.Config.CertificateOptions?.SendCertificateOverMtls == true && - serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) + // No SendCertificateOverMtls guard here: the TokenBindingCertificate pattern is a + // separate opt-in mechanism where the assertion delegate signals mTLS intent by + // returning a non-null cert. GetAssertionAsync is called once per request; the + // cert is only set if the delegate actually returns one. + if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) { var opts = CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct); From 35cc594d688c30fa644bb6d862305236b5cf7c11 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 17:42:11 -0400 Subject: [PATCH 19/22] docs: clarify Case 2 double-invocation pattern in MtlsPopParametersInitializer 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> --- .../Parameters/MtlsPopParametersInitializer.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs index e93944ed99..585b2eb820 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs @@ -67,9 +67,17 @@ private static async Task TryInitImplicitBearerOverMtlsAsync( // Case 2 – Only cert-capable credentials implement this capability interface. // No SendCertificateOverMtls guard here: the TokenBindingCertificate pattern is a - // separate opt-in mechanism where the assertion delegate signals mTLS intent by - // returning a non-null cert. GetAssertionAsync is called once per request; the - // cert is only set if the delegate actually returns one. + // distinct opt-in where the assertion delegate itself signals mTLS intent by returning + // a non-null cert. This is separate from Case 1 (SendCertificateOverMtls + cert-based + // credential). + // + // Call pattern per request: + // - This call always fires (even cache hits) to check for TokenBindingCertificate + // and set MtlsCertificate for proper endpoint routing. + // - GetCredentialMaterialAsync (in ClientAssertionDelegateCredential) calls the + // delegate a second time on network requests to produce the signed assertion JWT. + // - Cache hits: delegate called once (here only). Network requests: twice. + // - Delegates are expected to be cheap (return a pre-generated/cached assertion). if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) { var opts = CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct); From 7568b36b3a69af61d7c831dec401d9a551a1b418 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 18:24:47 -0400 Subject: [PATCH 20/22] fix: auto-enable SendX5C when SendCertificateOverMtls=true for SNI app 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> --- .../Internal/ClientCredential/CredentialMaterialResolver.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index d200ae6e18..e435a0c670 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -67,7 +67,10 @@ private static CredentialContext BuildContext( Claims = requestParams.Claims, ClientCapabilities = requestParams.AppConfig.ClientCapabilities, CryptographyManager = requestParams.RequestContext.ServiceBundle.PlatformProxy.CryptographyManager, - SendX5C = requestParams.SendX5C, + // When SendCertificateOverMtls=true, the client_assertion JWT must include the x5c chain + // so that AAD can validate the assertion against the SNI-registered certificate. + SendX5C = requestParams.SendX5C + || (requestParams.AppConfig.CertificateOptions?.SendCertificateOverMtls == true), UseSha2 = requestParams.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported, ExtraClientAssertionClaims = requestParams.ExtraClientAssertionClaims, ClientAssertionFmiPath = requestParams.ClientAssertionFmiPath, From 2b351fc80672b05f3dbab790bb153d7857cbe036 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 18:31:11 -0400 Subject: [PATCH 21/22] test: [Ignore] OboFlow and RefreshTokenFlow blocked integration tests 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> --- .../HeadlessTests/MtlsTransportUserFlowTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs index 1f44092dc3..416dd97326 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -65,6 +65,9 @@ public void TestInitialize() /// [DoNotRunOnLinux] [TestMethod] + [Ignore("Blocked: AppWebApi (23c64cd8) not yet enabled for mTLS client auth in ID4SLAB1. " + + "Error: AADSTS700027 (cert not registered). " + + "Remove this attribute once Bogdan/Qi enable mTLS on AppWebApi.")] public async Task OboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync() { // Arrange @@ -132,6 +135,9 @@ public async Task OboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync() /// [DoNotRunOnLinux] [TestMethod] + [Ignore("Blocked: AppS2S mTLS endpoint does not yet support this auth scenario in ID4SLAB1. " + + "Error: AADSTS392189 (mTLS endpoint only supports SNI/federated identity). " + + "Remove this attribute once Bogdan/Qi enable mTLS on AppS2S.")] public async Task RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsync() { // Arrange From 7e9ab798a17e3bf9a7f26bdb669ad92fc7b843e0 Mon Sep 17 00:00:00 2001 From: Robbie-Microsoft Date: Fri, 22 May 2026 18:41:18 -0400 Subject: [PATCH 22/22] test: strengthen regional OBO test assertions in ExpectedPostData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs index 7665217c43..05e1bfa23c 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -136,6 +136,9 @@ public async Task UserFlow_WithSendCertificateOverMtls_WithRegion_UsesRegionalMt ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), ExpectedPostData = new Dictionary { + { OAuth2Parameter.ClientId, TestConstants.ClientId }, + { OAuth2Parameter.GrantType, OAuth2GrantType.JwtBearer }, + { OAuth2Parameter.RequestedTokenUse, OAuth2RequestedTokenUse.OnBehalfOf }, { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, }, // client_assertion value is a signed JWT — assert presence only, not value