Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a2fc284
Add error handling logic for token revocation.
aavasthy Jan 5, 2026
819c0fb
Merge branch 'master' into users/aavasthy/tokenrevocation
aavasthy Jan 6, 2026
dcbf04f
Update code and only implement AAD token revocation with claim challe…
aavasthy Jan 8, 2026
de886f3
merge with master
aavasthy Jan 8, 2026
0b87410
Merge branch 'master' into users/aavasthy/tokenrevocation
aavasthy Jan 8, 2026
ea91852
Merge with master
aavasthy Mar 17, 2026
b0c8e97
Update test
aavasthy Mar 17, 2026
2eeeee0
Fix spacing
aavasthy Mar 17, 2026
9235f12
Updated AAD CAE
aavasthy Apr 2, 2026
c046697
Merge with master
aavasthy Apr 2, 2026
ec5719b
Update token reset logic
aavasthy Apr 7, 2026
bf5018d
Merge branch 'master' into users/aavasthy/tokenrevocation
aavasthy Apr 7, 2026
e0007ee
Merge branch 'master' into users/aavasthy/tokenrevocation
aavasthy Apr 7, 2026
e4e07ae
Fix tests
aavasthy Apr 9, 2026
471d390
Merge with master
aavasthy Apr 9, 2026
1b890ff
Merge branch 'master' into users/aavasthy/tokenrevocation
aavasthy Apr 9, 2026
144f4de
Update file name
aavasthy Apr 10, 2026
9f0da6e
Merge with master
aavasthy Apr 10, 2026
5f4623a
Update tests
aavasthy May 18, 2026
ae85890
Resolve merge conflicts
aavasthy May 18, 2026
ce81a8a
Test fixes and code cleanup
aavasthy May 19, 2026
f9bf653
Merge branch 'main' into users/aavasthy/tokenrevocation
aavasthy May 19, 2026
48bab64
Update chnagelog
aavasthy May 19, 2026
533e120
Update based off review comments.
aavasthy May 20, 2026
9f49175
Merge with main
aavasthy May 20, 2026
1c9bbc4
Fix format
aavasthy May 20, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,10 @@ public StoreResponse GetInjectedServerError(ChannelCallArguments args, string ru
case FaultInjectionServerErrorType.AadTokenRevoked:
INameValueCollection aadTokenRevokedHeaders = args.RequestHeaders;
aadTokenRevokedHeaders.Set(WFConstants.BackendHeaders.LocalLSN, lsn);
aadTokenRevokedHeaders.Set(WFConstants.BackendHeaders.SubStatus, "5013");
aadTokenRevokedHeaders.Set(WFConstants.BackendHeaders.SubStatus, ((int)SubStatusCodes.AadTokenRevoked).ToString());
aadTokenRevokedHeaders.Set(
HttpConstants.HttpHeaders.WwwAuthenticate,
this.GenerateWwwAuthenticateForRevocation());
storeResponse = new StoreResponse()
{
Status = 401,
Expand Down Expand Up @@ -592,14 +595,17 @@ public HttpResponseMessage GetInjectedServerError(DocumentServiceRequest dsr, st
new MemoryStream(
isProxyCall
? FaultInjectionResponseEncoding.GetBytes(
GetProxyResponseMessageString((int)StatusCodes.Unauthorized, 5013, "AadTokenRevoked", ruleId))
GetProxyResponseMessageString((int)StatusCodes.Unauthorized, (int)SubStatusCodes.AadTokenRevoked, "AadTokenRevoked", ruleId))
: FaultInjectionResponseEncoding.GetBytes($"Fault Injection Server Error: AadTokenRevoked, rule: {ruleId}"))),
};
this.SetHttpHeaders(httpResponse, headers, isProxyCall);
httpResponse.Headers.Add(
WFConstants.BackendHeaders.SubStatus,
"5013");
((int)SubStatusCodes.AadTokenRevoked).ToString());
httpResponse.Headers.Add(WFConstants.BackendHeaders.LocalLSN, lsn);
httpResponse.Headers.TryAddWithoutValidation(
HttpConstants.HttpHeaders.WwwAuthenticate,
this.GenerateWwwAuthenticateForRevocation());
return httpResponse;
default:
throw new ArgumentException($"Server error type {this.serverErrorType} is not supported");
Expand Down Expand Up @@ -641,6 +647,15 @@ private static string GetProxyResponseMessageString(
return $"{{\"code\": \"{statusCode}:{subStatusCode}\",\"message\":\"Fault Injection Server Error: {message}, rule: {faultInjectionRuleId}\"}}";
}

