From 64e17691539460a6648c1a05c1c824249d7983de Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Thu, 14 May 2026 13:28:24 -0400 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 3bca103fa46bac188b43dcc6d9a58b31681ae491 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 19 May 2026 09:58:23 -0700 Subject: [PATCH 6/6] Potential fix for issue highlighted in parent branch --- .../AuthenticationRequestParameters.cs | 11 +++ .../TokenCache.ITokenCacheInternal.cs | 18 ++-- .../PublicApiTests/MtlsBearerUserFlowTests.cs | 82 +++++++++++++++++++ 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index 9eab9f0bc8..f6f012c466 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -97,6 +97,17 @@ public AuthenticationRequestParameters( public AuthorityInfo AuthorityInfo => AuthorityManager.Authority.AuthorityInfo; + /// + /// Returns the authority info appropriate for cache alias resolution. + /// When mTLS is active, the current authority may be rewritten to mtlsauth.microsoft.com, + /// which is not a valid host for instance discovery. Use the original login.* authority + /// for all cache environment/alias lookups. + /// + public AuthorityInfo CacheAuthorityInfo => + RequestContext.IsMtlsRequested + ? AuthorityManager.OriginalAuthority.AuthorityInfo + : AuthorityInfo; + public AuthorityInfo AuthorityOverride => _commonParameters.AuthorityOverride; #endregion diff --git a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs index 6d570993b0..764349be94 100644 --- a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs +++ b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs @@ -723,7 +723,7 @@ private async Task> FilterTokensByEnvironmentAsyn // at this point we need environment aliases, try to get them without a discovery call var instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.CacheAuthorityInfo, tokenCacheItems.Select(at => at.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); @@ -841,7 +841,7 @@ async Task ITokenCacheInternal.FindRefreshTokenAsync( { var metadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.CacheAuthorityInfo, refreshTokens.Select(rt => rt.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); @@ -871,7 +871,7 @@ await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsyn { var metadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.CacheAuthorityInfo, refreshTokens.Select(rt => rt.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); @@ -942,7 +942,7 @@ private static void FilterRefreshTokensByHomeAccountIdOrAssertion( var allAppMetadata = Accessor.GetAllAppMetadata(); var instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.CacheAuthorityInfo, allAppMetadata.Select(m => m.Environment), requestParams.RequestContext) .ConfigureAwait(false); @@ -1014,7 +1014,7 @@ async Task> ITokenCacheInternal.GetAccountsAsync(Authentic } InstanceDiscoveryMetadataEntry instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParameters.AuthorityInfo, + requestParameters.CacheAuthorityInfo, allEnvironmentsInCache, requestParameters.RequestContext).ConfigureAwait(false); @@ -1183,14 +1183,8 @@ 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( - authorityInfoForAliases, + requestParameters.CacheAuthorityInfo, allEnvironmentsInCache, requestParameters.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..cb7a238635 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -277,5 +277,87 @@ public async Task OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointWith } } } + + /// + /// Regression test: verifies that a second OBO call with the same user assertion + /// retrieves the token from cache (not the network) when SendCertificateOverMtls = true. + /// + /// After ResolveAuthorityAsync, requestParams.AuthorityInfo points to + /// mtlsauth.microsoft.com. The cache alias lookup in FilterTokensByEnvironmentAsync + /// must still resolve aliases from the original login.* host so the cached token + /// (stored under login.microsoftonline.com) is found. + /// + /// If the fix is missing, the second call will either throw + /// MsalClientException(MtlsPopNotSupportedForEnvironment) or miss the cache and + /// fail because no mock HTTP handler is queued for a second network call. + /// + [TestMethod] + public async Task OboFlow_WithSendCertificateOverMtls_SecondCall_ReturnsCachedTokenAsync() + { + // Arrange + 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 mock handler — the second call must come from cache + 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 — first call hits the (mocked) identity provider + var firstResult = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, new UserAssertion(fakeUserAssertion)) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(firstResult.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, firstResult.AuthenticationResultMetadata.TokenSource); + + // Act — second call with same assertion should return from cache + var secondResult = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, new UserAssertion(fakeUserAssertion)) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(secondResult.AccessToken); + Assert.AreEqual(TokenSource.Cache, secondResult.AuthenticationResultMetadata.TokenSource, + "Second OBO call with same assertion should return a cached token, not hit the network. " + + "If this fails, FilterTokensByEnvironmentAsync is likely passing the mTLS-rewritten authority " + + "(mtlsauth.microsoft.com) to instance discovery, which either throws or returns incorrect aliases."); + } + } + } } }