Skip to content

Commit 0289ce2

Browse files
Surface token-acquisition failure metadata on MsalException
Adds a public AuthenticationResultMetadata property (getter public, setter internal) to the base MsalException and populates it in RequestBase.RunAsync for any in-request failure. Callers now get diagnostic metadata for failed token attempts — total duration (always captured, useful for latency), plus HTTP/cache durations, token endpoint, and region used when available — mirroring the success-path AuthenticationResult.AuthenticationResultMetadata. Only data actually captured is populated; RegionDetails is set only when a region was used. TokenSource has no meaningful value on failure (documented). The property is intentionally not part of the exception's JSON serialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b1dc1ba commit 0289ce2

11 files changed

Lines changed: 123 additions & 2 deletions

File tree

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Microsoft.Identity.Client.Cache.Items;
1515
using Microsoft.Identity.Client.Core;
1616
using Microsoft.Identity.Client.Instance.Discovery;
17+
using Microsoft.Identity.Client.Region;
1718
using Microsoft.Identity.Client.OAuth2;
1819
using Microsoft.Identity.Client.TelemetryCore.Internal.Events;
1920
using Microsoft.Identity.Client.Utils;
@@ -114,7 +115,15 @@ public async Task<AuthenticationResult> RunAsync(CancellationToken cancellationT
114115
}
115116
AuthenticationRequestParameters.RequestContext.Logger.ErrorPii(ex);
116117

117-
int httpStatusCode = ex is MsalServiceException serviceEx ? serviceEx.StatusCode : 0;
118+
if (ex.AuthenticationResultMetadata == null)
119+
{
120+
ex.AuthenticationResultMetadata = CreateFailureMetadata(
121+
apiEvent,
122+
requestStopwatch.ElapsedMilliseconds + measureTelemetryDurationResult.Milliseconds);
123+
}
124+
125+
MsalServiceException serviceException = ex as MsalServiceException;
126+
int httpStatusCode = serviceException?.StatusCode ?? 0;
118127

119128
LogFailureTelemetryToOtel(
120129
ex.ErrorCode,
@@ -123,7 +132,7 @@ public async Task<AuthenticationResult> RunAsync(CancellationToken cancellationT
123132
httpStatusCode,
124133
requestStopwatch.ElapsedMilliseconds + measureTelemetryDurationResult.Milliseconds,
125134
exception: ex,
126-
rawStsErrorCode: (ex as MsalServiceException)?.ErrorCodes?.FirstOrDefault());
135+
rawStsErrorCode: serviceException?.ErrorCodes?.FirstOrDefault());
127136
throw;
128137
}
129138
catch (Exception ex)
@@ -277,6 +286,26 @@ private void UpdateTelemetry(long elapsedMilliseconds, ApiEvent apiEvent, Authen
277286
Metrics.IncrementTotalDurationInMs(authenticationResult.AuthenticationResultMetadata.DurationTotalInMs);
278287
}
279288

