Skip to content

Commit f85737f

Browse files
Add a callback for background token refresh outcomes
Proactive (background) refresh is fire-and-forget, so callers can't see whether it succeeded, how long it took, or why it failed. This adds an opt-in callback (confidential client + managed identity) that hands back the AuthenticationResult on success or the exception on failure - with the HTTP duration and cache-refresh reason attached, so telemetry layers can report on this path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8aea6a6 commit f85737f

12 files changed

Lines changed: 223 additions & 2 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ public string ClientVersion
145145
/// </summary>
146146
public Func<AssertionRequestOptions, ExecutionResult, Task> OnCompletion { get; set; }
147147

148+
/// <summary>
149+
/// Callback invoked when a fire-and-forget background (proactive) token refresh completes, giving
150+
/// callers visibility into an otherwise-unobservable path. Receives an <see cref="ExecutionResult"/>
151+
/// describing the outcome: on success <see cref="ExecutionResult.Result"/> is the refreshed token; on
152+
/// failure <see cref="ExecutionResult.Exception"/> is the thrown exception (whose
153+
/// <see cref="MsalException.AuthenticationResultMetadata"/> carries the failed attempt's HTTP duration).
154+
/// Only set for confidential-client and managed-identity applications.
155+
/// </summary>
156+
public Func<ExecutionResult, Task> OnBackgroundTokenRefreshCompleted { get; set; }
157+
148158
#endregion
149159

150160
#region ClientCredentials

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,5 +189,38 @@ public static ConfidentialClientApplicationBuilder OnCompletion(
189189
builder.Config.OnCompletion = onCompletion;
190190
return builder;
191191
}
192+
193+
/// <summary>
194+
/// Configures an async callback invoked when a fire-and-forget background (proactive) token refresh completes.
195+
/// Proactive refresh runs on a background thread after the caller has already received a valid cached token,
196+
/// so its outcome (latency, failures) is otherwise unobservable. This callback surfaces it, e.g. for telemetry.
197+
/// </summary>
198+
/// <param name="builder">The confidential client application builder.</param>
199+
/// <param name="onBackgroundTokenRefreshCompleted">
200+
/// An async callback that receives the <see cref="ExecutionResult"/> of the background refresh. On success,
201+
/// <see cref="ExecutionResult.Result"/> holds the refreshed token; on failure, <see cref="ExecutionResult.Exception"/>
202+
/// holds the exception, whose <see cref="MsalException.AuthenticationResultMetadata"/> carries the failed
203+
/// attempt's HTTP duration.
204+
/// </param>
205+
/// <returns>The builder to chain additional configuration calls.</returns>
206+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="onBackgroundTokenRefreshCompleted"/> is null.</exception>
207+
/// <remarks>
208+
/// <para>Invoked on a background thread. Check <see cref="ExecutionResult.Successful"/> to determine the outcome.</para>
209+
/// <para>Exceptions thrown by this callback are caught and logged internally so they cannot disrupt the refresh.</para>
210+
/// </remarks>
211+
public static ConfidentialClientApplicationBuilder OnBackgroundTokenRefreshCompleted(
212+
this ConfidentialClientApplicationBuilder builder,
213+
Func<ExecutionResult, Task> onBackgroundTokenRefreshCompleted)
214+
{
215+
builder.ValidateUseOfExperimentalFeature();
216+
217+
if (onBackgroundTokenRefreshCompleted == null)
218+
{
219+
throw new ArgumentNullException(nameof(onBackgroundTokenRefreshCompleted));
220+
}
221+
222+
builder.Config.OnBackgroundTokenRefreshCompleted = onBackgroundTokenRefreshCompleted;
223+
return builder;
224+
}
192225
}
193226
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Threading.Tasks;
6+
7+
namespace Microsoft.Identity.Client.Extensibility
8+
{
9+
/// <summary>
10+
/// Extensibility methods for <see cref="ManagedIdentityApplicationBuilder"/>.
11+
/// </summary>
12+
#if !SUPPORTS_CONFIDENTIAL_CLIENT
13+
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide managed identity flow on mobile
14+
#endif
15+
public static class ManagedIdentityApplicationBuilderExtensions
16+
{
17+
/// <summary>
18+
/// Configures an async callback invoked when a fire-and-forget background (proactive) token refresh completes.
19+
/// Proactive refresh runs on a background thread after the caller has already received a valid cached token,
20+
/// so its outcome (latency, failures) is otherwise unobservable. This callback surfaces it, e.g. for telemetry.
21+
/// </summary>
22+
/// <param name="builder">The managed identity application builder.</param>
23+
/// <param name="onBackgroundTokenRefreshCompleted">
24+
/// An async callback that receives the <see cref="ExecutionResult"/> of the background refresh. On success,
25+
/// <see cref="ExecutionResult.Result"/> holds the refreshed token; on failure, <see cref="ExecutionResult.Exception"/>
26+
/// holds the exception, whose <see cref="MsalException.AuthenticationResultMetadata"/> carries the failed
27+
/// attempt's HTTP duration.
28+
/// </param>
29+
/// <returns>The builder to chain additional configuration calls.</returns>
30+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="onBackgroundTokenRefreshCompleted"/> is null.</exception>
31+
/// <remarks>
32+
/// <para>Invoked on a background thread. Check <see cref="ExecutionResult.Successful"/> to determine the outcome.</para>
33+
/// <para>Exceptions thrown by this callback are caught and logged internally so they cannot disrupt the refresh.</para>
34+
/// </remarks>
35+
public static ManagedIdentityApplicationBuilder OnBackgroundTokenRefreshCompleted(
36+
this ManagedIdentityApplicationBuilder builder,
37+
Func<ExecutionResult, Task> onBackgroundTokenRefreshCompleted)
38+
{
39+
builder.ValidateUseOfExperimentalFeature();
40+
41+
if (onBackgroundTokenRefreshCompleted == null)
42+
{
43+
throw new ArgumentNullException(nameof(onBackgroundTokenRefreshCompleted));
44+
}
45+
46+
builder.Config.OnBackgroundTokenRefreshCompleted = onBackgroundTokenRefreshCompleted;
47+
return builder;
48+
}
49+
}
50+
}

