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/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 072cf972ae..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); @@ -1184,7 +1184,7 @@ private async Task> GetTenantProfilesAsync( StringComparer.OrdinalIgnoreCase); InstanceDiscoveryMetadataEntry instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParameters.AuthorityInfo, + requestParameters.CacheAuthorityInfo, 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..68c4a09a40 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -0,0 +1,444 @@ +// 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 +{ + /// + /// Integration tests for mTLS bearer transport (SendCertificateOverMtls = true) + /// applied to user flows: OBO and refresh_token. + /// + /// 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. + /// + [TestClass] + public class MtlsTransportUserFlowTests + { + private static readonly string[] s_userReadScopes = { "User.Read" }; + + [TestInitialize] + public void TestInitialize() + { + ApplicationBase.ResetStateForTest(); + } + + /// + /// 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_WithSendCertificateOverMtls_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: Acquire a user assertion via ROPC (public client — no mTLS needed here) + 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 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) + .WithCertificate(mtlsCert, new CertificateOptions { SendCertificateOverMtls = true }) + .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); + 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."); + } + + /// + /// 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_WithSendCertificateOverMtls_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; + + // 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(); + + // 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."); + 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."); + } + + /// + /// 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) + { + // 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)"; + + // 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) + { + // 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)"; + + // 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)"; + + // 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..cb7a238635 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -0,0 +1,363 @@ +// 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."); + } + } + + /// + /// 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 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 UserFlow_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); + Environment.SetEnvironmentVariable("MSAL_FORCE_REGION", null); + + 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_type MUST be present (indicates cert credential is being serialized as a JWT) + 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."); + } + } + } + + /// + /// 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."); + } + } + } + } +}