289+
/// <summary>
290+
/// Builds the subset of <see cref="AuthenticationResultMetadata"/> that is available when a
291+
/// token request fails. Only values that were actually captured are populated; everything else
292+
/// is left at its default (0 / null). <see cref="AuthenticationResultMetadata.TokenSource"/> has
293+
/// no meaningful value on failure, so it stays at the constructor default and should not be relied on.
294+
/// </summary>
295+
private static AuthenticationResultMetadata CreateFailureMetadata(ApiEvent apiEvent, long totalDurationInMs)
296+
{
297+
return new AuthenticationResultMetadata(TokenSource.IdentityProvider)
298+
{
299+
DurationTotalInMs = totalDurationInMs,
300+
DurationInHttpInMs = apiEvent.DurationInHttpInMs,
301+
DurationInCacheInMs = apiEvent.DurationInCacheInMs,
302+
CachedAccessTokenCount = apiEvent.CachedAccessTokenCount,
303+
CacheRefreshReason = apiEvent.CacheInfo,
304+
TokenEndpoint = apiEvent.TokenEndpoint,
305+
RegionDetails = apiEvent.RegionOutcome == RegionOutcome.None ? null : CreateRegionDetails(apiEvent),
306+
};
307+
}
308+
280309
protected virtual void EnrichTelemetryApiEvent(ApiEvent apiEvent)
281310
{
282311
// In base classes have them override this to add their properties/fields to the event.

src/client/Microsoft.Identity.Client/MsalException.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,22 @@ private set
148148
public IReadOnlyDictionary<string, string> AdditionalExceptionData { get; set; }
149149
= CollectionHelpers.GetEmptyDictionary<string, string>();
150150

151+
/// <summary>
152+
/// Diagnostic metadata for the failed token-acquisition attempt — the total duration and, when the
153+
/// relevant stage ran, HTTP/cache durations, token endpoint, and region used — or <see langword="null"/>
154+
/// when MSAL threw before any metadata was collected. Only values actually captured are populated;
155+
/// in particular the total duration is always available (useful for latency measurement) while other
156+
/// fields may be 0/null when the corresponding stage did not run. On a failure
157+
/// <see cref="AuthenticationResultMetadata.TokenSource"/> has no meaningful value and should not be
158+
/// relied on. Reuses the type returned by <see cref="AuthenticationResult.AuthenticationResultMetadata"/>
159+
/// on the success path.
160+
/// </summary>
161+
/// <remarks>
162+
/// Populated in-process after the request completes; it is intentionally not part of the exception's
163+
/// JSON serialization, so it is not preserved if the exception is marshaled across a process boundary.
164+
/// </remarks>
165+
public AuthenticationResultMetadata AuthenticationResultMetadata { get; internal set; }
166+
151167
/// <summary>
152168
/// Creates and returns a string representation of the current exception.
153169
/// </summary>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void
55
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
8+
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void
55
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
8+
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void
55
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
8+
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void
55
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
8+
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void
55
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
8+
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void
55
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
8+
Microsoft.Identity.Client.MsalException.AuthenticationResultMetadata.get -> Microsoft.Identity.Client.AuthenticationResultMetadata

tests/Microsoft.Identity.Test.Unit/PublicApiTests/AuthenticationOperationTests.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,14 @@ public async Task WrongTokenType_Async()
189189
.ExecuteAsync()).ConfigureAwait(false);
190190

191191
Assert.AreEqual(MsalError.TokenTypeMismatch, ex.ErrorCode);
192+
193+
// This MsalClientException is raised while processing the token response (inside the
194+
// request), so the failed attempt still carries diagnostic metadata even though it is not
195+
// a MsalServiceException — e.g. the token endpoint that was contacted. This is the value of
196+
// exposing AuthenticationResultMetadata on the base MsalException (total duration is always
197+
// captured for latency measurement).
198+
Assert.IsNotNull(ex.AuthenticationResultMetadata);
199+
Assert.IsNotNull(ex.AuthenticationResultMetadata.TokenEndpoint);
192200
}
193201
}
194202

tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientCredentialWithRegionTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,36 @@ public async Task WithAzureRegionWithValidFormatRoutesRegionallyAsync()
594594
}
595595
}
596596

597+
[TestMethod]
598+
public async Task FailedTokenRequest_SurfacesMetadataOnMsalServiceException_Async()
599+
{
600+
// Arrange - a regional client-credentials request whose token endpoint returns a service error.
601+
using (var harness = base.CreateTestHarness())
602+
{
603+
var httpManager = harness.HttpManager;
604+
httpManager.AddRegionDiscoveryMockHandler(TestConstants.Region);
605+
httpManager.AddMockHandler(new MockHttpMessageHandler()
606+
{
607+
ExpectedUrl = $"https://{TestConstants.Region}.login.microsoft.com/common/oauth2/v2.0/token",
608+
ExpectedMethod = HttpMethod.Post,
609+
ResponseMessage = MockHelpers.CreateInvalidClientResponseMessage()
610+
});
611+
612+
IConfidentialClientApplication cca = CreateCca(httpManager, TestConstants.Region);
613+
614+
// Act
615+
MsalServiceException ex = await AssertException.TaskThrowsAsync<MsalServiceException>(() => cca
616+
.AcquireTokenForClient(TestConstants.s_scope)
617+
.ExecuteAsync())
618+
.ConfigureAwait(false);
619+
620+
// Assert - failure-path metadata is surfaced on the exception, including the region that was used.
621+
Assert.IsNotNull(ex.AuthenticationResultMetadata);
622+
Assert.IsNotNull(ex.AuthenticationResultMetadata.RegionDetails);
623+
Assert.AreEqual(TestConstants.Region, ex.AuthenticationResultMetadata.RegionDetails.RegionUsed);
624+
}
625+
}
626+
597627
[TestMethod]
598628
// regression: https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/issues/2686
599629
public async Task OtherCloudWithAuthorityValidationAsync()

0 commit comments

Comments
 (0)