Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -522,17 +522,27 @@ public Task<HttpResponseMessage> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,20 +267,24 @@ protected override async Task<HttpResponseMessage> 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))
{
_logger?.LogInformation(
"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
Expand Down Expand Up @@ -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,
Expand All @@ -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
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Identity.Web.Experimental.ICertificatesObserver!>!
static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Net.Http.HttpContent?>!
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
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Identity.Web.Experimental.ICertificatesObserver!>!
static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Net.Http.HttpContent?>!
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
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Identity.Web.Experimental.ICertificatesObserver!>!
static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Net.Http.HttpContent?>!
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
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Identity.Web.Experimental.ICertificatesObserver!>!
static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Net.Http.HttpContent?>!
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
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Identity.Web.Experimental.ICertificatesObserver!>!
static Microsoft.Identity.Web.WwwAuthenticateChallengeHelper.CloneHttpContentAsync(System.Net.Http.HttpContent? originalContent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Net.Http.HttpContent?>!
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
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Internal helper for handling WWW-Authenticate challenges from downstream APIs.
/// This helper provides shared logic for detecting claims challenges and preparing retry requests.
/// </summary>
internal static class WwwAuthenticateChallengeHelper
{
/// <summary>
/// Extracts the claims challenge from WWW-Authenticate response headers.
/// </summary>
/// <param name="responseHeaders">The HTTP response headers to examine.</param>
/// <returns>The claims challenge string if present; otherwise, null.</returns>
public static string? ExtractClaimsChallenge(HttpResponseHeaders responseHeaders)
{
return WwwAuthenticateParameters.GetClaimChallengeFromResponseHeaders(responseHeaders);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="originalContent">The original HttpContent to clone.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A new HttpContent instance with the same data and headers, or null if original was null.</returns>
/// <remarks>
/// 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.
/// </remarks>
public static async Task<HttpContent?> CloneHttpContentAsync(
Copy link
Contributor

@keegan-caruso keegan-caruso Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this method at all. Why can't we just use:

await originalContent.LoadIntoBufferAsync(cancellationToken);

This should buffer the content into memory allowing multiple reads.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also provide an upper bound of the size of the buffer

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);
Copy link
Contributor

@keegan-caruso keegan-caruso Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there worries along the same thoughts as #3532 ?

#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;
}

/// <summary>
/// Determines if a response should trigger a claims challenge retry.
/// </summary>
/// <param name="response">The HTTP response to evaluate.</param>
/// <returns>True if the response is a 401 Unauthorized; otherwise, false.</returns>
/// <remarks>
/// A 401 Unauthorized response may include a WWW-Authenticate header with a claims challenge.
/// The actual claims extraction should be done using <see cref="ExtractClaimsChallenge"/>.
/// </remarks>
public static bool ShouldAttemptClaimsChallengeRetry(HttpResponseMessage response)
Copy link
Contributor

@cpp11nullptr cpp11nullptr Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we consider a merge it with ExtractClaimsChallenge(), e.g., in form of TryExtractClaimsChallengeToRetry, returning bool with claims as string out parameter? It'd provide a slightly better user experience such as a single call/check would be needed considering that claims are used straight away.

{
return response.StatusCode == System.Net.HttpStatusCode.Unauthorized;
}
}
}
Loading
Loading