Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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 @@ -131,6 +131,25 @@ public string ClientVersion
internal IRetryPolicyFactory RetryPolicyFactory { get; set; }
internal ICsrFactory CsrFactory { get; set; }

#region Extensibility Callbacks

/// <summary>
/// Dynamic certificate provider callback for client credential flows.
/// </summary>
public Func<IAppConfig, X509Certificate2> ClientCredentialCertificateProvider { get; set; }

/// <summary>
/// Retry policy callback that determines whether to retry after a token acquisition failure.
/// </summary>
public Func<IAppConfig, MsalException, bool> RetryPolicy { get; set; }

/// <summary>
/// Execution observer callback that receives the final result of token acquisition attempts.
/// </summary>
public Action<IAppConfig, ExecutionResult> ExecutionObserver { get; set; }

#endregion

#region ClientCredentials

// Indicates if claims or assertions are used within the configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,16 @@ internal override void Validate()
throw new InvalidOperationException(MsalErrorMessage.InvalidRedirectUriReceived(Config.RedirectUri));
}

// Validate mutual exclusivity between static certificate and dynamic certificate provider
if (Config.ClientCredential is CertificateClientCredential &&
Config.ClientCredentialCertificateProvider != null)
{
throw new MsalClientException(
MsalError.InvalidClientCredentialConfiguration,
"Cannot use both WithCertificate(X509Certificate2) and WithCertificate(Func<IAppConfig, X509Certificate2>). " +
"Choose one approach for providing client credentials.");
}

ValidateAndUpdateRegion();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using System;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;

namespace Microsoft.Identity.Client.Extensibility
Expand Down Expand Up @@ -29,5 +30,103 @@ public static ConfidentialClientApplicationBuilder WithAppTokenProvider(
builder.Config.AppTokenProvider = appTokenProvider ?? throw new ArgumentNullException(nameof(appTokenProvider));
return builder;
}

/// <summary>
/// Configures a 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">
/// A 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>See https://aka.ms/msal-net-client-credentials for more details on client credentials.</para>
/// </remarks>
public static ConfidentialClientApplicationBuilder WithCertificate(
this ConfidentialClientApplicationBuilder builder,
Func<IAppConfig, X509Certificate2> certificateProvider)
{
if (certificateProvider == null)
{
throw new ArgumentNullException(nameof(certificateProvider));
}

builder.Config.ClientCredentialCertificateProvider = certificateProvider;
return builder;
}

/// <summary>
/// Configures a retry policy for token acquisition failures.
/// The policy is invoked after each failed token request to determine whether a retry should be attempted.
/// MSAL will respect throttling hints from the identity provider and apply appropriate delays between retries.
/// </summary>
/// <param name="builder">The confidential client application builder.</param>
/// <param name="retryPolicy">
/// A callback that determines whether to retry after a failure.
/// Receives the application configuration and the exception that occurred.
/// Returns <c>true</c> to retry the request, or <c>false</c> to stop retrying and throw the exception.
/// The callback will be invoked repeatedly after each failure until it returns <c>false</c>.
/// </param>
/// <returns>The builder to chain additional configuration calls.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="retryPolicy"/> is null.</exception>
/// <remarks>
/// <para>The retry policy is only invoked for network failures, not for cached token retrievals.</para>
/// <para>When the policy returns <c>true</c>, MSAL will invoke the certificate provider callback again (if configured)
/// 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.</para>
/// <para>To prevent infinite loops, ensure your retry policy has appropriate termination conditions.</para>
/// </remarks>
public static ConfidentialClientApplicationBuilder WithRetry(
this ConfidentialClientApplicationBuilder builder,
Func<IAppConfig, MsalException, bool> retryPolicy)
{
if (retryPolicy == null)
throw new ArgumentNullException(nameof(retryPolicy));

builder.Config.RetryPolicy = retryPolicy;
return builder;
}

