diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs index 0ab6951024..393020c649 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs @@ -145,6 +145,16 @@ public string ClientVersion /// public Func OnCompletion { get; set; } + /// + /// Callback invoked when a fire-and-forget background (proactive) token refresh completes, giving + /// callers visibility into an otherwise-unobservable path. Receives an + /// describing the outcome: on success is the refreshed token; on + /// failure is the thrown exception (whose + /// carries the failed attempt's HTTP duration). + /// Only set for confidential-client and managed-identity applications. + /// + public Func OnBackgroundTokenRefreshCompleted { get; set; } + #endregion #region ClientCredentials diff --git a/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs index 6d0502cddd..ca56ef7102 100644 --- a/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs +++ b/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs @@ -189,5 +189,38 @@ public static ConfidentialClientApplicationBuilder OnCompletion( builder.Config.OnCompletion = onCompletion; return builder; } + + /// + /// Configures an async callback invoked when a fire-and-forget background (proactive) token refresh completes. + /// Proactive refresh runs on a background thread after the caller has already received a valid cached token, + /// so its outcome (latency, failures) is otherwise unobservable. This callback surfaces it, e.g. for telemetry. + /// + /// The confidential client application builder. + /// + /// An async callback that receives the of the background refresh. On success, + /// holds the refreshed token; on failure, + /// holds the exception, whose carries the failed + /// attempt's HTTP duration. + /// + /// The builder to chain additional configuration calls. + /// Thrown when is null. + /// + /// Invoked on a background thread. Check to determine the outcome. + /// Exceptions thrown by this callback are caught and logged internally so they cannot disrupt the refresh. + /// + public static ConfidentialClientApplicationBuilder OnBackgroundTokenRefreshCompleted( + this ConfidentialClientApplicationBuilder builder, + Func onBackgroundTokenRefreshCompleted) + { + builder.ValidateUseOfExperimentalFeature(); + + if (onBackgroundTokenRefreshCompleted == null) + { + throw new ArgumentNullException(nameof(onBackgroundTokenRefreshCompleted)); + } + + builder.Config.OnBackgroundTokenRefreshCompleted = onBackgroundTokenRefreshCompleted; + return builder; + } } } diff --git a/src/client/Microsoft.Identity.Client/Extensibility/ManagedIdentityApplicationBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/ManagedIdentityApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..cbbcad0d91 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Extensibility/ManagedIdentityApplicationBuilderExtensions.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.Identity.Client.Extensibility +{ + /// + /// Extensibility methods for . + /// +#if !SUPPORTS_CONFIDENTIAL_CLIENT + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide managed identity flow on mobile +#endif + public static class ManagedIdentityApplicationBuilderExtensions + { + /// + /// Configures an async callback invoked when a fire-and-forget background (proactive) token refresh completes. + /// Proactive refresh runs on a background thread after the caller has already received a valid cached token, + /// so its outcome (latency, failures) is otherwise unobservable. This callback surfaces it, e.g. for telemetry. + /// + /// The managed identity application builder. + /// + /// An async callback that receives the of the background refresh. On success, + /// holds the refreshed token; on failure, + /// holds the exception, whose carries the failed + /// attempt's HTTP duration. + /// + /// The builder to chain additional configuration calls. + /// Thrown when is null. + /// + /// Invoked on a background thread. Check to determine the outcome. + /// Exceptions thrown by this callback are caught and logged internally so they cannot disrupt the refresh. + /// + public static ManagedIdentityApplicationBuilder OnBackgroundTokenRefreshCompleted( + this ManagedIdentityApplicationBuilder builder, + Func onBackgroundTokenRefreshCompleted) + { + builder.ValidateUseOfExperimentalFeature(); + + if (onBackgroundTokenRefreshCompleted == null) + { + throw new ArgumentNullException(nameof(onBackgroundTokenRefreshCompleted)); + } + + builder.Config.OnBackgroundTokenRefreshCompleted = onBackgroundTokenRefreshCompleted; + return builder; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs index fb4d346b7a..87bb9f6390 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs @@ -293,7 +293,7 @@ private void UpdateTelemetry(long elapsedMilliseconds, ApiEvent apiEvent, Authen /// is left at its default (0 / null). has /// no meaningful value on failure, so it stays at the constructor default and should not be relied on. /// - private static AuthenticationResultMetadata CreateFailureMetadata(ApiEvent apiEvent, long totalDurationInMs) + internal static AuthenticationResultMetadata CreateFailureMetadata(ApiEvent apiEvent, long totalDurationInMs) { return new AuthenticationResultMetadata(TokenSource.IdentityProvider) { diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs index e877e3153c..0c3e604c45 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/SilentRequestHelper.cs @@ -98,12 +98,13 @@ internal static void ProcessFetchInBackground( try { var authResult = await fetchAction().ConfigureAwait(false); + var executionResult = new ExecutionResult { Successful = true, Result = authResult }; // Invoke the enricher once for this background refresh and reuse the materialized // tags across every instrument below. IReadOnlyList> extraTags = OtelEnrichmentHelper.MaterializeExtraTags( tagsEnricher, - () => new ExecutionResult { Successful = true, Result = authResult }, + () => executionResult, logger); serviceBundle.PlatformProxy.OtelInstrumentation.IncrementSuccessCounter( @@ -135,6 +136,8 @@ internal static void ProcessFetchInBackground( authResult.AuthenticationResultMetadata, logger, extraTags); + + await InvokeBackgroundRefreshCallbackAsync(serviceBundle, executionResult, logger).ConfigureAwait(false); } catch (MsalServiceException ex) { @@ -150,22 +153,66 @@ internal static void ProcessFetchInBackground( LogBackgroundFailureTelemetry(serviceBundle, apiEvent, callerSdkId, callerSdkVersion, ex.ErrorCode, ex.StatusCode, ex.ErrorCodes?.FirstOrDefault(), ex, tagsEnricher, logger); + + // Background refresh doesn't go through RunAsync, so the exception isn't carrying metadata yet. + // Fill it in from apiEvent so the callback can see the HTTP duration and cache-refresh reason. + // Total duration stays 0 on purpose - the caller already got a token from the cache. + if (ex.AuthenticationResultMetadata == null) + { + ex.AuthenticationResultMetadata = RequestBase.CreateFailureMetadata(apiEvent, totalDurationInMs: 0); + } + + await InvokeBackgroundRefreshCallbackAsync(serviceBundle, new ExecutionResult { Successful = false, Exception = ex }, logger).ConfigureAwait(false); } catch (OperationCanceledException ex) { logger.WarningPiiWithPrefix(ex, ProactiveRefreshCancellationError); LogBackgroundFailureTelemetry(serviceBundle, apiEvent, callerSdkId, callerSdkVersion, ex.GetType().Name, httpStatusCode: 0, tagsEnricher: tagsEnricher, logger: logger); + + await InvokeBackgroundRefreshCallbackAsync(serviceBundle, new ExecutionResult { Successful = false, Exception = null }, logger).ConfigureAwait(false); } catch (Exception ex) { logger.ErrorPiiWithPrefix(ex, ProactiveRefreshGeneralError); LogBackgroundFailureTelemetry(serviceBundle, apiEvent, callerSdkId, callerSdkVersion, ex.GetType().Name, httpStatusCode: 0, tagsEnricher: tagsEnricher, logger: logger); + + MsalException msalException = ex as MsalException; + if (msalException != null && msalException.AuthenticationResultMetadata == null) + { + msalException.AuthenticationResultMetadata = RequestBase.CreateFailureMetadata(apiEvent, totalDurationInMs: 0); + } + + await InvokeBackgroundRefreshCallbackAsync(serviceBundle, new ExecutionResult { Successful = false, Exception = msalException }, logger).ConfigureAwait(false); } }); } + // Invokes the app-configured background-refresh completion callback (confidential client and managed + // identity only). Runs on the fire-and-forget background thread; a throwing callback is caught and + // logged so it cannot disrupt the refresh. + private static async Task InvokeBackgroundRefreshCallbackAsync( + IServiceBundle serviceBundle, + ExecutionResult executionResult, + ILoggerAdapter logger) + { + Func callback = serviceBundle.Config.OnBackgroundTokenRefreshCompleted; + if (callback == null) + { + return; + } + + try + { + await callback(executionResult).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.WarningPiiWithPrefix(ex, "OnBackgroundTokenRefreshCompleted callback threw an exception; it was suppressed."); + } + } + // Records telemetry for a fire-and-forget background refresh failure: increments the // failure counter and records V2 HTTP duration when an HTTP exchange happened. // Total duration is deliberately not recorded — the foreground user already received 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 bc378f6013..3126c1d9c0 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata +Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder 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 bc378f6013..3126c1d9c0 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata +Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder 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 bc378f6013..3126c1d9c0 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 @@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata +Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder 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 bc378f6013..3126c1d9c0 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 @@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata +Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder 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 bc378f6013..3126c1d9c0 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 @@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata +Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder 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 bc378f6013..3126c1d9c0 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 @@ -6,3 +6,6 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata +Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ManagedIdentityApplicationBuilderExtensions.OnBackgroundTokenRefreshCompleted(this Microsoft.Identity.Client.ManagedIdentityApplicationBuilder builder, System.Func onBackgroundTokenRefreshCompleted) -> Microsoft.Identity.Client.ManagedIdentityApplicationBuilder diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/RefreshInTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/RefreshInTests.cs index cb86284a2e..d1953bd6ce 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/RefreshInTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/RefreshInTests.cs @@ -10,6 +10,7 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.Cache; using Microsoft.Identity.Client.Cache.Items; +using Microsoft.Identity.Client.Extensibility; using Microsoft.Identity.Client.Internal; using Microsoft.Identity.Client.Internal.Logger; using Microsoft.Identity.Client.OAuth2.Throttling; @@ -536,6 +537,68 @@ void LocalLogCallback(LogLevel level, string message, bool containsPii) } } } + + [TestMethod] + [Description("Background (proactive) refresh succeeds and the completion callback receives the result.")] + public async Task ClientCredentials_BackgroundRefresh_Success_InvokesCallback_Async() + { + using (MockHttpAndServiceBundle harness = base.CreateTestHarness()) + { + ExecutionResult capturedResult = null; + ConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .WithExperimentalFeatures() + .OnBackgroundTokenRefreshCompleted(r => { capturedResult = r; return Task.CompletedTask; }) + .BuildConcrete(); + TokenCacheHelper.PopulateCache(app.AppTokenCacheInternal.Accessor, addSecondAt: false); + + TestCommon.UpdateATWithRefreshOn(app.AppTokenCacheInternal.Accessor); + harness.HttpManager.AddAllMocks(TokenResponseType.Valid_ClientCredentials); + + // Act - foreground returns the cached token and triggers a background refresh. + await app.AcquireTokenForClient(TestConstants.s_scope).ExecuteAsync().ConfigureAwait(false); + + // Assert - the background refresh completed and the callback received the successful outcome. + Assert.IsTrue(TestCommon.YieldTillSatisfied(() => capturedResult != null), "Background refresh callback was not invoked."); + Assert.IsTrue(capturedResult.Successful); + Assert.IsNotNull(capturedResult.Result); + Assert.IsNotNull(capturedResult.Result.AuthenticationResultMetadata); + } + } + + [TestMethod] + [Description("Background (proactive) refresh fails and the completion callback receives the exception with failure metadata.")] + public async Task ClientCredentials_BackgroundRefresh_Failure_InvokesCallbackWithMetadata_Async() + { + using (MockHttpAndServiceBundle harness = base.CreateTestHarness()) + { + ExecutionResult capturedResult = null; + ConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(AzureCloudInstance.AzurePublic, TestConstants.Utid) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .WithExperimentalFeatures() + .OnBackgroundTokenRefreshCompleted(r => { capturedResult = r; return Task.CompletedTask; }) + .BuildConcrete(); + TokenCacheHelper.PopulateCache(app.AppTokenCacheInternal.Accessor, addSecondAt: false); + + TestCommon.UpdateATWithRefreshOn(app.AppTokenCacheInternal.Accessor); + harness.HttpManager.AddAllMocks(TokenResponseType.InvalidGrant); + + // Act + await app.AcquireTokenForClient(TestConstants.s_scope).ExecuteAsync().ConfigureAwait(false); + + // Assert - the callback received a failed outcome whose exception carries the failed attempt's + // metadata (HTTP duration), which is only available because MsalException now exposes it. + Assert.IsTrue(TestCommon.YieldTillSatisfied(() => capturedResult != null), "Background refresh callback was not invoked."); + Assert.IsFalse(capturedResult.Successful); + Assert.IsNotNull(capturedResult.Exception); + Assert.IsNotNull(capturedResult.Exception.AuthenticationResultMetadata); + Assert.AreEqual(CacheRefreshReason.ProactivelyRefreshed, capturedResult.Exception.AuthenticationResultMetadata.CacheRefreshReason); + } + } #endregion } }