From d21c9fc37d4bf49d823ad8a8538c1a569b84eed8 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:34:43 -0800 Subject: [PATCH 1/5] Fix SNI bearer flow and stabilize e2e tests --- .../AcquireTokenForClientParameterBuilder.cs | 3 + .../AcquireTokenCommonParameters.cs | 49 ++- .../ConfidentialClientApplicationBuilder.cs | 19 +- .../RegionAndMtlsDiscoveryProvider.cs | 6 +- .../ClientAssertionDelegateCredential.cs | 7 +- .../Internal/RequestContext.cs | 4 +- .../Requests/ClientCredentialRequest.cs | 4 +- .../Microsoft.Identity.Client/MsalError.cs | 6 + .../MsalErrorMessage.cs | 1 + .../PublicApi/net462/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net472/PublicAPI.Unshipped.txt | 2 +- .../net8.0-android/PublicAPI.Unshipped.txt | 2 +- .../net8.0-ios/PublicAPI.Unshipped.txt | 2 +- .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 2 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 2 +- .../SeleniumTests/FociTests.cs | 1 + .../PublicApiTests/ClientAssertionTests.cs | 370 ++++++++++++++++-- .../ConfidentialClientApplicationTests.cs | 4 +- 18 files changed, 425 insertions(+), 61 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs index 3d45a15dae..f18114a420 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs @@ -193,6 +193,9 @@ internal override Task ExecuteInternalAsync(CancellationTo /// for a comment inside this function for AzureRegion. protected override void Validate() { + // Derive "mTLS requested" from "mTLS PoP requested" + CommonParameters.IsMtlsRequested |= CommonParameters.IsMtlsPopRequested; + if (CommonParameters.MtlsCertificate != null) { // Check for Azure region only if the authority is AAD diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index 28e732efa6..becb2386cb 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -42,7 +42,8 @@ internal class AcquireTokenCommonParameters public string ClientAssertionFmiPath { get; internal set; } public bool IsMtlsPopRequested { get; set; } public string ExtraClientAssertionClaims { get; internal set; } - + public bool IsMtlsRequested { get; internal set; } + /// /// Optional delegate for obtaining attestation JWT for Credential Guard keys. /// Set by the KeyAttestation package via .WithAttestationSupport(). @@ -52,14 +53,52 @@ internal class AcquireTokenCommonParameters internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct) { + // ───────────────────────────────────────────────────────────── + // Bearer-over-mTLS (implicit) for client assertion delegate + // If PoP is NOT requested, we still might need mTLS transport + // when the assertion delegate returns a TokenBindingCertificate. + // ───────────────────────────────────────────────────────────── if (!IsMtlsPopRequested) { - return; // PoP not requested + if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc && + cadc.CanReturnTokenBindingCertificate) + { + var opts = new AssertionRequestOptions + { + ClientID = serviceBundle.Config.ClientId, + ClientCapabilities = serviceBundle.Config.ClientCapabilities, + Claims = Claims, + CancellationToken = ct, + }; + + ClientSignedAssertion ar = await cadc.GetAssertionAsync(opts, ct).ConfigureAwait(false); + + if (ar?.TokenBindingCertificate != null) + { + MtlsCertificate = ar.TokenBindingCertificate; + IsMtlsRequested = true; + + // Check for Azure region only if the authority is AAD + // AzureRegion is by default set to null or set to null when the application is created + // with region set to DisableForceRegion (see ConfidentialClientApplicationBuilder.Validate) + if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad && + serviceBundle.Config.AzureRegion == null) + { + throw new MsalClientException( + MsalError.MtlsBearerWithoutRegion, + MsalErrorMessage.MtlsBearerWithoutRegion); + } + } + } + + return; // IMPORTANT: do not run PoP logic } // ──────────────────────────────────── - // Case 1 – Certificate credential + // EXISTING PoP behavior (UNCHANGED) // ──────────────────────────────────── + + // Case 1 – Certificate credential if (serviceBundle.Config.ClientCredential is CertificateClientCredential certCred) { if (certCred.Certificate == null) @@ -75,7 +114,7 @@ internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, Can // ──────────────────────────────────── // Case 2 – Client‑assertion delegate // ──────────────────────────────────── - if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc) + if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc2) { var opts = new AssertionRequestOptions { @@ -85,7 +124,7 @@ internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, Can CancellationToken = ct }; - ClientSignedAssertion ar = await cadc.GetAssertionAsync(opts, ct).ConfigureAwait(false); + ClientSignedAssertion ar = await cadc2.GetAssertionAsync(opts, ct).ConfigureAwait(false); if (ar.TokenBindingCertificate == null) { diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs index fa4b88c6b6..6e6b1de641 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs @@ -279,7 +279,8 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func cli Task.FromResult(new ClientSignedAssertion { Assertion = clientAssertionDelegate() // bearer - })); + }), + canReturnTokenBindingCertificate: false); } /// @@ -302,7 +303,8 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func @@ -324,7 +326,8 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func @@ -343,18 +346,22 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func> clientSignedAssertionProvider) { ValidateUseOfExperimentalFeature(); - return WithClientAssertionInternal(clientSignedAssertionProvider); + return WithClientAssertionInternal( + clientSignedAssertionProvider: clientSignedAssertionProvider, + canReturnTokenBindingCertificate: true); } /// /// Internal helper to set the client assertion provider. /// /// + /// /// internal ConfidentialClientApplicationBuilder WithClientAssertionInternal( - Func> clientSignedAssertionProvider) + Func> clientSignedAssertionProvider, + bool canReturnTokenBindingCertificate) { - Config.ClientCredential = new ClientAssertionDelegateCredential(clientSignedAssertionProvider); + Config.ClientCredential = new ClientAssertionDelegateCredential(clientSignedAssertionProvider, canReturnTokenBindingCertificate); return this; } diff --git a/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs b/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs index 5238347fb4..ad619541c6 100644 --- a/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs +++ b/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs @@ -57,7 +57,7 @@ public async Task GetMetadataAsync(Uri authority } string region = null; - bool isMtlsEnabled = requestContext.MtlsCertificate != null; + bool isMtlsEnabled = requestContext.IsMtlsRequested; if (requestContext.ApiEvent?.ApiId == TelemetryCore.Internal.Events.ApiEvent.ApiIds.AcquireTokenForClient) { @@ -106,7 +106,7 @@ private static string GetRegionalizedEnvironment(Uri authority, string region, R if (KnownMetadataProvider.IsPublicEnvironment(host)) { - if (requestContext.MtlsCertificate != null) + if (requestContext.IsMtlsRequested) { requestContext.Logger.Info(() => $"[Region discovery] Using MTLS regional environment: {region}.{PublicEnvForRegionalMtlsAuth}"); return $"{region}.{PublicEnvForRegionalMtlsAuth}"; @@ -125,7 +125,7 @@ private static string GetRegionalizedEnvironment(Uri authority, string region, R host = preferredNetworkEnv; } - if (requestContext.MtlsCertificate != null) + if (requestContext.IsMtlsRequested) { // Modify the host to replace "login" with "mtlsauth" for mTLS scenarios if (host.StartsWith("login")) diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index 19190df8f9..0017c289dc 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -28,12 +28,15 @@ internal Task GetAssertionAsync( CancellationToken cancellationToken) => _provider(options, cancellationToken); - public ClientAssertionDelegateCredential( - Func> provider) + internal ClientAssertionDelegateCredential( + Func> provider, + bool canReturnTokenBindingCertificate) { _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + CanReturnTokenBindingCertificate = canReturnTokenBindingCertificate; } + internal bool CanReturnTokenBindingCertificate { get; } public AssertionType AssertionType => AssertionType.ClientAssertion; // ────────────────────────────────── diff --git a/src/client/Microsoft.Identity.Client/Internal/RequestContext.cs b/src/client/Microsoft.Identity.Client/Internal/RequestContext.cs index 02b88de971..b06cb2b568 100644 --- a/src/client/Microsoft.Identity.Client/Internal/RequestContext.cs +++ b/src/client/Microsoft.Identity.Client/Internal/RequestContext.cs @@ -32,6 +32,8 @@ internal class RequestContext public X509Certificate2 MtlsCertificate { get; } public bool IsAttestationRequested { get; set; } + + public bool IsMtlsRequested { get; set; } public RequestContext(IServiceBundle serviceBundle, Guid correlationId, X509Certificate2 mtlsCertificate, CancellationToken cancellationToken = default) { @@ -39,7 +41,7 @@ public RequestContext(IServiceBundle serviceBundle, Guid correlationId, X509Cert Logger = LoggerHelper.CreateLogger(correlationId, ServiceBundle.Config); CorrelationId = correlationId; UserCancellationToken = cancellationToken; - MtlsCertificate = mtlsCertificate; + IsMtlsRequested = mtlsCertificate != null; } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs index 194e5cdbb0..c7b9f77469 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs @@ -399,8 +399,8 @@ private bool ShouldUseCachedToken(MsalAccessTokenCacheItem cacheItem) // 2) If an mTLS cert is supplied for THIS request, reuse cache only if // the cached token's KeyId matches the one provided in the request. X509Certificate2 requestCert = AuthenticationRequestParameters.MtlsCertificate; - - if (requestCert != null) + + if (requestCert != null && AuthenticationRequestParameters.IsMtlsPopRequested) { string expectedKid = CoreHelpers.ComputeX5tS256KeyId(requestCert); diff --git a/src/client/Microsoft.Identity.Client/MsalError.cs b/src/client/Microsoft.Identity.Client/MsalError.cs index c7775c8d2c..97949237c5 100644 --- a/src/client/Microsoft.Identity.Client/MsalError.cs +++ b/src/client/Microsoft.Identity.Client/MsalError.cs @@ -1190,6 +1190,12 @@ public static class MsalError /// public const string MtlsPopWithoutRegion = "mtls_pop_without_region"; + /// + /// What happened?mTLS Bearer is configured but a region was not specified. + /// MitigationEnsure that the AzureRegion configuration is set when using mTLS Bearer as it requires a regional endpoint. + /// + public const string MtlsBearerWithoutRegion = "mtls_bearer_without_region"; + /// /// What happened? mTLS Proof of Possession (mTLS PoP) is configured but a certificate was not provided. /// Mitigation Ensure that a valid certificate is provided in the configuration when using mTLS PoP as it is required for secure authentication. diff --git a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs index 5289509ed6..114fe6169e 100644 --- a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs +++ b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs @@ -439,6 +439,7 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName) public const string CryptographicError = "A cryptographic exception occurred. Possible cause: the certificate has been disposed. See inner exception for full details."; public const string MtlsPopWithoutRegion = "mTLS Proof of Possession requires a region to be specified. Please set AzureRegion in the configuration at the application level."; public const string MtlsCertificateNotProvidedMessage = "mTLS Proof‑of‑Possession requires a certificate for this request. Either configure the application with .WithCertificate(...) or pass a certificate‑bound client‑assertion and chain .WithMtlsProofOfPossession() on the request builder. See https://aka.ms/msal-net-pop for details."; + public const string MtlsBearerWithoutRegion = "mTLS Proof of Possession requires a region to be specified. Please set AzureRegion in the configuration at the application level."; public const string MtlsInvalidAuthorityTypeMessage = "mTLS PoP is only supported for AAD authority type. See https://aka.ms/msal-net-pop for details."; public const string MtlsNonTenantedAuthorityNotAllowedMessage = "mTLS authentication requires a tenanted authority. Using 'common', 'organizations', or similar non-tenanted authorities is not allowed. Please provide an authority with a specific tenant ID (e.g., 'https://login.microsoftonline.com/{tenantId}'). See https://aka.ms/msal-net-pop for details."; public const string MtlsNotSupportedForManagedIdentityMessage = "IMDSv2 flow is not supported on .NET Framework 4.6.2. Cryptographic operations required for managed identity authentication are unavailable on this platform."; diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index 8b13789179..e45c6de984 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1 @@ - +const Microsoft.Identity.Client.MsalError.MtlsBearerWithoutRegion = "mtls_bearer_without_region" -> string \ No newline at end of file diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index 8b13789179..e45c6de984 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1 +1 @@ - +const Microsoft.Identity.Client.MsalError.MtlsBearerWithoutRegion = "mtls_bearer_without_region" -> string \ No newline at end of file diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index 8b13789179..e45c6de984 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -1 +1 @@ - +const Microsoft.Identity.Client.MsalError.MtlsBearerWithoutRegion = "mtls_bearer_without_region" -> string \ No newline at end of file diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index 8b13789179..e45c6de984 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -1 +1 @@ - +const Microsoft.Identity.Client.MsalError.MtlsBearerWithoutRegion = "mtls_bearer_without_region" -> string \ No newline at end of file diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index 8b13789179..e45c6de984 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -1 +1 @@ - +const Microsoft.Identity.Client.MsalError.MtlsBearerWithoutRegion = "mtls_bearer_without_region" -> string \ No newline at end of file diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index 8b13789179..e45c6de984 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1 @@ - +const Microsoft.Identity.Client.MsalError.MtlsBearerWithoutRegion = "mtls_bearer_without_region" -> string \ No newline at end of file diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/SeleniumTests/FociTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/SeleniumTests/FociTests.cs index 07f103efca..e7c40e863a 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/SeleniumTests/FociTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/SeleniumTests/FociTests.cs @@ -42,6 +42,7 @@ public void TestInitialize() /// /// The FOCI flag does not appear in the U/P flow, an interactive flow is required. Interactive flow /// cannot be automated because http://localhost cannot currently be added to the family apps + [Ignore] [TestMethod] public async Task FociSignInSignOutAsync() { diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs index 70db27353d..460c1096a6 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading; @@ -14,9 +15,11 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensibility; using Microsoft.Identity.Client.Internal; +using Microsoft.Identity.Client.Internal.ClientCredential; using Microsoft.Identity.Client.OAuth2; using Microsoft.Identity.Test.Common.Core.Helpers; using Microsoft.Identity.Test.Common.Core.Mocks; +using Microsoft.Identity.Test.Integration.Infrastructure; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.Identity.Test.Unit.PublicApiTests @@ -367,27 +370,64 @@ public async Task ClientAssertion_BearerAsync() } [TestMethod] - public async Task ClientAssertion_WithPoPDelegate_No_Mtls_Api_SendsBearer_Async() + public async Task WithMtlsPop_AfterPoPDelegate_Works() { - using var http = new MockHttpManager(); + const string region = "eastus"; + + using (var envContext = new EnvVariableContext()) { - http.AddInstanceDiscoveryMockHandler(); - var handler = http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); - var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) - .WithExperimentalFeatures(true) - .WithClientSecret(TestConstants.ClientSecret) - .WithHttpManager(http) - .WithClientAssertion(PopDelegate()) - .BuildConcrete(); + Environment.SetEnvironmentVariable("REGION_NAME", region); - var result = await cca.AcquireTokenForClient(TestConstants.s_scope) - .ExecuteAsync().ConfigureAwait(false); + // Set the expected mTLS endpoint for public cloud + string globalEndpoint = "mtlsauth.microsoft.com"; + string expectedTokenEndpoint = $"https://{region}.{globalEndpoint}/123456-1234-2345-1234561234/oauth2/v2.0/token"; - Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + using (var httpManager = new MockHttpManager()) + { + // Set up mock handler with expected token endpoint URL + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + tokenType: "mtls_pop"); + + var cert = CertHelper.GetOrCreateTestCert(); - Assert.AreEqual( - "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - handler.ActualRequestPostData["client_assertion_type"]); + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures(true) + .WithClientAssertion(PopDelegate()) + .WithAuthority($"https://login.microsoftonline.com/123456-1234-2345-1234561234") + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // First token acquisition - should hit the identity provider + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual("header.payload.signature", result.AccessToken); + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, result.TokenType); + Assert.AreEqual(region, result.AuthenticationResultMetadata.RegionDetails.RegionUsed); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + + Assert.IsNotNull(result.BindingCertificate, "BindingCertificate should be present."); + Assert.AreEqual(cert.Thumbprint, result.BindingCertificate.Thumbprint, + "BindingCertificate must match the cert passed to WithCertificate()."); + + // Second token acquisition - should retrieve from cache + AuthenticationResult secondResult = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual("header.payload.signature", secondResult.AccessToken); + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, secondResult.TokenType); + Assert.AreEqual(TokenSource.Cache, secondResult.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + // Cached result must still carry the cert + Assert.IsNotNull(secondResult.BindingCertificate); + Assert.AreEqual(result.BindingCertificate.Thumbprint, + secondResult.BindingCertificate.Thumbprint); + } } } @@ -465,7 +505,7 @@ await AssertException.TaskThrowsAsync(() => } [TestMethod] - public async Task WithMtlsPop_AfterPoPDelegate_Works() + public async Task BearerClientAssertion_WithPoPDelegate_Works() { const string region = "eastus"; @@ -480,8 +520,7 @@ public async Task WithMtlsPop_AfterPoPDelegate_Works() using (var httpManager = new MockHttpManager()) { // Set up mock handler with expected token endpoint URL - httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( - tokenType: "mtls_pop"); + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); var cert = CertHelper.GetOrCreateTestCert(); @@ -495,33 +534,26 @@ public async Task WithMtlsPop_AfterPoPDelegate_Works() // First token acquisition - should hit the identity provider AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithMtlsProofOfPossession() .ExecuteAsync() .ConfigureAwait(false); Assert.AreEqual("header.payload.signature", result.AccessToken); - Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, result.TokenType); + Assert.AreEqual(Constants.BearerTokenType, result.TokenType, ignoreCase: true); Assert.AreEqual(region, result.AuthenticationResultMetadata.RegionDetails.RegionUsed); Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); - Assert.IsNotNull(result.BindingCertificate, "BindingCertificate should be present."); - Assert.AreEqual(cert.Thumbprint, result.BindingCertificate.Thumbprint, - "BindingCertificate must match the cert passed to WithCertificate()."); + Assert.IsNull(result.BindingCertificate, "BindingCertificate should not be present."); // Second token acquisition - should retrieve from cache AuthenticationResult secondResult = await app.AcquireTokenForClient(TestConstants.s_scope) - .WithMtlsProofOfPossession() .ExecuteAsync() .ConfigureAwait(false); Assert.AreEqual("header.payload.signature", secondResult.AccessToken); - Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, secondResult.TokenType); + Assert.AreEqual(Constants.BearerTokenType, secondResult.TokenType, ignoreCase: true); Assert.AreEqual(TokenSource.Cache, secondResult.AuthenticationResultMetadata.TokenSource); Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); - // Cached result must still carry the cert - Assert.IsNotNull(secondResult.BindingCertificate); - Assert.AreEqual(result.BindingCertificate.Thumbprint, - secondResult.BindingCertificate.Thumbprint); + Assert.IsNull(secondResult.BindingCertificate); } } } @@ -635,14 +667,14 @@ public async Task ClientAssertion_NotCalledWhenTokenFromCacheAsync() _ = await cca.AcquireTokenForClient(TestConstants.s_scope) .ExecuteAsync() .ConfigureAwait(false); - - Assert.AreEqual(1, callCount); + + Assert.AreEqual(2, callCount); _ = await cca.AcquireTokenForClient(TestConstants.s_scope) .ExecuteAsync() .ConfigureAwait(false); - Assert.AreEqual(1, callCount); + Assert.AreEqual(3, callCount); } [TestMethod] @@ -670,6 +702,261 @@ await cca.AcquireTokenForClient(TestConstants.s_scope) } } + [TestMethod] + public async Task BearerClientAssertion_WithPoPDelegate_CanReturnDifferentPairsAcrossTheTwoDelegateInvocations() + { + const string region = "eastus"; + const string tenantId = "123456-1234-2345-1234561234"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", region); + + // Set the expected mTLS endpoint for public cloud + string globalEndpoint = "mtlsauth.microsoft.com"; + string expectedTokenEndpoint = + $"https://{region}.{globalEndpoint}/{tenantId}/oauth2/v2.0/token"; + + using (var httpManager = new MockHttpManager()) + { + // Token endpoint mock + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + using var certA = CreateSelfSignedCert(TimeSpan.FromDays(3), "CN=A"); + using var certB = CreateSelfSignedCert(TimeSpan.FromDays(3), "CN=B"); + { + var calls = new List<(string TokenEndpoint, string Assertion, string CertThumbprint)>(); + int callCount = 0; + + Func> provider = + (options, ct) => + { + int call = Interlocked.Increment(ref callCount); + + X509Certificate2 cert = call == 1 ? certA : certB; + string assertion = call == 1 ? "assertion-a" : "assertion-b"; + + calls.Add((options?.TokenEndpoint, assertion, cert.Thumbprint)); + + return Task.FromResult(new ClientSignedAssertion + { + Assertion = assertion, + TokenBindingCertificate = cert + }); + }; + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures(true) + .WithClientAssertion(provider) + .WithAuthority($"https://login.microsoftonline.com/{tenantId}") + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // Act + AuthenticationResult result = await app + .AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.AreEqual("header.payload.signature", result.AccessToken); + Assert.AreEqual(Constants.BearerTokenType, result.TokenType, ignoreCase: true); + Assert.AreEqual(region, result.AuthenticationResultMetadata.RegionDetails.RegionUsed); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + Assert.IsNull(result.BindingCertificate, "BindingCertificate should not be present."); + + // Core of the test: prove 2 invocations + capture the two distinct pairs + Assert.AreEqual(2, calls.Count, + "Expected the client assertion provider delegate to be invoked twice for a single token acquisition."); + + // First invocation: cert A + assertion A + Assert.AreEqual("assertion-a", calls[0].Assertion); + Assert.AreEqual(certA.Thumbprint, calls[0].CertThumbprint); + + // Second invocation: cert B + assertion B + Assert.AreEqual("assertion-b", calls[1].Assertion); + Assert.AreEqual(certB.Thumbprint, calls[1].CertThumbprint); + } + } + } + } + + [TestMethod] + public async Task WithMtlsAssertion_NoRegion_ThrowsAsync() + { + using var http = new MockHttpManager(); + { + // Arrange – CCA with PoP delegate (returns JWT + cert) but **no AzureRegion configured** + var cert = CertHelper.GetOrCreateTestCert(); + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures(true) + .WithClientAssertion(PopDelegate()) + .WithHttpManager(http) + .BuildConcrete(); + + // Act & Assert – should fail because region is missing + var ex = await AssertException.TaskThrowsAsync(async () => + await cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.MtlsBearerWithoutRegion, ex.ErrorCode); + } + } + + [TestMethod] + public async Task BearerOverMtls_CertChangesAcrossRequests_DoesNotBypassCache_Async() + { + const string region = "eastus"; + const string tenantId = "123456-1234-2345-1234561234"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", region); + + using (var httpManager = new MockHttpManager()) + { + // Only ONE network response. If MSAL tries a second network call, test will fail. + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(token: "bearer-token"); + + using var certA = CreateSelfSignedCert(TimeSpan.FromDays(3), "CN=A"); + using var certB = CreateSelfSignedCert(TimeSpan.FromDays(3), "CN=B"); + + // The delegate can be called multiple times per acquire. Keep cert stable per acquire. + X509Certificate2 currentCert = certA; + + Func> provider = + (options, ct) => Task.FromResult(new ClientSignedAssertion + { + Assertion = "jwt", + TokenBindingCertificate = currentCert + }); + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures(true) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion(provider) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}"), validateAuthority: false) + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // Acquire #1 -> network, with certA + currentCert = certA; + var first = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, first.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual("bearer-token", first.AccessToken); + Assert.AreEqual(Constants.BearerTokenType, first.TokenType, ignoreCase: true); + + // Acquire #2 -> MUST be cache even though cert changes to certB. + currentCert = certB; + var second = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, second.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(first.AccessToken, second.AccessToken); + Assert.AreEqual(Constants.BearerTokenType, second.TokenType, ignoreCase: true); + } + } + } + + [TestMethod] + public async Task PopRequest_DoesNotReuseCachedBearerOverMtlsToken_Async() + { + const string region = "eastus"; + const string tenantId = "123456-1234-2345-1234561234"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", region); + + using (var httpManager = new MockHttpManager()) + { + // 1) First acquire returns bearer token + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + token: "bearer-token"); + + // 2) Second acquire returns PoP token + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + token: "pop-token", + tokenType: "mtls_pop"); + + using var cert = CreateSelfSignedCert(TimeSpan.FromDays(3), "CN=PoP"); + + Func> provider = + (options, ct) => Task.FromResult(new ClientSignedAssertion + { + Assertion = "jwt", + TokenBindingCertificate = cert + }); + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures(true) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion(provider) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}"), validateAuthority: false) + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // Step 1: implicit bearer-over-mTLS (cert returned, but no WithMtlsProofOfPossession) + var bearer = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, bearer.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual("bearer-token", bearer.AccessToken); + Assert.AreEqual(Constants.BearerTokenType, bearer.TokenType, ignoreCase: true); + Assert.IsNull(bearer.BindingCertificate); + + // Step 2: explicit PoP must NOT reuse the cached bearer token + var pop = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, pop.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual("pop-token", pop.AccessToken); + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, pop.TokenType); + Assert.IsNotNull(pop.BindingCertificate); + Assert.AreEqual(cert.Thumbprint, pop.BindingCertificate.Thumbprint); + } + } + } + + [TestMethod] + public void ClientAssertion_CanReturnTokenBindingCertificate_FlagIsCorrect() + { + // Old overloads (returning string) should NOT be marked as “can return cert” + var app1 = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures(true) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion((AssertionRequestOptions o) => Task.FromResult("jwt")) + .BuildConcrete(); + + var cred1 = (app1.AppConfig as ApplicationConfiguration).ClientCredential as ClientAssertionDelegateCredential; + Assert.IsNotNull(cred1); + Assert.IsFalse(cred1.CanReturnTokenBindingCertificate); + + // New overload (returning ClientSignedAssertion) SHOULD be marked as “can return cert” + var app2 = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures(true) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion((AssertionRequestOptions o, CancellationToken ct) => + Task.FromResult(new ClientSignedAssertion { Assertion = "jwt", TokenBindingCertificate = null })) + .BuildConcrete(); + + var cred2 = (app2.AppConfig as ApplicationConfiguration).ClientCredential as ClientAssertionDelegateCredential; + Assert.IsNotNull(cred2); + Assert.IsTrue(cred2.CanReturnTokenBindingCertificate); + } + #region Helper --------------------------------------------------------------- private static Func> BearerDelegate(string jwt = "fake_jwt") => @@ -692,6 +979,21 @@ private static Func(() => - new ClientAssertionDelegateCredential(nullDelegate)); + new ClientAssertionDelegateCredential(nullDelegate, false)); } [DataTestMethod] @@ -889,7 +889,7 @@ public void Constructor_ValidDelegate_DoesNotThrow(bool withCert) }); // Act - var credential = new ClientAssertionDelegateCredential(validDelegate); + var credential = new ClientAssertionDelegateCredential(validDelegate, true); // Assert Assert.IsNotNull(credential); From 28826585d623b1c8b22d1683e2d16b5daf90b6e7 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:48:50 -0800 Subject: [PATCH 2/5] pr comments --- .../AcquireTokenForClientParameterBuilder.cs | 3 - .../Executors/ConfidentialClientExecutor.cs | 2 +- .../AcquireTokenCommonParameters.cs | 17 +++- .../ConfidentialClientApplicationBuilder.cs | 18 ++-- .../ClientAssertionDelegateCredential.cs | 11 +-- .../AuthenticationRequestParameters.cs | 8 +- .../MsalErrorMessage.cs | 2 +- .../ClientCredentialsMtlsPopTests.cs | 93 +++++++++++++++++++ .../PublicApiTests/ClientAssertionTests.cs | 2 - .../ConfidentialClientApplicationTests.cs | 4 +- 10 files changed, 127 insertions(+), 33 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs index f18114a420..3d45a15dae 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs @@ -193,9 +193,6 @@ internal override Task ExecuteInternalAsync(CancellationTo /// for a comment inside this function for AzureRegion. protected override void Validate() { - // Derive "mTLS requested" from "mTLS PoP requested" - CommonParameters.IsMtlsRequested |= CommonParameters.IsMtlsPopRequested; - if (CommonParameters.MtlsCertificate != null) { // Check for Azure region only if the authority is AAD diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs index 82b6a0be09..671870daee 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs @@ -59,7 +59,7 @@ public async Task ExecuteAsync( AcquireTokenForClientParameters clientParameters, CancellationToken cancellationToken) { - await commonParameters.InitMtlsPopParametersAsync(ServiceBundle, cancellationToken) + await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken) .ConfigureAwait(false); RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index becb2386cb..eb6b2af5aa 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -42,7 +42,7 @@ internal class AcquireTokenCommonParameters public string ClientAssertionFmiPath { get; internal set; } public bool IsMtlsPopRequested { get; set; } public string ExtraClientAssertionClaims { get; internal set; } - public bool IsMtlsRequested { get; internal set; } + internal bool IsEffectiveMtlsPop => IsMtlsPopRequested || MtlsCertificate != null; /// /// Optional delegate for obtaining attestation JWT for Credential Guard keys. @@ -51,7 +51,15 @@ internal class AcquireTokenCommonParameters /// public Func> AttestationTokenProvider { get; set; } - internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct) + /// + /// This tries to see if the token request should be done over mTLS or over normal HTTP + /// and set the correct parameters + /// + /// + /// + /// + /// + internal async Task TryInitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct) { // ───────────────────────────────────────────────────────────── // Bearer-over-mTLS (implicit) for client assertion delegate @@ -60,8 +68,7 @@ internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, Can // ───────────────────────────────────────────────────────────── if (!IsMtlsPopRequested) { - if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc && - cadc.CanReturnTokenBindingCertificate) + if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc) { var opts = new AssertionRequestOptions { @@ -69,6 +76,7 @@ internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, Can ClientCapabilities = serviceBundle.Config.ClientCapabilities, Claims = Claims, CancellationToken = ct, + TokenEndpoint = serviceBundle.Config.Authority.AuthorityInfo.CanonicalAuthority.Authority }; ClientSignedAssertion ar = await cadc.GetAssertionAsync(opts, ct).ConfigureAwait(false); @@ -76,7 +84,6 @@ internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, Can if (ar?.TokenBindingCertificate != null) { MtlsCertificate = ar.TokenBindingCertificate; - IsMtlsRequested = true; // Check for Azure region only if the authority is AAD // AzureRegion is by default set to null or set to null when the application is created diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs index 6e6b1de641..afca83fcf7 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs @@ -279,8 +279,7 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func cli Task.FromResult(new ClientSignedAssertion { Assertion = clientAssertionDelegate() // bearer - }), - canReturnTokenBindingCertificate: false); + })); } /// @@ -303,8 +302,7 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func @@ -326,8 +324,7 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func @@ -347,21 +344,18 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func /// Internal helper to set the client assertion provider. /// /// - /// /// internal ConfidentialClientApplicationBuilder WithClientAssertionInternal( - Func> clientSignedAssertionProvider, - bool canReturnTokenBindingCertificate) + Func> clientSignedAssertionProvider) { - Config.ClientCredential = new ClientAssertionDelegateCredential(clientSignedAssertionProvider, canReturnTokenBindingCertificate); + Config.ClientCredential = new ClientAssertionDelegateCredential(clientSignedAssertionProvider); return this; } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index 0017c289dc..bd025779ab 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -29,14 +29,11 @@ internal Task GetAssertionAsync( _provider(options, cancellationToken); internal ClientAssertionDelegateCredential( - Func> provider, - bool canReturnTokenBindingCertificate) + Func> provider) { _provider = provider ?? throw new ArgumentNullException(nameof(provider)); - CanReturnTokenBindingCertificate = canReturnTokenBindingCertificate; } - internal bool CanReturnTokenBindingCertificate { get; } public AssertionType AssertionType => AssertionType.ClientAssertion; // ────────────────────────────────── @@ -67,10 +64,12 @@ public async Task AddConfidentialClientParametersAsync( MsalErrorMessage.InvalidClientAssertionEmpty); } + // ────────────────────────────── // Decide bearer vs mTLS PoP - bool IsMtlsPopRequested = p.IsMtlsPopRequested; + // ─────────────────────────────── + bool useClientAssertionJwtPop = p.UseClientAssertionJwtPop; - if (IsMtlsPopRequested && resp.TokenBindingCertificate != null) + if (useClientAssertionJwtPop) { oAuth2Client.AddBodyParameter( OAuth2Parameter.ClientAssertionType, diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index bb23445538..17c94ec499 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -111,10 +111,16 @@ public AuthenticationRequestParameters( public Guid CorrelationId => _commonParameters.CorrelationId; - public X509Certificate2 MtlsCertificate => _commonParameters.MtlsCertificate; + public X509Certificate2 MtlsCertificate + { + get => _commonParameters.MtlsCertificate; + internal set => _commonParameters.MtlsCertificate = value; + } public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested; + internal bool UseClientAssertionJwtPop => IsMtlsPopRequested || MtlsCertificate != null; + /// /// The certificate resolved and used for client authentication (if certificate-based authentication was used). /// This is set during the token request when the certificate is resolved. diff --git a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs index 114fe6169e..e98b23cc77 100644 --- a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs +++ b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs @@ -439,7 +439,7 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName) public const string CryptographicError = "A cryptographic exception occurred. Possible cause: the certificate has been disposed. See inner exception for full details."; public const string MtlsPopWithoutRegion = "mTLS Proof of Possession requires a region to be specified. Please set AzureRegion in the configuration at the application level."; public const string MtlsCertificateNotProvidedMessage = "mTLS Proof‑of‑Possession requires a certificate for this request. Either configure the application with .WithCertificate(...) or pass a certificate‑bound client‑assertion and chain .WithMtlsProofOfPossession() on the request builder. See https://aka.ms/msal-net-pop for details."; - public const string MtlsBearerWithoutRegion = "mTLS Proof of Possession requires a region to be specified. Please set AzureRegion in the configuration at the application level."; + public const string MtlsBearerWithoutRegion = "mTLS Bearer requires a region to be specified. Please set AzureRegion in the configuration at the application level."; public const string MtlsInvalidAuthorityTypeMessage = "mTLS PoP is only supported for AAD authority type. See https://aka.ms/msal-net-pop for details."; public const string MtlsNonTenantedAuthorityNotAllowedMessage = "mTLS authentication requires a tenanted authority. Using 'common', 'organizations', or similar non-tenanted authorities is not allowed. Please provide an authority with a specific tenant ID (e.g., 'https://login.microsoftonline.com/{tenantId}'). See https://aka.ms/msal-net-pop for details."; public const string MtlsNotSupportedForManagedIdentityMessage = "IMDSv2 flow is not supported on .NET Framework 4.6.2. Cryptographic operations required for managed identity authentication are unavailable on this platform."; diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs index e149d5adb6..09caf1bda2 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs @@ -176,5 +176,98 @@ public async Task Sni_AssertionFlow_Uses_JwtPop_And_Succeeds_TestAsync() // Optional: if you rely on regional mTLS endpoints, check the host StringAssert.Contains(requestUriSeen ?? "", "mtlsauth.microsoft.com"); } + + [DoNotRunOnLinux] + //[TestMethod] // Temporarily disabled due to feature not ready in ESTS + public async Task Sni_AssertionFlow_Uses_JwtPop_And_Acquires_Bearer_Token_TestAsync() + { + X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + + // Step 1: obtain a real JWT to reuse as the "assertion" + IConfidentialClientApplication firstApp = ConfidentialClientApplicationBuilder.Create(MsiAllowListedAppIdforSNI) + .WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c") + .WithAzureRegion("westus3") + .WithCertificate(cert, true) + .WithTestLogging() + .Build(); + + AuthenticationResult first = await firstApp + .AcquireTokenForClient(new[] { TokenExchangeUrl }) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + string assertionJwt = first.AccessToken; + Assert.IsFalse(string.IsNullOrEmpty(assertionJwt), "First leg did not return an access token to reuse as assertion."); + + // Step 2: build the assertion-based app (NO WithCertificate here) + bool assertionProviderCalled = false; + string tokenEndpointSeenByProvider = null; + + string requestUriSeen = null; + string clientAssertionType = null; + bool sawClientAssertionParam = false; + bool sawClientAssertionTypeParam = false; + + IConfidentialClientApplication assertionApp = ConfidentialClientApplicationBuilder.Create(MsiAllowListedAppIdforSNI) + .WithExperimentalFeatures() + .WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c") + .WithAzureRegion("westus3") + .WithClientAssertion((AssertionRequestOptions options, CancellationToken ct) => + { + assertionProviderCalled = true; + tokenEndpointSeenByProvider = options.TokenEndpoint; + + return Task.FromResult(new ClientSignedAssertion + { + Assertion = assertionJwt, // forwarded as client_assertion + TokenBindingCertificate = cert // binds assertion for mTLS PoP (jwt-pop) + }); + }) + .WithTestLogging() + .Build(); + + // Step 3: second leg should now SUCCEED + AuthenticationResult second = await assertionApp + .AcquireTokenForClient(new[] { "https://vault.azure.net/.default" }) + .OnBeforeTokenRequest(data => + { + requestUriSeen = data.RequestUri?.ToString(); + + if (data.BodyParameters != null) + { + sawClientAssertionParam = data.BodyParameters.ContainsKey("client_assertion"); + sawClientAssertionTypeParam = data.BodyParameters.ContainsKey("client_assertion_type"); + + data.BodyParameters.TryGetValue("client_assertion_type", out clientAssertionType); + } + + return Task.CompletedTask; + }) + .ExecuteAsync() + .ConfigureAwait(false); + + // Success assertions + Assert.IsNotNull(second, "Second leg returned null AuthenticationResult."); + Assert.IsFalse(string.IsNullOrEmpty(second.AccessToken), "Second leg did not return an access token."); + CollectionAssert.Contains(second.Scopes.ToArray(), "https://vault.azure.net/.default", + "Second leg token is not for Key Vault scope."); + + // Prove MSAL used the assertion + jwt-pop binding + Assert.IsTrue(assertionProviderCalled, "Client assertion provider should have been invoked."); + Assert.IsFalse(string.IsNullOrEmpty(tokenEndpointSeenByProvider), + "AssertionRequestOptions.TokenEndpoint should be provided to the callback."); + + Assert.IsTrue(sawClientAssertionParam, "Token request should include client_assertion body parameter."); + Assert.IsTrue(sawClientAssertionTypeParam, "Token request should include client_assertion_type body parameter."); + + Assert.AreEqual( + "urn:ietf:params:oauth:client-assertion-type:jwt-pop", + clientAssertionType, + "When TokenBindingCertificate is supplied and PoP is enabled, MSAL should use jwt-pop client_assertion_type."); + + // Optional: if you rely on regional mTLS endpoints, check the host + StringAssert.Contains(requestUriSeen ?? "", "mtlsauth.microsoft.com"); + } } } diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs index 460c1096a6..3f813ea478 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs @@ -942,7 +942,6 @@ public void ClientAssertion_CanReturnTokenBindingCertificate_FlagIsCorrect() var cred1 = (app1.AppConfig as ApplicationConfiguration).ClientCredential as ClientAssertionDelegateCredential; Assert.IsNotNull(cred1); - Assert.IsFalse(cred1.CanReturnTokenBindingCertificate); // New overload (returning ClientSignedAssertion) SHOULD be marked as “can return cert” var app2 = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) @@ -954,7 +953,6 @@ public void ClientAssertion_CanReturnTokenBindingCertificate_FlagIsCorrect() var cred2 = (app2.AppConfig as ApplicationConfiguration).ClientCredential as ClientAssertionDelegateCredential; Assert.IsNotNull(cred2); - Assert.IsTrue(cred2.CanReturnTokenBindingCertificate); } #region Helper --------------------------------------------------------------- diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs index da70502392..553a7a9b49 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs @@ -870,7 +870,7 @@ public void Constructor_NullDelegate_ThrowsArgumentNullException() // Act &  Assert Assert.ThrowsException(() => - new ClientAssertionDelegateCredential(nullDelegate, false)); + new ClientAssertionDelegateCredential(nullDelegate)); } [DataTestMethod] @@ -889,7 +889,7 @@ public void Constructor_ValidDelegate_DoesNotThrow(bool withCert) }); // Act - var credential = new ClientAssertionDelegateCredential(validDelegate, true); + var credential = new ClientAssertionDelegateCredential(validDelegate); // Assert Assert.IsNotNull(credential); From 071b857432f20be18874c877d9be4c50f7d9288e Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:24:13 -0800 Subject: [PATCH 3/5] pr comments --- .../AcquireTokenCommonParameters.cs | 56 +++++++++++-------- .../CertificateAndClaimsClientCredential.cs | 50 +++++++++++------ .../ClientAssertionDelegateCredential.cs | 30 ++++------ .../ClientCredentialApplicationResult.cs | 41 ++++++++++++++ .../ClientCredential/IClientCredential.cs | 2 +- .../SecretStringClientCredential.cs | 4 +- .../SignedAssertionClientCredential.cs | 4 +- .../ClientCredentialsMtlsPopTests.cs | 7 ++- 8 files changed, 128 insertions(+), 66 deletions(-) create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index eb6b2af5aa..dd92c98381 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -62,12 +62,21 @@ internal class AcquireTokenCommonParameters internal async Task TryInitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct) { // ───────────────────────────────────────────────────────────── - // Bearer-over-mTLS (implicit) for client assertion delegate - // If PoP is NOT requested, we still might need mTLS transport - // when the assertion delegate returns a TokenBindingCertificate. + // NON-PoP request: + // We may still need mTLS transport if the client-assertion delegate + // returns a TokenBindingCertificate (implicit bearer-over-mTLS). + // This behavior is required by existing unit/integration tests. // ───────────────────────────────────────────────────────────── if (!IsMtlsPopRequested) { + // If a cert is already known, just enforce policy and return. + if (MtlsCertificate != null) + { + ThrowIfRegionMissingForImplicitMtls(serviceBundle); + return; + } + + // Only the assertion delegate can dynamically return a token-binding cert. if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc) { var opts = new AssertionRequestOptions @@ -85,24 +94,17 @@ internal async Task TryInitMtlsPopParametersAsync(IServiceBundle serviceBundle, { MtlsCertificate = ar.TokenBindingCertificate; - // Check for Azure region only if the authority is AAD - // AzureRegion is by default set to null or set to null when the application is created - // with region set to DisableForceRegion (see ConfidentialClientApplicationBuilder.Validate) - if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad && - serviceBundle.Config.AzureRegion == null) - { - throw new MsalClientException( - MsalError.MtlsBearerWithoutRegion, - MsalErrorMessage.MtlsBearerWithoutRegion); - } + // Implicit bearer-over-mTLS policy check + ThrowIfRegionMissingForImplicitMtls(serviceBundle); } } - return; // IMPORTANT: do not run PoP logic + return; // IMPORTANT: do not run explicit PoP logic } // ──────────────────────────────────── - // EXISTING PoP behavior (UNCHANGED) + // EXPLICIT PoP requested: + // Validate and initialize PoP parameters (auth scheme + cert + region check). // ──────────────────────────────────── // Case 1 – Certificate credential @@ -115,12 +117,12 @@ internal async Task TryInitMtlsPopParametersAsync(IServiceBundle serviceBundle, MsalErrorMessage.MtlsCertificateNotProvidedMessage); } + // IMPORTANT: initialize auth scheme + MtlsCertificate + InitMtlsPopParameters(certCred.Certificate, serviceBundle); return; } - // ──────────────────────────────────── - // Case 2 – Client‑assertion delegate - // ──────────────────────────────────── + // Case 2 – Client-assertion delegate if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc2) { var opts = new AssertionRequestOptions @@ -133,7 +135,7 @@ internal async Task TryInitMtlsPopParametersAsync(IServiceBundle serviceBundle, ClientSignedAssertion ar = await cadc2.GetAssertionAsync(opts, ct).ConfigureAwait(false); - if (ar.TokenBindingCertificate == null) + if (ar?.TokenBindingCertificate == null) { throw new MsalClientException( MsalError.MtlsCertificateNotProvided, @@ -144,9 +146,7 @@ internal async Task TryInitMtlsPopParametersAsync(IServiceBundle serviceBundle, return; } - // ──────────────────────────────────── - // Case 3 – Any other credential (client‑secret etc.) - // ──────────────────────────────────── + // Case 3 – Any other credential (client-secret etc.) throw new MsalClientException( MsalError.MtlsCertificateNotProvided, MsalErrorMessage.MtlsCertificateNotProvidedMessage); @@ -164,5 +164,17 @@ private void InitMtlsPopParameters(X509Certificate2 cert, IServiceBundle service AuthenticationOperation = new MtlsPopAuthenticationOperation(cert); MtlsCertificate = cert; } + + private static void ThrowIfRegionMissingForImplicitMtls(IServiceBundle serviceBundle) + { + // Implicit bearer-over-mTLS requires region only for AAD + if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad && + serviceBundle.Config.AzureRegion == null) + { + throw new MsalClientException( + MsalError.MtlsBearerWithoutRegion, + MsalErrorMessage.MtlsBearerWithoutRegion); + } + } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index cc7480c734..9922770a1e 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -38,7 +38,7 @@ internal class CertificateAndClaimsClientCredential : IClientCredential /// Optional static certificate for backward compatibility public CertificateAndClaimsClientCredential( Func> certificateProvider, - IDictionary claimsToSign, + IDictionary claimsToSign, bool appendDefaultClaims, X509Certificate2 certificate = null) { @@ -48,24 +48,29 @@ public CertificateAndClaimsClientCredential( Certificate = certificate; } - public async Task AddConfidentialClientParametersAsync( + public async Task AddConfidentialClientParametersAsync( OAuth2Client oAuth2Client, - AuthenticationRequestParameters requestParameters, - ICryptographyManager cryptographyManager, + AuthenticationRequestParameters requestParameters, + ICryptographyManager cryptographyManager, string tokenEndpoint, CancellationToken cancellationToken) { string clientId = requestParameters.AppConfig.ClientId; // Log the incoming request parameters for diagnostic purposes - requestParameters.RequestContext.Logger.Verbose(() => $"Building assertion from certificate with clientId: {clientId} at endpoint: {tokenEndpoint}"); + requestParameters.RequestContext.Logger.Verbose( + () => $"Building assertion from certificate with clientId: {clientId} at endpoint: {tokenEndpoint}"); + // If mTLS cert is not already set for the request, proceed with JWT bearer client assertion. if (requestParameters.MtlsCertificate == null) { - requestParameters.RequestContext.Logger.Verbose(() => "Proceeding with JWT token creation and adding client assertion."); + requestParameters.RequestContext.Logger.Verbose( + () => "Proceeding with JWT token creation and adding client assertion."); // Resolve the certificate via the provider - X509Certificate2 certificate = await ResolveCertificateAsync(requestParameters, tokenEndpoint, cancellationToken).ConfigureAwait(false); + X509Certificate2 certificate = + await ResolveCertificateAsync(requestParameters, tokenEndpoint, cancellationToken) + .ConfigureAwait(false); // Store the resolved certificate in request parameters for later use (e.g., ExecutionResult) requestParameters.ResolvedCertificate = certificate; @@ -96,15 +101,24 @@ public async Task AddConfidentialClientParametersAsync( oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion); + + // No extra outputs for the common case. + return ClientCredentialApplicationResult.None; } - else + + // mTLS path: a certificate is already set on the request (e.g., mTLS/PoP transport). + requestParameters.RequestContext.Logger.Verbose( + () => "mTLS certificate is set for this request. Skipping JWT client assertion generation."); + + requestParameters.ResolvedCertificate = requestParameters.MtlsCertificate; + + // Return the mTLS certificate via the result object so the pipeline can use it + // (HTTP handler + policy/region checks). + return new ClientCredentialApplicationResult { - // Log that MTLS PoP is required and JWT token creation is skipped - requestParameters.RequestContext.Logger.Verbose(() => "MTLS PoP Client credential request. Skipping client assertion."); - - // Store the mTLS certificate in request parameters for later use (e.g., ExecutionResult) - requestParameters.ResolvedCertificate = requestParameters.MtlsCertificate; - } + MtlsCertificate = requestParameters.MtlsCertificate, + UseJwtPopClientAssertion = false // no client assertion set here + }; } /// @@ -126,7 +140,7 @@ private async Task ResolveCertificateAsync( // Create AssertionRequestOptions for the callback var options = new AssertionRequestOptions( - requestParameters.AppConfig, + requestParameters.AppConfig, tokenEndpoint, requestParameters.AuthorityManager.Authority.TenantId) { @@ -143,7 +157,7 @@ private async Task ResolveCertificateAsync( { requestParameters.RequestContext.Logger.Error( "[CertificateAndClaimsClientCredential] Certificate provider returned null."); - + throw new MsalClientException( MsalError.InvalidClientAssertion, "The certificate provider callback returned null. Ensure the callback returns a valid X509Certificate2 instance."); @@ -155,7 +169,7 @@ private async Task ResolveCertificateAsync( { requestParameters.RequestContext.Logger.Error( "[CertificateAndClaimsClientCredential] Certificate from provider does not have a private key."); - + throw new MsalClientException( MsalError.CertWithoutPrivateKey, MsalErrorMessage.CertMustHavePrivateKey(certificate.FriendlyName)); @@ -165,7 +179,7 @@ private async Task ResolveCertificateAsync( { requestParameters.RequestContext.Logger.Error( "[CertificateAndClaimsClientCredential] A cryptographic error occurred while accessing the certificate."); - + throw new MsalClientException( MsalError.CryptographicError, MsalErrorMessage.CryptographicError, diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index bd025779ab..c04b710a39 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -39,7 +39,7 @@ internal ClientAssertionDelegateCredential( // ────────────────────────────────── // Main hook for token requests // ────────────────────────────────── - public async Task AddConfidentialClientParametersAsync( + public async Task AddConfidentialClientParametersAsync( OAuth2Client oAuth2Client, AuthenticationRequestParameters p, ICryptographyManager _, @@ -60,29 +60,23 @@ public async Task AddConfidentialClientParametersAsync( if (string.IsNullOrWhiteSpace(resp?.Assertion)) { - throw new MsalClientException(MsalError.InvalidClientAssertion, + throw new MsalClientException( + MsalError.InvalidClientAssertion, MsalErrorMessage.InvalidClientAssertionEmpty); } - // ────────────────────────────── - // Decide bearer vs mTLS PoP - // ─────────────────────────────── - bool useClientAssertionJwtPop = p.UseClientAssertionJwtPop; + // JWT-PoP if explicit PoP was requested OR delegate returned a cert (implicit mTLS) + bool useJwtPop = p.IsMtlsPopRequested || resp.TokenBindingCertificate != null; - if (useClientAssertionJwtPop) - { - oAuth2Client.AddBodyParameter( - OAuth2Parameter.ClientAssertionType, - OAuth2AssertionType.JwtPop /* constant added in OAuth2AssertionType */); - } - else - { - oAuth2Client.AddBodyParameter( - OAuth2Parameter.ClientAssertionType, - OAuth2AssertionType.JwtBearer); - } + oAuth2Client.AddBodyParameter( + OAuth2Parameter.ClientAssertionType, + useJwtPop ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer); oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, resp.Assertion); + + return (useJwtPop || resp.TokenBindingCertificate != null) + ? new ClientCredentialApplicationResult(useJwtPop, resp.TokenBindingCertificate) + : ClientCredentialApplicationResult.None; } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs new file mode 100644 index 0000000000..d6b4cff86e --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Result returned by . + /// Contains extra data about how the client credential was applied. + /// Returns the information needed to set JWT-PoP and mTLS transport. + /// Internal and can be extended in the future to return more data as needed. + /// + internal sealed class ClientCredentialApplicationResult + { + /// + /// Shared default instance for the common case where the credential has no extra data to return. + /// + public static ClientCredentialApplicationResult None { get; } = new ClientCredentialApplicationResult(); + + public ClientCredentialApplicationResult() { } + + public ClientCredentialApplicationResult( + bool useJwtPopClientAssertion, + X509Certificate2 mtlsCertificate) + { + UseJwtPopClientAssertion = useJwtPopClientAssertion; + MtlsCertificate = mtlsCertificate; + } + + /// + /// Indicates whether the client_assertion_type was set to JWT-PoP. + /// + internal bool UseJwtPopClientAssertion { get; set; } + + /// + /// Optional certificate that should be used for mTLS transport / token binding. + /// + internal X509Certificate2 MtlsCertificate { get; set; } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs index 83302502cd..fd189c39be 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs @@ -21,7 +21,7 @@ internal interface IClientCredential { AssertionType AssertionType { get; } - Task AddConfidentialClientParametersAsync( + Task AddConfidentialClientParametersAsync( OAuth2Client oAuth2Client, AuthenticationRequestParameters authenticationRequestParameters, ICryptographyManager cryptographyManager, diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs index 86c003d398..6d9825ded3 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs @@ -23,7 +23,7 @@ public SecretStringClientCredential(string secret) Secret = secret; } - public Task AddConfidentialClientParametersAsync( + public Task AddConfidentialClientParametersAsync( OAuth2Client oAuth2Client, AuthenticationRequestParameters requestParameters, ICryptographyManager cryptographyManager, @@ -31,7 +31,7 @@ public Task AddConfidentialClientParametersAsync( CancellationToken cancellationToken) { oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientSecret, Secret); - return Task.CompletedTask; + return Task.FromResult(ClientCredentialApplicationResult.None); } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs index 0cf788f71c..3e407e4a1d 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs @@ -23,7 +23,7 @@ public SignedAssertionClientCredential(string signedAssertion) _signedAssertion = signedAssertion; } - public Task AddConfidentialClientParametersAsync( + public Task AddConfidentialClientParametersAsync( OAuth2Client oAuth2Client, AuthenticationRequestParameters requestParameters, ICryptographyManager cryptographyManager, @@ -32,7 +32,7 @@ public Task AddConfidentialClientParametersAsync( { oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, _signedAssertion); - return Task.CompletedTask; + return Task.FromResult(ClientCredentialApplicationResult.None); } } } diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs index 09caf1bda2..393ee3b623 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs @@ -177,8 +177,9 @@ public async Task Sni_AssertionFlow_Uses_JwtPop_And_Succeeds_TestAsync() StringAssert.Contains(requestUriSeen ?? "", "mtlsauth.microsoft.com"); } + //Downgraded test to verify bearer token acquisition works in SNI + jwt-pop scenario [DoNotRunOnLinux] - //[TestMethod] // Temporarily disabled due to feature not ready in ESTS + [TestMethod] public async Task Sni_AssertionFlow_Uses_JwtPop_And_Acquires_Bearer_Token_TestAsync() { X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); @@ -229,7 +230,7 @@ public async Task Sni_AssertionFlow_Uses_JwtPop_And_Acquires_Bearer_Token_TestAs // Step 3: second leg should now SUCCEED AuthenticationResult second = await assertionApp - .AcquireTokenForClient(new[] { "https://vault.azure.net/.default" }) + .AcquireTokenForClient(new[] { "https://storage.azure.com/.default" }) .OnBeforeTokenRequest(data => { requestUriSeen = data.RequestUri?.ToString(); @@ -250,7 +251,7 @@ public async Task Sni_AssertionFlow_Uses_JwtPop_And_Acquires_Bearer_Token_TestAs // Success assertions Assert.IsNotNull(second, "Second leg returned null AuthenticationResult."); Assert.IsFalse(string.IsNullOrEmpty(second.AccessToken), "Second leg did not return an access token."); - CollectionAssert.Contains(second.Scopes.ToArray(), "https://vault.azure.net/.default", + CollectionAssert.Contains(second.Scopes.ToArray(), "https://storage.azure.com/.default", "Second leg token is not for Key Vault scope."); // Prove MSAL used the assertion + jwt-pop binding From f2aabcec96b8e979e6be78999f864ec373b19c8b Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:59:48 -0800 Subject: [PATCH 4/5] pr comments --- .../AcquireTokenCommonParameters.cs | 116 +------------ .../MtlsPopParametersInitializer.cs | 162 ++++++++++++++++++ .../ConfidentialClientApplicationBuilder.cs | 33 ++-- .../ClientAssertionDelegateCredential.cs | 44 +++-- ...ClientAssertionStringDelegateCredential.cs | 62 +++++++ .../IClientSignedAssertionProvider.cs | 18 ++ .../PublicApiTests/ClientAssertionTests.cs | 110 +++++++++++- 7 files changed, 394 insertions(+), 151 deletions(-) create mode 100644 src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientSignedAssertionProvider.cs diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index dd92c98381..c35530d806 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -42,7 +42,6 @@ internal class AcquireTokenCommonParameters public string ClientAssertionFmiPath { get; internal set; } public bool IsMtlsPopRequested { get; set; } public string ExtraClientAssertionClaims { get; internal set; } - internal bool IsEffectiveMtlsPop => IsMtlsPopRequested || MtlsCertificate != null; /// /// Optional delegate for obtaining attestation JWT for Credential Guard keys. @@ -61,120 +60,7 @@ internal class AcquireTokenCommonParameters /// internal async Task TryInitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct) { - // ───────────────────────────────────────────────────────────── - // NON-PoP request: - // We may still need mTLS transport if the client-assertion delegate - // returns a TokenBindingCertificate (implicit bearer-over-mTLS). - // This behavior is required by existing unit/integration tests. - // ───────────────────────────────────────────────────────────── - if (!IsMtlsPopRequested) - { - // If a cert is already known, just enforce policy and return. - if (MtlsCertificate != null) - { - ThrowIfRegionMissingForImplicitMtls(serviceBundle); - return; - } - - // Only the assertion delegate can dynamically return a token-binding cert. - if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc) - { - var opts = new AssertionRequestOptions - { - ClientID = serviceBundle.Config.ClientId, - ClientCapabilities = serviceBundle.Config.ClientCapabilities, - Claims = Claims, - CancellationToken = ct, - TokenEndpoint = serviceBundle.Config.Authority.AuthorityInfo.CanonicalAuthority.Authority - }; - - ClientSignedAssertion ar = await cadc.GetAssertionAsync(opts, ct).ConfigureAwait(false); - - if (ar?.TokenBindingCertificate != null) - { - MtlsCertificate = ar.TokenBindingCertificate; - - // Implicit bearer-over-mTLS policy check - ThrowIfRegionMissingForImplicitMtls(serviceBundle); - } - } - - return; // IMPORTANT: do not run explicit PoP logic - } - - // ──────────────────────────────────── - // EXPLICIT PoP requested: - // Validate and initialize PoP parameters (auth scheme + cert + region check). - // ──────────────────────────────────── - - // Case 1 – Certificate credential - if (serviceBundle.Config.ClientCredential is CertificateClientCredential certCred) - { - if (certCred.Certificate == null) - { - throw new MsalClientException( - MsalError.MtlsCertificateNotProvided, - MsalErrorMessage.MtlsCertificateNotProvidedMessage); - } - - // IMPORTANT: initialize auth scheme + MtlsCertificate - InitMtlsPopParameters(certCred.Certificate, serviceBundle); - return; - } - - // Case 2 – Client-assertion delegate - if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc2) - { - var opts = new AssertionRequestOptions - { - ClientID = serviceBundle.Config.ClientId, - ClientCapabilities = serviceBundle.Config.ClientCapabilities, - Claims = Claims, - CancellationToken = ct - }; - - ClientSignedAssertion ar = await cadc2.GetAssertionAsync(opts, ct).ConfigureAwait(false); - - if (ar?.TokenBindingCertificate == null) - { - throw new MsalClientException( - MsalError.MtlsCertificateNotProvided, - MsalErrorMessage.MtlsCertificateNotProvidedMessage); - } - - InitMtlsPopParameters(ar.TokenBindingCertificate, serviceBundle); - return; - } - - // Case 3 – Any other credential (client-secret etc.) - throw new MsalClientException( - MsalError.MtlsCertificateNotProvided, - MsalErrorMessage.MtlsCertificateNotProvidedMessage); - } - - private void InitMtlsPopParameters(X509Certificate2 cert, IServiceBundle serviceBundle) - { - // region check (AAD only) - if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad && - serviceBundle.Config.AzureRegion == null) - { - throw new MsalClientException(MsalError.MtlsPopWithoutRegion, MsalErrorMessage.MtlsPopWithoutRegion); - } - - AuthenticationOperation = new MtlsPopAuthenticationOperation(cert); - MtlsCertificate = cert; - } - - private static void ThrowIfRegionMissingForImplicitMtls(IServiceBundle serviceBundle) - { - // Implicit bearer-over-mTLS requires region only for AAD - if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad && - serviceBundle.Config.AzureRegion == null) - { - throw new MsalClientException( - MsalError.MtlsBearerWithoutRegion, - MsalErrorMessage.MtlsBearerWithoutRegion); - } + await MtlsPopParametersInitializer.TryInitAsync(this, serviceBundle, ct).ConfigureAwait(false); } } } diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs new file mode 100644 index 0000000000..1d5f6942af --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Identity.Client.AppConfig; +using Microsoft.Identity.Client.AuthScheme.PoP; +using Microsoft.Identity.Client.Internal; +using Microsoft.Identity.Client.Internal.ClientCredential; +using Microsoft.Identity.Client.TelemetryCore; + +namespace Microsoft.Identity.Client.ApiConfig.Parameters +{ + /// + /// Encapsulates the mTLS/PoP initialization logic for token requests. + /// Keeps AcquireTokenCommonParameters lean and makes the init logic testable in isolation. + /// + internal static class MtlsPopParametersInitializer + { + internal static async Task TryInitAsync( + AcquireTokenCommonParameters p, + IServiceBundle serviceBundle, + CancellationToken ct) + { + if (p.IsMtlsPopRequested) + { + await InitExplicitMtlsPopAsync(p, serviceBundle, ct).ConfigureAwait(false); + return; + } + + await TryInitImplicitBearerOverMtlsAsync(p, serviceBundle, ct).ConfigureAwait(false); + } + + /// + /// NON-PoP request: + /// We may still need mTLS transport if the credential can return a TokenBindingCertificate. + /// + private static async Task TryInitImplicitBearerOverMtlsAsync( + AcquireTokenCommonParameters p, + IServiceBundle serviceBundle, + CancellationToken ct) + { + if (p.MtlsCertificate != null) + { + ThrowIfRegionMissingForImplicitMtls(serviceBundle); + return; + } + + // Only cert-capable credentials implement this capability interface. + if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) + { + var opts = CreateAssertionRequestOptions(p, serviceBundle, ct); + + ClientSignedAssertion ar = + await signedProvider.GetAssertionAsync(opts, ct).ConfigureAwait(false); + + if (ar?.TokenBindingCertificate != null) + { + p.MtlsCertificate = ar.TokenBindingCertificate; + ThrowIfRegionMissingForImplicitMtls(serviceBundle); + } + } + } + + /// + /// EXPLICIT PoP requested: + /// Validate and initialize PoP parameters (auth scheme + cert + region check). + /// + private static async Task InitExplicitMtlsPopAsync( + AcquireTokenCommonParameters p, + IServiceBundle serviceBundle, + CancellationToken ct) + { + // Case 1 – Certificate credential + if (serviceBundle.Config.ClientCredential is CertificateClientCredential certCred) + { + if (certCred.Certificate == null) + { + throw new MsalClientException( + MsalError.MtlsCertificateNotProvided, + MsalErrorMessage.MtlsCertificateNotProvidedMessage); + } + + InitMtlsPopParameters(p, certCred.Certificate, serviceBundle); + return; + } + + // Case 2 – Signed assertion provider (JWT + optional cert) + if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) + { + var opts = CreateAssertionRequestOptions(p, serviceBundle, ct); + + ClientSignedAssertion ar = + await signedProvider.GetAssertionAsync(opts, ct).ConfigureAwait(false); + + if (ar?.TokenBindingCertificate == null) + { + throw new MsalClientException( + MsalError.MtlsCertificateNotProvided, + MsalErrorMessage.MtlsCertificateNotProvidedMessage); + } + + InitMtlsPopParameters(p, ar.TokenBindingCertificate, serviceBundle); + return; + } + + // Case 3 – Any other credential (client-secret etc.) + throw new MsalClientException( + MsalError.MtlsCertificateNotProvided, + MsalErrorMessage.MtlsCertificateNotProvidedMessage); + } + + private static AssertionRequestOptions CreateAssertionRequestOptions( + AcquireTokenCommonParameters p, + IServiceBundle serviceBundle, + CancellationToken ct) + { + return new AssertionRequestOptions + { + ClientID = serviceBundle.Config.ClientId, + ClientCapabilities = serviceBundle.Config.ClientCapabilities, + Claims = p.Claims, + CancellationToken = ct, + ClientAssertionFmiPath = p.ClientAssertionFmiPath, + + // Best-effort context. IMPORTANT: use AbsoluteUri, not Uri.Authority (host only). + TokenEndpoint = serviceBundle.Config.Authority.AuthorityInfo.CanonicalAuthority.AbsoluteUri + }; + } + + private static void InitMtlsPopParameters( + AcquireTokenCommonParameters p, + X509Certificate2 cert, + IServiceBundle serviceBundle) + { + // region check (AAD only) + if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad && + serviceBundle.Config.AzureRegion == null) + { + throw new MsalClientException( + MsalError.MtlsPopWithoutRegion, + MsalErrorMessage.MtlsPopWithoutRegion); + } + + p.AuthenticationOperation = new MtlsPopAuthenticationOperation(cert); + p.MtlsCertificate = cert; + } + + private static void ThrowIfRegionMissingForImplicitMtls(IServiceBundle serviceBundle) + { + // Implicit bearer-over-mTLS requires region only for AAD + if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad && + serviceBundle.Config.AzureRegion == null) + { + throw new MsalClientException( + MsalError.MtlsBearerWithoutRegion, + MsalErrorMessage.MtlsBearerWithoutRegion); + } + } + } +} diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs index afca83fcf7..4c2f86198b 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs @@ -274,12 +274,9 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func cli throw new ArgumentNullException(nameof(clientAssertionDelegate)); } + // String assertion => cannot return TokenBindingCertificate => use string credential return WithClientAssertionInternal( - (opts, ct) => - Task.FromResult(new ClientSignedAssertion - { - Assertion = clientAssertionDelegate() // bearer - })); + (opts, ct) => Task.FromResult(clientAssertionDelegate())); } /// @@ -297,12 +294,9 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func cannot return TokenBindingCertificate => use string credential return WithClientAssertionInternal( - async (opts, ct) => - { - string jwt = await clientAssertionAsyncDelegate(ct).ConfigureAwait(false); - return new ClientSignedAssertion { Assertion = jwt }; // bearer - }); + (opts, ct) => clientAssertionAsyncDelegate(ct)); } /// @@ -319,12 +313,9 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func cannot return TokenBindingCertificate => use string credential return WithClientAssertionInternal( - async (opts, _) => - { - string jwt = await clientAssertionAsyncDelegate(opts).ConfigureAwait(false); - return new ClientSignedAssertion { Assertion = jwt }; // bearer - }); + (opts, ct) => clientAssertionAsyncDelegate(opts)); } /// @@ -359,6 +350,18 @@ internal ConfidentialClientApplicationBuilder WithClientAssertionInternal( return this; } + /// + /// Internal helper to set the client assertion provider that returns a string. + /// + /// + /// + internal ConfidentialClientApplicationBuilder WithClientAssertionInternal( + Func> clientAssertionProvider) + { + Config.ClientCredential = new ClientAssertionStringDelegateCredential(clientAssertionProvider); + return this; + } + /// /// Instructs MSAL to use an Azure regional token service. This feature is currently available to /// first-party applications only. diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index c04b710a39..d14aedf88e 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -2,11 +2,8 @@ // Licensed under the MIT License. using System; -using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; -using Microsoft.Identity.Client; -using Microsoft.Identity.Client.AuthScheme.PoP; using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.Internal.Requests; using Microsoft.Identity.Client.OAuth2; @@ -19,21 +16,28 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential /// Handles client assertions supplied via a delegate that returns an /// (JWT + optional certificate bound for mTLS‑PoP). /// - internal sealed class ClientAssertionDelegateCredential : IClientCredential + internal sealed class ClientAssertionDelegateCredential : IClientCredential, IClientSignedAssertionProvider { private readonly Func> _provider; - internal Task GetAssertionAsync( - AssertionRequestOptions options, - CancellationToken cancellationToken) => - _provider(options, cancellationToken); - internal ClientAssertionDelegateCredential( Func> provider) { _provider = provider ?? throw new ArgumentNullException(nameof(provider)); } + // Private helper for internal readability + private Task GetAssertionAsync( + AssertionRequestOptions options, + CancellationToken cancellationToken) => + _provider(options, cancellationToken); + + // Capability interface (only used where we intentionally cast to check the capability) + Task IClientSignedAssertionProvider.GetAssertionAsync( + AssertionRequestOptions options, + CancellationToken cancellationToken) => + GetAssertionAsync(options, cancellationToken); + public AssertionType AssertionType => AssertionType.ClientAssertion; // ────────────────────────────────── @@ -56,7 +60,7 @@ public async Task AddConfidentialClientParame ClientAssertionFmiPath = p.ClientAssertionFmiPath }; - ClientSignedAssertion resp = await _provider(opts, ct).ConfigureAwait(false); + ClientSignedAssertion resp = await GetAssertionAsync(opts, ct).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(resp?.Assertion)) { @@ -65,8 +69,19 @@ public async Task AddConfidentialClientParame MsalErrorMessage.InvalidClientAssertionEmpty); } - // JWT-PoP if explicit PoP was requested OR delegate returned a cert (implicit mTLS) - bool useJwtPop = p.IsMtlsPopRequested || resp.TokenBindingCertificate != null; + bool hasCert = resp.TokenBindingCertificate != null; + + // If PoP was explicitly requested, we must have a certificate. + // (Preflight should enforce this too, but keep this defensive.) + if (p.IsMtlsPopRequested && !hasCert) + { + throw new MsalClientException( + MsalError.MtlsCertificateNotProvided, + MsalErrorMessage.MtlsCertificateNotProvidedMessage); + } + + // JWT-PoP if explicit PoP was requested OR delegate returned a cert (implicit bearer-over-mTLS) + bool useJwtPop = p.IsMtlsPopRequested || hasCert; oAuth2Client.AddBodyParameter( OAuth2Parameter.ClientAssertionType, @@ -74,8 +89,9 @@ public async Task AddConfidentialClientParame oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, resp.Assertion); - return (useJwtPop || resp.TokenBindingCertificate != null) - ? new ClientCredentialApplicationResult(useJwtPop, resp.TokenBindingCertificate) + // Only return a cert if we actually have one. + return hasCert + ? new ClientCredentialApplicationResult(useJwtPopClientAssertion: useJwtPop, mtlsCertificate: resp.TokenBindingCertificate) : ClientCredentialApplicationResult.None; } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs new file mode 100644 index 0000000000..90ed8fb71b --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Internal.Requests; +using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Client.PlatformsCommon.Interfaces; +using Microsoft.Identity.Client.TelemetryCore; + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Client assertion provided as a string JWT. Cannot return TokenBindingCertificate (no mTLS preflight). + /// + internal sealed class ClientAssertionStringDelegateCredential : IClientCredential + { + private readonly Func> _provider; + + internal ClientAssertionStringDelegateCredential( + Func> provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + public AssertionType AssertionType => AssertionType.ClientAssertion; + + public async Task AddConfidentialClientParametersAsync( + OAuth2Client oAuth2Client, + AuthenticationRequestParameters p, + ICryptographyManager _, + string tokenEndpoint, + CancellationToken ct) + { + var opts = new AssertionRequestOptions + { + CancellationToken = ct, + ClientID = p.AppConfig.ClientId, + TokenEndpoint = tokenEndpoint, + ClientCapabilities = p.RequestContext.ServiceBundle.Config.ClientCapabilities, + Claims = p.Claims, + ClientAssertionFmiPath = p.ClientAssertionFmiPath + }; + + string assertion = await _provider(opts, ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(assertion)) + { + throw new MsalClientException( + MsalError.InvalidClientAssertion, + MsalErrorMessage.InvalidClientAssertionEmpty); + } + + oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); + oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion); + + return ClientCredentialApplicationResult.None; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientSignedAssertionProvider.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientSignedAssertionProvider.cs new file mode 100644 index 0000000000..2372d8a285 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientSignedAssertionProvider.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.TelemetryCore; + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Capability interface: implemented only by credentials that can produce a ClientSignedAssertion + /// (JWT + optional TokenBindingCertificate). + /// + internal interface IClientSignedAssertionProvider + { + Task GetAssertionAsync(AssertionRequestOptions options, CancellationToken cancellationToken); + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs index 3f813ea478..16729b492e 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs @@ -931,19 +931,24 @@ public async Task PopRequest_DoesNotReuseCachedBearerOverMtlsToken_Async() } [TestMethod] - public void ClientAssertion_CanReturnTokenBindingCertificate_FlagIsCorrect() + public void ClientAssertion_CredentialTypesAndCapabilities_AreCorrect() { - // Old overloads (returning string) should NOT be marked as “can return cert” + // Old overloads (returning string) should NOT be cert-capable and should NOT implement IClientSignedAssertionProvider var app1 = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) .WithExperimentalFeatures(true) .WithClientSecret(TestConstants.ClientSecret) .WithClientAssertion((AssertionRequestOptions o) => Task.FromResult("jwt")) .BuildConcrete(); - var cred1 = (app1.AppConfig as ApplicationConfiguration).ClientCredential as ClientAssertionDelegateCredential; - Assert.IsNotNull(cred1); + var cc1 = (app1.AppConfig as ApplicationConfiguration).ClientCredential; + Assert.IsNotNull(cc1); - // New overload (returning ClientSignedAssertion) SHOULD be marked as “can return cert” + Assert.IsInstanceOfType(cc1, typeof(ClientAssertionStringDelegateCredential), + "String assertion overloads must use the string credential type."); + Assert.IsFalse(cc1 is IClientSignedAssertionProvider, + "String assertion credential must NOT be signed-assertion capable (cannot return TokenBindingCertificate)."); + + // New overload (returning ClientSignedAssertion) SHOULD be cert-capable and implement IClientSignedAssertionProvider var app2 = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) .WithExperimentalFeatures(true) .WithClientSecret(TestConstants.ClientSecret) @@ -951,8 +956,99 @@ public void ClientAssertion_CanReturnTokenBindingCertificate_FlagIsCorrect() Task.FromResult(new ClientSignedAssertion { Assertion = "jwt", TokenBindingCertificate = null })) .BuildConcrete(); - var cred2 = (app2.AppConfig as ApplicationConfiguration).ClientCredential as ClientAssertionDelegateCredential; - Assert.IsNotNull(cred2); + var cc2 = (app2.AppConfig as ApplicationConfiguration).ClientCredential; + Assert.IsNotNull(cc2); + + Assert.IsInstanceOfType(cc2, typeof(ClientAssertionDelegateCredential), + "ClientSignedAssertion overloads must use the signed-assertion credential type."); + Assert.IsTrue(cc2 is IClientSignedAssertionProvider, + "Signed assertion credential must implement IClientSignedAssertionProvider for mTLS preflight."); + } + + [TestMethod] + public async Task FmiPathClientAssertion_StringDelegate_IsNeverInvokedWithNullFmiPathAsync() + { + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + + var handler = httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + handler.ExpectedPostData = new Dictionary(); + + int callCount = 0; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(TestConstants.AuthorityTestTenant) + .WithExperimentalFeatures(true) + .WithHttpManager(httpManager) + .WithClientAssertion(async (AssertionRequestOptions o) => + { + Interlocked.Increment(ref callCount); + + Assert.AreEqual( + AssertionFmiPath1, + o.ClientAssertionFmiPath, + "ClientAssertionFmiPath must be set for every invocation of the client assertion delegate."); + + return await Task.FromResult("dummy_assertion").ConfigureAwait(false); + }) + .BuildConcrete(); + + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithFmiPathForClientAssertion(AssertionFmiPath1) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + Assert.IsTrue(callCount >= 1, "Expected the client assertion delegate to be called at least once."); + } + } + + [TestMethod] + public async Task FmiPathClientAssertion_ClientSignedAssertionProvider_PreflightPassesFmiPathAsync() + { + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + + var handler = httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + handler.ExpectedPostData = new Dictionary(); + + int callCount = 0; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(TestConstants.AuthorityTestTenant) + .WithExperimentalFeatures(true) + .WithHttpManager(httpManager) + .WithClientAssertion((AssertionRequestOptions o, CancellationToken ct) => + { + Interlocked.Increment(ref callCount); + + // Key guard: preflight calls must also carry FMI path + Assert.AreEqual( + AssertionFmiPath1, + o.ClientAssertionFmiPath, + "ClientAssertionFmiPath must be set for every invocation of the client assertion provider."); + + // Return NO cert to avoid region requirements for this unit test + return Task.FromResult(new ClientSignedAssertion + { + Assertion = "jwt", + TokenBindingCertificate = null + }); + }) + .BuildConcrete(); + + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithFmiPathForClientAssertion(AssertionFmiPath1) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + Assert.IsTrue(callCount >= 1, "Expected the client assertion provider to be called at least once."); + } } #region Helper --------------------------------------------------------------- From b83006318ce55eb6d4f62cd0830cc7480f090b3e Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:39:08 -0800 Subject: [PATCH 5/5] pr comments --- .../ApiConfig/Parameters/MtlsPopParametersInitializer.cs | 8 ++++---- .../Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs | 9 ++++----- .../Internal/Requests/AuthenticationRequestParameters.cs | 2 -- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs index 1d5f6942af..82a2d1e986 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs @@ -37,11 +37,11 @@ internal static async Task TryInitAsync( /// We may still need mTLS transport if the credential can return a TokenBindingCertificate. /// private static async Task TryInitImplicitBearerOverMtlsAsync( - AcquireTokenCommonParameters p, + AcquireTokenCommonParameters tokenParameters, IServiceBundle serviceBundle, CancellationToken ct) { - if (p.MtlsCertificate != null) + if (tokenParameters.MtlsCertificate != null) { ThrowIfRegionMissingForImplicitMtls(serviceBundle); return; @@ -50,14 +50,14 @@ private static async Task TryInitImplicitBearerOverMtlsAsync( // Only cert-capable credentials implement this capability interface. if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider) { - var opts = CreateAssertionRequestOptions(p, serviceBundle, ct); + var opts = CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct); ClientSignedAssertion ar = await signedProvider.GetAssertionAsync(opts, ct).ConfigureAwait(false); if (ar?.TokenBindingCertificate != null) { - p.MtlsCertificate = ar.TokenBindingCertificate; + tokenParameters.MtlsCertificate = ar.TokenBindingCertificate; ThrowIfRegionMissingForImplicitMtls(serviceBundle); } } diff --git a/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs b/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs index ad619541c6..b3e55e7df8 100644 --- a/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs +++ b/src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs @@ -18,7 +18,7 @@ internal class RegionAndMtlsDiscoveryProvider : IRegionDiscoveryProvider public const string PublicEnvForRegionalMtlsAuth = "mtlsauth.microsoft.com"; // Map of unsupported sovereign cloud hosts for mTLS PoP to their error messages - private static readonly Dictionary s_unsupportedMtlsHosts = + private static readonly Dictionary s_unsupportedMtlsHosts = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "login.usgovcloudapi.net", MsalErrorMessage.MtlsPopNotSupportedForUsGovCloudApiMessage }, @@ -33,10 +33,10 @@ public RegionAndMtlsDiscoveryProvider(IHttpManager httpManager) public async Task GetMetadataAsync(Uri authority, RequestContext requestContext) { // Fail fast: Check for unsupported mTLS hosts before any region discovery - if (requestContext.MtlsCertificate != null) + if (requestContext.IsMtlsRequested) { string host = authority.Host; - + // Check if host is in the unsupported list if (s_unsupportedMtlsHosts.TryGetValue(host, out string errorMessage)) { @@ -45,7 +45,7 @@ public async Task GetMetadataAsync(Uri authority MsalError.MtlsPopNotSupportedForEnvironment, errorMessage); } - + // Check if host starts with "login." if (!host.StartsWith("login.", StringComparison.OrdinalIgnoreCase)) { @@ -88,7 +88,6 @@ public async Task GetMetadataAsync(Uri authority string regionalEnv = GetRegionalizedEnvironment(authority, region, requestContext); return CreateEntry(authority.Host, regionalEnv); } - private static InstanceDiscoveryMetadataEntry CreateEntry(string originalEnv, string regionalEnv) { diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index 17c94ec499..9eab9f0bc8 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -119,8 +119,6 @@ public X509Certificate2 MtlsCertificate public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested; - internal bool UseClientAssertionJwtPop => IsMtlsPopRequested || MtlsCertificate != null; - /// /// The certificate resolved and used for client authentication (if certificate-based authentication was used). /// This is set during the token request when the certificate is resolved.