diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs
index 25d64952be..fcb67f34c6 100644
--- a/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs
+++ b/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs
@@ -48,7 +48,8 @@ protected override void Validate()
// Confidential client must have a credential
if (ServiceBundle?.Config.ClientCredential == null &&
CommonParameters.OnBeforeTokenRequestHandler == null &&
- ServiceBundle?.Config.AppTokenProvider == null
+ ServiceBundle?.Config.AppTokenProvider == null &&
+ ServiceBundle?.Config.ClientCredentialCertificateProvider == null
)
{
throw new MsalClientException(
diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs
index 4e22a2855c..7371f02a7d 100644
--- a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs
+++ b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs
@@ -131,6 +131,26 @@ public string ClientVersion
internal IRetryPolicyFactory RetryPolicyFactory { get; set; }
internal ICsrFactory CsrFactory { get; set; }
+ #region Extensibility Callbacks
+
+ ///
+ /// Dynamic certificate provider callback for client credential flows.
+ ///
+ public Func> ClientCredentialCertificateProvider { get; set; }
+
+ ///
+ /// MSAL service failure callback that determines whether to retry after a token acquisition failure from the identity provider.
+ /// Only invoked for MsalServiceException (errors from the Security Token Service).
+ ///
+ public Func> OnMsalServiceFailureCallback { get; set; }
+
+ ///
+ /// Success callback that receives the result of token acquisition attempts (typically successful, but can include failures after retries are exhausted).
+ ///
+ public Func OnSuccessCallback { get; set; }
+
+ #endregion
+
#region ClientCredentials
// Indicates if claims or assertions are used within the configuration
diff --git a/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs b/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs
index 245db3fada..41ee6bcf63 100644
--- a/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs
+++ b/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs
@@ -6,15 +6,34 @@
namespace Microsoft.Identity.Client
{
- ///
- /// Information about the client assertion that need to be generated See https://aka.ms/msal-net-client-assertion
- ///
- /// Use the provided information to generate the client assertion payload
+ ///
+ /// Information about the client assertion that need to be generated See https://aka.ms/msal-net-client-assertion
+ ///
+ /// Use the provided information to generate the client assertion payload
#if !SUPPORTS_CONFIDENTIAL_CLIENT
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile
#endif
public class AssertionRequestOptions {
///
+ /// Default constructor for AssertionRequestOptions
+ ///
+ public AssertionRequestOptions()
+ {
+ }
+
+ ///
+ /// Internal constructor that creates AssertionRequestOptions from ApplicationConfiguration
+ ///
+ /// The application configuration
+ internal AssertionRequestOptions(ApplicationConfiguration appConfig)
+ {
+ ClientID = appConfig.ClientId;
+ TenantId = appConfig.Authority?.TenantId;
+ Authority = appConfig.Authority?.AuthorityInfo?.CanonicalAuthority?.ToString();
+ }
+
+ ///
+ /// Cancellation token to cancel the operation
///
public CancellationToken CancellationToken { get; set; }
@@ -23,6 +42,16 @@ public class AssertionRequestOptions {
///
public string ClientID { get; set; }
+ ///
+ /// Tenant ID for the authentication request
+ ///
+ public string TenantId { get; set; }
+
+ ///
+ /// The authority URL (e.g., https://login.microsoftonline.com/{tenantId})
+ ///
+ public string Authority { get; set; }
+
///
/// The intended token endpoint
///
diff --git a/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs
index 7328b69ad3..8b7e1f90ec 100644
--- a/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs
+++ b/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs
@@ -2,7 +2,9 @@
// Licensed under the MIT License.
using System;
+using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
+using Microsoft.Identity.Client.Internal.ClientCredential;
namespace Microsoft.Identity.Client.Extensibility
{
@@ -29,5 +31,158 @@ public static ConfidentialClientApplicationBuilder WithAppTokenProvider(
builder.Config.AppTokenProvider = appTokenProvider ?? throw new ArgumentNullException(nameof(appTokenProvider));
return builder;
}
+
+ ///
+ /// Configures an async callback to provide the client credential certificate dynamically.
+ /// The callback is invoked before each token acquisition request to the identity provider (including retries).
+ /// This enables scenarios such as certificate rotation and dynamic certificate selection based on application context.
+ ///
+ /// The confidential client application builder.
+ ///
+ /// An async callback that provides the certificate based on the application configuration.
+ /// Called before each network request to acquire a token.
+ /// Must return a valid with a private key.
+ ///
+ /// The builder to chain additional configuration calls.
+ /// Thrown when is null.
+ ///
+ /// Thrown at build time if both
+ /// and this method are configured.
+ ///
+ ///
+ /// This method cannot be used together with .
+ /// The callback is not invoked when tokens are retrieved from cache, only for network calls.
+ /// The certificate returned by the callback will be used to sign the client assertion (JWT) for that token request.
+ /// The callback can perform async operations such as fetching certificates from Azure Key Vault or other secret management systems.
+ /// See https://aka.ms/msal-net-client-credentials for more details on client credentials.
+ ///
+ public static ConfidentialClientApplicationBuilder WithCertificate(
+ this ConfidentialClientApplicationBuilder builder,
+ Func> certificateProvider)
+ {
+ if (certificateProvider == null)
+ {
+ throw new ArgumentNullException(nameof(certificateProvider));
+ }
+
+ builder.Config.ClientCredentialCertificateProvider = certificateProvider;
+
+ // Create a CertificateAndClaimsClientCredential with null certificate
+ // The certificate will be resolved dynamically via the provider in ResolveCertificateAsync
+ builder.Config.ClientCredential = new Microsoft.Identity.Client.Internal.ClientCredential.CertificateAndClaimsClientCredential(
+ certificate: null,
+ claimsToSign: null,
+ appendDefaultClaims: true);
+
+ return builder;
+ }
+
+ ///
+ /// Configures an async callback that is invoked when MSAL receives an error response from the identity provider (Security Token Service).
+ /// The callback determines whether MSAL should retry the token request or propagate the exception.
+ /// This callback is invoked after each service failure and can be called multiple times until it returns false or the request succeeds.
+ ///
+ /// The confidential client application builder.
+ ///
+ /// An async callback that determines whether to retry after a service failure.
+ /// Receives the assertion request options and the that occurred.
+ /// Returns true to retry the request, or false to stop retrying and propagate the exception.
+ /// The callback will be invoked repeatedly after each service failure until it returns false or the request succeeds.
+ ///
+ /// The builder to chain additional configuration calls.
+ /// Thrown when is null.
+ ///
+ /// This callback is ONLY triggered for - errors returned by the identity provider (e.g., HTTP 500, 503, throttling).
+ /// This callback is NOT triggered for client-side errors () or network failures handled internally by MSAL.
+ /// This callback is only invoked for network token acquisition attempts, not when tokens are retrieved from cache.
+ /// When the callback returns true, MSAL will invoke the certificate provider (if configured via )
+ /// before making another token request, enabling certificate rotation scenarios.
+ /// MSAL's internal throttling and retry mechanisms will still apply, including respecting Retry-After headers from the identity provider.
+ /// To prevent infinite loops, ensure your callback has appropriate termination conditions (e.g., max retry count, timeout).
+ /// The callback can perform async operations such as logging to remote services, checking external health endpoints, or querying configuration stores.
+ ///
+ ///
+ ///
+ /// int retryCount = 0;
+ /// var app = ConfidentialClientApplicationBuilder
+ /// .Create(clientId)
+ /// .WithCertificate(async options => await GetCertificateFromKeyVaultAsync(options.TokenEndpoint))
+ /// .OnMsalServiceFailure(async (options, serviceException) =>
+ /// {
+ /// retryCount++;
+ /// await LogExceptionAsync(serviceException);
+ ///
+ /// // Retry up to 3 times for transient service errors (5xx)
+ /// return serviceException.StatusCode >= 500 && retryCount < 3;
+ /// })
+ /// .Build();
+ ///
+ ///
+ public static ConfidentialClientApplicationBuilder OnMsalServiceFailure(
+ this ConfidentialClientApplicationBuilder builder,
+ Func> onMsalServiceFailureCallback)
+ {
+ if (onMsalServiceFailureCallback == null)
+ throw new ArgumentNullException(nameof(onMsalServiceFailureCallback));
+
+ builder.Config.OnMsalServiceFailureCallback = onMsalServiceFailureCallback;
+ return builder;
+ }
+
+ ///
+ /// Configures an async callback that is invoked when a token acquisition request completes.
+ /// This callback is invoked once per AcquireTokenForClient call, after all retry attempts have been exhausted.
+ /// While named OnSuccess for the common case, this callback fires for both successful and failed acquisitions.
+ /// This enables scenarios such as telemetry, logging, and custom result handling.
+ ///
+ /// The confidential client application builder.
+ ///
+ /// An async callback that receives the assertion request options and the execution result.
+ /// The result contains either the successful or the that occurred.
+ /// This callback is invoked after all retries have been exhausted (if an handler is configured).
+ ///
+ /// The builder to chain additional configuration calls.
+ /// Thrown when is null.
+ ///
+ /// This callback is invoked for both successful and failed token acquisitions. Check to determine the outcome.
+ /// This callback is only invoked for network token acquisition attempts, not when tokens are retrieved from cache.
+ /// If multiple calls to OnSuccess are made, only the last configured callback will be used.
+ /// Exceptions thrown by this callback will be caught and logged internally to prevent disruption of the authentication flow.
+ /// The callback is invoked on the same thread/context as the token acquisition request.
+ /// The callback can perform async operations such as sending telemetry to Application Insights, persisting logs to databases, or triggering webhooks.
+ ///
+ ///
+ ///
+ /// var app = ConfidentialClientApplicationBuilder
+ /// .Create(clientId)
+ /// .WithCertificate(certificate)
+ /// .OnSuccess(async (options, result) =>
+ /// {
+ /// if (result.Successful)
+ /// {
+ /// await telemetry.TrackEventAsync("TokenAcquired", new { ClientId = options.ClientID });
+ /// }
+ /// else
+ /// {
+ /// await telemetry.TrackExceptionAsync(result.Exception);
+ /// }
+ /// })
+ /// .Build();
+ ///
+ ///
+ public static ConfidentialClientApplicationBuilder OnSuccess(
+ this ConfidentialClientApplicationBuilder builder,
+ Func onSuccessCallback)
+ {
+ builder.ValidateUseOfExperimentalFeature();
+
+ if (onSuccessCallback == null)
+ {
+ throw new ArgumentNullException(nameof(onSuccessCallback));
+ }
+
+ builder.Config.OnSuccessCallback = onSuccessCallback;
+ return builder;
+ }
}
}
diff --git a/src/client/Microsoft.Identity.Client/Extensibility/ExecutionResult.cs b/src/client/Microsoft.Identity.Client/Extensibility/ExecutionResult.cs
new file mode 100644
index 0000000000..f6660206b2
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/Extensibility/ExecutionResult.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+namespace Microsoft.Identity.Client.Extensibility
+{
+ ///
+ /// Represents the result of a token acquisition attempt.
+ /// Used by the execution observer configured via .
+ ///
+ public class ExecutionResult
+ {
+ ///
+ /// Internal constructor for ExecutionResult.
+ ///
+ internal ExecutionResult() { }
+
+ ///
+ /// Indicates whether the token acquisition was successful.
+ ///
+ ///
+ /// true if the token was successfully acquired; otherwise, false.
+ ///
+ public bool Successful { get; internal set; }
+
+ ///
+ /// The authentication result if the token acquisition was successful.
+ ///
+ ///
+ /// An containing the access token and related metadata if is true;
+ /// otherwise, null.
+ ///
+ public AuthenticationResult Result { get; internal set; }
+
+ ///
+ /// The exception that occurred if the token acquisition failed.
+ ///
+ ///
+ /// An describing the failure if is false;
+ /// otherwise, null.
+ ///
+ public MsalException Exception { get; internal set; }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs
index 035571f7f2..c2446b2a33 100644
--- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs
@@ -1,10 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-using System;
using System.Collections.Generic;
-using System.Runtime.ConstrainedExecution;
-using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
@@ -35,10 +32,15 @@ public CertificateAndClaimsClientCredential(
Certificate = certificate;
_claimsToSign = claimsToSign;
_appendDefaultClaims = appendDefaultClaims;
- _base64EncodedThumbprint = Base64UrlHelpers.Encode(certificate.GetCertHash());
+
+ // Certificate can be null when using dynamic certificate provider
+ if (certificate != null)
+ {
+ _base64EncodedThumbprint = Base64UrlHelpers.Encode(certificate.GetCertHash());
+ }
}
- public Task AddConfidentialClientParametersAsync(
+ public async Task AddConfidentialClientParametersAsync(
OAuth2Client oAuth2Client,
AuthenticationRequestParameters requestParameters,
ICryptographyManager cryptographyManager,
@@ -54,6 +56,9 @@ public Task AddConfidentialClientParametersAsync(
{
requestParameters.RequestContext.Logger.Verbose(() => "Proceeding with JWT token creation and adding client assertion.");
+ // Resolve the certificate - either from static config or dynamic provider
+ X509Certificate2 effectiveCertificate = await ResolveCertificateAsync(requestParameters, cancellationToken).ConfigureAwait(false);
+
bool useSha2 = requestParameters.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported;
var jwtToken = new JsonWebToken(
@@ -63,7 +68,7 @@ public Task AddConfidentialClientParametersAsync(
_claimsToSign,
_appendDefaultClaims);
- string assertion = jwtToken.Sign(Certificate, requestParameters.SendX5C, useSha2);
+ string assertion = jwtToken.Sign(effectiveCertificate, requestParameters.SendX5C, useSha2);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion);
@@ -73,8 +78,82 @@ public Task AddConfidentialClientParametersAsync(
// Log that MTLS PoP is required and JWT token creation is skipped
requestParameters.RequestContext.Logger.Verbose(() => "MTLS PoP Client credential request. Skipping client assertion.");
}
+ }
+
+ ///
+ /// Resolves the certificate to use for signing the client assertion.
+ /// If a dynamic certificate provider is configured, it will be invoked to get the certificate.
+ /// Otherwise, the static certificate configured at build time is used.
+ ///
+ /// The authentication request parameters containing app config
+ /// Cancellation token for the async operation
+ /// The X509Certificate2 to use for signing
+ /// Thrown if the certificate provider returns null or an invalid certificate
+ private async Task ResolveCertificateAsync(
+ AuthenticationRequestParameters requestParameters,
+ CancellationToken cancellationToken)
+ {
+ // Check if dynamic certificate provider is configured
+ if (requestParameters.AppConfig.ClientCredentialCertificateProvider != null)
+ {
+ requestParameters.RequestContext.Logger.Verbose(
+ () => "[CertificateAndClaimsClientCredential] Resolving certificate from dynamic provider.");
+
+ // Create AssertionRequestOptions for the callback
+ var options = new AssertionRequestOptions((ApplicationConfiguration)requestParameters.AppConfig)
+ {
+ CancellationToken = cancellationToken
+ };
+
+ // Invoke the provider to get the certificate
+ X509Certificate2 providedCertificate = await requestParameters.AppConfig
+ .ClientCredentialCertificateProvider(options)
+ .ConfigureAwait(false);
+
+ // Validate the certificate returned by the provider
+ if (providedCertificate == null)
+ {
+ 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.");
+ }
+
+ if (!providedCertificate.HasPrivateKey)
+ {
+ requestParameters.RequestContext.Logger.Error(
+ "[CertificateAndClaimsClientCredential] Certificate from provider does not have a private key.");
+
+ throw new MsalClientException(
+ MsalError.CertWithoutPrivateKey,
+ "The certificate returned by the provider does not have a private key. " +
+ "Ensure the certificate has a private key for signing operations.");
+ }
+
+ requestParameters.RequestContext.Logger.Info(
+ () => $"[CertificateAndClaimsClientCredential] Successfully resolved certificate from provider. " +
+ $"Thumbprint: {providedCertificate.Thumbprint}");
+
+ return providedCertificate;
+ }
+
+ // Use the static certificate configured at build time
+ if (Certificate == null)
+ {
+ requestParameters.RequestContext.Logger.Error(
+ "[CertificateAndClaimsClientCredential] No certificate configured (static or dynamic).");
+
+ throw new MsalClientException(
+ MsalError.InvalidClientAssertion,
+ "No certificate is configured. Use WithCertificate() to provide a certificate.");
+ }
+
+ requestParameters.RequestContext.Logger.Verbose(
+ () => $"[CertificateAndClaimsClientCredential] Using static certificate. Thumbprint: {Certificate.Thumbprint}");
- return Task.CompletedTask;
+ return Certificate;
}
}
}
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
index e7c08f0fc8..c583743f3a 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
@@ -3,10 +3,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;
using System.Threading.Tasks;
using Microsoft.Identity.Client.ApiConfig.Parameters;
@@ -14,8 +11,6 @@
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;
@@ -127,14 +122,148 @@ private async Task GetAccessTokenAsync(
{
await ResolveAuthorityAsync().ConfigureAwait(false);
- // Get a token from AAD
- if (ServiceBundle.Config.AppTokenProvider == null)
+ AuthenticationResult authResult = null;
+
+ // Retry loop using the retry callback if configured
+ while (true)
{
- MsalTokenResponse msalTokenResponse = await SendTokenRequestAsync(GetBodyParameters(), cancellationToken).ConfigureAwait(false);
- return await CacheTokenResponseAndCreateAuthenticationResultAsync(msalTokenResponse).ConfigureAwait(false);
+ try
+ {
+ // Get a token from AAD
+ if (ServiceBundle.Config.AppTokenProvider == null)
+ {
+ logger.Verbose(() => "[ClientCredentialRequest] Sending token request to AAD.");
+ MsalTokenResponse msalTokenResponse = await SendTokenRequestAsync(
+ GetBodyParameters(),
+ cancellationToken).ConfigureAwait(false);
+
+ authResult = await CacheTokenResponseAndCreateAuthenticationResultAsync(msalTokenResponse)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ // Get a token from the app provider delegate
+ authResult = await GetAccessTokenFromAppProviderAsync(cancellationToken, logger)
+ .ConfigureAwait(false);
+ }
+
+ // Success - invoke OnSuccess callback if configured
+ await InvokeOnSuccessCallbackAsync(authResult, exception: null, logger).ConfigureAwait(false);
+
+ return authResult;
+ }
+ catch (MsalServiceException serviceEx)
+ {
+ // Check if OnMsalServiceFailureCallback is configured
+ if (AuthenticationRequestParameters.AppConfig.OnMsalServiceFailureCallback != null)
+ {
+ logger.Info("[ClientCredentialRequest] MsalServiceException caught. Invoking OnMsalServiceFailureCallback.");
+
+ bool shouldRetry = await InvokeOnMsalServiceFailureCallbackAsync(serviceEx, logger)
+ .ConfigureAwait(false);
+
+ if (shouldRetry)
+ {
+ logger.Info("[ClientCredentialRequest] OnMsalServiceFailureCallback returned true. Retrying token request.");
+ continue; // Retry the loop
+ }
+
+ logger.Info("[ClientCredentialRequest] OnMsalServiceFailureCallback returned false. Propagating exception.");
+ }
+
+ // Invoke OnSuccess callback with failure result
+ await InvokeOnSuccessCallbackAsync(authResult: null, exception: serviceEx, logger).ConfigureAwait(false);
+
+ // Re-throw if no callback or callback returned false
+ throw;
+ }
+ catch (MsalException ex)
+ {
+ // For non-service exceptions (MsalClientException, etc.), invoke OnSuccess and re-throw
+ await InvokeOnSuccessCallbackAsync(authResult: null, exception: ex, logger).ConfigureAwait(false);
+ throw;
+ }
}
+ }
- // Get a token from the app provider delegate
+ ///
+ /// Invokes the OnMsalServiceFailureCallback if configured.
+ /// Returns true if the request should be retried, false otherwise.
+ ///
+ private async Task InvokeOnMsalServiceFailureCallbackAsync(
+ MsalServiceException serviceException,
+ ILoggerAdapter logger)
+ {
+ try
+ {
+ var options = new AssertionRequestOptions(AuthenticationRequestParameters.AppConfig);
+
+ bool shouldRetry = await AuthenticationRequestParameters.AppConfig
+ .OnMsalServiceFailureCallback(options, serviceException)
+ .ConfigureAwait(false);
+
+ logger.Verbose(() => $"[ClientCredentialRequest] OnMsalServiceFailureCallback returned: {shouldRetry}");
+ return shouldRetry;
+ }
+ catch (Exception ex)
+ {
+ // If the callback throws, log and don't retry
+ logger.Error($"[ClientCredentialRequest] OnMsalServiceFailureCallback threw an exception: {ex.Message}");
+ logger.ErrorPii(ex);
+ return false;
+ }
+ }
+
+ ///
+ /// Invokes the OnSuccessCallback if configured.
+ /// Exceptions from the callback are caught and logged to prevent disrupting the authentication flow.
+ ///
+ private async Task InvokeOnSuccessCallbackAsync(
+ AuthenticationResult authResult,
+ MsalException exception,
+ ILoggerAdapter logger)
+ {
+ if (AuthenticationRequestParameters.AppConfig.OnSuccessCallback == null)
+ {
+ return;
+ }
+
+ try
+ {
+ logger.Verbose(() => "[ClientCredentialRequest] Invoking OnSuccess callback.");
+
+ var options = new AssertionRequestOptions(AuthenticationRequestParameters.AppConfig);
+
+ var executionResult = new ExecutionResult
+ {
+ Successful = authResult != null,
+ Result = authResult,
+ Exception = exception
+ };
+
+ await AuthenticationRequestParameters.AppConfig
+ .OnSuccessCallback(options, executionResult)
+ .ConfigureAwait(false);
+
+ logger.Verbose(() => "[ClientCredentialRequest] OnSuccess callback completed successfully.");
+ }
+ catch (Exception ex)
+ {
+ // Catch and log any exceptions from the observer callback
+ // Do not propagate - observer should not disrupt authentication flow
+ logger.Error($"[ClientCredentialRequest] OnSuccess callback threw an exception: {ex.Message}");
+ logger.ErrorPii(ex);
+ }
+ }
+
+ ///
+ /// Gets an access token from the app token provider.
+ /// Uses semaphore to prevent concurrent calls to the external provider.
+ ///
+ private async Task GetAccessTokenFromAppProviderAsync(
+ CancellationToken cancellationToken,
+ ILoggerAdapter logger)
+ {
AuthenticationResult authResult;
MsalAccessTokenCacheItem cachedAccessTokenItem;
@@ -301,15 +430,15 @@ private void MarkAccessTokenAsCacheHit()
private AuthenticationResult CreateAuthenticationResultFromCache(MsalAccessTokenCacheItem cachedAccessTokenItem)
{
AuthenticationResult authResult = new AuthenticationResult(
- cachedAccessTokenItem,
- null,
- AuthenticationRequestParameters.AuthenticationScheme,
- AuthenticationRequestParameters.RequestContext.CorrelationId,
- TokenSource.Cache,
- AuthenticationRequestParameters.RequestContext.ApiEvent,
- account: null,
- spaAuthCode: null,
- additionalResponseParameters: null);
+ cachedAccessTokenItem,
+ null,
+ AuthenticationRequestParameters.AuthenticationScheme,
+ AuthenticationRequestParameters.RequestContext.CorrelationId,
+ TokenSource.Cache,
+ AuthenticationRequestParameters.RequestContext.ApiEvent,
+ account: null,
+ spaAuthCode: null,
+ additionalResponseParameters: null);
return authResult;
}
diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs
index 52ef40dbad..f3935b5152 100644
--- a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs
+++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs
@@ -312,7 +312,7 @@ private void HandleException(Exception ex,
_requestContext.Logger.Error($"[Managed Identity] Format Exception: {errorMessage}");
CreateAndThrowException(MsalError.InvalidManagedIdentityEndpoint, errorMessage, formatException, source);
}
- else if (ex is not MsalServiceException or TaskCanceledException)
+ else if (ex is not MsalServiceException)
{
_requestContext.Logger.Error($"[Managed Identity] Exception: {ex.Message}");
CreateAndThrowException(MsalError.ManagedIdentityRequestFailed, ex.Message, ex, source);
diff --git a/src/client/Microsoft.Identity.Client/MsalError.cs b/src/client/Microsoft.Identity.Client/MsalError.cs
index 526718e7df..90d12e9116 100644
--- a/src/client/Microsoft.Identity.Client/MsalError.cs
+++ b/src/client/Microsoft.Identity.Client/MsalError.cs
@@ -702,6 +702,12 @@ public static class MsalError
///
public const string ClientCredentialAuthenticationTypeMustBeDefined = "Client_Credentials_Required_In_Confidential_Client_Application";
+ ///
+ /// What happens?You configured both a static certificate (WithCertificate(X509Certificate2)) and a dynamic certificate provider (WithCertificate(Func)).
+ /// MitigationChoose one approach for providing the client certificate.
+ ///
+ public const string InvalidClientCredentialConfiguration = "invalid_client_credential_configuration";
+
#region InvalidGrant suberrors
///
/// Issue can be resolved by user interaction during the interactive authentication flow.
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 5f282702bb..0028dea8d0 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,12 @@
-
\ No newline at end of file
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void
+const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string
+Microsoft.Identity.Client.Extensibility.ExecutionResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
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 5f282702bb..0028dea8d0 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,12 @@
-
\ No newline at end of file
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void
+const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string
+Microsoft.Identity.Client.Extensibility.ExecutionResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
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 5f282702bb..487820e412 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,12 @@
-
\ No newline at end of file
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void
+const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string
+Microsoft.Identity.Client.Extensibility.ExecutionResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
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 5f282702bb..487820e412 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,12 @@
-
\ No newline at end of file
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void
+const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string
+Microsoft.Identity.Client.Extensibility.ExecutionResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
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 5f282702bb..0028dea8d0 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,12 @@
-
\ No newline at end of file
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void
+const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string
+Microsoft.Identity.Client.Extensibility.ExecutionResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
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 5f282702bb..0028dea8d0 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,12 @@
-
\ No newline at end of file
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string
+Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void
+const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string
+Microsoft.Identity.Client.Extensibility.ExecutionResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult
+Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
diff --git a/tests/Microsoft.Identity.Test.Unit/AppConfigTests/ConfidentialClientApplicationExtensibilityApiTests.cs b/tests/Microsoft.Identity.Test.Unit/AppConfigTests/ConfidentialClientApplicationExtensibilityApiTests.cs
new file mode 100644
index 0000000000..a8ff9440d9
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/AppConfigTests/ConfidentialClientApplicationExtensibilityApiTests.cs
@@ -0,0 +1,353 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client;
+using Microsoft.Identity.Client.Extensibility;
+using Microsoft.Identity.Test.Common;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Test.Unit.AppConfigTests
+{
+ [TestClass]
+ [TestCategory(TestCategories.BuilderTests)]
+ public class ConfidentialClientApplicationExtensibilityApiTests
+ {
+ private X509Certificate2 _certificate;
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ ApplicationBase.ResetStateForTest();
+ }
+
+ [TestCleanup]
+ public void TestCleanup()
+ {
+ _certificate?.Dispose();
+ }
+
+ #region WithCertificate Tests
+
+ [TestMethod]
+ public void WithCertificate_CallbackIsStored()
+ {
+ // Arrange
+ bool callbackInvoked = false;
+ Task certificateProvider(AssertionRequestOptions options)
+ {
+ callbackInvoked = true;
+ return Task.FromResult(GetTestCertificate());
+ }
+
+ // Act
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithCertificate(certificateProvider)
+ .BuildConcrete();
+
+ // Assert
+ Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.ClientCredentialCertificateProvider);
+ Assert.IsFalse(callbackInvoked, "Certificate provider callback is not yet invoked.");
+ }
+
+ [TestMethod]
+ public void WithCertificate_ThrowsOnNullCallback()
+ {
+ // Act & Assert
+ var ex = Assert.ThrowsException(() =>
+ ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithCertificate((Func>)null)
+ .Build());
+
+ Assert.AreEqual("certificateProvider", ex.ParamName);
+ }
+
+ [TestMethod]
+ public void WithCertificate_AllowsMultipleCallbackRegistrations_LastOneWins()
+ {
+ // Arrange
+ int firstCallbackInvoked = 0;
+ int secondCallbackInvoked = 0;
+
+ Task firstProvider(AssertionRequestOptions options)
+ {
+ firstCallbackInvoked++;
+ return Task.FromResult(GetTestCertificate());
+ }
+
+ Task secondProvider(AssertionRequestOptions options)
+ {
+ secondCallbackInvoked++;
+ return Task.FromResult(GetTestCertificate());
+ }
+
+ // Act
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithCertificate(firstProvider)
+ .WithCertificate(secondProvider)
+ .BuildConcrete();
+
+ // Assert - last one should be stored
+ var config = app.AppConfig as ApplicationConfiguration;
+ Assert.IsNotNull(config);
+ Assert.IsNotNull(config.ClientCredentialCertificateProvider);
+ Assert.AreNotSame(firstProvider, config.ClientCredentialCertificateProvider);
+ }
+
+ #endregion
+
+ #region OnMsalServiceFailure Tests
+
+ [TestMethod]
+ public void OnMsalServiceFailure_CallbackIsStored()
+ {
+ // Arrange
+ Task onMsalServiceFailureCallback(AssertionRequestOptions options, MsalException ex) => Task.FromResult(false);
+
+ // Act
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithClientSecret(TestConstants.ClientSecret)
+ .OnMsalServiceFailure(onMsalServiceFailureCallback)
+ .BuildConcrete();
+
+ // Assert
+ Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.OnMsalServiceFailureCallback);
+ }
+
+ [TestMethod]
+ public void OnMsalServiceFailure_ThrowsOnNullCallback()
+ {
+ // Act & Assert
+ var ex = Assert.ThrowsException(() =>
+ ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithClientSecret(TestConstants.ClientSecret)
+ .OnMsalServiceFailure(null)
+ .Build());
+
+ Assert.AreEqual("onMsalServiceFailureCallback", ex.ParamName);
+ }
+
+ #endregion
+
+ #region OnSuccess Tests
+
+ [TestMethod]
+ public void OnSuccess_CallbackIsStored()
+ {
+ // Arrange
+ Task onSuccessCallback(AssertionRequestOptions options, ExecutionResult result) => Task.CompletedTask;
+
+ // Act
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithClientSecret(TestConstants.ClientSecret)
+ .OnSuccess(onSuccessCallback)
+ .BuildConcrete();
+
+ // Assert
+ Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.OnSuccessCallback);
+ }
+
+ [TestMethod]
+ public void OnSuccess_ThrowsOnNullCallback()
+ {
+ // Act & Assert
+ var ex = Assert.ThrowsException(() =>
+ ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithClientSecret(TestConstants.ClientSecret)
+ .OnSuccess(null)
+ .Build());
+
+ Assert.AreEqual("onSuccessCallback", ex.ParamName);
+ }
+
+ #endregion
+
+ #region ExecutionResult Tests
+
+ [TestMethod]
+ public void ExecutionResult_CanBeCreated()
+ {
+ // Act
+ var result = new ExecutionResult();
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.IsFalse(result.Successful);
+ Assert.IsNull(result.Result);
+ Assert.IsNull(result.Exception);
+ }
+
+ [TestMethod]
+ public void ExecutionResult_PropertiesCanBeSet()
+ {
+ // Arrange
+ var authResult = new AuthenticationResult(
+ accessToken: "token",
+ isExtendedLifeTimeToken: false,
+ uniqueId: "unique_id",
+ expiresOn: DateTimeOffset.UtcNow.AddHours(1),
+ extendedExpiresOn: DateTimeOffset.UtcNow.AddHours(2),
+ tenantId: TestConstants.TenantId,
+ account: null,
+ idToken: "id_token",
+ scopes: new[] { "scope1" },
+ correlationId: Guid.NewGuid(),
+ tokenType: "Bearer",
+ authenticationResultMetadata: null);
+
+ var msalException = new MsalServiceException("error_code", "error_message");
+
+ // Act - Success case
+ var successResult = new ExecutionResult
+ {
+ Successful = true,
+ Result = authResult,
+ Exception = null
+ };
+
+ // Assert
+ Assert.IsTrue(successResult.Successful);
+ Assert.AreSame(authResult, successResult.Result);
+ Assert.IsNull(successResult.Exception);
+
+ // Act - Failure case
+ var failureResult = new ExecutionResult
+ {
+ Successful = false,
+ Result = null,
+ Exception = msalException
+ };
+
+ // Assert
+ Assert.IsFalse(failureResult.Successful);
+ Assert.IsNull(failureResult.Result);
+ Assert.AreSame(msalException, failureResult.Exception);
+ }
+
+ #endregion
+
+ #region Integration Tests
+
+ [TestMethod]
+ public void AllThreeExtensibilityPoints_CanBeConfiguredTogether()
+ {
+ // Arrange
+ Task certificateProvider(AssertionRequestOptions options) => Task.FromResult(GetTestCertificate());
+ Task onMsalServiceFailure(AssertionRequestOptions options, MsalException ex) => Task.FromResult(false);
+ Task onSuccess(AssertionRequestOptions options, ExecutionResult result) => Task.CompletedTask;
+
+ // Act
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithCertificate(certificateProvider)
+ .OnMsalServiceFailure(onMsalServiceFailure)
+ .OnSuccess(onSuccess)
+ .BuildConcrete();
+
+ // Assert
+ var config = app.AppConfig as ApplicationConfiguration;
+ Assert.IsNotNull(config.ClientCredentialCertificateProvider);
+ Assert.IsNotNull(config.OnMsalServiceFailureCallback);
+ Assert.IsNotNull(config.OnSuccessCallback);
+ }
+
+ [TestMethod]
+ public void ExtensibilityPoints_CanBeConfiguredInAnyOrder()
+ {
+ // Arrange
+ Task certificateProvider(AssertionRequestOptions options) => Task.FromResult(GetTestCertificate());
+ Task onMsalServiceFailure(AssertionRequestOptions options, MsalException ex) => Task.FromResult(false);
+ Task onSuccess(AssertionRequestOptions options, ExecutionResult result) => Task.CompletedTask;
+
+ // Act - Order: OnSuccess, OnMsalServiceFailure, Certificate
+ var app1 = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .OnSuccess(onSuccess)
+ .OnMsalServiceFailure(onMsalServiceFailure)
+ .WithCertificate(certificateProvider)
+ .BuildConcrete();
+
+ // Act - Order: OnMsalServiceFailure, Certificate, OnSuccess
+ var app2 = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .OnMsalServiceFailure(onMsalServiceFailure)
+ .WithCertificate(certificateProvider)
+ .OnSuccess(onSuccess)
+ .BuildConcrete();
+
+ // Assert
+ var config1 = app1.AppConfig as ApplicationConfiguration;
+ Assert.IsNotNull(config1);
+ Assert.IsNotNull(config1.ClientCredentialCertificateProvider);
+ Assert.IsNotNull(config1.OnMsalServiceFailureCallback);
+ Assert.IsNotNull(config1.OnSuccessCallback);
+
+ var config2 = app2.AppConfig as ApplicationConfiguration;
+ Assert.IsNotNull(config2, "app2.AppConfig should be of type ApplicationConfiguration");
+ Assert.IsNotNull(config2.ClientCredentialCertificateProvider);
+ Assert.IsNotNull(config2.OnMsalServiceFailureCallback);
+ Assert.IsNotNull(config2.OnSuccessCallback);
+ }
+
+ [TestMethod]
+ public void WithCertificate_WorksWithOtherConfidentialClientOptions()
+ {
+ // Arrange
+ Task certificateProvider(AssertionRequestOptions options)
+ {
+ Assert.AreEqual(TestConstants.ClientId, options.ClientID);
+ return Task.FromResult(GetTestCertificate());
+ }
+
+ // Act
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AadAuthorityWithTestTenantId)
+ .WithCertificate(certificateProvider)
+ .BuildConcrete();
+
+ // Assert
+ Assert.IsNotNull(app);
+ Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.ClientCredentialCertificateProvider);
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Internal.Analyzers", "IA5352:DoNotMisuseCryptographicApi",
+ Justification = "Test code only")]
+ private X509Certificate2 GetTestCertificate()
+ {
+ if (_certificate == null)
+ {
+ _certificate = new X509Certificate2(
+ ResourceHelper.GetTestResourceRelativePath("testCert.crtfile"),
+ TestConstants.TestCertPassword);
+ }
+ return _certificate;
+ }
+
+ #endregion
+ }
+}
diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationExtensibilityTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationExtensibilityTests.cs
new file mode 100644
index 0000000000..d9727fd777
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationExtensibilityTests.cs
@@ -0,0 +1,491 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#if !ANDROID && !iOS
+using System;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client;
+using Microsoft.Identity.Client.Extensibility;
+using Microsoft.Identity.Test.Common.Core.Helpers;
+using Microsoft.Identity.Test.Common.Core.Mocks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Test.Unit.PublicApiTests
+{
+ [TestClass]
+ [DeploymentItem(@"Resources\testCert.crtfile")]
+ public class ConfidentialClientApplicationExtensibilityTests : TestBase
+ {
+ [TestInitialize]
+ public override void TestInitialize()
+ {
+ base.TestInitialize();
+ }
+
+ #region WithCertificate (Dynamic Provider) Integration Tests
+
+ [TestMethod]
+ [Description("Dynamic certificate provider is invoked and cert is used for client assertion")]
+ public async Task DynamicCertificateProvider_IsInvoked_AndUsedForAssertionAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ bool providerInvoked = false;
+ AssertionRequestOptions capturedOptions = null;
+
+ var certificate = CertHelper.GetOrCreateTestCert();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(harness.HttpManager)
+ .WithCertificate((AssertionRequestOptions options) =>
+ {
+ providerInvoked = true;
+ capturedOptions = options;
+
+ // Validate options
+ Assert.AreEqual(TestConstants.ClientId, options.ClientID);
+ Assert.IsNotNull(options.TokenEndpoint);
+
+ return Task.FromResult(certificate);
+ })
+ .Build();
+
+ harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ // Act
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.IsTrue(providerInvoked, "Certificate provider should have been invoked");
+ Assert.IsNotNull(capturedOptions);
+ Assert.IsNotNull(result.AccessToken);
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource);
+ }
+ }
+
+ [TestMethod]
+ [Description("Dynamic certificate provider returning null throws appropriate exception")]
+ public async Task DynamicCertificateProvider_ReturnsNull_ThrowsExceptionAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(harness.HttpManager)
+ .WithCertificate((AssertionRequestOptions options) =>
+ {
+ return Task.FromResult(null); // Provider returns null
+ })
+ .Build();
+
+ // Act & Assert
+ var exception = await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+
+ Assert.AreEqual(MsalError.InvalidClientAssertion, exception.ErrorCode);
+ Assert.IsTrue(exception.Message.Contains("returned null"));
+ }
+ }
+
+ #endregion
+
+ #region OnMsalServiceFailure Integration Tests
+
+ [TestMethod]
+ [Description("OnMsalServiceFailure is invoked on service exception and retries successfully")]
+ public async Task OnMsalServiceFailure_RetriesOnServiceError_SucceedsAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ int failureCallbackCount = 0;
+ MsalServiceException capturedException = null;
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) =>
+ {
+ failureCallbackCount++;
+ capturedException = ex as MsalServiceException;
+
+ Assert.IsNotNull(capturedException, "Exception should be MsalServiceException");
+ Assert.AreEqual(TestConstants.ClientId, options.ClientID);
+
+ // Retry on 503
+ return Task.FromResult(capturedException.StatusCode == 400 && failureCallbackCount < 3);
+ })
+ .Build();
+
+ // Mock 2 failures, then success
+ harness.HttpManager.AddFailureTokenEndpointResponse("request_failed");
+ harness.HttpManager.AddFailureTokenEndpointResponse("request_failed");
+ harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ // Act
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(2, failureCallbackCount, "Callback should be invoked twice");
+ Assert.IsNotNull(result.AccessToken);
+ Assert.AreEqual(400, capturedException.StatusCode);
+ }
+ }
+
+ [TestMethod]
+ [Description("OnMsalServiceFailure returns false and exception is propagated")]
+ public async Task OnMsalServiceFailure_ReturnsFalse_PropagatesExceptionAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ bool callbackInvoked = false;
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) =>
+ {
+ callbackInvoked = true;
+ return Task.FromResult(false); // Don't retry
+ })
+ .Build();
+
+ harness.HttpManager.AddFailureTokenEndpointResponse("request_failed");
+
+ // Act & Assert
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+
+ Assert.IsTrue(callbackInvoked);
+ }
+ }
+
+ [TestMethod]
+ [Description("OnMsalServiceFailure is NOT invoked for client exceptions")]
+ public async Task OnMsalServiceFailure_NotInvokedForClientExceptionsAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ bool callbackInvoked = false;
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithCertificate((AssertionRequestOptions options) =>
+ {
+ return Task.FromResult(null); // Will cause MsalClientException
+ })
+ .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) =>
+ {
+ callbackInvoked = true;
+ return Task.FromResult(false);
+ })
+ .Build();
+
+ // Act & Assert
+ var exception = await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+
+ Assert.IsFalse(callbackInvoked, "Callback should NOT be invoked for client exceptions");
+ Assert.AreEqual(MsalError.InvalidClientAssertion, exception.ErrorCode);
+ }
+ }
+
+ #endregion
+
+ #region OnSuccess Integration Tests
+
+ [TestMethod]
+ [Description("OnSuccess is invoked with successful result")]
+ public async Task OnSuccess_InvokedWithSuccessfulResultAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ bool observerInvoked = false;
+ ExecutionResult capturedResult = null;
+ AssertionRequestOptions capturedOptions = null;
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .OnSuccess((AssertionRequestOptions options, ExecutionResult result) =>
+ {
+ observerInvoked = true;
+ capturedResult = result;
+ capturedOptions = options;
+
+ Assert.IsTrue(result.Successful);
+ Assert.IsNotNull(result.Result);
+ Assert.IsNull(result.Exception);
+ Assert.AreEqual(TestConstants.ClientId, options.ClientID);
+
+ return Task.CompletedTask;
+ })
+ .Build();
+
+ harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ // Act
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.IsTrue(observerInvoked, "Observer should be invoked");
+ Assert.IsNotNull(capturedResult);
+ Assert.IsTrue(capturedResult.Successful);
+ Assert.IsNotNull(capturedResult.Result);
+ Assert.AreEqual(result.AccessToken, capturedResult.Result.AccessToken);
+ }
+ }
+
+ [TestMethod]
+ [Description("OnSuccess is invoked with failure result after retries exhausted")]
+ public async Task OnSuccess_InvokedWithFailureResult_AfterRetriesExhaustedAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ int retryCount = 0;
+ bool observerInvoked = false;
+ ExecutionResult capturedResult = null;
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) =>
+ {
+ retryCount++;
+ return Task.FromResult(retryCount < 2); // Retry once, then give up
+ })
+ .OnSuccess((AssertionRequestOptions options, ExecutionResult result) =>
+ {
+ observerInvoked = true;
+ capturedResult = result;
+
+ Assert.IsFalse(result.Successful);
+ Assert.IsNull(result.Result);
+ Assert.IsNotNull(result.Exception);
+ Assert.IsInstanceOfType(result.Exception, typeof(MsalServiceException));
+
+ return Task.CompletedTask;
+ })
+ .Build();
+
+ // Mock 2 failures
+ harness.HttpManager.AddFailureTokenEndpointResponse("request_failed");
+ harness.HttpManager.AddFailureTokenEndpointResponse("request_failed");
+
+ // Act & Assert
+ var exception = await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+
+ Assert.IsTrue(observerInvoked, "Observer should be invoked even on failure");
+ Assert.IsNotNull(capturedResult);
+ Assert.IsFalse(capturedResult.Successful);
+ Assert.AreEqual(exception, capturedResult.Exception);
+ }
+ }
+
+ [TestMethod]
+ [Description("OnSuccess exception is caught and logged, doesn't disrupt flow")]
+ public async Task OnSuccess_ExceptionIsCaught_DoesNotDisruptFlowAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(harness.HttpManager)
+ .OnSuccess((AssertionRequestOptions options, ExecutionResult result) =>
+ {
+ throw new InvalidOperationException("Observer threw exception");
+ })
+ .Build();
+
+ harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ // Act - should NOT throw, observer exception should be caught
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.IsNotNull(result.AccessToken);
+ }
+ }
+
+ #endregion
+
+ #region Combined Scenarios
+
+ [TestMethod]
+ [Description("All three extensibility points work together: cert provider, retry, observer")]
+ public async Task AllThreeExtensibilityPoints_WorkTogetherAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ int certProviderCount = 0;
+ int retryCallbackCount = 0;
+ bool observerInvoked = false;
+
+ var certificate = CertHelper.GetOrCreateTestCert();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(harness.HttpManager)
+ .WithCertificate((AssertionRequestOptions options) =>
+ {
+ certProviderCount++;
+ Assert.AreEqual(TestConstants.ClientId, options.ClientID);
+ return Task.FromResult(certificate);
+ })
+ .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) =>
+ {
+ retryCallbackCount++;
+ Assert.IsInstanceOfType(ex, typeof(MsalServiceException));
+ return Task.FromResult(retryCallbackCount < 2); // Retry once
+ })
+ .OnSuccess((AssertionRequestOptions options, ExecutionResult result) =>
+ {
+ observerInvoked = true;
+ Assert.IsTrue(result.Successful);
+ Assert.IsNotNull(result.Result);
+ return Task.CompletedTask;
+ })
+ .Build();
+
+ // Mock: fail once, then succeed
+ harness.HttpManager.AddFailureTokenEndpointResponse("request_failed");
+ harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ // Act
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(2, certProviderCount, "Cert provider invoked for initial + retry");
+ Assert.AreEqual(1, retryCallbackCount, "Retry callback invoked once");
+ Assert.IsTrue(observerInvoked, "Observer invoked once at completion");
+ Assert.IsNotNull(result.AccessToken);
+ }
+ }
+
+ [TestMethod]
+ [Description("Certificate rotation scenario: different cert returned on retry")]
+ public async Task CertificateRotation_DifferentCertOnRetryAsync()
+ {
+ // Arrange
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+
+ int certProviderCount = 0;
+ var cert1 = CertHelper.GetOrCreateTestCert();
+ var cert2 = CertHelper.GetOrCreateTestCert(regenerateCert: true);
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(harness.HttpManager)
+ .WithCertificate((AssertionRequestOptions options) =>
+ {
+ certProviderCount++;
+ // Return different cert on retry
+ return Task.FromResult(certProviderCount == 1 ? cert1 : cert2);
+ })
+ .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) =>
+ {
+ return Task.FromResult(true); // Always retry once
+ })
+ .Build();
+
+ // First call fails (cert1), second succeeds (cert2)
+ harness.HttpManager.AddFailureTokenEndpointResponse("request_failed");
+ harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ // Act
+ var result = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ // Assert
+ Assert.AreEqual(2, certProviderCount, "Provider should be called twice");
+ Assert.IsNotNull(result.AccessToken);
+ }
+ }
+
+ #endregion
+ }
+}
+#endif