Skip to content

Commit 8f87b40

Browse files
Add extensibility APIs (#5573)
* Add extensibility APIs * Add functionality for retry and onSuccess. * Fix build * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Make the APIs experimental * Fix build failures in pipeline * Fix tests * Update to use AssertionRequestOptions * Resolve conflicts * Fix build issue * Public API analyzers * Address comments * Update to send execution result back for OnMsalServiceFailure callback * Update the name * Fix merge conflicts issue * Fix build * Fix build * Fix missed code while merge --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 20474b8 commit 8f87b40

21 files changed

+1563
-43
lines changed

src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ protected override void Validate()
4848
// Confidential client must have a credential
4949
if (ServiceBundle?.Config.ClientCredential == null &&
5050
CommonParameters.OnBeforeTokenRequestHandler == null &&
51-
ServiceBundle?.Config.AppTokenProvider == null
51+
ServiceBundle?.Config.AppTokenProvider == null
5252
)
5353
{
5454
throw new MsalClientException(

src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,21 @@ public string ClientVersion
131131
internal IRetryPolicyFactory RetryPolicyFactory { get; set; }
132132
internal ICsrFactory CsrFactory { get; set; }
133133

134+
#region Extensibility Callbacks
135+
136+
/// <summary>
137+
/// MSAL service failure callback that determines whether to retry after a token acquisition failure from the identity provider.
138+
/// Only invoked for MsalServiceException (errors from the Security Token Service).
139+
/// </summary>
140+
public Func<AssertionRequestOptions, ExecutionResult, Task<bool>> OnMsalServiceFailure { get; set; }
141+
142+
/// <summary>
143+
/// Success callback that receives the result of token acquisition attempts (typically successful, but can include failures after retries are exhausted).
144+
/// </summary>
145+
public Func<AssertionRequestOptions, ExecutionResult, Task> OnCompletion { get; set; }
146+
147+
#endregion
148+
134149
#region ClientCredentials
135150

136151
// Indicates if claims or assertions are used within the configuration
@@ -154,14 +169,22 @@ public string ClientSecret
154169

155170
/// <summary>
156171
/// This is here just to support the public IAppConfig. Should not be used internally, instead use the <see cref="ClientCredential" /> abstraction.
172+
/// Note: This returns null when using dynamic certificate providers since the certificate is resolved at runtime.
157173
/// </summary>
158174
public X509Certificate2 ClientCredentialCertificate
159175
{
160176
get
161177
{
162-
if (ClientCredential is CertificateAndClaimsClientCredential cred)
178+
// Return the certificate if using static certificate (CertificateClientCredential)
179+
if (ClientCredential is CertificateClientCredential certCred)
180+
{
181+
return certCred.Certificate;
182+
}
183+
184+
// Return the certificate if using CertificateAndClaimsClientCredential with a static certificate
185+
if (ClientCredential is CertificateAndClaimsClientCredential certAndClaimsCred)
163186
{
164-
return cred.Certificate;
187+
return certAndClaimsCred.Certificate;
165188
}
166189

167190
return null;

src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,37 @@
66

77
namespace Microsoft.Identity.Client
88
{
9-
/// <summary>
10-
/// Information about the client assertion that need to be generated See https://aka.ms/msal-net-client-assertion
11-
/// </summary>
12-
/// <remarks> Use the provided information to generate the client assertion payload </remarks>
9+
/// <summary>
10+
/// Information about the client assertion that need to be generated See https://aka.ms/msal-net-client-assertion
11+
/// </summary>
12+
/// <remarks> Use the provided information to generate the client assertion payload </remarks>
1313
#if !SUPPORTS_CONFIDENTIAL_CLIENT
1414
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile
1515
#endif
1616
public class AssertionRequestOptions {
1717
/// <summary>
18+
/// Default constructor for AssertionRequestOptions
19+
/// </summary>
20+
public AssertionRequestOptions()
21+
{
22+
}
23+
24+
/// <summary>
25+
/// Internal constructor that creates AssertionRequestOptions from ApplicationConfiguration
26+
/// </summary>
27+
/// <param name="appConfig">The application configuration</param>
28+
/// <param name="tokenEndpoint">The token endpoint used to acquire the token</param>
29+
/// <param name="tenantId">The tenant ID from the runtime authority</param>
30+
internal AssertionRequestOptions(ApplicationConfiguration appConfig, string tokenEndpoint, string tenantId)
31+
{
32+
ClientID = appConfig.ClientId;
33+
TokenEndpoint = tokenEndpoint;
34+
Authority = appConfig.Authority?.AuthorityInfo?.CanonicalAuthority?.ToString();
35+
TenantId = tenantId;
36+
}
37+
38+
/// <summary>
39+
/// Cancellation token to cancel the operation
1840
/// </summary>
1941
public CancellationToken CancellationToken { get; set; }
2042

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

48+
/// <summary>
49+
/// Tenant ID for the authentication request
50+
/// </summary>
51+
public string TenantId { get; set; }
52+
53+
/// <summary>
54+
/// The authority URL (e.g., https://login.microsoftonline.com/{tenantId})
55+
/// </summary>
56+
public string Authority { get; set; }
57+
2658
/// <summary>
2759
/// The intended token endpoint
2860
/// </summary>

src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,12 @@ public ConfidentialClientApplicationBuilder WithClientClaims(X509Certificate2 ce
169169
throw new ArgumentNullException(nameof(claimsToSign));
170170
}
171171

172-
Config.ClientCredential = new CertificateAndClaimsClientCredential(certificate, claimsToSign, mergeWithDefaultClaims);
172+
// Wrap the static certificate in a provider delegate
173+
Config.ClientCredential = new CertificateAndClaimsClientCredential(
174+
certificateProvider: _ => Task.FromResult(certificate),
175+
claimsToSign: claimsToSign,
176+
appendDefaultClaims: mergeWithDefaultClaims,
177+
certificate: certificate);
173178
Config.SendX5C = sendX5C;
174179
return this;
175180
}

src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Security.Cryptography.X509Certificates;
56
using System.Threading.Tasks;
7+
using Microsoft.Identity.Client.Internal.ClientCredential;
68

79
namespace Microsoft.Identity.Client.Extensibility
810
{
@@ -29,5 +31,154 @@ public static ConfidentialClientApplicationBuilder WithAppTokenProvider(
2931
builder.Config.AppTokenProvider = appTokenProvider ?? throw new ArgumentNullException(nameof(appTokenProvider));
3032
return builder;
3133
}
34+
35+
/// <summary>
36+
/// Configures an async callback to provide the client credential certificate dynamically.
37+
/// The callback is invoked before each token acquisition request to the identity provider (including retries).
38+
/// This enables scenarios such as certificate rotation and dynamic certificate selection based on application context.
39+
/// </summary>
40+
/// <param name="builder">The confidential client application builder.</param>
41+
/// <param name="certificateProvider">
42+
/// An async callback that provides the certificate based on the application configuration.
43+
/// Called before each network request to acquire a token.
44+
/// Must return a valid <see cref="X509Certificate2"/> with a private key.
45+
/// </param>
46+
/// <returns>The builder to chain additional configuration calls.</returns>
47+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="certificateProvider"/> is null.</exception>
48+
/// <exception cref="MsalClientException">
49+
/// Thrown at build time if both <see cref="ConfidentialClientApplicationBuilder.WithCertificate(X509Certificate2)"/>
50+
/// and this method are configured.
51+
/// </exception>
52+
/// <remarks>
53+
/// <para>This method cannot be used together with <see cref="ConfidentialClientApplicationBuilder.WithCertificate(X509Certificate2)"/>.</para>
54+
/// <para>The callback is not invoked when tokens are retrieved from cache, only for network calls.</para>
55+
/// <para>The certificate returned by the callback will be used to sign the client assertion (JWT) for that token request.</para>
56+
/// <para>The callback can perform async operations such as fetching certificates from Azure Key Vault or other secret management systems.</para>
57+
/// <para>See https://aka.ms/msal-net-client-credentials for more details on client credentials.</para>
58+
/// </remarks>
59+
public static ConfidentialClientApplicationBuilder WithCertificate(
60+
this ConfidentialClientApplicationBuilder builder,
61+
Func<AssertionRequestOptions, Task<X509Certificate2>> certificateProvider)
62+
{
63+
if (certificateProvider == null)
64+
{
65+
throw new ArgumentNullException(nameof(certificateProvider));
66+
}
67+
68+
// Create a DynamicCertificateClientCredential with the certificate provider
69+
// The certificate will be resolved dynamically via the provider in ResolveCertificateAsync
70+
builder.Config.ClientCredential = new DynamicCertificateClientCredential(
71+
certificateProvider: certificateProvider);
72+
73+
return builder;
74+
}
75+
76+
/// <summary>
77+
/// Configures an async callback that is invoked when MSAL receives an error response from the identity provider (Security Token Service).
78+
/// The callback determines whether MSAL should retry the token request or propagate the exception.
79+
/// This callback is invoked after each service failure and can be called multiple times until it returns <c>false</c> or the request succeeds.
80+
/// </summary>
81+
/// <param name="builder">The confidential client application builder.</param>
82+
/// <param name="onMsalServiceFailure">
83+
/// An async callback that determines whether to retry after a service failure.
84+
/// Receives the assertion request options and the <see cref="MsalServiceException"/> that occurred.
85+
/// Returns <c>true</c> to retry the request, or <c>false</c> to stop retrying and propagate the exception.
86+
/// The callback will be invoked repeatedly after each service failure until it returns <c>false</c> or the request succeeds.
87+
/// </param>
88+
/// <returns>The builder to chain additional configuration calls.</returns>
89+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="onMsalServiceFailure"/> is null.</exception>
90+
/// <remarks>
91+
/// <para>This callback is ONLY triggered for <see cref="MsalServiceException"/> - errors returned by the identity provider (e.g., HTTP 500, 503, throttling).</para>
92+
/// <para>This callback is NOT triggered for client-side errors (<see cref="MsalClientException"/>) or network failures handled internally by MSAL.</para>
93+
/// <para>This callback is only invoked for network token acquisition attempts, not when tokens are retrieved from cache.</para>
94+
/// <para>When the callback returns <c>true</c>, MSAL will invoke the certificate provider (if configured via <see cref="WithCertificate"/>)
95+
/// before making another token request, enabling certificate rotation scenarios.</para>
96+
/// <para>MSAL's internal throttling and retry mechanisms will still apply, including respecting Retry-After headers from the identity provider.</para>
97+
/// <para>To prevent infinite loops, ensure your callback has appropriate termination conditions (e.g., max retry count, timeout).</para>
98+
/// <para>The callback can perform async operations such as logging to remote services, checking external health endpoints, or querying configuration stores.</para>
99+
/// </remarks>
100+
/// <example>
101+
/// <code>
102+
/// int retryCount = 0;
103+
/// var app = ConfidentialClientApplicationBuilder
104+
/// .Create(clientId)
105+
/// .WithCertificate(async options => await GetCertificateFromKeyVaultAsync(options.TokenEndpoint))
106+
/// .OnMsalServiceFailure(async (options, serviceException) =>
107+
/// {
108+
/// retryCount++;
109+
/// await LogExceptionAsync(serviceException);
110+
///
111+
/// // Retry up to 3 times for transient service errors (5xx)
112+
/// return serviceException.StatusCode >= 500 &amp;&amp; retryCount &lt; 3;
113+
/// })
114+
/// .Build();
115+
/// </code>
116+
/// </example>
117+
public static ConfidentialClientApplicationBuilder OnMsalServiceFailure(
118+
this ConfidentialClientApplicationBuilder builder,
119+
Func<AssertionRequestOptions, ExecutionResult, Task<bool>> onMsalServiceFailure)
120+
{
121+
if (onMsalServiceFailure == null)
122+
throw new ArgumentNullException(nameof(onMsalServiceFailure));
123+
124+
builder.Config.OnMsalServiceFailure = onMsalServiceFailure;
125+
return builder;
126+
}
127+
128+
/// <summary>
129+
/// Configures an async callback that is invoked when a token acquisition request completes.
130+
/// This callback is invoked once per <c>AcquireTokenForClient</c> call, after all retry attempts have been exhausted.
131+
/// While named <c>OnCompletion</c> for the common case, this callback fires for both successful and failed acquisitions.
132+
/// This enables scenarios such as telemetry, logging, and custom result handling.
133+
/// </summary>
134+
/// <param name="builder">The confidential client application builder.</param>
135+
/// <param name="onCompletion">
136+
/// An async callback that receives the assertion request options and the execution result.
137+
/// The result contains either the successful <see cref="AuthenticationResult"/> or the <see cref="MsalException"/> that occurred.
138+
/// This callback is invoked after all retries have been exhausted (if an <see cref="OnMsalServiceFailure"/> handler is configured).
139+
/// </param>
140+
/// <returns>The builder to chain additional configuration calls.</returns>
141+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="onCompletion"/> is null.</exception>
142+
/// <remarks>
143+
/// <para>This callback is invoked for both successful and failed token acquisitions. Check <see cref="ExecutionResult.Successful"/> to determine the outcome.</para>
144+
/// <para>This callback is only invoked for network token acquisition attempts, not when tokens are retrieved from cache.</para>
145+
/// <para>If multiple calls to <c>OnCompletion</c> are made, only the last configured callback will be used.</para>
146+
/// <para>Exceptions thrown by this callback will be caught and logged internally to prevent disruption of the authentication flow.</para>
147+
/// <para>The callback is invoked on the same thread/context as the token acquisition request.</para>
148+
/// <para>The callback can perform async operations such as sending telemetry to Application Insights, persisting logs to databases, or triggering webhooks.</para>
149+
/// </remarks>
150+
/// <example>
151+
/// <code>
152+
/// var app = ConfidentialClientApplicationBuilder
153+
/// .Create(clientId)
154+
/// .WithCertificate(certificate)
155+
/// .OnCompletion(async (options, result) =>
156+
/// {
157+
/// if (result.Successful)
158+
/// {
159+
/// await telemetry.TrackEventAsync("TokenAcquired", new { ClientId = options.ClientID });
160+
/// }
161+
/// else
162+
/// {
163+
/// await telemetry.TrackExceptionAsync(result.Exception);
164+
/// }
165+
/// })
166+
/// .Build();
167+
/// </code>
168+
/// </example>
169+
public static ConfidentialClientApplicationBuilder OnCompletion(
170+
this ConfidentialClientApplicationBuilder builder,
171+
Func<AssertionRequestOptions, ExecutionResult, Task> onCompletion)
172+
{
173+
builder.ValidateUseOfExperimentalFeature();
174+
175+
if (onCompletion == null)
176+
{
177+
throw new ArgumentNullException(nameof(onCompletion));
178+
}
179+
180+
builder.Config.OnCompletion = onCompletion;
181+
return builder;
182+
}
32183
}
33184
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System.Security.Cryptography.X509Certificates;
5+
6+
namespace Microsoft.Identity.Client.Extensibility
7+
{
8+
/// <summary>
9+
/// Represents the result of a token acquisition attempt.
10+
/// Used by the execution observer configured via <see cref="ConfidentialClientApplicationBuilderExtensions.OnCompletion"/>.
11+
/// </summary>
12+
#if !SUPPORTS_CONFIDENTIAL_CLIENT
13+
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile
14+
#endif
15+
public class ExecutionResult
16+
{
17+
/// <summary>
18+
/// Internal constructor for ExecutionResult.
19+
/// </summary>
20+
internal ExecutionResult() { }
21+
22+
/// <summary>
23+
/// Indicates whether the token acquisition was successful.
24+
/// </summary>
25+
/// <value>
26+
/// <c>true</c> if the token was successfully acquired; otherwise, <c>false</c>.
27+
/// </value>
28+
public bool Successful { get; internal set; }
29+
30+
/// <summary>
31+
/// The authentication result if the token acquisition was successful.
32+
/// </summary>
33+
/// <value>
34+
/// An <see cref="AuthenticationResult"/> containing the access token and related metadata if <see cref="Successful"/> is <c>true</c>;
35+
/// otherwise, <c>null</c>.
36+
/// </value>
37+
public AuthenticationResult Result { get; internal set; }
38+
39+
/// <summary>
40+
/// The exception that occurred if the token acquisition failed.
41+
/// </summary>
42+
/// <value>
43+
/// An <see cref="MsalException"/> describing the failure if <see cref="Successful"/> is <c>false</c>;
44+
/// otherwise, <c>null</c>.
45+
/// </value>
46+
public MsalException Exception { get; internal set; }
47+
48+
/// <summary>
49+
/// The certificate used for authentication, if certificate-based authentication was used.
50+
/// </summary>
51+
/// <value>
52+
/// An <see cref="X509Certificate2"/> used to authenticate the client application;
53+
/// otherwise, <c>null</c> if certificate authentication was not used or if the certificate is not available.
54+
/// </value>
55+
/// <remarks>
56+
/// This property provides access to the certificate used during the token acquisition for this request.
57+
/// </remarks>
58+
public X509Certificate2 ClientCertificate { get; internal set; }
59+
}
60+
}

0 commit comments

Comments
 (0)