src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ private void UpdateTelemetry(long elapsedMilliseconds, ApiEvent apiEvent, Authen
293293
/// is left at its default (0 / null). <see cref="AuthenticationResultMetadata.TokenSource"/> has
294294
/// no meaningful value on failure, so it stays at the constructor default and should not be relied on.
295295
/// </summary>
296-
private static AuthenticationResultMetadata CreateFailureMetadata(ApiEvent apiEvent, long totalDurationInMs)
296+
internal static AuthenticationResultMetadata CreateFailureMetadata(ApiEvent apiEvent, long totalDurationInMs)
297297
{
298298
return new AuthenticationResultMetadata(TokenSource.IdentityProvider)
299299
{

src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,13 @@ internal static void ProcessFetchInBackground(
9898
try
9999
{
100100
var authResult = await fetchAction().ConfigureAwait(false);
101+
var executionResult = new ExecutionResult { Successful = true, Result = authResult };
101102

102103
// Invoke the enricher once for this background refresh and reuse the materialized
103104
// tags across every instrument below.
104105
IReadOnlyList<KeyValuePair<string, object>> extraTags = OtelEnrichmentHelper.MaterializeExtraTags(
105106
tagsEnricher,
106-
() => new ExecutionResult { Successful = true, Result = authResult },
107+
() => executionResult,
107108
logger);
108109

109110
serviceBundle.PlatformProxy.OtelInstrumentation.IncrementSuccessCounter(
@@ -135,6 +136,8 @@ internal static void ProcessFetchInBackground(
135136
authResult.AuthenticationResultMetadata,
136137
logger,
137138
extraTags);
139+
140+
await InvokeBackgroundRefreshCallbackAsync(serviceBundle, executionResult, logger).ConfigureAwait(false);
138141
}
139142
catch (MsalServiceException ex)
140143
{
@@ -150,22 +153,66 @@ internal static void ProcessFetchInBackground(
150153

151154
LogBackgroundFailureTelemetry(serviceBundle, apiEvent, callerSdkId, callerSdkVersion,
152155
ex.ErrorCode, ex.StatusCode, ex.ErrorCodes?.FirstOrDefault(), ex, tagsEnricher, logger);
156+
157+
// Background refresh doesn't go through RunAsync, so the exception isn't carrying metadata yet.
158+
// Fill it in from apiEvent so the callback can see the HTTP duration and cache-refresh reason.
159+
// Total duration stays 0 on purpose - the caller already got a token from the cache.
160+
if (ex.AuthenticationResultMetadata == null)
161+
{
162+
ex.AuthenticationResultMetadata = RequestBase.CreateFailureMetadata(apiEvent, totalDurationInMs: 0);
163+
}
164+
165+
await InvokeBackgroundRefreshCallbackAsync(serviceBundle, new ExecutionResult { Successful = false, Exception = ex }, logger).ConfigureAwait(false);
153166
}
154167
catch (OperationCanceledException ex)
155168
{
156169
logger.WarningPiiWithPrefix(ex, ProactiveRefreshCancellationError);
157170
LogBackgroundFailureTelemetry(serviceBundle, apiEvent, callerSdkId, callerSdkVersion,
158171
ex.GetType().Name, httpStatusCode: 0, tagsEnricher: tagsEnricher, logger: logger);
172+
173+
await InvokeBackgroundRefreshCallbackAsync(serviceBundle, new ExecutionResult { Successful = false, Exception = null }, logger).ConfigureAwait(false);
159174
}
160175
catch (Exception ex)
161176
{
162177
logger.ErrorPiiWithPrefix(ex, ProactiveRefreshGeneralError);
163178
LogBackgroundFailureTelemetry(serviceBundle, apiEvent, callerSdkId, callerSdkVersion,
164179
ex.GetType().Name, httpStatusCode: 0, tagsEnricher: tagsEnricher, logger: logger);
180+
181+
MsalException msalException = ex as MsalException;
182+
if (msalException != null && msalException.AuthenticationResultMetadata == null)
183+
{
184+
msalException.AuthenticationResultMetadata = RequestBase.CreateFailureMetadata(apiEvent, totalDurationInMs: 0);
185+
}
186+
187+
await InvokeBackgroundRefreshCallbackAsync(serviceBundle, new ExecutionResult { Successful = false, Exception = msalException }, logger).ConfigureAwait(false);
165188
}
166189
});
167190
}
168191

192+
// Invokes the app-configured background-refresh completion callback (confidential client and managed
193+
// identity only). Runs on the fire-and-forget background thread; a throwing callback is caught and
194+
// logged so it cannot disrupt the refresh.
195+
private static async Task InvokeBackgroundRefreshCallbackAsync(
196+
IServiceBundle serviceBundle,
197+
ExecutionResult executionResult,
198+
ILoggerAdapter logger)
199+
{
200+
Func<ExecutionResult, Task> callback = serviceBundle.Config.OnBackgroundTokenRefreshCompleted;
201+
if (callback == null)
202+
{
203+
return;
204+
}
205+
206+
try
207+
{
208+
await callback(executionResult).ConfigureAwait(false);
209+
}
210+
catch (Exception ex)
211+
{
212+
logger.WarningPiiWithPrefix(ex, "OnBackgroundTokenRefreshCompleted callback threw an exception; it was suppressed.");
213+
}
214+
}
215+
169216
// Records telemetry for a fire-and-forget background refresh failure: increments the
170217
// failure counter and records V2 HTTP duration when an HTTP exchange happened.
171218
// Total duration is deliberately not recorded — the foreground user already received

src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void
66
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value, bool partitionRefreshToken) -> T
77
static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
88
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata
9+
Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions
10+
static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
11+
static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder

src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void
66
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value, bool partitionRefreshToken) -> T
77
static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
88
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata
9+
Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions
10+
static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
11+
static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder

src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void
66
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value, bool partitionRefreshToken) -> T
77
static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
88
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata
9+
Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions
10+
static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
11+
static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder

src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void
66
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value, bool partitionRefreshToken) -> T
77
static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
88
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata
9+
Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions
10+
static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
11+
static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder

src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void
66
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value, bool partitionRefreshToken) -> T
77
static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder
88
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata
9+
Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions
10+
static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder
11+
static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func<Microsoft.Identity.Client.Extensibility.ExecutionResult, System.Threading.Tasks.Task> onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder

0 commit comments

Comments
 (0)