Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3c32188
Add extensibility APIs
neha-bhargava Nov 7, 2025
2ea7e48
Merge branch 'main' into nebharg/certExtensibility
neha-bhargava Nov 10, 2025
dac7c01
Add functionality for retry and onSuccess.
neha-bhargava Nov 14, 2025
303d8f2
Merge
neha-bhargava Nov 14, 2025
17765a0
Fix build
neha-bhargava Nov 14, 2025
9270a68
Apply suggestions from code review
neha-bhargava Nov 14, 2025
9d28ea3
Merge branch 'main' into nebharg/certExtensibility
neha-bhargava Nov 14, 2025
c7363e0
Make the APIs experimental
neha-bhargava Nov 17, 2025
ef00c82
Merge branch 'main' into nebharg/certExtensibility
neha-bhargava Nov 17, 2025
1f5b23a
Fix build failures in pipeline
neha-bhargava Nov 18, 2025
463eb4e
Merge branch 'main' into nebharg/certExtensibility
neha-bhargava Nov 18, 2025
107548e
Merge branch 'nebharg/certExtensibility' of https://github.com/AzureA…
neha-bhargava Nov 18, 2025
037e52e
Fix tests
neha-bhargava Nov 19, 2025
1a6b296
Merge branch 'main' into nebharg/certExtensibility
neha-bhargava Nov 20, 2025
9fb9dc7
Update to use AssertionRequestOptions
neha-bhargava Nov 24, 2025
e8f7137
Merge conflicts
neha-bhargava Nov 24, 2025
198de3f
Merge branch 'main' into nebharg/certExtensibility
neha-bhargava Nov 24, 2025
20ce7bc
Resolve conflicts
neha-bhargava Nov 24, 2025
745f44c
Merge branch 'nebharg/certExtensibility' of https://github.com/AzureA…
neha-bhargava Nov 24, 2025
589a614
Fix build issue
neha-bhargava Nov 24, 2025
6d303a9
Public API analyzers
neha-bhargava Nov 25, 2025
d0129be
Address comments
neha-bhargava Dec 9, 2025
3cb6ad0
Merge branch 'main' into nebharg/certExtensibility
neha-bhargava Dec 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,21 @@ public string ClientVersion
internal IRetryPolicyFactory RetryPolicyFactory { get; set; }
internal ICsrFactory CsrFactory { get; set; }

#region Extensibility Callbacks

/// <summary>
/// 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).
/// </summary>
public Func<AssertionRequestOptions, MsalException, Task<bool>> OnMsalServiceFailure { get; set; }

/// <summary>
/// Success callback that receives the result of token acquisition attempts (typically successful, but can include failures after retries are exhausted).
/// </summary>
public Func<AssertionRequestOptions, ExecutionResult, Task> OnCompletion { get; set; }

#endregion

#region ClientCredentials

// Indicates if claims or assertions are used within the configuration
Expand All @@ -154,14 +169,16 @@ public string ClientSecret

/// <summary>
/// This is here just to support the public IAppConfig. Should not be used internally, instead use the <see cref="ClientCredential" /> abstraction.
/// Note: This returns null when using dynamic certificate providers since the certificate is resolved at runtime.
/// </summary>
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,37 @@