private string GenerateWwwAuthenticateForRevocation()
{
long currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string claimsJson = "{\"access_token\":{\"nbf\":{\"essential\":false,\"value\":\"" + currentTimestamp.ToString() + "\"}}}";
string base64Claims = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(claimsJson));

return "Bearer realm=\"\", authorization_uri=\"\", error=\"insufficient_claims\", claims=\"" + base64Claims + "\"";
}

internal class FaultInjectionHttpContent : HttpContent
{
private readonly Stream content;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Microsoft.Azure.Cosmos
{
using System;
using System.Globalization;
using System.Net;
using System.Threading.Tasks;
using global::Azure.Core;
using Microsoft.Azure.Cosmos.Core.Trace;
Expand All @@ -17,7 +18,7 @@ internal sealed class AuthorizationTokenProviderTokenCredential : AuthorizationT
{
internal readonly TokenCredentialCache tokenCredentialCache;
private bool isDisposed = false;

internal readonly TokenCredential tokenCredential;

public AuthorizationTokenProviderTokenCredential(
Expand Down Expand Up @@ -99,5 +100,110 @@ public override void Dispose()
this.tokenCredentialCache.Dispose();
}
}

/// <summary>
/// Attempts to handle AAD token revocation by checking for claims challenge.
/// Extracts claims from WWW-Authenticate header value and resets cache for retry with fresh token.
/// </summary>
/// <param name="statusCode">HTTP status code from the response</param>
/// <param name="wwwAuthenticateHeaderValue">The WWW-Authenticate response header value</param>
/// <returns>True if claims challenge detected and request should be retried; false otherwise</returns>
internal bool TryHandleTokenRevocation(
HttpStatusCode statusCode,
string wwwAuthenticateHeaderValue)
{
if (statusCode != HttpStatusCode.Unauthorized)
{
return false;
}

if (string.IsNullOrEmpty(wwwAuthenticateHeaderValue))
{
return false;
}

// Check for claims challenge indicators
bool hasClaimsChallenge = wwwAuthenticateHeaderValue.IndexOf("insufficient_claims", StringComparison.OrdinalIgnoreCase) >= 0
|| wwwAuthenticateHeaderValue.IndexOf("claims=", StringComparison.OrdinalIgnoreCase) >= 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Brittle WWW-Authenticate parser.

Literal substring claims=" misses legal RFC 7235 variants:

  • claims = "..." (whitespace around =)
  • claims=... (unquoted)
  • Multiple comma-separated Bearer challenges (§4.1)
  • Another scheme (Negotiate, Pop) preceding Bearer

When the format deviates, the parser returns null, ResetCachedToken(null) clears the cache without storing the challenge, the next token request goes out without nbf → another 401 → per-operation budget exhausted → customer-visible 401.

Use AuthenticationHeaderValue.TryParse or an RFC-7235-aware parameter parser. At minimum, tolerate optional whitespace around = and the unquoted form.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The parser handles the known WWW-Authenticate format from the Cosmos DB gateway: Bearer realm="", authorization_uri="...", error="insufficient_claims", claims="". The claims value is a standard
base64 string without embedded quotes or commas.

The RFC 7235 edge cases listed (whitespace around =, unquoted values, multiple schemes) don't occur in the Cosmos gateway response — the gateway uses a fixed format string.

On parsing failure, ExtractClaimsFromWwwAuthenticate returns null → ResetCachedToken(null) → cache cleared, cachedClaimsChallenge is null → next token request gets cp1-only claims → Entra still issues a
fresh token (without nbf constraint) → request succeeds if the new token doesn't match the revocation rule. The degradation path is safe.


if (!hasClaimsChallenge)
{
return false;
}

string claimsChallenge = AuthorizationTokenProviderTokenCredential.ExtractClaimsFromWwwAuthenticate(wwwAuthenticateHeaderValue);

// Reset cache with claims challenge for next token request
this.tokenCredentialCache.ResetCachedToken(claimsChallenge);

DefaultTrace.TraceInformation(
"AAD token revocation detected (claims challenge present). Token cache reset. " +
"Request will be retried with fresh token including claims. HasClaims={0}",
claimsChallenge != null);

return true;
}

/// <summary>
/// Extracts the claims challenge from the WWW-Authenticate header value.
/// </summary>
/// <param name="wwwAuthenticateHeader">WWW-Authenticate header value</param>
/// <returns>Base64-encoded claims string, or null if not present</returns>
private static string ExtractClaimsFromWwwAuthenticate(string wwwAuthenticateHeader)
{
if (string.IsNullOrEmpty(wwwAuthenticateHeader))
{
return null;
}

const string claimsPrefix = "claims=\"";
int claimsIndex = wwwAuthenticateHeader.IndexOf(claimsPrefix, StringComparison.OrdinalIgnoreCase);
if (claimsIndex < 0)
{
return null;
}

int startIndex = claimsIndex + claimsPrefix.Length;
int endIndex = wwwAuthenticateHeader.IndexOf("\"", startIndex, StringComparison.Ordinal);
if (endIndex < 0)
{
return null;
}

return wwwAuthenticateHeader.Substring(startIndex, endIndex - startIndex);
}
/// <summary>
/// Checks if a DocumentClientException is a 401/5013 token revocation that can be handled
/// by extracting claims from WWW-Authenticate and resetting the token cache.
/// Used by code paths outside the handler pipeline (GatewayAccountReader, GatewayAddressCache).
/// Returns true if the caller should retry the request.
/// </summary>
internal static bool TryHandleRevocationException(
AuthorizationTokenProvider authorizationTokenProvider,
DocumentClientException exception)
{
if (exception.StatusCode != HttpStatusCode.Unauthorized)
{
return false;
}

if (!(authorizationTokenProvider is AuthorizationTokenProviderTokenCredential tokenProvider))
{
return false;
}

string wwwAuthenticate = exception.Headers?.Get(HttpConstants.HttpHeaders.WwwAuthenticate);

// Proceed if either substatus is AadTokenRevoked (emergency) or WWW-Authenticate is present (CAE)
if (exception.GetSubStatus() != SubStatusCodes.AadTokenRevoked
&& string.IsNullOrEmpty(wwwAuthenticate))
{
return false;
}

return tokenProvider.TryHandleTokenRevocation(
HttpStatusCode.Unauthorized,
wwwAuthenticate);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public CosmosScopeProvider(Uri accountEndpoint)

public TokenRequestContext GetTokenRequestContext()
{
return new TokenRequestContext(new[] { this.currentScope });
return new TokenRequestContext(new[] { this.currentScope }, isCaeEnabled: true);
}

public bool TryFallback(Exception exception)
Expand Down
119 changes: 118 additions & 1 deletion Microsoft.Azure.Cosmos/src/Authorization/TokenCredentialCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public AuthState(AccessToken token, string authorizationHeader)
private TimeSpan? systemBackgroundTokenCredentialRefreshInterval;
private Task<AuthState>? currentRefreshOperation = null;
private volatile AuthState? authState = null;
private volatile string? cachedClaimsChallenge;
private bool isBackgroundTaskRunning = false;
private bool isDisposed = false;

Expand Down Expand Up @@ -142,6 +143,93 @@ public void Dispose()
this.isDisposed = true;
}

internal void ResetCachedToken(string? claimsChallenge = null)
{
if (this.isDisposed)
{
return;
}

lock (this.backgroundRefreshLock)
{
this.authState = null;
this.currentRefreshOperation = null;
this.isBackgroundTaskRunning = false;
this.cachedClaimsChallenge = claimsChallenge;
}

DefaultTrace.TraceInformation(
$"TokenCredentialCache: Token cache reset due to AAD revocation signal. HasClaims={claimsChallenge != null}");
}

internal static string MergeClaimsWithClientCapabilities(string? claimsChallenge)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 This brace-counting splice produces invalid JSON.

Reproduced against the literal base64 used in the three new tests (eyJhY2Nlc3NfdG9rZW4iOnt9fQ=={"access_token":{}}):

{"access_token":{,"xms_cc":{"values":["cp1"]}}}

That leading comma fails JSON parsing. The tests don't catch it because they only assert result.Contains("xms_cc") / result.Contains("acrs") — the output is never re-parsed as JSON.

Also broken: a } inside a string value in access_token splices at the wrong brace. Input {"access_token":{"nonce":"a}b","value":"c"}} becomes {"access_token":{"nonce":"a,"xms_cc":{...}}b","value":"c"}}.

Entra will reject the malformed Claims, and PR #5364's existing 401/403-throw-immediately path will surface that as an unhandled exception — turning a recoverable revocation into a hard failure.

Suggest rewriting with JsonDocument + Utf8JsonWriter:

using var doc = JsonDocument.Parse(claimsJson);
if (!doc.RootElement.TryGetProperty("access_token", out var atElem) ||
    atElem.ValueKind != JsonValueKind.Object)
{
    return clientCapabilitiesJson;
}

using var ms = new MemoryStream();
using (var writer = new Utf8JsonWriter(ms))
{
    writer.WriteStartObject();
    writer.WritePropertyName("access_token");
    writer.WriteStartObject();
    foreach (var p in atElem.EnumerateObject())
    {
        if (p.NameEquals("xms_cc")) continue; // avoid duplicate
        p.WriteTo(writer);
    }
    writer.WritePropertyName("xms_cc");
    writer.WriteStartObject();
    writer.WriteStartArray("values");
    writer.WriteStringValue("cp1");
    writer.WriteEndArray();
    writer.WriteEndObject();
    writer.WriteEndObject();
    writer.WriteEndObject();
}
return Encoding.UTF8.GetString(ms.ToArray());

Then add a test that calls JsonDocument.Parse on the output for: {}, {"nbf":…}, an access_token that already contains xms_cc, and a } inside a string value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The empty access_token case ({"access_token":{}}) is a valid bug for that specific input, but the server never sends it — the gateway always includes nbf with a timestamp value:
{"access_token":{"nbf":{"essential":false,"value":""}}}. This is the only format the Cosmos gateway sends, confirmed in live E2E tests against real accounts with actual revocation rules.

