diff --git a/docs/mtls-bearer-transport.md b/docs/mtls-bearer-transport.md new file mode 100644 index 0000000000..649128dc29 --- /dev/null +++ b/docs/mtls-bearer-transport.md @@ -0,0 +1,115 @@ +# mTLS Bearer Transport for Confidential Client Applications + +## What is mTLS Bearer Transport? + +By default, MSAL authenticates a confidential client app by signing a JWT (`client_assertion`) with its certificate and including that assertion in the POST body of every token request. + +**mTLS bearer transport** is an alternative: the certificate is presented at the **TLS layer** during the handshake, and a `client_assertion` JWT is also included in the POST body. The token returned is still a standard Bearer token. + +This is enabled by the `SendCertificateOverMtls = true` option. When set: +- Token requests are routed to `mtlsauth.microsoft.com` (or a regional mTLS endpoint when `WithAzureRegion` is also configured) +- `client_assertion` **is** included in the POST body (ESTS requires it for this preview) +- The TLS certificate also authenticates the app at the transport layer + +## AAD Prerequisite: App Enablement (Preview) + +> ⚠️ **This feature is in preview. Your app must be enabled for mTLS client auth by Microsoft Entra before token requests will succeed.** +> +> There is no self-serve portal today. Without enablement, AAD returns `AADSTS51000: MtlsClientAuth is/are disabled`. + +## How to Opt In + +Two steps are required. + +### Step 1 — Configure the credential + +```csharp +var cca = ConfidentialClientApplicationBuilder + .Create(clientId) + .WithAuthority($"https://login.microsoftonline.com/{tenantId}") + .WithCertificate(cert, new CertificateOptions { SendCertificateOverMtls = true }) + .WithHttpClientFactory(new MyMtlsHttpClientFactory(cert)) // see Step 2 + .Build(); +``` + +`SendCertificateOverMtls` requires a certificate-based credential. Passing it with a client secret throws at `Build()` time. + +### Step 2 — Implement `IMsalMtlsHttpClientFactory` + +MSAL calls `GetHttpClient(X509Certificate2)` to obtain an `HttpClient` that presents the client certificate during the TLS handshake. You must provide an implementation via `WithHttpClientFactory`. + +```csharp +public class MyMtlsHttpClientFactory : IMsalMtlsHttpClientFactory +{ + // Reuse HttpClient instances — do NOT create a new one per call (socket exhaustion). + private readonly HttpClient _mtlsClient; + private readonly HttpClient _plainClient; + + public MyMtlsHttpClientFactory(X509Certificate2 cert) + { + var handler = new HttpClientHandler(); + handler.ClientCertificates.Add(cert); + _mtlsClient = new HttpClient(handler); + _plainClient = new HttpClient(); + } + + // Called for mTLS token requests (cert at TLS layer) + public HttpClient GetHttpClient(X509Certificate2 cert) => _mtlsClient; + + // Called for non-mTLS requests (e.g., instance discovery) + public HttpClient GetHttpClient() => _plainClient; +} +``` + +### Acquire a token + +```csharp +// S2S (app-to-app) +AuthenticationResult result = await cca + .AcquireTokenForClient(scopes) + .ExecuteAsync(); + +// On-behalf-of +AuthenticationResult result = await cca + .AcquireTokenOnBehalfOf(scopes, new UserAssertion(userToken)) + .ExecuteAsync(); +``` + +The same `WithCertificate(cert, new CertificateOptions { SendCertificateOverMtls = true })` configuration applies to all supported flows — no per-call change is needed. + +## Supported Flows + +| Flow | MSAL API | +|------|----------| +| App-to-app (S2S / client credentials) | `AcquireTokenForClient` | +| On-behalf-of (OBO) | `AcquireTokenOnBehalfOf` | +| Silent / refresh-token redemption | `AcquireTokenSilent`, `AcquireTokenByRefreshToken` | +| Authorization code | `AcquireTokenByAuthorizationCode` | + +## How to Verify It's Working + +### Option 1 — Check the token endpoint + +```csharp +AuthenticationResult result = await cca.AcquireTokenForClient(scopes).ExecuteAsync(); + +// Should contain "mtlsauth.microsoft.com", not "login.microsoftonline.com" +Console.WriteLine(result.AuthenticationResultMetadata.TokenEndpoint); +``` + +### Option 2 — Intercept the request (unit/integration tests) + +Use a recording `IMsalMtlsHttpClientFactory` (see `RecordingMtlsHttpClientFactory` in `MtlsTransportUserFlowTests.cs`) to capture the outgoing request. Assert: +- URL contains `mtlsauth` +- Body **contains** `client_assertion` (cert at TLS + assertion in body) + +## Known Limitations + +- **Integration test setup is Windows-only** — the provided integration tests use `[DoNotRunOnLinux]` due to test infrastructure constraints. The mTLS bearer transport feature itself works cross-platform wherever `HttpClientHandler` client certificate authentication is supported. +- **AAD-side enablement required (preview)** — there is no self-serve portal today; app enablement requires Microsoft Entra configuration. +- **Certificate credential required** — `SendCertificateOverMtls = true` is incompatible with client secrets and throws at `Build()` time. + +## Related Docs + +- [sni_mtls_bearer_token_design.md](sni_mtls_bearer_token_design.md) — internal design spec +- [mtlspop_architecture.md](mtlspop_architecture.md) — mTLS PoP architecture (distinct from Bearer transport) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/BaseAbstractAcquireTokenParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/BaseAbstractAcquireTokenParameterBuilder.cs index 4c331558d9..c2f50c33cc 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/BaseAbstractAcquireTokenParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/BaseAbstractAcquireTokenParameterBuilder.cs @@ -135,7 +135,6 @@ public T WithExtraQueryParameters(IDictionary /// Validates the parameters of the AcquireToken operation. /// diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs index 0556dd2569..7f93428a8d 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs @@ -28,6 +28,9 @@ public async Task ExecuteAsync( AcquireTokenSilentParameters silentParameters, CancellationToken cancellationToken) { + await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken) + .ConfigureAwait(false); + var requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); var requestParameters = await _clientApplicationBase.CreateRequestParametersAsync( @@ -47,6 +50,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/ApiConfig/Parameters/MtlsPopParametersInitializer.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs index 011317132a..585b2eb820 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs @@ -66,6 +66,18 @@ private static async Task TryInitImplicitBearerOverMtlsAsync( } // Case 2 – Only cert-capable credentials implement this capability interface. + // No SendCertificateOverMtls guard here: the TokenBindingCertificate pattern is a + // distinct opt-in where the assertion delegate itself signals mTLS intent by returning + // a non-null cert. This is separate from Case 1 (SendCertificateOverMtls + cert-based + // credential). + // + // Call pattern per request: + // - This call always fires (even cache hits) to check for TokenBindingCertificate + // and set MtlsCertificate for proper endpoint routing. + // - GetCredentialMaterialAsync (in ClientAssertionDelegateCredential) calls the + // delegate a second time on network requests to produce the signed assertion JWT. + // - Cache hits: delegate called once (here only). Network requests: twice. + // - Delegates are expected to be cheap (return a pre-generated/cached assertion). if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) { var opts = CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct); 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/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index 7a819160c1..e435a0c670 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -61,13 +61,16 @@ private static CredentialContext BuildContext( { ClientId = requestParams.AppConfig.ClientId, TokenEndpoint = tokenEndpoint, - Mode = requestParams.MtlsCertificate != null || requestParams.IsMtlsPopRequested + Mode = requestParams.IsMtlsPopRequested ? CredentialTransportProtocol.Mtls : CredentialTransportProtocol.OAuth, Claims = requestParams.Claims, ClientCapabilities = requestParams.AppConfig.ClientCapabilities, CryptographyManager = requestParams.RequestContext.ServiceBundle.PlatformProxy.CryptographyManager, - SendX5C = requestParams.SendX5C, + // When SendCertificateOverMtls=true, the client_assertion JWT must include the x5c chain + // so that AAD can validate the assertion against the SNI-registered certificate. + SendX5C = requestParams.SendX5C + || (requestParams.AppConfig.CertificateOptions?.SendCertificateOverMtls == true), UseSha2 = requestParams.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported, ExtraClientAssertionClaims = requestParams.ExtraClientAssertionClaims, ClientAssertionFmiPath = requestParams.ClientAssertionFmiPath, diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index 24a5d5c9bf..1b7fd2a83e 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -75,7 +75,6 @@ public AuthenticationRequestParameters( HomeAccountId = homeAccountId; CacheKeyComponents = cacheKeyComponents; - SendOfflineAccessScope = commonParameters.SendOfflineAccessScope; } public ApplicationConfiguration AppConfig => _serviceBundle.Config; @@ -119,6 +118,7 @@ public X509Certificate2 MtlsCertificate } public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested; + public bool? SendOfflineAccessScope => _commonParameters.SendOfflineAccessScope; /// /// The certificate resolved and used for client authentication (if certificate-based authentication was used). @@ -153,7 +153,6 @@ public IAuthenticationOperation AuthenticationScheme public IEnumerable PersistedCacheParameters => _commonParameters.AdditionalCacheParameters; public SortedList CacheKeyComponents {get; private set; } - public bool? SendOfflineAccessScope { get; private set; } #region TODO REMOVE FROM HERE AND USE FROM SPECIFIC REQUEST PARAMETERS // TODO: ideally, these can come from the particular request instance and not be in RequestBase since it's not valid for all requests. diff --git a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs index 072cf972ae..3db2de2141 100644 --- a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs +++ b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs @@ -722,8 +722,13 @@ private async Task> FilterTokensByEnvironmentAsyn } // at this point we need environment aliases, try to get them without a discovery call + // Use OriginalAuthority so that mTLS-transformed authorities (mtlsauth.microsoft.com) don't + // propagate into alias resolution. After ResolveAuthorityAsync, requestParams.AuthorityInfo + // may reflect the mtlsauth host; passing that to GetMetadataEntryTryAvoidNetworkAsync causes + // RegionAndMtlsDiscoveryProvider to throw MtlsPopNotSupportedForEnvironment. Using the + // original login.* authority ensures alias lookup always succeeds via instance discovery. var instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo, tokenCacheItems.Select(at => at.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); @@ -841,7 +846,7 @@ async Task ITokenCacheInternal.FindRefreshTokenAsync( { var metadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo, refreshTokens.Select(rt => rt.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); @@ -871,7 +876,7 @@ await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsyn { var metadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync( - requestParams.AuthorityInfo, + requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo, refreshTokens.Select(rt => rt.Environment), // if all environments are known, a network call can be avoided requestParams.RequestContext) .ConfigureAwait(false); @@ -1183,8 +1188,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..416dd97326 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/MtlsTransportUserFlowTests.cs @@ -0,0 +1,581 @@ +// 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 all flows: S2S, OBO, refresh_token, and auth_code. + /// + /// Each test validates the two conditions required for mTLS bearer transport: + /// 1. The token request goes to the mTLS endpoint (mtlsauth.microsoft.com). + /// 2. client_assertion IS in the POST body — cert authenticates at the TLS layer + /// AND the body carries the assertion (required by ESTS for this preview). + /// + /// This is distinct from mTLS PoP (.WithMtlsProofOfPossession()), which binds the + /// token cryptographically to a certificate and is only available on AcquireTokenForClient. + /// + [TestClass] + public class MtlsTransportUserFlowTests + { + private static readonly string[] s_userReadScopes = { "User.Read" }; + + [TestInitialize] + public void TestInitialize() + { + ApplicationBase.ResetStateForTest(); + } + + /// + /// Integration test — 2x2 matrix cell (OBO × mTLS bearer): + /// Verifies that an OBO token request with SendCertificateOverMtls = true + /// satisfies both mTLS transport conditions: + /// 1. The request goes to mtlsauth.microsoft.com (not login.microsoftonline.com). + /// 2. The IMsalMtlsHttpClientFactory cert overload is invoked, confirming the mTLS + /// transport factory is used for the OBO flow. + /// + /// Expected outcome: PASSES once AppWebApi is enabled for mTLS client auth in the lab. + /// Until then, AAD returns HTTP 412 / AADSTS51000: MtlsClientAuth is/are disabled. + /// This error means the app is not yet configured — it does NOT mean the grant is unsupported. + /// See for the (OBO × client_secret) cell + /// that proves the OBO grant itself works and the mTLS failure is purely app-config. + /// + /// Full 2x2 matrix: + /// client_credentials + mTLS → ClientCredentialsMtlsPopTests.Sni_Over_Mtls_Gets_Bearer_Token_Successfully_TestAsync (PASSES) + /// client_credentials + secret → ClientCredentialsTests (PASSES) + /// OBO + mTLS → this test (FAILS until AppWebApi is mTLS-enabled) + /// OBO + secret → (PASSES) + /// + [DoNotRunOnLinux] + [TestMethod] + [Ignore("Blocked: AppWebApi (23c64cd8) not yet enabled for mTLS client auth in ID4SLAB1. " + + "Error: AADSTS700027 (cert not registered). " + + "Remove this attribute once Bogdan/Qi enable mTLS on AppWebApi.")] + public async Task OboFlow_WithSendCertificateOverMtls_AcquiresTokenAsync() + { + // Arrange + 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 AND client_assertion is sent in the body + // (preview behavior — ESTS requires the assertion for user flows). + // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory. + var cca = ConfidentialClientApplicationBuilder + .Create(appApiConfig.AppId) + .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] + [Ignore("Blocked: AppS2S mTLS endpoint does not yet support this auth scenario in ID4SLAB1. " + + "Error: AADSTS392189 (mTLS endpoint only supports SNI/federated identity). " + + "Remove this attribute once Bogdan/Qi enable mTLS on AppS2S.")] + public async Task RefreshTokenFlow_WithSendCertificateOverMtls_AcquiresTokenAsync() + { + // Arrange + 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 client_assertion is sent in the body (preview behavior). + // NOTE: WithHttpClientFactory must come AFTER WithTestLogging to override the sniffer factory. + var cca = ConfidentialClientApplicationBuilder + .Create(appConfig.AppId) + .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."); + } + + /// + /// Negative test: verifies that OBO WITHOUT SendCertificateOverMtls = true does NOT + /// route to the mTLS endpoint. The certificate is presented as a JWT client_assertion + /// in the POST body via the regular login.microsoftonline.com endpoint. + /// + /// This is the contrast case for : + /// the opt-in flag is what distinguishes mTLS transport from standard cert-assertion auth. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task OboFlow_WithoutSendCertificateOverMtls_UsesRegularEndpointAsync() + { + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var appApiConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppWebApi).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + // Step 1: Acquire user assertion via ROPC + var pca = PublicClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs) + .WithTestLogging() + .Build(); + +#pragma warning disable CS0618 + AuthenticationResult userResult = await pca + .AcquireTokenByUsernamePassword([appApiConfig.DefaultScopes], user.Upn, user.GetOrFetchPassword()) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); +#pragma warning restore CS0618 + + Assert.IsNotNull(userResult?.AccessToken, "Failed to acquire user token via ROPC."); + + // Step 2: OBO with cert but WITHOUT SendCertificateOverMtls — cert goes in the body as + // client_assertion, NOT at the TLS layer. Request goes to login.microsoftonline.com. + var recordingFactory = new RecordingMtlsHttpClientFactory(); + var cca = ConfidentialClientApplicationBuilder + .Create(appApiConfig.AppId) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{userResult.TenantId}"), true) + .WithCertificate(mtlsCert) // no SendCertificateOverMtls + .WithHttpClientFactory(recordingFactory) + .Build(); + + try + { + await cca + .AcquireTokenOnBehalfOf(s_userReadScopes, new UserAssertion(userResult.AccessToken)) + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (MsalServiceException) + { + // AAD may reject for cert/config reasons — we only care about the request format. + } + + string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; + string requestBody = recordingFactory.LastCapturedBody ?? ""; + + // Without SendCertificateOverMtls, request must NOT go to mtlsauth + Assert.DoesNotContain(requestUrl, "mtlsauth", + $"OBO without SendCertificateOverMtls should use login.microsoftonline.com, but got: {requestUrl}"); + + // The cert is sent as client_assertion in the body (standard cert auth) + StringAssert.Contains(requestBody, "client_assertion", + "OBO without SendCertificateOverMtls should include client_assertion in the body."); + } + + /// + /// Tests the two conditions required for mTLS transport auth on OBO: + /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com), not the regular endpoint. + /// 2. client_assertion IS in the POST body — cert at TLS layer + assertion in body. + /// + /// Uses CertificateOptions.SendCertificateOverMtls = true to opt in to mTLS bearer transport. + /// AAD may reject the request if the cert is not registered for AppWebApi, but MSAL's request + /// 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: client_assertion must be in body (cert at TLS + assertion in body) + StringAssert.Contains(lastBody, "client_assertion", + "Condition 2 FAILED: client_assertion is NOT present in the OBO POST body — should be present for mTLS transport."); + } + + /// + /// Tests the two conditions required for mTLS transport auth on refresh_token redemption: + /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com). + /// 2. client_assertion IS 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: client_assertion must be in body + StringAssert.Contains(lastBody, "client_assertion", + "Condition 2 FAILED: client_assertion is NOT present in the RT POST body — should be present for mTLS transport."); + } + + /// + /// Control test: verifies that for AcquireTokenForClient with SendCertificateOverMtls=true, + /// BOTH mTLS transport conditions ARE met: + /// 1. Request goes to mtlsauth.microsoft.com (mTLS endpoint). + /// 2. client_assertion IS in the POST body (cert at TLS + assertion in body). + /// + /// 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: client_assertion must be in body + StringAssert.Contains(lastBody, "client_assertion", + "client_assertion should be in the body when SendCertificateOverMtls=true (cert at TLS + assertion in body)."); + } + + /// + /// Tests the two conditions required for mTLS transport on the auth_code flow: + /// 1. Token request goes to the mTLS endpoint (mtlsauth.microsoft.com). + /// 2. client_assertion IS in the POST body (cert at TLS layer + assertion in body). + /// + /// Uses a fake/expired auth code to trigger the token request without a real browser session. + /// AAD will reject the code, but the assertions verify MSAL's request format before the response. + /// + [DoNotRunOnLinux] + [TestMethod] + public async Task AuthCodeFlow_WithSendCertificateOverMtls_BothMtlsConditionsMetAsync() + { + X509Certificate2 mtlsCert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + Assert.IsNotNull(mtlsCert, "Lab cert must be installed to run this test."); + + var appConfig = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false); + var user = await LabResponseHelper.GetUserConfigAsync(KeyVaultSecrets.UserPublicCloud).ConfigureAwait(false); + + var recordingFactory = new RecordingMtlsHttpClientFactory(); + var cca = ConfidentialClientApplicationBuilder + .Create(appConfig.AppId) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{user.TenantId}"), true) + .WithCertificate(mtlsCert, new CertificateOptions { SendCertificateOverMtls = true }) + .WithRedirectUri("http://localhost") + .WithHttpClientFactory(recordingFactory) + .Build(); + + try + { + // Use a fake auth code to trigger the token request. + // AAD will reject it, but MSAL sends the request first — we capture and assert on it. + await cca + .AcquireTokenByAuthorizationCode(s_userReadScopes, "fake_auth_code_for_mtls_format_test") + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (MsalServiceException) + { + // Expected — AAD rejects the fake code. We assert on the captured request below. + } + + string requestUrl = recordingFactory.LastCapturedUrl ?? "(none captured)"; + string requestBody = recordingFactory.LastCapturedBody ?? ""; + + // Condition 1: request must go to the mTLS endpoint + StringAssert.Contains(requestUrl, "mtlsauth", + $"Condition 1 FAILED: auth_code token request went to '{requestUrl}' instead of mtlsauth.microsoft.com."); + + // Condition 2: client_assertion must be in body + StringAssert.Contains(requestBody, "client_assertion", + "Condition 2 FAILED: client_assertion is NOT present in the auth_code POST body — should be present for mTLS transport."); + } + + /// + /// 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 { get { lock (_captured) { return _captured.LastOrDefault(c => c.Url.Contains("/oauth2/")).Url; } } } + public string LastCapturedBody { get { lock (_captured) { return _captured.LastOrDefault(c => !string.IsNullOrEmpty(c.Body)).Body; } } } + + private HttpClient BuildRecordingClient(X509Certificate2 cert = null) + { + 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().ConfigureAwait(false).GetAwaiter().GetResult(); + body = req.Content.ReadAsStringAsync().ConfigureAwait(false).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..05e1bfa23c --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsBearerUserFlowTests.cs @@ -0,0 +1,361 @@ +// 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.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 token requests to the mTLS endpoint + /// (mtlsauth.microsoft.com) and includes client_assertion in the POST body + /// for all flows — the cert authenticates at the TLS layer AND the body carries the assertion. + /// This applies to all flows: S2S, OBO, refresh_token, and auth_code. + /// + [TestClass] + public class MtlsBearerUserFlowTests : TestBase + { + 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. Includes client_assertion in the POST body (cert at TLS layer + assertion in body). + /// + [TestMethod] + public async Task OboFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAsync() + { + 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 }, + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + }, + // client_assertion value is a signed JWT — assert presence only, not value + AdditionalRequestValidation = req => + { + string body = req.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + StringAssert.Contains(body, "client_assertion=", + "client_assertion must be present in the OBO POST body."); + } + }; + + 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) + /// and includes client_assertion in the POST body. + /// + /// OBO is used as the representative user flow here. The regional routing code + /// (RegionAndMtlsDiscoveryProvider) is shared across all user flows (OBO, refresh_token, + /// 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(), + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientId, TestConstants.ClientId }, + { OAuth2Parameter.GrantType, OAuth2GrantType.JwtBearer }, + { OAuth2Parameter.RequestedTokenUse, OAuth2RequestedTokenUse.OnBehalfOf }, + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + }, + // client_assertion value is a signed JWT — assert presence only, not value + AdditionalRequestValidation = req => + { + string body = req.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + StringAssert.Contains(body, "client_assertion=", + "client_assertion must be present in the regional OBO POST body."); + } + }; + + 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. Includes client_assertion in the POST body (cert at TLS layer + assertion in body). + /// + [TestMethod] + public async Task RefreshTokenFlow_WithSendCertificateOverMtls_UsesGlobalMtlsEndpointAsync() + { + 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 }, + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + }, + // client_assertion value is a signed JWT — assert presence only, not value + AdditionalRequestValidation = req => + { + string body = req.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + StringAssert.Contains(body, "client_assertion=", + "client_assertion must be present in the RT POST body."); + } + }; + + 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 for Bug #1: a second AcquireTokenOnBehalfOf call on the same app + /// instance must NOT throw MsalClientException(MtlsPopNotSupportedForEnvironment). + /// + /// Root cause: after the first call, the AT cache is populated with an entry whose + /// Environment is mtlsauth.microsoft.com. On the second call, FilterTokensByEnvironmentAsync + /// passes requestParams.AuthorityInfo (which is mtlsauth.microsoft.com after authority + /// resolution) to GetMetadataEntryTryAvoidNetworkAsync, which throws because + /// RegionAndMtlsDiscoveryProvider only accepts login.* hosts. + /// The fix uses requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo instead. + /// + [TestMethod] + public async Task OboFlow_WithSendCertificateOverMtls_SecondCallDoesNotCrashAsync() + { + string tenantId = "123456-1234-2345-1234561234"; + string authorityUrl = $"https://login.microsoftonline.com/{tenantId}"; + string expectedTokenEndpoint = $"https://mtlsauth.microsoft.com/{tenantId}/oauth2/v2.0/token"; + string fakeUserAssertion = "fake.user.assertion.token"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", null); + Environment.SetEnvironmentVariable("MSAL_FORCE_REGION", null); + + using (var harness = new MockHttpAndServiceBundle()) + { + // Only one network response — the second call must be served from cache. + harness.HttpManager.AddMockHandler(new MockHttpMessageHandler() + { + ExpectedUrl = expectedTokenEndpoint, + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage(), + }); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(authorityUrl) + .WithHttpManager(harness.HttpManager) + .WithCertificate(s_testCertificate, new CertificateOptions { SendCertificateOverMtls = true }) + .Build(); + + var assertion = new UserAssertion(fakeUserAssertion); + + // First call — hits network, populates cache. + var result1 = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, assertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result1.AccessToken); + + // Second call — must not throw MsalClientException(MtlsPopNotSupportedForEnvironment). + // Should be served from cache without a network call. + var result2 = await app + .AcquireTokenOnBehalfOf(TestConstants.s_scope, assertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result2.AccessToken); + Assert.AreEqual(result1.AccessToken, result2.AccessToken, "Second call should return the cached token."); + } + } + } + + /// + /// Regression test: without SendCertificateOverMtls, a cert-credential OBO request + /// still uses the regular (non-mTLS) endpoint and sends client_assertion. + /// + [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."); + } + } + } + } +}