diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs
index 25d64952be..a96c9fbe68 100644
--- a/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs
+++ b/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs
@@ -48,7 +48,7 @@ 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
)
{
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..27fc024de8 100644
--- a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs
+++ b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs
@@ -131,6 +131,21 @@ public string ClientVersion
internal IRetryPolicyFactory RetryPolicyFactory { get; set; }
internal ICsrFactory CsrFactory { get; set; }
+ #region Extensibility Callbacks
+
+ ///
+ /// 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> OnMsalServiceFailure { get; set; }
+
+ ///
+ /// Success callback that receives the result of token acquisition attempts (typically successful, but can include failures after retries are exhausted).
+ ///
+ public Func OnCompletion { get; set; }
+
+ #endregion
+
#region ClientCredentials
// Indicates if claims or assertions are used within the configuration
@@ -154,14 +169,16 @@ public string ClientSecret
///
/// This is here just to support the public IAppConfig. Should not be used internally, instead use the abstraction.
+ /// Note: This returns null when using dynamic certificate providers since the certificate is resolved at runtime.
///
public X509Certificate2 ClientCredentialCertificate
{
get
{
- if (ClientCredential is CertificateAndClaimsClientCredential cred)
+ // Return the certificate if using static certificate (CertificateClientCredential)
+ if (ClientCredential is CertificateClientCredential certCred)
{
- return cred.Certificate;
+ return certCred.Certificate;
}
return null;
diff --git a/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs b/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs
index 245db3fada..4cacac7cb2 100644
--- a/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs
+++ b/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs
@@ -6,15 +6,37 @@
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
+ /// The token endpoint used to acquire the token
+ /// The tenant ID from the runtime authority
+ internal AssertionRequestOptions(ApplicationConfiguration appConfig, string tokenEndpoint, string tenantId)
+ {
+ ClientID = appConfig.ClientId;
+ TokenEndpoint = tokenEndpoint;
+ Authority = appConfig.Authority?.AuthorityInfo?.CanonicalAuthority?.ToString();
+ TenantId = tenantId;
+ }
+
+ ///
+ /// Cancellation token to cancel the operation
///
public CancellationToken CancellationToken { get; set; }
@@ -23,6 +45,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/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs
index 2f1463e56d..0f33ce83bc 100644
--- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs
+++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs
@@ -169,7 +169,11 @@ public ConfidentialClientApplicationBuilder WithClientClaims(X509Certificate2 ce
throw new ArgumentNullException(nameof(claimsToSign));
}
- Config.ClientCredential = new CertificateAndClaimsClientCredential(certificate, claimsToSign, mergeWithDefaultClaims);
+ // Wrap the static certificate in a provider delegate
+ Config.ClientCredential = new CertificateAndClaimsClientCredential(
+ certificateProvider: _ => Task.FromResult(certificate),
+ claimsToSign: claimsToSign,
+ appendDefaultClaims: mergeWithDefaultClaims);
Config.SendX5C = sendX5C;
return this;
}
diff --git a/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs
index 7328b69ad3..f41b176bd5 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,154 @@ 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));
+ }
+
+ // Create a DynamicCertificateClientCredential with the certificate provider
+ // The certificate will be resolved dynamically via the provider in ResolveCertificateAsync
+ builder.Config.ClientCredential = new DynamicCertificateClientCredential(
+ certificateProvider: certificateProvider);
+
+ 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> onMsalServiceFailure)
+ {
+ if (onMsalServiceFailure == null)
+ throw new ArgumentNullException(nameof(onMsalServiceFailure));
+
+ builder.Config.OnMsalServiceFailure = onMsalServiceFailure;
+ 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 OnCompletion 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 OnCompletion 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)
+ /// .OnCompletion(async (options, result) =>
+ /// {
+ /// if (result.Successful)
+ /// {
+ /// await telemetry.TrackEventAsync("TokenAcquired", new { ClientId = options.ClientID });
+ /// }
+ /// else
+ /// {
+ /// await telemetry.TrackExceptionAsync(result.Exception);
+ /// }
+ /// })
+ /// .Build();
+ ///
+ ///
+ public static ConfidentialClientApplicationBuilder OnCompletion(
+ this ConfidentialClientApplicationBuilder builder,
+ Func onCompletion)
+ {
+ builder.ValidateUseOfExperimentalFeature();
+
+ if (onCompletion == null)
+ {
+ throw new ArgumentNullException(nameof(onCompletion));
+ }
+
+ builder.Config.OnCompletion = onCompletion;
+ 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..ea81384d38
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/Extensibility/ExecutionResult.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Security.Cryptography.X509Certificates;
+
+namespace Microsoft.Identity.Client.Extensibility
+{
+#if !SUPPORTS_CONFIDENTIAL_CLIENT
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile
+#endif
+ ///
+ /// 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; }
+
+ ///
+ /// The certificate used for authentication, if certificate-based authentication was used.
+ ///
+ ///
+ /// An used to authenticate the client application;
+ /// otherwise, null if certificate authentication was not used or if the certificate is not available.
+ ///
+ ///
+ /// This property provides access to the certificate for logging and auditing purposes.
+ /// The certificate may be disposed after the token acquisition completes, so accessing its properties
+ /// may throw exceptions if the certificate has been disposed.
+ ///
+ public X509Certificate2 Certificate { 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..a8c69823fc 100644
--- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs
@@ -3,8 +3,6 @@
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;
@@ -13,7 +11,6 @@
using Microsoft.Identity.Client.OAuth2;
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
using Microsoft.Identity.Client.TelemetryCore;
-using Microsoft.Identity.Client.Utils;
namespace Microsoft.Identity.Client.Internal.ClientCredential
{
@@ -21,24 +18,28 @@ internal class CertificateAndClaimsClientCredential : IClientCredential
{
private readonly IDictionary _claimsToSign;
private readonly bool _appendDefaultClaims;
- private readonly string _base64EncodedThumbprint; // x5t
-
- public X509Certificate2 Certificate { get; }
+ private readonly Func> _certificateProvider;
public AssertionType AssertionType => AssertionType.CertificateWithoutSni;
+ ///
+ /// Constructor that accepts a certificate provider delegate.
+ /// This allows both static certificates (via a simple delegate) and dynamic certificate resolution.
+ ///
+ /// Async delegate that provides the certificate
+ /// Additional claims to include in the client assertion
+ /// Whether to append default claims
public CertificateAndClaimsClientCredential(
- X509Certificate2 certificate,
+ Func> certificateProvider,
IDictionary claimsToSign,
bool appendDefaultClaims)
{
- Certificate = certificate;
+ _certificateProvider = certificateProvider;
_claimsToSign = claimsToSign;
_appendDefaultClaims = appendDefaultClaims;
- _base64EncodedThumbprint = Base64UrlHelpers.Encode(certificate.GetCertHash());
}
- public Task AddConfidentialClientParametersAsync(
+ public async Task AddConfidentialClientParametersAsync(
OAuth2Client oAuth2Client,
AuthenticationRequestParameters requestParameters,
ICryptographyManager cryptographyManager,
@@ -54,6 +55,12 @@ public Task AddConfidentialClientParametersAsync(
{
requestParameters.RequestContext.Logger.Verbose(() => "Proceeding with JWT token creation and adding client assertion.");
+ // Resolve the certificate via the provider
+ X509Certificate2 certificate = await ResolveCertificateAsync(requestParameters, tokenEndpoint, cancellationToken).ConfigureAwait(false);
+
+ // Store the resolved certificate in request parameters for later use (e.g., ExecutionResult)
+ requestParameters.ResolvedCertificate = certificate;
+
bool useSha2 = requestParameters.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported;
var jwtToken = new JsonWebToken(
@@ -63,7 +70,7 @@ public Task AddConfidentialClientParametersAsync(
_claimsToSign,
_appendDefaultClaims);
- string assertion = jwtToken.Sign(Certificate, requestParameters.SendX5C, useSha2);
+ string assertion = jwtToken.Sign(certificate, requestParameters.SendX5C, useSha2);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion);
@@ -72,9 +79,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.");
+
+ // Store the mTLS certificate in request parameters for later use (e.g., ExecutionResult)
+ requestParameters.ResolvedCertificate = requestParameters.MtlsCertificate;
}
+ }
+
+ ///
+ /// Resolves the certificate to use for signing the client assertion.
+ /// Invokes the certificate provider delegate to get the certificate.
+ ///
+ /// The authentication request parameters containing app config
+ /// The token endpoint URL
+ /// 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,
+ string tokenEndpoint,
+ CancellationToken cancellationToken)
+ {
+ requestParameters.RequestContext.Logger.Verbose(
+ () => "[CertificateAndClaimsClientCredential] Resolving certificate from provider.");
+
+ // Create AssertionRequestOptions for the callback
+ var options = new AssertionRequestOptions(
+ requestParameters.AppConfig,
+ tokenEndpoint,
+ requestParameters.AuthorityManager.Authority.TenantId)
+ {
+ Claims = requestParameters.Claims,
+ ClientCapabilities = requestParameters.AppConfig.ClientCapabilities,
+ CancellationToken = cancellationToken
+ };
+
+ // Invoke the provider to get the certificate
+ X509Certificate2 certificate = await _certificateProvider(options).ConfigureAwait(false);
+
+ // Validate the certificate returned by the provider
+ if (certificate == 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.");
+ }
+
+ try
+ {
+ if (!certificate.HasPrivateKey)
+ {
+ requestParameters.RequestContext.Logger.Error(
+ "[CertificateAndClaimsClientCredential] Certificate from provider does not have a private key.");
+
+ throw new MsalClientException(
+ MsalError.CertWithoutPrivateKey,
+ MsalErrorMessage.CertMustHavePrivateKey(certificate.FriendlyName));
+ }
+ }
+ catch (System.Security.Cryptography.CryptographicException ex)
+ {
+ requestParameters.RequestContext.Logger.Error(
+ "[CertificateAndClaimsClientCredential] A cryptographic error occurred while accessing the certificate.");
+
+ throw new MsalClientException(
+ MsalError.CryptographicError,
+ MsalErrorMessage.CryptographicError,
+ ex);
+ }
+
+ requestParameters.RequestContext.Logger.Info(
+ () => $"[CertificateAndClaimsClientCredential] Successfully resolved certificate from provider. " +
+ $"Thumbprint: {certificate.Thumbprint}");
- return Task.CompletedTask;
+ return certificate;
}
}
}
diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateClientCredential.cs
index d516a5480c..a58fa33418 100644
--- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateClientCredential.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateClientCredential.cs
@@ -1,6 +1,7 @@
// 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;
@@ -13,9 +14,17 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential
{
internal class CertificateClientCredential : CertificateAndClaimsClientCredential
{
- public CertificateClientCredential(X509Certificate2 certificate) : base(certificate, null, true)
- {
+ ///
+ /// Gets the static certificate when using WithCertificate(X509Certificate2).
+ /// This is needed for mTLS scenarios where we need synchronous access to the certificate.
+ /// Returns null when using dynamic certificate providers.
+ ///
+ public X509Certificate2 Certificate { get; }
+ public CertificateClientCredential(X509Certificate2 certificate)
+ : base(certificateProvider: _ => Task.FromResult(certificate), claimsToSign: null, appendDefaultClaims: true)
+ {
+ Certificate = certificate;
}
}
}
diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/DynamicCertificateClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/DynamicCertificateClientCredential.cs
new file mode 100644
index 0000000000..32433081cd
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/DynamicCertificateClientCredential.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading.Tasks;
+
+namespace Microsoft.Identity.Client.Internal.ClientCredential
+{
+ ///
+ /// Client credential that resolves certificates dynamically at runtime via a provider delegate.
+ /// Used when certificates need to be rotated or selected based on runtime conditions.
+ ///
+ internal class DynamicCertificateClientCredential : CertificateAndClaimsClientCredential
+ {
+ public DynamicCertificateClientCredential(
+ Func> certificateProvider)
+ : base(
+ certificateProvider: certificateProvider,
+ claimsToSign: null,
+ appendDefaultClaims: true)
+ {
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
index 4e8e66f2ef..c13aaf7e0b 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs
@@ -115,6 +115,12 @@ public AuthenticationRequestParameters(
public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested;
+ ///
+ /// The certificate resolved and used for client authentication (if certificate-based authentication was used).
+ /// This is set during the token request when the certificate is resolved.
+ ///
+ public X509Certificate2 ResolvedCertificate { get; set; }
+
///
/// 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 e7c08f0fc8..88bd435764 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,159 @@ private async Task GetAccessTokenAsync(
{
await ResolveAuthorityAsync().ConfigureAwait(false);
- // Get a token from AAD
- if (ServiceBundle.Config.AppTokenProvider == null)
+ AuthenticationResult authResult = null;
+ int retryCount = 0;
+
+ // 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 OnCompletion callback if configured
+ await InvokeOnSuccessCallbackAsync(authResult, exception: null, logger).ConfigureAwait(false);
+
+ return authResult;
+ }
+ catch (MsalServiceException serviceEx)
+ {
+ // Check if OnMsalServiceFailure is configured
+ if (AuthenticationRequestParameters.AppConfig.OnMsalServiceFailure != null)
+ {
+ logger.Info("[ClientCredentialRequest] MsalServiceException caught. Invoking OnMsalServiceFailure.");
+
+ bool shouldRetry = await InvokeOnMsalServiceFailureCallbackAsync(serviceEx, logger)
+ .ConfigureAwait(false);
+
+ if (shouldRetry)
+ {
+ retryCount++;
+ logger.Info($"[ClientCredentialRequest] OnMsalServiceFailure returned true. Retrying token request (Retry #{retryCount}).");
+ continue; // Retry the loop
+ }
+
+ logger.Info("[ClientCredentialRequest] OnMsalServiceFailure returned false. Propagating exception.");
+ }
+
+ // Invoke OnCompletion 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 OnCompletion and re-throw
+ await InvokeOnSuccessCallbackAsync(authResult: null, exception: ex, logger).ConfigureAwait(false);
+ throw;
+ }
}
+ }
- // Get a token from the app provider delegate
+ ///
+ /// Invokes the OnMsalServiceFailure if configured.
+ /// Returns true if the request should be retried, false otherwise.
+ ///
+ private async Task InvokeOnMsalServiceFailureCallbackAsync(
+ MsalServiceException serviceException,
+ ILoggerAdapter logger)
+ {
+ try
+ {
+ var tokenEndpoint = await AuthenticationRequestParameters.Authority.GetTokenEndpointAsync(AuthenticationRequestParameters.RequestContext).ConfigureAwait(false);
+ var options = new AssertionRequestOptions(
+ AuthenticationRequestParameters.AppConfig,
+ tokenEndpoint,
+ AuthenticationRequestParameters.AuthorityManager.Authority.TenantId);
+
+ bool shouldRetry = await AuthenticationRequestParameters.AppConfig
+ .OnMsalServiceFailure(options, serviceException)
+ .ConfigureAwait(false);
+
+ logger.Verbose(() => $"[ClientCredentialRequest] OnMsalServiceFailure returned: {shouldRetry}");
+ return shouldRetry;
+ }
+ catch (Exception ex)
+ {
+ // If the callback throws, log and don't retry
+ logger.Error($"[ClientCredentialRequest] OnMsalServiceFailure threw an exception: {ex.Message}");
+ logger.ErrorPii(ex);
+ return false;
+ }
+ }
+
+ ///
+ /// Invokes the OnCompletion 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.OnCompletion == null)
+ {
+ return;
+ }
+
+ try
+ {
+ logger.Verbose(() => "[ClientCredentialRequest] Invoking OnCompletion callback.");
+
+ var tokenEndpoint = await AuthenticationRequestParameters.Authority.GetTokenEndpointAsync(AuthenticationRequestParameters.RequestContext).ConfigureAwait(false);
+ var options = new AssertionRequestOptions(
+ AuthenticationRequestParameters.AppConfig,
+ tokenEndpoint,
+ AuthenticationRequestParameters.AuthorityManager.Authority.TenantId);
+
+ var executionResult = new ExecutionResult
+ {
+ Successful = authResult != null,
+ Result = authResult,
+ Exception = exception,
+ Certificate = AuthenticationRequestParameters.ResolvedCertificate
+ };
+
+ await AuthenticationRequestParameters.AppConfig
+ .OnCompletion(options, executionResult)
+ .ConfigureAwait(false);
+
+ logger.Verbose(() => "[ClientCredentialRequest] OnCompletion 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] OnCompletion 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 +441,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 688458a3cf..721d6405c2 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 3241ccd9cd..ef252c3345 100644
--- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt
+++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt
@@ -1,2 +1,15 @@
+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.Certificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+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.OnCompletion(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onCompletion) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailure) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
const Microsoft.Identity.Client.MsalError.ManagedIdentityAllSourcesUnavailable = "managed_identity_all_sources_unavailable" -> string
Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentitySourceAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
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 3241ccd9cd..ef252c3345 100644
--- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt
+++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt
@@ -1,2 +1,15 @@
+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.Certificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+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.OnCompletion(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onCompletion) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailure) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
const Microsoft.Identity.Client.MsalError.ManagedIdentityAllSourcesUnavailable = "managed_identity_all_sources_unavailable" -> string
Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentitySourceAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
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 3241ccd9cd..9387bc51ee 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,2 +1,14 @@
+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
const Microsoft.Identity.Client.MsalError.ManagedIdentityAllSourcesUnavailable = "managed_identity_all_sources_unavailable" -> string
Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentitySourceAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
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 3241ccd9cd..9387bc51ee 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,2 +1,14 @@
+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
const Microsoft.Identity.Client.MsalError.ManagedIdentityAllSourcesUnavailable = "managed_identity_all_sources_unavailable" -> string
Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentitySourceAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
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 3241ccd9cd..ef252c3345 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,2 +1,15 @@
+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.Certificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+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.OnCompletion(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onCompletion) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailure) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
const Microsoft.Identity.Client.MsalError.ManagedIdentityAllSourcesUnavailable = "managed_identity_all_sources_unavailable" -> string
Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentitySourceAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
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 3241ccd9cd..ef252c3345 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,2 +1,15 @@
+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.Certificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
+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.OnCompletion(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onCompletion) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailure) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
+static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
const Microsoft.Identity.Client.MsalError.ManagedIdentityAllSourcesUnavailable = "managed_identity_all_sources_unavailable" -> string
Microsoft.Identity.Client.ManagedIdentityApplication.GetManagedIdentitySourceAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task
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..853a654d5c
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/AppConfigTests/ConfidentialClientApplicationExtensibilityApiTests.cs
@@ -0,0 +1,355 @@
+// 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.Client.Internal.ClientCredential;
+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)?.ClientCredential);
+ Assert.IsInstanceOfType((app.AppConfig as ApplicationConfiguration)?.ClientCredential, typeof(DynamicCertificateClientCredential));
+ 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.ClientCredential);
+ Assert.IsInstanceOfType(config.ClientCredential, typeof(DynamicCertificateClientCredential));
+ }
+
+ #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)?.OnMsalServiceFailure);
+ }
+
+ [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)
+ .OnCompletion(onSuccessCallback)
+ .BuildConcrete();
+
+ // Assert
+ Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.OnCompletion);
+ }
+
+ [TestMethod]
+ public void OnSuccess_ThrowsOnNullCallback()
+ {
+ // Act & Assert
+ var ex = Assert.ThrowsException(() =>
+ ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .WithClientSecret(TestConstants.ClientSecret)
+ .OnCompletion(null)
+ .Build());
+
+ Assert.AreEqual("onCompletion", 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)
+ .OnCompletion(onSuccess)
+ .BuildConcrete();
+
+ // Assert
+ var config = app.AppConfig as ApplicationConfiguration;
+ Assert.IsNotNull(config.ClientCredential);
+ Assert.IsNotNull(config.OnMsalServiceFailure);
+ Assert.IsNotNull(config.OnCompletion);
+ }
+
+ [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: OnCompletion, OnMsalServiceFailure, Certificate
+ var app1 = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .OnCompletion(onSuccess)
+ .OnMsalServiceFailure(onMsalServiceFailure)
+ .WithCertificate(certificateProvider)
+ .BuildConcrete();
+
+ // Act - Order: OnMsalServiceFailure, Certificate, OnCompletion
+ var app2 = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithExperimentalFeatures()
+ .OnMsalServiceFailure(onMsalServiceFailure)
+ .WithCertificate(certificateProvider)
+ .OnCompletion(onSuccess)
+ .BuildConcrete();
+
+ // Assert
+ var config1 = app1.AppConfig as ApplicationConfiguration;
+ Assert.IsNotNull(config1);
+ Assert.IsNotNull(config1.ClientCredential);
+ Assert.IsNotNull(config1.OnMsalServiceFailure);
+ Assert.IsNotNull(config1.OnCompletion);
+
+ var config2 = app2.AppConfig as ApplicationConfiguration;
+ Assert.IsNotNull(config2, "app2.AppConfig should be of type ApplicationConfiguration");
+ Assert.IsNotNull(config2.ClientCredential);
+ Assert.IsNotNull(config2.OnMsalServiceFailure);
+ Assert.IsNotNull(config2.OnCompletion);
+ }
+
+ [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)?.ClientCredential);
+ }
+
+ #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..7b5f2eac99
--- /dev/null
+++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationExtensibilityTests.cs
@@ -0,0 +1,514 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#if !ANDROID && !iOS
+using System;
+using System.Linq;
+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);
+ Assert.IsNotNull(options.TokenEndpoint, "TokenEndpoint should be available in failure callback");
+
+ // 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("OnCompletion 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)
+ .OnCompletion((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);
+ Assert.IsNotNull(options.TokenEndpoint, "TokenEndpoint should be available in success callback");
+
+ 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("OnCompletion is invoked with failure result after retries exhausted")]
+ public async Task OnSuccess_InvokedWithFailureResult_AfterRetriesExhaustedAsync()
+ {
+ // Arrange
+ var logMessages = new System.Collections.Generic.List();
+ LogCallback logCallback = (level, message, pii) => logMessages.Add(message);
+
+ using (var harness = CreateTestHarness(logCallback: logCallback))
+ {
+ 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)
+ .WithLogging(logCallback, LogLevel.Info, enablePiiLogging: true, enableDefaultPlatformLogging: false)
+ .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) =>
+ {
+ retryCount++;
+ return Task.FromResult(retryCount < 2); // Retry once, then give up
+ })
+ .OnCompletion((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));
+ Assert.IsNotNull(options.TokenEndpoint, "TokenEndpoint should be available even on failure");
+
+ 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);
+
+ // Verify retry logging
+ Assert.IsTrue(logMessages.Any(m => m.Contains("[ClientCredentialRequest] OnMsalServiceFailure returned true. Retrying token request (Retry #1).")),
+ "Should log retry #1");
+ }
+ }
+
+ [TestMethod]
+ [Description("OnCompletion 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)
+ .OnCompletion((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
+ var logMessages = new System.Collections.Generic.List();
+ LogCallback logCallback = (level, message, pii) => logMessages.Add(message);
+
+ using (var harness = CreateTestHarness(logCallback: logCallback))
+ {
+ 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)
+ .WithLogging(logCallback, LogLevel.Info, enablePiiLogging: true, enableDefaultPlatformLogging: false)
+ .WithCertificate((AssertionRequestOptions options) =>
+ {
+ certProviderCount++;
+ Assert.AreEqual(TestConstants.ClientId, options.ClientID);
+ Assert.IsNotNull(options.TokenEndpoint, "TokenEndpoint should be available in cert provider");
+ return Task.FromResult(certificate);
+ })
+ .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) =>
+ {
+ retryCallbackCount++;
+ Assert.IsInstanceOfType(ex, typeof(MsalServiceException));
+ Assert.IsNotNull(options.TokenEndpoint, "TokenEndpoint should be available in retry callback");
+ return Task.FromResult(retryCallbackCount < 2); // Retry once
+ })
+ .OnCompletion((AssertionRequestOptions options, ExecutionResult result) =>
+ {
+ observerInvoked = true;
+ Assert.IsTrue(result.Successful);
+ Assert.IsNotNull(result.Result);
+ Assert.IsNotNull(options.TokenEndpoint, "TokenEndpoint should be available in success callback");
+ 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);
+
+ // Verify retry logging
+ Assert.IsTrue(logMessages.Any(m => m.Contains("[ClientCredentialRequest] OnMsalServiceFailure returned true. Retrying token request (Retry #1).")),
+ "Should log retry #1");
+ }
+ }
+
+ [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