/// <summary>
/// Configures an observer callback that receives the final result of token acquisition.
/// The observer is invoked once at the completion of <c>ExecuteAsync</c>, with either a success or failure result.
/// This enables scenarios such as telemetry, logging, and custom error handling.
/// </summary>
/// <param name="builder">The confidential client application builder.</param>
/// <param name="observer">
/// A callback that receives the application configuration 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 retry policy is configured).
/// </param>
/// <returns>The builder to chain additional configuration calls.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="observer"/> is null.</exception>
/// <remarks>
/// <para>The observer is only invoked for network token acquisition attempts, not for cached token retrievals.</para>
/// <para>If multiple calls to <c>WithObserver</c> are made, only the last configured observer will be used.</para>
/// <para>Exceptions thrown by the observer callback will be caught and logged internally to prevent disruption of the authentication flow.</para>
/// <para>The observer is called on the same thread as the token acquisition request.</para>
/// </remarks>
public static ConfidentialClientApplicationBuilder WithObserver(
this ConfidentialClientApplicationBuilder builder,
Action<IAppConfig, ExecutionResult> observer)
{
if (observer == null)
throw new ArgumentNullException(nameof(observer));

builder.Config.ExecutionObserver = observer;
return builder;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

namespace Microsoft.Identity.Client.Extensibility
{
/// <summary>
/// Represents the result of a token acquisition attempt.
/// Used by the execution observer configured via <see cref="ConfidentialClientApplicationBuilderExtensions.WithObserver"/>.
/// </summary>
public class ExecutionResult
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this include the credential used (primarily care about the exact certificate used). If that information is in AuthenticationResult then that is fine.

{
/// <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; }
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Runtime.ConstrainedExecution;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -35,7 +32,12 @@ public CertificateAndClaimsClientCredential(
Certificate = certificate;
_claimsToSign = claimsToSign;
_appendDefaultClaims = appendDefaultClaims;
_base64EncodedThumbprint = Base64UrlHelpers.Encode(certificate.GetCertHash());

// Certificate can be null when using dynamic certificate provider
if (certificate != null)
{
_base64EncodedThumbprint = Base64UrlHelpers.Encode(certificate.GetCertHash());
}
}

public Task AddConfidentialClientParametersAsync(
Expand All @@ -54,6 +56,9 @@ public Task AddConfidentialClientParametersAsync(
{
requestParameters.RequestContext.Logger.Verbose(() => "Proceeding with JWT token creation and adding client assertion.");

// Resolve the certificate - either from static config or dynamic provider
X509Certificate2 effectiveCertificate = ResolveCertificate(requestParameters);

bool useSha2 = requestParameters.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported;

var jwtToken = new JsonWebToken(
Expand All @@ -63,7 +68,7 @@ public Task AddConfidentialClientParametersAsync(
_claimsToSign,
_appendDefaultClaims);

string assertion = jwtToken.Sign(Certificate, requestParameters.SendX5C, useSha2);
string assertion = jwtToken.Sign(effectiveCertificate, requestParameters.SendX5C, useSha2);

oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion);
Expand All @@ -76,5 +81,71 @@ public Task AddConfidentialClientParametersAsync(

return Task.CompletedTask;
}

/// <summary>
/// Resolves the certificate to use for signing the client assertion.
/// If a dynamic certificate provider is configured, it will be invoked to get the certificate.
/// Otherwise, the static certificate configured at build time is used.
/// </summary>
/// <param name="requestParameters">The authentication request parameters containing app config</param>
/// <returns>The X509Certificate2 to use for signing</returns>
/// <exception cref="MsalClientException">Thrown if the certificate provider returns null or an invalid certificate</exception>
private X509Certificate2 ResolveCertificate(AuthenticationRequestParameters requestParameters)
{
// Check if dynamic certificate provider is configured
if (requestParameters.AppConfig.ClientCredentialCertificateProvider != null)
{
requestParameters.RequestContext.Logger.Verbose(
() => "[CertificateAndClaimsClientCredential] Resolving certificate from dynamic provider.");

// Invoke the provider to get the certificate
X509Certificate2 providedCertificate = requestParameters.AppConfig.ClientCredentialCertificateProvider(
requestParameters.AppConfig);

// Validate the certificate returned by the provider
if (providedCertificate == null)
{
requestParameters.RequestContext.Logger.Error(
"[CertificateAndClaimsClientCredential] Certificate provider returned null.");

throw new MsalClientException(
MsalError.InvalidClientAssertion,
"The certificate provider callback returned null. Ensure the callback returns a valid X509Certificate2 instance.");
}

if (!providedCertificate.HasPrivateKey)
{
requestParameters.RequestContext.Logger.Error(
"[CertificateAndClaimsClientCredential] Certificate from provider does not have a private key.");

throw new MsalClientException(
MsalError.CertWithoutPrivateKey,
"The certificate returned by the provider does not have a private key. " +
"Ensure the certificate has a private key for signing operations.");
}

requestParameters.RequestContext.Logger.Info(
() => $"[CertificateAndClaimsClientCredential] Successfully resolved certificate from provider. " +
$"Thumbprint: {providedCertificate.Thumbprint}");

return providedCertificate;
}

// Use the static certificate configured at build time
if (Certificate == null)
{
requestParameters.RequestContext.Logger.Error(
"[CertificateAndClaimsClientCredential] No certificate configured (static or dynamic).");

throw new MsalClientException(
MsalError.InvalidClientAssertion,
"No certificate is configured. Use WithCertificate() to provide a certificate.");
}

requestParameters.RequestContext.Logger.Verbose(
() => $"[CertificateAndClaimsClientCredential] Using static certificate. Thumbprint: {Certificate.Thumbprint}");

return Certificate;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<Compile Include="$(PathToMsalSources)\**\*.cs" Exclude="$(PathToMsalSources)\obj\**\*.*" />
<Compile Remove="$(PathToMsalSources)\Platforms\**\*.*;$(PathToMsalSources)\Resources\*.cs" />
<Compile Remove="$(PathToMsalSources)\PlatformsCommon\PlatformNotSupported\ApiConfig\SystemWebViewOptions.cs" />
<None Remove="Extensibility\ExecutionResult.cs" />
<EmbeddedResource Include="$(PathToMsalSources)\Properties\Microsoft.Identity.Client.rd.xml" />
<None Include="$(PathToMsalSources)\..\..\..\README.md" Pack="true" PackagePath="\" />
<None Include="Platforms\net\JsonObjectAttribute.cs" />
Expand Down Expand Up @@ -161,4 +162,8 @@
<AdditionalFiles Include="PublicAPI/$(TargetFramework)/PublicAPI.Unshipped.txt" />
</ItemGroup>

<ItemGroup>
<Compile Include="Extensibility\ExecutionResult.cs" />
</ItemGroup>

</Project>
6 changes: 6 additions & 0 deletions src/client/Microsoft.Identity.Client/MsalError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,12 @@ public static class MsalError
/// </summary>
public const string ClientCredentialAuthenticationTypeMustBeDefined = "Client_Credentials_Required_In_Confidential_Client_Application";

/// <summary>
/// <para>What happens?</para>You configured both a static certificate (WithCertificate(X509Certificate2)) and a dynamic certificate provider (WithCertificate(Func)).
/// <para>Mitigation</para>Choose one approach for providing the client certificate.
/// </summary>
public const string InvalidClientCredentialConfiguration = "invalid_client_credential_configuration";

#region InvalidGrant suberrors
/// <summary>
/// Issue can be resolved by user interaction during the interactive authentication flow.
Expand Down
Loading