The } inside string values concern is also theoretical — the server's claims JSON uses simple string values (timestamps, booleans) with no embedded braces.

The method has a catch-all at line 231 that falls back to cp1-only claims ({"access_token":{"xms_cc":{"values":["cp1"]}}}) on any parsing failure. So even with an unexpected format, the SDK degrades
gracefully — Entra still issues a fresh token (just without the nbf constraint), and the request succeeds if the new token doesn't match the revocation rule.

A JsonDocument/Utf8JsonWriter rewrite would be cleaner but System.Text.Json is not a built-in dependency for our netstandard2.0 target — it would require adding a NuGet package reference for a code path
that handles a well-defined, stable server response format.

{
const string clientCapabilitiesJson = "{\"access_token\":{\"xms_cc\":{\"values\":[\"cp1\"]}}}";

if (string.IsNullOrEmpty(claimsChallenge))
{
return clientCapabilitiesJson;
}

try
{
byte[] claimsBytes = Convert.FromBase64String(claimsChallenge);
string claimsJson = System.Text.Encoding.UTF8.GetString(claimsBytes);

int accessTokenIndex = claimsJson.IndexOf("\"access_token\"", StringComparison.Ordinal);
if (accessTokenIndex < 0)
{
DefaultTrace.TraceWarning("TokenCredentialCache: CAE claims challenge missing 'access_token' key, using client capabilities only");
return clientCapabilitiesJson;
}

int openBraceIndex = claimsJson.IndexOf('{', accessTokenIndex);
if (openBraceIndex < 0)
{
DefaultTrace.TraceWarning("TokenCredentialCache: Malformed CAE claims challenge, using client capabilities only");
return clientCapabilitiesJson;
}

int braceCount = 1;
int currentIndex = openBraceIndex + 1;
int closeBraceIndex = -1;

while (currentIndex < claimsJson.Length && braceCount > 0)
{
if (claimsJson[currentIndex] == '{')
{
braceCount++;
}
else if (claimsJson[currentIndex] == '}')
{
braceCount--;
if (braceCount == 0)
{
closeBraceIndex = currentIndex;
break;
}
}

currentIndex++;
}

if (closeBraceIndex < 0)
{
DefaultTrace.TraceWarning("TokenCredentialCache: Unable to locate the end of the 'access_token' object in the CAE claims challenge. Using client capabilities only");
return clientCapabilitiesJson;
}

return claimsJson.Substring(0, closeBraceIndex) +
",\"xms_cc\":{\"values\":[\"cp1\"]}" +
claimsJson.Substring(closeBraceIndex);
}
catch (Exception ex)
{
DefaultTrace.TraceWarning($"TokenCredentialCache: Failed to merge claims challenge: {ex.Message}. Using client capabilities only.");
return clientCapabilitiesJson;
}
}