namespace Microsoft.Identity.Client
{
/// <summary>
/// Information about the client assertion that need to be generated See https://aka.ms/msal-net-client-assertion
/// </summary>
/// <remarks> Use the provided information to generate the client assertion payload </remarks>
/// <summary>
/// Information about the client assertion that need to be generated See https://aka.ms/msal-net-client-assertion
/// </summary>
/// <remarks> Use the provided information to generate the client assertion payload </remarks>
#if !SUPPORTS_CONFIDENTIAL_CLIENT
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile
#endif
public class AssertionRequestOptions {
/// <summary>
/// Default constructor for AssertionRequestOptions
/// </summary>
public AssertionRequestOptions()
{
}

/// <summary>
/// Internal constructor that creates AssertionRequestOptions from ApplicationConfiguration
/// </summary>
/// <param name="appConfig">The application configuration</param>
/// <param name="tokenEndpoint">The token endpoint used to acquire the token</param>
/// <param name="tenantId">The tenant ID from the runtime authority</param>
internal AssertionRequestOptions(ApplicationConfiguration appConfig, string tokenEndpoint, string tenantId)
{
ClientID = appConfig.ClientId;
TokenEndpoint = tokenEndpoint;
Authority = appConfig.Authority?.AuthorityInfo?.CanonicalAuthority?.ToString();
TenantId = tenantId;
}

/// <summary>
/// Cancellation token to cancel the operation
/// </summary>
public CancellationToken CancellationToken { get; set; }

Expand All @@ -23,6 +45,16 @@ public class AssertionRequestOptions {
/// </summary>
public string ClientID { get; set; }

/// <summary>
/// Tenant ID for the authentication request
/// </summary>
public string TenantId { get; set; }

/// <summary>
/// The authority URL (e.g., https://login.microsoftonline.com/{tenantId})
/// </summary>
public string Authority { get; set; }

/// <summary>
/// The intended token endpoint
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -29,5 +31,154 @@ public static ConfidentialClientApplicationBuilder WithAppTokenProvider(
builder.Config.AppTokenProvider = appTokenProvider ?? throw new ArgumentNullException(nameof(appTokenProvider));
return builder;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="builder">The confidential client application builder.</param>
/// <param name="certificateProvider">
/// 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 <see cref="X509Certificate2"/> with a private key.
/// </param>
/// <returns>The builder to chain additional configuration calls.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="certificateProvider"/> is null.</exception>
/// <exception cref="MsalClientException">
/// Thrown at build time if both <see cref="ConfidentialClientApplicationBuilder.WithCertificate(X509Certificate2)"/>
/// and this method are configured.
/// </exception>
/// <remarks>
/// <para>This method cannot be used together with <see cref="ConfidentialClientApplicationBuilder.WithCertificate(X509Certificate2)"/>.</para>
/// <para>The callback is not invoked when tokens are retrieved from cache, only for network calls.</para>
/// <para>The certificate returned by the callback will be used to sign the client assertion (JWT) for that token request.</para>
/// <para>The callback can perform async operations such as fetching certificates from Azure Key Vault or other secret management systems.</para>
/// <para>See https://aka.ms/msal-net-client-credentials for more details on client credentials.</para>
/// </remarks>
public static ConfidentialClientApplicationBuilder WithCertificate(
this ConfidentialClientApplicationBuilder builder,
Func<AssertionRequestOptions, Task<X509Certificate2>> 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;
}

/// <summary>
/// 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 <c>false</c> or the request succeeds.
/// </summary>
/// <param name="builder">The confidential client application builder.</param>
/// <param name="onMsalServiceFailure">
/// An async callback that determines whether to retry after a service failure.
/// Receives the assertion request options and the <see cref="MsalServiceException"/> that occurred.
/// Returns <c>true</c> to retry the request, or <c>false</c> to stop retrying and propagate the exception.
/// The callback will be invoked repeatedly after each service failure until it returns <c>false</c> or the request succeeds.
/// </param>
/// <returns>The builder to chain additional configuration calls.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="onMsalServiceFailure"/> is null.</exception>
/// <remarks>
/// <para>This callback is ONLY triggered for <see cref="MsalServiceException"/> - errors returned by the identity provider (e.g., HTTP 500, 503, throttling).</para>
/// <para>This callback is NOT triggered for client-side errors (<see cref="MsalClientException"/>) or network failures handled internally by MSAL.</para>
/// <para>This callback is only invoked for network token acquisition attempts, not when tokens are retrieved from cache.</para>
/// <para>When the callback returns <c>true</c>, MSAL will invoke the certificate provider (if configured via <see cref="WithCertificate"/>)
/// before making another token request, enabling certificate rotation scenarios.</para>
/// <para>MSAL's internal throttling and retry mechanisms will still apply, including respecting Retry-After headers from the identity provider.</para>
/// <para>To prevent infinite loops, ensure your callback has appropriate termination conditions (e.g., max retry count, timeout).</para>
/// <para>The callback can perform async operations such as logging to remote services, checking external health endpoints, or querying configuration stores.</para>
/// </remarks>
/// <example>
/// <code>
/// 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 &amp;&amp; retryCount &lt; 3;
/// })
/// .Build();
/// </code>
/// </example>
public static ConfidentialClientApplicationBuilder OnMsalServiceFailure(
this ConfidentialClientApplicationBuilder builder,
Func<AssertionRequestOptions, MsalException, Task<bool>> onMsalServiceFailure)
{
if (onMsalServiceFailure == null)
throw new ArgumentNullException(nameof(onMsalServiceFailure));

builder.Config.OnMsalServiceFailure = onMsalServiceFailure;
return builder;
}

/// <summary>
/// Configures an async callback that is invoked when a token acquisition request completes.
/// This callback is invoked once per <c>AcquireTokenForClient</c> call, after all retry attempts have been exhausted.
/// While named <c>OnCompletion</c> for the common case, this callback fires for both successful and failed acquisitions.
/// This enables scenarios such as telemetry, logging, and custom result handling.
/// </summary>
/// <param name="builder">The confidential client application builder.</param>
/// <param name="onCompletion">
/// An async callback that receives the assertion request options and the execution result.
/// The result contains either the successful <see cref="AuthenticationResult"/> or the <see cref="MsalException"/> that occurred.
/// This callback is invoked after all retries have been exhausted (if an <see cref="OnMsalServiceFailure"/> handler is configured).
/// </param>
/// <returns>The builder to chain additional configuration calls.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="onCompletion"/> is null.</exception>
/// <remarks>
/// <para>This callback is invoked for both successful and failed token acquisitions. Check <see cref="ExecutionResult.Successful"/> to determine the outcome.</para>
/// <para>This callback is only invoked for network token acquisition attempts, not when tokens are retrieved from cache.</para>
/// <para>If multiple calls to <c>OnCompletion</c> are made, only the last configured callback will be used.</para>
/// <para>Exceptions thrown by this callback will be caught and logged internally to prevent disruption of the authentication flow.</para>
/// <para>The callback is invoked on the same thread/context as the token acquisition request.</para>
/// <para>The callback can perform async operations such as sending telemetry to Application Insights, persisting logs to databases, or triggering webhooks.</para>
/// </remarks>
/// <example>
/// <code>
/// 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();
/// </code>
/// </example>
public static ConfidentialClientApplicationBuilder OnCompletion(
this ConfidentialClientApplicationBuilder builder,
Func<AssertionRequestOptions, ExecutionResult, Task> onCompletion)
{
builder.ValidateUseOfExperimentalFeature();

if (onCompletion == null)
{
throw new ArgumentNullException(nameof(onCompletion));
}

builder.Config.OnCompletion = onCompletion;
return builder;
}
}
}
Original file line number Diff line number Diff line change
@@ -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
/// <summary>
/// Represents the result of a token acquisition attempt.
/// Used by the execution observer configured via <see cref="ConfidentialClientApplicationBuilderExtensions.OnCompletion"/>.
/// </summary>
public class ExecutionResult
{
/// <summary>
/// Internal constructor for ExecutionResult.
/// </summary>
internal ExecutionResult() { }

/// <summary>
/// Indicates whether the token acquisition was successful.
/// </summary>
/// <value>
/// <c>true</c> if the token was successfully acquired; otherwise, <c>false</c>.
/// </value>
public bool Successful { get; internal set; }

/// <summary>
/// The authentication result if the token acquisition was successful.
/// </summary>
/// <value>
/// An <see cref="AuthenticationResult"/> containing the access token and related metadata if <see cref="Successful"/> is <c>true</c>;
/// otherwise, <c>null</c>.
/// </value>
public AuthenticationResult Result { get; internal set; }

/// <summary>
/// The exception that occurred if the token acquisition failed.
/// </summary>
/// <value>
/// An <see cref="MsalException"/> describing the failure if <see cref="Successful"/> is <c>false</c>;
/// otherwise, <c>null</c>.
/// </value>
public MsalException Exception { get; internal set; }

/// <summary>
/// The certificate used for authentication, if certificate-based authentication was used.
/// </summary>
/// <value>
/// An <see cref="X509Certificate2"/> used to authenticate the client application;
/// otherwise, <c>null</c> if certificate authentication was not used or if the certificate is not available.
/// </value>
/// <remarks>
/// 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.
/// </remarks>
public X509Certificate2 Certificate { get; internal set; }
}
}
Loading
Loading