From 7aaf454f78036c54baa591a863f04d0287f71c1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:12:15 +0000 Subject: [PATCH 1/3] Initial plan From 46aa9e96ccbd8b7559aa1c1c87cd94511cb7da48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:28:39 +0000 Subject: [PATCH 2/3] Add WwwAuthenticateChallengeHelper and refactor DownstreamApi to use it Co-authored-by: jmprieur <13203188+jmprieur@users.noreply.github.com> --- .../DownstreamApi.cs | 18 +- .../Properties/InternalsVisibleTo.cs | 1 + .../net462/InternalAPI.Unshipped.txt | 4 + .../net472/InternalAPI.Unshipped.txt | 4 + .../net8.0/InternalAPI.Unshipped.txt | 4 + .../net9.0/InternalAPI.Unshipped.txt | 4 + .../netstandard2.0/InternalAPI.Unshipped.txt | 4 + .../WwwAuthenticateChallengeHelper.cs | 84 ++++++++ .../WwwAuthenticateChallengeHelperTests.cs | 191 ++++++++++++++++++ 9 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 src/Microsoft.Identity.Web.TokenAcquisition/WwwAuthenticateChallengeHelper.cs create mode 100644 tests/Microsoft.Identity.Web.Test/WwwAuthenticateChallengeHelperTests.cs diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs index cfe802afe..75c0e8ef5 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs @@ -522,17 +522,27 @@ public Task CallApiForAppAsync( var downstreamApiResult = await client.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); // Retry only if the resource sent 401 Unauthorized with WWW-Authenticate header and claims - if (downstreamApiResult.StatusCode == System.Net.HttpStatusCode.Unauthorized) + if (WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(downstreamApiResult)) { - effectiveOptions.AcquireTokenOptions.Claims = WwwAuthenticateParameters.GetClaimChallengeFromResponseHeaders(downstreamApiResult.Headers); + string? claimsChallenge = WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(downstreamApiResult.Headers); - if (!string.IsNullOrEmpty(effectiveOptions.AcquireTokenOptions.Claims)) + if (!string.IsNullOrEmpty(claimsChallenge)) { + // Clone the content defensively to handle non-seekable streams. + // HttpContent can only be read once, so we need to clone it for the retry. + HttpContent? clonedContent = await WwwAuthenticateChallengeHelper.CloneHttpContentAsync(content, cancellationToken).ConfigureAwait(false); + + // Set the claims challenge in the acquire token options. + // Note: We do NOT set ForceRefresh when claims are present because MSAL.NET + // automatically bypasses the cache when claims are included (see + // https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/main/src/client/Microsoft.Identity.Client/Cache/CacheRefreshReason.cs#L16). + effectiveOptions.AcquireTokenOptions.Claims = claimsChallenge; + using HttpRequestMessage retryHttpRequestMessage = new( new HttpMethod(effectiveOptions.HttpMethod), apiUrl); - await UpdateRequestAsync(retryHttpRequestMessage, content, effectiveOptions, appToken, user, cancellationToken); + await UpdateRequestAsync(retryHttpRequestMessage, clonedContent, effectiveOptions, appToken, user, cancellationToken); return await client.SendAsync(retryHttpRequestMessage, cancellationToken).ConfigureAwait(false); } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/Properties/InternalsVisibleTo.cs b/src/Microsoft.Identity.Web.TokenAcquisition/Properties/InternalsVisibleTo.cs index a44e7bbcd..b67c33cc8 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/Properties/InternalsVisibleTo.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/Properties/InternalsVisibleTo.cs @@ -6,6 +6,7 @@ // Allow this assembly to be serviced when run on desktop CLR [assembly: InternalsVisibleTo("Microsoft.Identity.Web, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] [assembly: InternalsVisibleTo("Microsoft.Identity.Web.OWIN, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] +[assembly: InternalsVisibleTo("Microsoft.Identity.Web.DownstreamApi, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] [assembly: InternalsVisibleTo("Microsoft.Identity.Web.GraphServiceClient, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] [assembly: InternalsVisibleTo("Microsoft.Identity.Web.GraphServiceClientBeta, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] [assembly: InternalsVisibleTo("Microsoft.Identity.Web.MicrosoftGraph, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/InternalAPI.Unshipped.txt index 7a7ba6c3f..05002606d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/InternalAPI.Unshipped.txt @@ -1,3 +1,7 @@ #nullable enable const Microsoft.Identity.Web.Constants.UserIdKey = "IDWEB_USER_ID" -> string! +Microsoft.Identity.Web.WwwAuthenticateChallengeHelper readonly Microsoft.Identity.Web.TokenAcquisition._certificatesObservers -> System.Collections.Generic.IReadOnlyList! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(System.Net.Http.Headers.HttpResponseHeaders! responseHeaders) -> string? +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(System.Net.Http.HttpResponseMessage! response) -> bool diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/InternalAPI.Unshipped.txt index 7a7ba6c3f..05002606d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/InternalAPI.Unshipped.txt @@ -1,3 +1,7 @@ #nullable enable const Microsoft.Identity.Web.Constants.UserIdKey = "IDWEB_USER_ID" -> string! +Microsoft.Identity.Web.WwwAuthenticateChallengeHelper readonly Microsoft.Identity.Web.TokenAcquisition._certificatesObservers -> System.Collections.Generic.IReadOnlyList! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(System.Net.Http.Headers.HttpResponseHeaders! responseHeaders) -> string? +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(System.Net.Http.HttpResponseMessage! response) -> bool diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/InternalAPI.Unshipped.txt index 7a7ba6c3f..05002606d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/InternalAPI.Unshipped.txt @@ -1,3 +1,7 @@ #nullable enable const Microsoft.Identity.Web.Constants.UserIdKey = "IDWEB_USER_ID" -> string! +Microsoft.Identity.Web.WwwAuthenticateChallengeHelper readonly Microsoft.Identity.Web.TokenAcquisition._certificatesObservers -> System.Collections.Generic.IReadOnlyList! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(System.Net.Http.Headers.HttpResponseHeaders! responseHeaders) -> string? +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(System.Net.Http.HttpResponseMessage! response) -> bool diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt index 7a7ba6c3f..05002606d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt @@ -1,3 +1,7 @@ #nullable enable const Microsoft.Identity.Web.Constants.UserIdKey = "IDWEB_USER_ID" -> string! +Microsoft.Identity.Web.WwwAuthenticateChallengeHelper readonly Microsoft.Identity.Web.TokenAcquisition._certificatesObservers -> System.Collections.Generic.IReadOnlyList! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(System.Net.Http.Headers.HttpResponseHeaders! responseHeaders) -> string? +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(System.Net.Http.HttpResponseMessage! response) -> bool diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt index 7a7ba6c3f..05002606d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt @@ -1,3 +1,7 @@ #nullable enable const Microsoft.Identity.Web.Constants.UserIdKey = "IDWEB_USER_ID" -> string! +Microsoft.Identity.Web.WwwAuthenticateChallengeHelper readonly Microsoft.Identity.Web.TokenAcquisition._certificatesObservers -> System.Collections.Generic.IReadOnlyList! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(System.Net.Http.Headers.HttpResponseHeaders! responseHeaders) -> string? +static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(System.Net.Http.HttpResponseMessage! response) -> bool diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/WwwAuthenticateChallengeHelper.cs b/src/Microsoft.Identity.Web.TokenAcquisition/WwwAuthenticateChallengeHelper.cs new file mode 100644 index 000000000..5cfc35a50 --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/WwwAuthenticateChallengeHelper.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client; + +namespace Microsoft.Identity.Web +{ + /// + /// Internal helper for handling WWW-Authenticate challenges from downstream APIs. + /// This helper provides shared logic for detecting claims challenges and preparing retry requests. + /// + internal static class WwwAuthenticateChallengeHelper + { + /// + /// Extracts the claims challenge from WWW-Authenticate response headers. + /// + /// The HTTP response headers to examine. + /// The claims challenge string if present; otherwise, null. + public static string? ExtractClaimsChallenge(HttpResponseHeaders responseHeaders) + { + return WwwAuthenticateParameters.GetClaimChallengeFromResponseHeaders(responseHeaders); + } + + /// + /// Clones HttpContent for retry scenarios. This is necessary because HttpContent can only be + /// read once, especially with non-seekable streams. The clone allows the content to be sent + /// again in a retry request. + /// + /// The original HttpContent to clone. + /// Cancellation token. + /// A new HttpContent instance with the same data and headers, or null if original was null. + /// + /// This method defensively handles content cloning by reading the content into a byte array + /// and creating new ByteArrayContent. This ensures the content can be sent multiple times, + /// even if the original stream was non-seekable. + /// + public static async Task CloneHttpContentAsync( + HttpContent? originalContent, + CancellationToken cancellationToken = default) + { + if (originalContent == null) + { + return null; + } + + // Read the content into a byte array to ensure it can be reused +#if NET + byte[] contentBytes = await originalContent.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); +#else + byte[] contentBytes = await originalContent.ReadAsByteArrayAsync().ConfigureAwait(false); +#endif + + // Create new content with the same data + var clonedContent = new ByteArrayContent(contentBytes); + + // Copy headers from original content + foreach (var header in originalContent.Headers) + { + clonedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return clonedContent; + } + + /// + /// Determines if a response should trigger a claims challenge retry. + /// + /// The HTTP response to evaluate. + /// True if the response is a 401 Unauthorized; otherwise, false. + /// + /// A 401 Unauthorized response may include a WWW-Authenticate header with a claims challenge. + /// The actual claims extraction should be done using . + /// + public static bool ShouldAttemptClaimsChallengeRetry(HttpResponseMessage response) + { + return response.StatusCode == System.Net.HttpStatusCode.Unauthorized; + } + } +} diff --git a/tests/Microsoft.Identity.Web.Test/WwwAuthenticateChallengeHelperTests.cs b/tests/Microsoft.Identity.Web.Test/WwwAuthenticateChallengeHelperTests.cs new file mode 100644 index 000000000..994ee697a --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/WwwAuthenticateChallengeHelperTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + public class WwwAuthenticateChallengeHelperTests + { + private const string CaeClaims = "{\\\"access_token\\\":{\\\"capolids\\\":{\\\"essential\\\":true,\\\"values\\\":[\\\"c1\\\"]}}}"; + + [Fact] + public void ExtractClaimsChallenge_WithValidClaimsChallenge_ReturnsClaimsString() + { + // Arrange + string challengeB64 = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(CaeClaims)); + + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.ParseAdd( + $"Bearer realm=\"\", error=\"insufficient_claims\", " + + $"error_description=\"token requires claims\", " + + $"claims=\"{challengeB64}\""); + + // Act + string? claims = WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(response.Headers); + + // Assert + Assert.NotNull(claims); + Assert.Equal(challengeB64, claims); + } + + [Fact] + public void ExtractClaimsChallenge_WithoutClaimsChallenge_ReturnsNull() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.ParseAdd("Bearer realm=\"\""); + + // Act + string? claims = WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(response.Headers); + + // Assert + Assert.Null(claims); + } + + [Fact] + public void ExtractClaimsChallenge_WithEmptyHeaders_ReturnsNull() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + + // Act + string? claims = WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(response.Headers); + + // Assert + Assert.Null(claims); + } + + [Fact] + public async Task CloneHttpContentAsync_WithNullContent_ReturnsNull() + { + // Act + var clonedContent = await WwwAuthenticateChallengeHelper.CloneHttpContentAsync(null); + + // Assert + Assert.Null(clonedContent); + } + + [Fact] + public async Task CloneHttpContentAsync_WithStringContent_ClonesContentAndHeaders() + { + // Arrange + string originalText = "Hello, World!"; + var originalContent = new StringContent(originalText, Encoding.UTF8, "application/json"); + + // Act + var clonedContent = await WwwAuthenticateChallengeHelper.CloneHttpContentAsync(originalContent); + + // Assert + Assert.NotNull(clonedContent); + + // Verify content is the same + string clonedText = await clonedContent!.ReadAsStringAsync(); + Assert.Equal(originalText, clonedText); + + // Verify headers are copied + Assert.Equal(originalContent.Headers.ContentType?.MediaType, clonedContent.Headers.ContentType?.MediaType); + Assert.Equal(originalContent.Headers.ContentType?.CharSet, clonedContent.Headers.ContentType?.CharSet); + } + + [Fact] + public async Task CloneHttpContentAsync_WithByteArrayContent_ClonesContent() + { + // Arrange + byte[] originalBytes = Encoding.UTF8.GetBytes("Test data"); + var originalContent = new ByteArrayContent(originalBytes); + originalContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + + // Act + var clonedContent = await WwwAuthenticateChallengeHelper.CloneHttpContentAsync(originalContent); + + // Assert + Assert.NotNull(clonedContent); + + // Verify content is the same + byte[] clonedBytes = await clonedContent!.ReadAsByteArrayAsync(); + Assert.Equal(originalBytes, clonedBytes); + + // Verify headers are copied + Assert.Equal("application/octet-stream", clonedContent.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task CloneHttpContentAsync_CanBeUsedMultipleTimes() + { + // Arrange + string originalText = "Reusable content"; + var originalContent = new StringContent(originalText, Encoding.UTF8, "text/plain"); + + // Act - Clone the content + var clonedContent = await WwwAuthenticateChallengeHelper.CloneHttpContentAsync(originalContent); + + // Read the cloned content multiple times to verify it's reusable + string firstRead = await clonedContent!.ReadAsStringAsync(); + + // Create another clone from the first clone + var secondClone = await WwwAuthenticateChallengeHelper.CloneHttpContentAsync(clonedContent); + string secondRead = await secondClone!.ReadAsStringAsync(); + + // Assert + Assert.Equal(originalText, firstRead); + Assert.Equal(originalText, secondRead); + } + + [Fact] + public void ShouldAttemptClaimsChallengeRetry_With401Response_ReturnsTrue() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + + // Act + bool shouldRetry = WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(response); + + // Assert + Assert.True(shouldRetry); + } + + [Theory] + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.InternalServerError)] + public void ShouldAttemptClaimsChallengeRetry_WithNon401Response_ReturnsFalse(HttpStatusCode statusCode) + { + // Arrange + var response = new HttpResponseMessage(statusCode); + + // Act + bool shouldRetry = WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(response); + + // Assert + Assert.False(shouldRetry); + } + + [Fact] + public async Task CloneHttpContentAsync_WithCustomHeaders_CopiesAllHeaders() + { + // Arrange + var originalContent = new ByteArrayContent(Encoding.UTF8.GetBytes("data")); + originalContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + originalContent.Headers.Add("X-Custom-Header", "CustomValue"); + originalContent.Headers.ContentEncoding.Add("gzip"); + + // Act + var clonedContent = await WwwAuthenticateChallengeHelper.CloneHttpContentAsync(originalContent); + + // Assert + Assert.NotNull(clonedContent); + Assert.Equal("application/json", clonedContent!.Headers.ContentType?.MediaType); + Assert.Contains("CustomValue", clonedContent.Headers.GetValues("X-Custom-Header")); + Assert.Contains("gzip", clonedContent.Headers.ContentEncoding); + } + } +} From 2d591ce0ab125ccf394d40c4f9e243184474f265 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:35:25 +0000 Subject: [PATCH 3/3] Refactor MicrosoftIdentityMessageHandler to use WwwAuthenticateChallengeHelper and fix ForceRefresh behavior Co-authored-by: jmprieur <13203188+jmprieur@users.noreply.github.com> --- .../MicrosoftIdentityMessageHandler.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs index 1577ea19a..cfbcc140e 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityMessageHandler.cs @@ -267,10 +267,10 @@ protected override async Task SendAsync( var response = await SendWithAuthenticationAsync(request, options, scopes, cancellationToken).ConfigureAwait(false); // Handle WWW-Authenticate challenge if present - if (response.StatusCode == HttpStatusCode.Unauthorized) + if (WwwAuthenticateChallengeHelper.ShouldAttemptClaimsChallengeRetry(response)) { // Use MSAL's WWW-Authenticate parser to extract claims from challenge headers - string? challengeClaims = WwwAuthenticateParameters.GetClaimChallengeFromResponseHeaders(response.Headers); + string? challengeClaims = WwwAuthenticateChallengeHelper.ExtractClaimsChallenge(response.Headers); if (!string.IsNullOrEmpty(challengeClaims)) { @@ -278,9 +278,13 @@ protected override async Task SendAsync( "Received WWW-Authenticate challenge with claims. Attempting token refresh."); // Create a new options instance with the challenge claims - var challengeOptions = CreateOptionsWithChallengeClaims(options, challengeClaims); + // Note: We do NOT set ForceRefresh when claims are present because MSAL.NET + // automatically bypasses the cache when claims are included (see + // https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/main/src/client/Microsoft.Identity.Client/Cache/CacheRefreshReason.cs#L16). + var challengeOptions = CreateOptionsWithChallengeClaims(options, challengeClaims!); - // Clone the original request for retry + // Clone the original request for retry. + // This is necessary because HttpContent can only be read once, especially with non-seekable streams. using var retryRequest = await CloneHttpRequestMessageAsync(request).ConfigureAwait(false); // Attempt to get a new token with the challenge claims @@ -382,7 +386,9 @@ private static MicrosoftIdentityMessageHandlerOptions CreateOptionsWithChallenge ExtraHeadersParameters = originalOptions.AcquireTokenOptions.ExtraHeadersParameters, ExtraQueryParameters = originalOptions.AcquireTokenOptions.ExtraQueryParameters, ExtraParameters = originalOptions.AcquireTokenOptions.ExtraParameters, - ForceRefresh = true, // Force refresh when handling challenges + // Note: We do NOT set ForceRefresh when claims are present because MSAL.NET + // automatically bypasses the cache when claims are included. + ForceRefresh = originalOptions.AcquireTokenOptions.ForceRefresh, ManagedIdentity = originalOptions.AcquireTokenOptions.ManagedIdentity, PopPublicKey = originalOptions.AcquireTokenOptions.PopPublicKey, Tenant = originalOptions.AcquireTokenOptions.Tenant, @@ -393,8 +399,8 @@ private static MicrosoftIdentityMessageHandlerOptions CreateOptionsWithChallenge { challengeOptions.AcquireTokenOptions = new AcquireTokenOptions { - Claims = challengeClaims, - ForceRefresh = true + Claims = challengeClaims + // ForceRefresh is not set - MSAL.NET will automatically bypass cache when claims are present }; }