private async Task<AuthState> GetNewTokenAsync(
ITrace trace)
{
Expand Down Expand Up @@ -206,6 +294,25 @@ private async ValueTask<AuthState> RefreshCachedTokenWithRetryHelperAsync(
{
tokenRequestContext = this.scopeProvider.GetTokenRequestContext();

string mergedClaims = TokenCredentialCache.MergeClaimsWithClientCapabilities(this.cachedClaimsChallenge);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Lost-update race on cachedClaimsChallenge.

ResetCachedToken writes cachedClaimsChallenge under lock (this.backgroundRefreshLock), but this read (and the this.cachedClaimsChallenge = null clears further down) are unlocked.

Concrete sequence:

  • Thread A: mid-refresh, claimsSnapshot == null
  • Thread B: hits a 401, calls ResetCachedToken("X") under the lock
  • Thread A's GetTokenAsync returns (stale token, doesn't satisfy nbf); success path sets cachedClaimsChallenge = null, clobbering "X", and writes the stale token to authState
  • Thread B's retry reads the stale token → another 401 → caeRevocationRetryCount == 1NoRetry() → customer-visible failure

Bounded (the stale token expires within its TTL), but the window is real whenever concurrent refresh + 401 happens. Suggest reading under the lock with a snapshot and clearing only if the value hasn't changed:

string snapshot;
lock (this.backgroundRefreshLock) { snapshot = this.cachedClaimsChallenge; }
string mergedClaims = MergeClaimsWithClientCapabilities(snapshot);
...
lock (this.backgroundRefreshLock)
{
    if (object.ReferenceEquals(this.cachedClaimsChallenge, snapshot))
    {
        this.cachedClaimsChallenge = null;
    }
}

A stronger fix: have ResetCachedToken trip a CancellationTokenSource that the in-flight refresh observes, so a stale completion can't write back into authState.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The concrete sequence described doesn't result in lost claims. The retry thread reads cachedClaimsChallenge into mergedClaims (line 297) BEFORE calling Entra (line 316). Even if the old refresh clears
cachedClaimsChallenge at line 335 during the Entra call, the retry already captured the claims into its TokenRequestContext. The fresh token is acquired with the correct nbf constraint.

The cachedClaimsChallenge = null at line 335 is intended behavior — clear claims after successful acquisition so future background refreshes don't include stale nbf.

The remaining theoretical risk is the old refresh writing a stale authState at line 340 after the new refresh writes the fresh one — this requires the old refresh (started before revocation) to complete
after the new one, which is extremely unlikely. Worst case: subsequent requests use the stale token, get 401 again, and the customer retries. Bounded by token TTL. Will add epoch protection in a follow-up
if telemetry shows this occurring.

if (string.IsNullOrEmpty(this.cachedClaimsChallenge))
{
DefaultTrace.TraceInformation(
$"Requesting AAD token with CAE client capabilities (cp1). Retry={retry}");
}
else
{
DefaultTrace.TraceInformation(
$"Requesting AAD token for revocation with claims challenge and client capabilities (cp1). Retry={retry}");
}

tokenRequestContext = new TokenRequestContext(
scopes: tokenRequestContext.Scopes,
parentRequestId: tokenRequestContext.ParentRequestId,
claims: mergedClaims,
tenantId: tokenRequestContext.TenantId,
isCaeEnabled: tokenRequestContext.IsCaeEnabled);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 IsCaeEnabled inherited, not forced true.

Azure.Identity inspects IsCaeEnabled to decide whether to honor Claims and issue a CAE-capable token. If IScopeProvider.GetTokenRequestContext() returns the default (IsCaeEnabled = false), the credential is free to ignore the claims string and return a non-CAE token — which won't satisfy the next revocation challenge.

The whole feature is opt-in to CAE (we send xms_cc=cp1 unconditionally and the retry path exists to handle claims challenges). Force it on:

isCaeEnabled: true

And add a test that captures the TokenRequestContext passed to a Mock<TokenCredential> and asserts IsCaeEnabled == true plus the Claims content.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — added isCaeEnabled: true to CosmosScopeProvider.GetTokenRequestContext(). This is the proper Azure.Identity contract for signaling CAE support and ensures MSAL uses the CAE-aware token cache
partition. Combined with xms_cc=cp1 in claims, this gives full CAE compliance.


AccessToken accessToken = await this.tokenCredential.GetTokenAsync(
requestContext: tokenRequestContext,
cancellationToken: this.cancellationToken);
Expand All @@ -225,6 +332,8 @@ private async ValueTask<AuthState> RefreshCachedTokenWithRetryHelperAsync(
this.systemBackgroundTokenCredentialRefreshInterval = TimeSpan.FromSeconds(refreshIntervalInSeconds);
}

this.cachedClaimsChallenge = null;

AuthState newState = new AuthState(
accessToken,
this.tokenToAuthorizationHeader(accessToken.Token));
Expand Down Expand Up @@ -257,14 +366,20 @@ private async ValueTask<AuthState> RefreshCachedTokenWithRetryHelperAsync(
$"Exception at {DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)}",
exception.Message);

DefaultTrace.TraceError($"TokenCredential.GetToken() failed with RequestFailedException. scope = {string.Join(";", tokenRequestContext.Scopes)}, retry = {retry}, Exception = {lastException.Message}");
DefaultTrace.TraceError(
$"TokenCredential.GetTokenAuthorizationHeaderAsync() failed. " +
$"scope = {string.Join(";", tokenRequestContext.Scopes)}, " +
$"hasClaimsChallenge = {this.cachedClaimsChallenge != null}, " +
$"retry = {retry}, " +
$"Exception = {lastException.Message}");

// Don't retry on auth failures
if (exception is RequestFailedException requestFailedException &&
(requestFailedException.Status == (int)HttpStatusCode.Unauthorized ||
requestFailedException.Status == (int)HttpStatusCode.Forbidden))
{
this.authState = null;
this.cachedClaimsChallenge = null;
throw;
}
bool didFallback = this.scopeProvider.TryFallback(exception);
Expand All @@ -282,6 +397,8 @@ private async ValueTask<AuthState> RefreshCachedTokenWithRetryHelperAsync(
throw new ArgumentException("Last exception is null.");
}

this.cachedClaimsChallenge = null;

// The retries have been exhausted. Throw the last exception.
throw lastException;
}
Expand Down
Loading
Loading