diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs
index 195b02a17a..d23792ff9f 100644
--- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs
+++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -15,6 +15,7 @@
using Microsoft.Identity.Client.Utils;
using Microsoft.Identity.Client.Extensibility;
using Microsoft.Identity.Client.OAuth2;
+using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;
using System.Text;
@@ -98,18 +99,20 @@ public AcquireTokenForClientParameterBuilder WithSendX5C(bool withSendX5C)
/// The current instance of to enable method chaining.
public AcquireTokenForClientParameterBuilder WithMtlsProofOfPossession()
{
- if (ServiceBundle.Config.ClientCredential is not CertificateClientCredential certificateCredential)
+ if (ServiceBundle.Config.ClientCredential is CertificateClientCredential certificateCredential)
{
- throw new MsalClientException(
+ if (certificateCredential.Certificate == null)
+ {
+ throw new MsalClientException(
MsalError.MtlsCertificateNotProvided,
MsalErrorMessage.MtlsCertificateNotProvidedMessage);
- }
- else
- {
+ }
+
CommonParameters.AuthenticationOperation = new MtlsPopAuthenticationOperation(certificateCredential.Certificate);
- CommonParameters.MtlsCertificate = certificateCredential.Certificate;
+ CommonParameters.MtlsCertificate = certificateCredential.Certificate;
}
+ CommonParameters.IsMtlsPopRequested = true;
return this;
}
diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs
index 93e8eaf785..82b6a0be09 100644
--- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs
+++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs
@@ -2,12 +2,15 @@
// Licensed under the MIT License.
using System;
+using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.ApiConfig.Parameters;
using Microsoft.Identity.Client.AuthScheme.PoP;
using Microsoft.Identity.Client.Instance.Discovery;
+using Microsoft.Identity.Client.Instance.Validation;
using Microsoft.Identity.Client.Internal;
+using Microsoft.Identity.Client.Internal.ClientCredential;
using Microsoft.Identity.Client.Internal.Requests;
using Microsoft.Identity.Client.ManagedIdentity;
using Microsoft.Identity.Client.Utils;
@@ -56,6 +59,9 @@ public async Task ExecuteAsync(
AcquireTokenForClientParameters clientParameters,
CancellationToken cancellationToken)
{
+ await commonParameters.InitMtlsPopParametersAsync(ServiceBundle, cancellationToken)
+ .ConfigureAwait(false);
+
RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken);
AuthenticationRequestParameters requestParams = await _confidentialClientApplication.CreateRequestParametersAsync(
diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs
index d9820cef8e..c9ba8a3037 100644
--- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs
+++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs
@@ -9,8 +9,12 @@
using Microsoft.Identity.Client.AppConfig;
using Microsoft.Identity.Client.AuthScheme;
using Microsoft.Identity.Client.AuthScheme.Bearer;
+using Microsoft.Identity.Client.AuthScheme.PoP;
using Microsoft.Identity.Client.Extensibility;
+using Microsoft.Identity.Client.Internal;
+using Microsoft.Identity.Client.Internal.ClientCredential;
using Microsoft.Identity.Client.TelemetryCore.Internal.Events;
+using Microsoft.Identity.Client.Utils;
using static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension;
namespace Microsoft.Identity.Client.ApiConfig.Parameters
@@ -34,5 +38,75 @@ internal class AcquireTokenCommonParameters
public SortedList>> CacheKeyComponents { get; internal set; }
public string FmiPathSuffix { get; internal set; }
public string ClientAssertionFmiPath { get; internal set; }
+ public bool IsMtlsPopRequested { get; set; }
+
+ internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct)
+ {
+ if (!IsMtlsPopRequested)
+ {
+ return; // PoP not requested
+ }
+
+ // ────────────────────────────────────
+ // Case 1 – Certificate credential
+ // ────────────────────────────────────
+ if (serviceBundle.Config.ClientCredential is CertificateClientCredential certCred)
+ {
+ if (certCred.Certificate == null)
+ {
+ throw new MsalClientException(
+ MsalError.MtlsCertificateNotProvided,
+ MsalErrorMessage.MtlsCertificateNotProvidedMessage);
+ }
+
+ return;
+ }
+
+ // ────────────────────────────────────
+ // Case 2 – Client‑assertion delegate
+ // ────────────────────────────────────
+ if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc)
+ {
+ var opts = new AssertionRequestOptions
+ {
+ ClientID = serviceBundle.Config.ClientId,
+ ClientCapabilities = serviceBundle.Config.ClientCapabilities,
+ Claims = Claims,
+ CancellationToken = ct
+ };
+
+ ClientAssertion ar = await cadc.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;
+ }
}
}
diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ClientAssertion.cs b/src/client/Microsoft.Identity.Client/AppConfig/ClientAssertion.cs
new file mode 100644
index 0000000000..74b386be1f
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/AppConfig/ClientAssertion.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
+{
+ ///
+ /// Container returned from WithClientAssertion.
+ ///
+ public class ClientAssertion
+ {
+ ///
+ /// Represents the client assertion (JWT) and optional mutual‑TLS binding certificate returned
+ /// by the clientAssertionProvider callback supplied to
+ /// .
+ ///
+ ///
+ /// MSAL forwards to the token endpoint as the client_assertion parameter.
+ /// When mutual‑TLS Proof‑of‑Possession (PoP) is enabled on the application and a
+ /// is provided, MSAL sets client_assertion_type to
+ /// urn:ietf:params:oauth:client-assertion-type:jwt-pop; otherwise it uses jwt-bearer.
+ ///
+ /// Guidance on constructing the client assertion (required claims, audience, and lifetime) is available at
+ /// aka.ms/msal-net-client-assertion.
+ /// The assertion is created by your callback; MSAL does not modify or re‑sign it.
+ /// **Note:** It is up to the caller to cache the assertion and certificate if reuse is desired.
+ ///
+ public string Assertion { get; set; }
+
+ ///
+ /// Optional. Certificate used to bind the client assertion for mutual‑TLS Proof‑of‑Possession (PoP).
+ ///
+ ///
+ /// Provide a value only when PoP is enabled on the application. The certificate should include an
+ /// accessible private key. If null, MSAL treats the assertion as a bearer assertion and uses
+ /// client_assertion_type=jwt-bearer.
+ ///
+ public X509Certificate2 TokenBindingCertificate { get; set; }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs
index c5ceb0168e..2825efd708 100644
--- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs
+++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs
@@ -228,13 +228,12 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func cli
throw new ArgumentNullException(nameof(clientAssertionDelegate));
}
- Func> clientAssertionAsyncDelegate = (_) =>
- {
- return Task.FromResult(clientAssertionDelegate());
- };
-
- Config.ClientCredential = new SignedAssertionDelegateClientCredential(clientAssertionAsyncDelegate);
- return this;
+ return WithClientAssertion(
+ (opts, ct) =>
+ Task.FromResult(new ClientAssertion
+ {
+ Assertion = clientAssertionDelegate() // bearer
+ }));
}
///
@@ -252,8 +251,12 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func
+ {
+ string jwt = await clientAssertionAsyncDelegate(ct).ConfigureAwait(false);
+ return new ClientAssertion { Assertion = jwt }; // bearer
+ });
}
///
@@ -270,7 +273,30 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func
+ {
+ string jwt = await clientAssertionAsyncDelegate(opts).ConfigureAwait(false);
+ return new ClientAssertion { Assertion = jwt }; // bearer
+ });
+ }
+
+ ///
+ /// Configures the client application to use a client assertion for authentication.
+ ///
+ /// This method allows the client application to authenticate using a custom client
+ /// assertion, which can be useful in scenarios where the assertion needs to be dynamically generated or
+ /// retrieved.
+ /// A delegate that asynchronously provides an based on the given and . This delegate must not be .
+ /// The instance configured with the specified client
+ /// assertion.
+ /// Thrown if is .
+ public ConfidentialClientApplicationBuilder WithClientAssertion(Func> clientAssertionProvider)
+ {
+ Config.ClientCredential = new ClientAssertionDelegateCredential(clientAssertionProvider);
return this;
}
diff --git a/src/client/Microsoft.Identity.Client/AuthScheme/PoP/MtlsPopAuthenticationOperation.cs b/src/client/Microsoft.Identity.Client/AuthScheme/PoP/MtlsPopAuthenticationOperation.cs
index 7ce35c6d25..980ac388f7 100644
--- a/src/client/Microsoft.Identity.Client/AuthScheme/PoP/MtlsPopAuthenticationOperation.cs
+++ b/src/client/Microsoft.Identity.Client/AuthScheme/PoP/MtlsPopAuthenticationOperation.cs
@@ -17,7 +17,7 @@ internal class MtlsPopAuthenticationOperation : IAuthenticationOperation
public MtlsPopAuthenticationOperation(X509Certificate2 mtlsCert)
{
_mtlsCert = mtlsCert;
- KeyId = ComputeX5tS256KeyId(_mtlsCert);
+ KeyId = CoreHelpers.ComputeX5tS256KeyId(_mtlsCert);
}
public int TelemetryTokenType => TelemetryTokenTypeConstants.MtlsPop;
@@ -40,20 +40,5 @@ public void FormatResult(AuthenticationResult authenticationResult)
{
authenticationResult.BindingCertificate = _mtlsCert;
}
-
- private static string ComputeX5tS256KeyId(X509Certificate2 certificate)
- {
- // Extract the raw bytes of the certificate’s public key.
- var publicKey = certificate.GetPublicKey();
-
- // Compute the SHA-256 hash of the public key.
- using (var sha256 = SHA256.Create())
- {
- byte[] hash = sha256.ComputeHash(publicKey);
-
- // Return the hash encoded in Base64 URL format.
- return Base64UrlHelpers.Encode(hash);
- }
- }
}
}
diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs
new file mode 100644
index 0000000000..e6c3308f17
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs
@@ -0,0 +1,86 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// 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;
+using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
+using Microsoft.Identity.Client.TelemetryCore;
+
+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
+ {
+ private readonly Func> _provider;
+
+ internal Task GetAssertionAsync(
+ AssertionRequestOptions options,
+ CancellationToken cancellationToken) =>
+ _provider(options, cancellationToken);
+
+ public ClientAssertionDelegateCredential(
+ Func> provider)
+ {
+ _provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ }
+
+ public AssertionType AssertionType => AssertionType.ClientAssertion;
+
+ // ──────────────────────────────────
+ // Main hook for token requests
+ // ──────────────────────────────────
+ 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
+ };
+
+ ClientAssertion resp = await _provider(opts, ct).ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(resp?.Assertion))
+ {
+ throw new MsalClientException(MsalError.InvalidClientAssertion,
+ MsalErrorMessage.InvalidClientAssertionEmpty);
+ }
+
+ // Decide bearer vs mTLS PoP
+ bool IsMtlsPopRequested = p.IsMtlsPopRequested;
+
+ if (IsMtlsPopRequested && resp.TokenBindingCertificate != null)
+ {
+ oAuth2Client.AddBodyParameter(
+ OAuth2Parameter.ClientAssertionType,
+ OAuth2AssertionType.JwtPop /* constant added in OAuth2AssertionType */);
+ }
+ else
+ {
+ oAuth2Client.AddBodyParameter(
+ OAuth2Parameter.ClientAssertionType,
+ OAuth2AssertionType.JwtBearer);
+ }
+
+ oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, resp.Assertion);
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionDelegateClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionDelegateClientCredential.cs
deleted file mode 100644
index d1dd0e3d9b..0000000000
--- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionDelegateClientCredential.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
-// Licensed under the MIT License.
-
-using System;
-using System.Linq;
-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
-{
- internal class SignedAssertionDelegateClientCredential : IClientCredential
- {
- internal Func> _signedAssertionDelegate { get; }
- internal Func> _signedAssertionWithInfoDelegate { get; }
- public AssertionType AssertionType => AssertionType.ClientAssertion;
-
- [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
- public SignedAssertionDelegateClientCredential(Func> signedAssertionDelegate)
- {
- _signedAssertionDelegate = signedAssertionDelegate;
- }
-
- public SignedAssertionDelegateClientCredential(Func> signedAssertionDelegate)
- {
- _signedAssertionWithInfoDelegate = signedAssertionDelegate ?? throw new ArgumentNullException(nameof(signedAssertionDelegate),
- "Signed assertion delegate cannot be null.");
- }
-
- public async Task AddConfidentialClientParametersAsync(
- OAuth2Client oAuth2Client,
- AuthenticationRequestParameters requestParameters,
- ICryptographyManager cryptographyManager,
- string tokenEndpoint,
- CancellationToken cancellationToken)
- {
- if (_signedAssertionDelegate != null)
- {
- // If no "AssertionRequestOptions" delegate is supplied
- string signedAssertion = await _signedAssertionDelegate(cancellationToken).ConfigureAwait(false);
- oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
- oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion);
- }
- else
- {
- // Build the AssertionRequestOptions and conditionally set ClientCapabilities
- var assertionOptions = new AssertionRequestOptions
- {
- CancellationToken = cancellationToken,
- ClientID = requestParameters.AppConfig.ClientId,
- TokenEndpoint = tokenEndpoint
- };
-
- // Set client capabilities
- var configuredCapabilities = requestParameters
- .RequestContext
- .ServiceBundle
- .Config
- .ClientCapabilities;
-
- assertionOptions.ClientCapabilities = configuredCapabilities;
-
- //Set claims
- assertionOptions.Claims = requestParameters.Claims;
-
- //Set client assertion FMI path
- assertionOptions.ClientAssertionFmiPath = requestParameters.ClientAssertionFmiPath;
-
- // Delegate that uses AssertionRequestOptions
- string signedAssertion = await _signedAssertionWithInfoDelegate(assertionOptions).ConfigureAwait(false);
-
- oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
- oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion);
- }
- }
- }
-}
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
index a497158134..3619c73864 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
@@ -113,6 +113,8 @@ public AuthenticationRequestParameters(
public X509Certificate2 MtlsCertificate => _commonParameters.MtlsCertificate;
+ public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested;
+
///
/// Indicates if the user configured claims via .WithClaims. Not affected by Client Capabilities
///
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
index 34948dcb71..48662d8f42 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -13,6 +14,8 @@
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.Extensibility;
using Microsoft.Identity.Client.Instance;
+using Microsoft.Identity.Client.Internal.ClientCredential;
+using Microsoft.Identity.Client.Internal.Requests;
using Microsoft.Identity.Client.OAuth2;
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
using Microsoft.Identity.Client.Utils;
@@ -24,7 +27,7 @@ internal class ClientCredentialRequest : RequestBase
private readonly AcquireTokenForClientParameters _clientParameters;
private static readonly SemaphoreSlim s_semaphoreSlim = new SemaphoreSlim(1, 1);
private readonly ICryptographyManager _cryptoManager;
-
+
public ClientCredentialRequest(
IServiceBundle serviceBundle,
AuthenticationRequestParameters authenticationRequestParameters,
@@ -236,7 +239,28 @@ private bool ShouldUseCachedToken(MsalAccessTokenCacheItem cacheItem)
return false;
}
- // 2) If the token’s hash matches AccessTokenHashToRefresh, ignore it
+ // 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)
+ {
+ string expectedKid = CoreHelpers.ComputeX5tS256KeyId(requestCert);
+
+ // If the certificate cannot produce a valid KeyId (SPKI-SHA256), expectedKid will be null or empty.
+ // In this case, the cache will be bypassed, as we cannot safely match the cached token to the certificate.
+ if (!string.Equals(cacheItem.KeyId, expectedKid, StringComparison.Ordinal))
+ {
+ AuthenticationRequestParameters.RequestContext.Logger.Verbose(() =>
+ "[ClientCredentialRequest] Cached token KeyId does not match request certificate (SPKI-SHA256 mismatch). Bypassing cache.");
+ return false;
+ }
+
+ AuthenticationRequestParameters.RequestContext.Logger.Verbose(() =>
+ "[ClientCredentialRequest] Cached token KeyId matches request certificate (SPKI-SHA256). Using cached token.");
+ }
+
+ // 3) If the token’s hash matches AccessTokenHashToRefresh, ignore it
if (!string.IsNullOrEmpty(_clientParameters.AccessTokenHashToRefresh) &&
IsMatchingTokenHash(cacheItem.Secret, _clientParameters.AccessTokenHashToRefresh))
{
diff --git a/src/client/Microsoft.Identity.Client/MsalError.cs b/src/client/Microsoft.Identity.Client/MsalError.cs
index 33cc9dc035..4bcc576a42 100644
--- a/src/client/Microsoft.Identity.Client/MsalError.cs
+++ b/src/client/Microsoft.Identity.Client/MsalError.cs
@@ -61,6 +61,14 @@ public static class MsalError
///
public const string UserAssertionNullError = "user_assertion_null";
+ ///
+ /// Returned when a WithClientAssertion delegate provides
+ /// a or empty JWT string.
+ /// Mitigation
+ /// Ensure the delegate returns a non‑empty, base‑64‑encoded JWT.
+ ///
+ public const string InvalidClientAssertion = "invalid_client_assertion";
+
///
/// This error code comes back from calls when the
/// user is passed as the account parameter. Only some brokers (WAM) can login the current user.
diff --git a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
index d5b61c67e8..883b9ab30f 100644
--- a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
+++ b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
@@ -89,6 +89,8 @@ public static string iOSBrokerKeySaveFailed(string keyChainResult)
public const string UserMismatch = "User '{0}' returned by service does not match user '{1}' in the request. ";
public const string UserCredentialAssertionTypeEmpty = "credential.AssertionType cannot be empty. ";
+ public const string InvalidClientAssertionEmpty = "Client‑assertion JWT cannot be null or empty. Verify the delegate supplied to WithClientAssertion returns a valid signed JWT.";
+
public const string NoPromptFailedErrorMessage =
"One of two conditions was encountered: "
+
@@ -436,7 +438,7 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName)
public const string ClaimsChallenge = "The returned error contains a claims challenge. For additional info on how to handle claims related to multifactor authentication, Conditional Access, and incremental consent, see https://aka.ms/msal-conditional-access-claims. If you are using the On-Behalf-Of flow, see https://aka.ms/msal-conditional-access-claims-obo for details.";
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 to be configured. Please provide a certificate at the application level using the .WithCertificate() instead of passing an assertion. See https://aka.ms/msal-net-pop for details.";
+ 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 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 RegionRequiredForMtlsPopMessage = "Regional auto-detect failed. mTLS Proof-of-Possession requires a region to be specified, as there is no global endpoint for mTLS. See https://aka.ms/msal-net-pop for details.";
diff --git a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs
index 90d20f4a15..7aa76e3cd1 100644
--- a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs
+++ b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs
@@ -66,6 +66,7 @@ internal static class OAuth2ResponseType
internal static class OAuth2AssertionType
{
public const string JwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
+ public const string JwtPop = "urn:ietf:params:oauth:client-assertion-type:jwt-pop";
}
internal static class OAuth2RequestedTokenUse
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 d02c6b8081..9a7ea38ff9 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,9 @@
+const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string
+Microsoft.Identity.Client.ClientAssertion
+Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string
+Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void
+Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void
+Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
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 d02c6b8081..9a7ea38ff9 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,9 @@
+const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string
+Microsoft.Identity.Client.ClientAssertion
+Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string
+Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void
+Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void
+Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
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 d02c6b8081..9a7ea38ff9 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,9 @@
+const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string
+Microsoft.Identity.Client.ClientAssertion
+Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string
+Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void
+Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void
+Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
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 d02c6b8081..9a7ea38ff9 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,9 @@
+const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string
+Microsoft.Identity.Client.ClientAssertion
+Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string
+Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void
+Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void
+Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
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 d02c6b8081..9a7ea38ff9 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,9 @@
+const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string
+Microsoft.Identity.Client.ClientAssertion
+Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string
+Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void
+Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void
+Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
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 d02c6b8081..9a7ea38ff9 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,9 @@
+const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string
+Microsoft.Identity.Client.ClientAssertion
+Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string
+Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void
+Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void
+Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
diff --git a/src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs b/src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs
index deadcb07d1..fd53a2ee0c 100644
--- a/src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs
+++ b/src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs
@@ -6,6 +6,7 @@
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
using System.Text;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.Internal;
@@ -216,5 +217,20 @@ internal static string ComputeAccessTokenExtCacheKey(SortedList
return Base64UrlHelpers.Encode(hashBytes);
}
}
+
+ internal static string ComputeX5tS256KeyId(X509Certificate2 certificate)
+ {
+ // Extract the raw bytes of the certificate’s public key.
+ var publicKey = certificate.GetPublicKey();
+
+ // Compute the SHA-256 hash of the public key.
+ using (var sha256 = SHA256.Create())
+ {
+ byte[] hash = sha256.ComputeHash(publicKey);
+
+ // Return the hash encoded in Base64 URL format.
+ return Base64UrlHelpers.Encode(hash);
+ }
+ }
}
}
diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs
index d4abb9fd06..226f0b70e9 100644
--- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs
+++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs
@@ -3,13 +3,19 @@
using System;
using System.Collections.Generic;
+using System.Drawing;
using System.Linq;
using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography.X509Certificates;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensibility;
+using Microsoft.Identity.Client.Internal;
using Microsoft.Identity.Client.OAuth2;
+using Microsoft.Identity.Test.Common.Core.Helpers;
using Microsoft.Identity.Test.Common.Core.Mocks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -330,5 +336,352 @@ public async Task LongRunningObo_RunsSuccessfully_TestAsync()
Assert.IsTrue(verified, "The client assertion delegate should have been called with the correct FMI path.");
}
}
+
+ [TestMethod]
+ public async Task ClientAssertion_BearerAsync()
+ {
+ using var http = new MockHttpManager();
+ http.AddInstanceDiscoveryMockHandler();
+
+ var handler = http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(http)
+ .WithClientAssertion(BearerDelegate())
+ .BuildConcrete();
+
+ var result = await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+
+ Assert.AreEqual(
+ "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+ handler.ActualRequestPostData["client_assertion_type"]);
+
+ result = await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource);
+ }
+
+ [TestMethod]
+ public async Task ClientAssertion_WithPoPDelegate_No_Mtls_Api_SendsBearer_Async()
+ {
+ using var http = new MockHttpManager();
+ {
+ http.AddInstanceDiscoveryMockHandler();
+ var handler = http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(http)
+ .WithClientAssertion(PopDelegate())
+ .BuildConcrete();
+
+ var result = await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+
+ Assert.AreEqual(
+ "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+ handler.ActualRequestPostData["client_assertion_type"]);
+ }
+ }
+
+ [TestMethod]
+ public async Task ClientAssertion_ReceivesClientCapabilitiesAsync()
+ {
+ using var http = new MockHttpManager();
+ {
+ http.AddInstanceDiscoveryMockHandler();
+ http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ bool checkedCaps = false;
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithClientCapabilities(TestConstants.ClientCapabilities)
+ .WithHttpManager(http)
+ .WithClientAssertion((opts, ct) =>
+ {
+ checkedCaps = true;
+ CollectionAssert.AreEqual(
+ TestConstants.ClientCapabilities,
+ opts.ClientCapabilities.ToList());
+ return Task.FromResult(new ClientAssertion
+ {
+ Assertion = "jwt"
+ });
+ })
+ .BuildConcrete();
+
+ _ = await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ Assert.IsTrue(checkedCaps);
+ }
+ }
+
+ [TestMethod]
+ public async Task ClientAssertion_EmptyJwt_ThrowsAsync()
+ {
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithClientAssertion((o, c) =>
+ Task.FromResult(new ClientAssertion { Assertion = string.Empty }))
+ .BuildConcrete();
+
+ await AssertException.TaskThrowsAsync(() =>
+ cca.AcquireTokenForClient(TestConstants.s_scope).ExecuteAsync())
+ .ConfigureAwait(false);
+ }
+
+ [TestMethod]
+ public async Task ClientAssertion_CancellationTokenPropagatesAsync()
+ {
+ using var cts = new CancellationTokenSource();
+
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithClientAssertion((o, ct) =>
+ {
+ Assert.AreEqual(cts.Token, ct);
+ cts.Cancel();
+ ct.ThrowIfCancellationRequested();
+ return Task.FromResult(new ClientAssertion { Assertion = "jwt" });
+ })
+ .BuildConcrete();
+
+ await AssertException.TaskThrowsAsync(() =>
+ cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync(cts.Token))
+ .ConfigureAwait(false);
+ }
+
+ [TestMethod]
+ public async Task WithMtlsPop_AfterPoPDelegate_Works()
+ {
+ const string region = "eastus";
+
+ 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}/123456-1234-2345-1234561234/oauth2/v2.0/token";
+
+ using (var httpManager = new MockHttpManager())
+ {
+ // Set up mock handler with expected token endpoint URL
+ httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(
+ tokenType: "mtls_pop");
+
+ var cert = CertHelper.GetOrCreateTestCert();
+
+ var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .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);
+ }
+ }
+ }
+
+ [TestMethod]
+ public async Task PoP_CachedTokenWithDifferentCertificate_IsBypassedAsync()
+ {
+ const string region = "eastus";
+
+ // ─────────── Set up HTTP mocks ───────────
+ using var httpManager = new MockHttpManager();
+ using (var envContext = new EnvVariableContext())
+ {
+ Environment.SetEnvironmentVariable("REGION_NAME", region);
+
+ // 1st network call returns token‑A
+ httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(
+ tokenType: "mtls_pop");
+
+ // 2nd network call returns token‑B
+ httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(
+ tokenType: "mtls_pop");
+
+ // ─────────── Two distinct certificates ───────────
+ var certA = CertHelper.GetOrCreateTestCert();
+ var certB = CertHelper.GetOrCreateTestCert(regenerateCert: true);
+
+ // Delegate returns certA on first call, certB on second call
+ int callCount = 0;
+ Func> popDelegate =
+ (opts, ct) =>
+ {
+ callCount++;
+ var cert = (callCount == 1) ? certA : certB;
+ return Task.FromResult(new ClientAssertion
+ {
+ Assertion = $"jwt_{callCount}", // payload not important for this test
+ TokenBindingCertificate = cert
+ });
+ };
+
+ // ─────────── Build the app ───────────
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithClientAssertion(popDelegate)
+ .WithAuthority($"https://login.microsoftonline.com/123456-1234-2345-1234561234")
+ .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery)
+ .WithHttpManager(httpManager)
+ .BuildConcrete();
+
+ // ─────────── First acquire – network call, caches token‑A bound to certA ───────────
+ AuthenticationResult first = await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .WithMtlsProofOfPossession()
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(TokenSource.IdentityProvider, first.AuthenticationResultMetadata.TokenSource);
+ Assert.AreEqual(certA.Thumbprint, first.BindingCertificate.Thumbprint);
+
+ // ─────────── Second acquire – delegate now returns certB ───────────
+ AuthenticationResult second = await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .WithMtlsProofOfPossession()
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // The serial number mismatch should have forced a network call, not a cache hit
+ Assert.AreEqual(TokenSource.IdentityProvider, second.AuthenticationResultMetadata.TokenSource);
+ Assert.AreEqual(certB.Thumbprint, second.BindingCertificate.Thumbprint);
+ }
+ }
+
+ [TestMethod]
+ public async Task WithMtlsPop_AfterBearerDelegate_Throws()
+ {
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithClientAssertion(BearerDelegate())
+ .BuildConcrete();
+
+ var ex = await AssertException.TaskThrowsAsync(() =>
+ cca.AcquireTokenForClient(TestConstants.s_scope)
+ .WithMtlsProofOfPossession()
+ .ExecuteAsync())
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(MsalError.MtlsCertificateNotProvided, ex.ErrorCode);
+ }
+
+ [TestMethod]
+ public async Task ClientAssertion_NotCalledWhenTokenFromCacheAsync()
+ {
+ using var http = new MockHttpManager();
+ http.AddInstanceDiscoveryMockHandler();
+
+ int callCount = 0;
+ http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); // first call => network
+
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(http)
+ .WithClientAssertion((o, c) =>
+ {
+ callCount++;
+ return Task.FromResult(new ClientAssertion { Assertion = "jwt" });
+ })
+ .BuildConcrete();
+
+ _ = await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(1, callCount);
+
+ _ = await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(1, callCount);
+ }
+
+ [TestMethod]
+ public async Task WithMtlsPop_AfterPoPDelegate_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)
+ .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)
+ .WithMtlsProofOfPossession()
+ .ExecuteAsync()
+ .ConfigureAwait(false))
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(MsalError.MtlsPopWithoutRegion, ex.ErrorCode);
+ }
+ }
+
+ #region Helper ---------------------------------------------------------------
+ private static Func>
+ BearerDelegate(string jwt = "fake_jwt") =>
+ (opts, ct) => Task.FromResult(new ClientAssertion
+ {
+ Assertion = jwt,
+ TokenBindingCertificate = null
+ });
+
+ private static Func>
+ PopDelegate(string jwt = "fake_jwt") =>
+ (opts, ct) =>
+ {
+ // Obtain (or generate) the test certificate once per call
+ X509Certificate2 cert = CertHelper.GetOrCreateTestCert();
+
+ return Task.FromResult(new ClientAssertion
+ {
+ Assertion = jwt,
+ TokenBindingCertificate = cert
+ });
+ };
+#endregion
}
}
diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs
index 2666f67bd2..a2dfc5586d 100644
--- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs
+++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs
@@ -832,32 +832,26 @@ public async Task SignedAssertionWithClientCapabilitiesTestAsync()
[TestMethod]
public async Task ConfidentialClientUsingSignedClientAssertion_AsyncDelegate_CancellationTestAsync()
{
- using (var httpManager = new MockHttpManager())
- {
- httpManager.AddInstanceDiscoveryMockHandler();
-
- CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
+ CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
- var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
- .WithHttpManager(httpManager)
- .WithClientAssertion(
- async ct =>
- {
- // make sure that the cancellation token given to AcquireToken method
- // is propagated to here
- cancellationTokenSource.Cancel();
- ct.ThrowIfCancellationRequested();
- return await Task.FromResult(TestConstants.DefaultClientAssertion)
- .ConfigureAwait(false);
- });
+ var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientAssertion(
+ async ct =>
+ {
+ // make sure that the cancellation token given to AcquireToken method
+ // is propagated to here
+ cancellationTokenSource.Cancel();
+ ct.ThrowIfCancellationRequested();
+ return await Task.FromResult(TestConstants.DefaultClientAssertion)
+ .ConfigureAwait(false);
+ });
- var app = builder.BuildConcrete();
- Assert.IsNull(app.Certificate);
+ var app = builder.BuildConcrete();
+ Assert.IsNull(app.Certificate);
- await AssertException.TaskThrowsAsync(
- () => app.AcquireTokenForClient(TestConstants.s_scope.ToArray())
- .ExecuteAsync(cancellationTokenSource.Token)).ConfigureAwait(false);
- }
+ await AssertException.TaskThrowsAsync(
+ () => app.AcquireTokenForClient(TestConstants.s_scope.ToArray())
+ .ExecuteAsync(cancellationTokenSource.Token)).ConfigureAwait(false);
}
[TestMethod]
@@ -901,27 +895,58 @@ public async Task SignedAssertionWithSingleClientCapabilityTestAsync()
[TestMethod]
public void Constructor_NullDelegate_ThrowsArgumentNullException()
{
- // Arrange
- Func> nullDelegate = null;
+ // Arrange
+ Func> nullDelegate = null;
- // Act & Assert
+ // Act & Assert
Assert.ThrowsException(() =>
- new SignedAssertionDelegateClientCredential(nullDelegate));
+ new ClientAssertionDelegateCredential(nullDelegate));
}
- [TestMethod]
- public void Constructor_ValidDelegate_DoesNotThrow()
+ [DataTestMethod]
+ [DataRow(false)] // bearer (no cert)
+ [DataRow(true)] // PoP (with cert)
+ public void Constructor_ValidDelegate_DoesNotThrow(bool withCert)
{
// Arrange
- Func> validDelegate =
- (options) => Task.FromResult("fake_assertion");
+ X509Certificate2 cert = withCert ? CertHelper.GetOrCreateTestCert() : null;
- // Act & Assert
- // Should not throw
- var credential = new SignedAssertionDelegateClientCredential(validDelegate);
+ Func> validDelegate =
+ (options, ct) => Task.FromResult(new ClientAssertion
+ {
+ Assertion = "fake_assertion",
+ TokenBindingCertificate = cert
+ });
+
+ // Act
+ var credential = new ClientAssertionDelegateCredential(validDelegate);
+
+ // Assert
Assert.IsNotNull(credential);
}
+ [TestMethod]
+ public async Task AcquireTokenForClient_EmptyAssertion_ThrowsArgumentExceptionAsync()
+ {
+ // Build a CCA whose assertion‑delegate returns NO JWT (error case)
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithClientAssertion(
+ (opts, ct) => Task.FromResult(new ClientAssertion
+ {
+ Assertion = string.Empty, // <-- invalid: must be non‑empty
+ TokenBindingCertificate = null // no cert => jwt-bearer
+ }))
+ .BuildConcrete();
+
+ // Act & Assert – the first token request will execute the delegate
+ // and should surface ArgumentException from the credential layer.
+ await AssertException.TaskThrowsAsync(() =>
+ cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync())
+ .ConfigureAwait(false);
+ }
+
[TestMethod]
public async Task GetAuthorizationRequestUrlNoRedirectUriTestAsync()
{
diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs
index 50543fef1d..23e9375cf2 100644
--- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs
+++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs
@@ -63,8 +63,8 @@ public async Task MtlsPop_AadAuthorityWithoutCertificateAsync()
.ExecuteAsync())
.ConfigureAwait(false);
- Assert.AreEqual(MsalError.MtlsCertificateNotProvided, ex.ErrorCode);
- Assert.AreEqual(MsalErrorMessage.MtlsCertificateNotProvidedMessage, ex.Message);
+ Assert.AreEqual(MsalError.ClientCredentialAuthenticationTypeMustBeDefined, ex.ErrorCode);
+ Assert.AreEqual(MsalErrorMessage.ClientCredentialAuthenticationTypeMustBeDefined, ex.Message);
}
[TestMethod]
@@ -328,6 +328,74 @@ public async Task AcquireMtlsPopTokenForClientWithTenantId_SuccessAsync()
}
}
+ [TestMethod]
+ public async Task AcquireMtlsPopTokenForClientWithTenantIdCertChecks_Async()
+ {
+ const string region = "eastus";
+
+ // ─────────── Two distinct certificates ───────────
+ var certA = CertHelper.GetOrCreateTestCert();
+ var certB = CertHelper.GetOrCreateTestCert(regenerateCert: true);
+
+ 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}/123456-1234-2345-1234561234/oauth2/v2.0/token";
+
+ using (var httpManager = new MockHttpManager())
+ {
+ // Set up mock handler with expected token endpoint URL
+ httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(
+ tokenType: "mtls_pop");
+
+ var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithCertificate(certA)
+ .WithTenantId("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.AreEqual(certA.Thumbprint, result.BindingCertificate.Thumbprint);
+
+ app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithCertificate(certB)
+ .WithTenantId("123456-1234-2345-1234561234")
+ .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery)
+ .WithHttpManager(httpManager)
+ .BuildConcrete();
+
+ // Set up mock handler with expected token endpoint URL
+ httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(
+ tokenType: "mtls_pop");
+
+ // Second token acquisition - should also be from IDP because we have a new cert
+ 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.IdentityProvider, secondResult.AuthenticationResultMetadata.TokenSource);
+ Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint);
+ Assert.AreEqual(certB.Thumbprint, secondResult.BindingCertificate.Thumbprint);
+ }
+ }
+ }
+
[TestMethod]
public async Task MtlsPop_KnownRegionAsync()
{