From 6e5274795ce8c2c67047a1d2af9c4dd0c536abb8 Mon Sep 17 00:00:00 2001 From: Kailash B Date: Wed, 10 Jun 2026 17:55:19 +0530 Subject: [PATCH 1/7] feat: Initial MRRT implementation --- .../AccessTokenRequest.cs | 21 ++ .../AccessTokenResponse.cs | 6 + .../AccessTokenSet.cs | 48 ++++ .../Auth0WebAppWithAccessTokenOptions.cs | 10 +- .../HttpContextExtensions.cs | 184 ++++++++++++++- .../TokenClient.cs | 12 +- .../TokenSetHelpers.cs | 214 ++++++++++++++++++ .../TokenClientTests.cs | 73 ++++++ .../TokenSetHelpersTests.cs | 139 ++++++++++++ 9 files changed, 693 insertions(+), 14 deletions(-) create mode 100644 src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs create mode 100644 src/Auth0.AspNetCore.Authentication/AccessTokenSet.cs create mode 100644 src/Auth0.AspNetCore.Authentication/TokenSetHelpers.cs create mode 100644 tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs diff --git a/src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs b/src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs new file mode 100644 index 00000000..7385183c --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs @@ -0,0 +1,21 @@ +namespace Auth0.AspNetCore.Authentication +{ + /// + /// Describes a request for an access token, optionally targeting a specific + /// audience and/or scope. + /// + public class AccessTokenRequest + { + /// + /// The audience to request an access token for. When omitted, the audience + /// configured via is used. + /// + public string? Audience { get; set; } + + /// + /// The scopes to request. Merged (order-preserving union) with the configured + /// default scopes for the resolved audience. + /// + public string? Scope { get; set; } + } +} diff --git a/src/Auth0.AspNetCore.Authentication/AccessTokenResponse.cs b/src/Auth0.AspNetCore.Authentication/AccessTokenResponse.cs index 3b0aa855..c55aa5fb 100644 --- a/src/Auth0.AspNetCore.Authentication/AccessTokenResponse.cs +++ b/src/Auth0.AspNetCore.Authentication/AccessTokenResponse.cs @@ -30,5 +30,11 @@ internal class AccessTokenResponse /// [JsonPropertyName("access_token")] public string AccessToken { get; set; } = null!; + + /// + /// Space-separated scopes granted for the access token. + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } } } diff --git a/src/Auth0.AspNetCore.Authentication/AccessTokenSet.cs b/src/Auth0.AspNetCore.Authentication/AccessTokenSet.cs new file mode 100644 index 00000000..110d4280 --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/AccessTokenSet.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace Auth0.AspNetCore.Authentication +{ + /// + /// Represents an additional access token retrieved for a specific audience/scope + /// combination, stored alongside the primary token in the session. + /// + internal class AccessTokenSet + { + /// + /// The access token. + /// + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = null!; + + /// + /// Expiration time as a Unix timestamp (seconds). + /// + [JsonPropertyName("expires_at")] + public long ExpiresAt { get; set; } + + /// + /// The audience the token was requested for. + /// + [JsonPropertyName("audience")] + public string Audience { get; set; } = null!; + + /// + /// The scopes actually granted by the authorization server. + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + /// + /// The scopes originally requested. Tracked separately from + /// so that future requests for a subset/superset resolve to the same entry. + /// + [JsonPropertyName("requested_scope")] + public string? RequestedScope { get; set; } + + /// + /// The token type (e.g. "Bearer"). Optional. + /// + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + } +} diff --git a/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenOptions.cs b/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenOptions.cs index c9830645..3a962ffe 100644 --- a/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenOptions.cs +++ b/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenOptions.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; namespace Auth0.AspNetCore.Authentication { @@ -17,6 +18,13 @@ public class Auth0WebAppWithAccessTokenOptions /// public string? Scope { get; set; } + /// + /// Optional per-audience default scopes, used when requesting access tokens for + /// additional audiences (MRRT). When an audience is present in this map, its value + /// is used as the default scope for that audience; otherwise is used. + /// + public IReadOnlyDictionary? ScopeByAudience { get; set; } + /// /// Define whether or not Refresh Tokens should be used internally when the access token is expired. /// diff --git a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs index 86cfdea5..f9f5c3c4 100644 --- a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs +++ b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs @@ -1,20 +1,180 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; -namespace Auth0.AspNetCore.Authentication; - -internal static class HttpContextExtensions +namespace Auth0.AspNetCore.Authentication { /// - /// Retrieves the resolved domain from the collection. + /// extensions for retrieving access tokens, including + /// on-demand tokens for additional audiences/scopes (Multi-Resource Refresh Token). /// - /// The current HTTP context. - /// - /// The resolved domain as a string if present; otherwise, null. - /// - internal static string? GetResolvedDomain(this HttpContext httpContext) + public static class HttpContextExtensions { - return httpContext.Items.TryGetValue(Auth0Constants.ResolvedDomainKey, out var domainObj) - ? domainObj as string - : null; + internal const string AccessTokensItemKey = ".Token.access_tokens"; + private const int ExpiryLeewaySeconds = 60; + + /// + /// Retrieves an access token for the audience/scope described by . + /// Reuses a cached token from the session when one is present and not expired; otherwise + /// exchanges the session's refresh token for a new token and persists it. + /// + /// The current . + /// The audience/scope to request a token for. + /// The Auth0 authentication scheme. Defaults to . + /// The access token, or null when no refresh token is available or the refresh failed. + public static async Task GetAccessTokenAsync(this HttpContext context, AccessTokenRequest request, string? scheme = null) + { + scheme ??= Auth0Constants.AuthenticationScheme; + + var options = context.RequestServices.GetRequiredService>().Get(scheme); + var optionsWithAccessToken = context.RequestServices.GetRequiredService>().Get(scheme); + + var audience = request.Audience ?? optionsWithAccessToken.Audience; + var mergedScope = TokenSetHelpers.MergeScopeWithDefaults(request.Scope, audience, optionsWithAccessToken.Scope, optionsWithAccessToken.ScopeByAudience); + + var authenticateResult = await context.AuthenticateAsync(options.CookieAuthenticationScheme).ConfigureAwait(false); + if (!authenticateResult.Succeeded || authenticateResult.Properties == null) + { + return null; + } + + var properties = authenticateResult.Properties; + var matchesPrimaryToken = MatchesPrimaryToken(audience, mergedScope, optionsWithAccessToken); + + // 1. Try to satisfy the request from what is already stored in the session. + if (matchesPrimaryToken) + { + if (properties.Items.TryGetValue(".Token.access_token", out var primaryToken) && + !string.IsNullOrEmpty(primaryToken) && + !IsPrimaryExpired(properties)) + { + return primaryToken; + } + } + else + { + var sets = ReadAccessTokenSets(properties); + var match = TokenSetHelpers.FindAccessTokenSet(sets, audience!, mergedScope, ScopeMatchMode.RequestedScope); + if (match != null && match.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(ExpiryLeewaySeconds).ToUnixTimeSeconds()) + { + return match.AccessToken; + } + } + + // 2. No usable token cached — we need the refresh token to obtain one. + if (!properties.Items.TryGetValue(".Token.refresh_token", out var refreshToken) || string.IsNullOrWhiteSpace(refreshToken)) + { + if (optionsWithAccessToken.Events?.OnMissingRefreshToken != null) + { + await optionsWithAccessToken.Events.OnMissingRefreshToken(context).ConfigureAwait(false); + } + + return null; + } + + var httpClient = options.Backchannel ?? new HttpClient(); + var tokenClient = new TokenClient(httpClient); + var resolvedDomain = context.GetResolvedDomain(); + + var response = await tokenClient.Refresh(options, refreshToken, resolvedDomain, audience, mergedScope).ConfigureAwait(false); + if (response == null) + { + return null; + } + + // 3. Merge the new token into the session (primary slot or additional array) and persist. + ApplyTokenResponse(properties, response, audience, mergedScope, matchesPrimaryToken); + + await context.SignInAsync(options.CookieAuthenticationScheme, authenticateResult.Principal!, properties).ConfigureAwait(false); + + return response.AccessToken; + } + + /// + /// Retrieves the resolved domain from the collection. + /// + /// The current HTTP context. + /// + /// The resolved domain as a string if present; otherwise, null. + /// + internal static string? GetResolvedDomain(this HttpContext httpContext) + { + return httpContext.Items.TryGetValue(Auth0Constants.ResolvedDomainKey, out var domainObj) + ? domainObj as string + : null; + } + + /// + /// Determines whether the requested audience/scope matches the application's primary + /// (login-time) token — the one stored in the .Token.access_token slot — rather + /// than an additional MRRT audience/scope kept in the access-token sets. + /// + private static bool MatchesPrimaryToken(string? audience, string? mergedScope, Auth0WebAppWithAccessTokenOptions options) + { + var matchesPrimaryAudience = audience == null || audience == options.Audience; + + var primaryScope = TokenSetHelpers.GetScopeForAudience(options.Scope, options.ScopeByAudience, audience); + var matchesPrimaryScope = string.IsNullOrEmpty(mergedScope) || TokenSetHelpers.CompareScopes(primaryScope, mergedScope); + + return matchesPrimaryAudience && matchesPrimaryScope; + } + + private static bool IsPrimaryExpired(AuthenticationProperties properties) + { + if (!properties.Items.TryGetValue(".Token.expires_at", out var expiresAtRaw) || string.IsNullOrEmpty(expiresAtRaw)) + { + return true; + } + + var expiresAt = DateTimeOffset.Parse(expiresAtRaw); + return DateTimeOffset.Compare(expiresAt, DateTimeOffset.Now.AddSeconds(ExpiryLeewaySeconds)) <= 0; + } + + private static void ApplyTokenResponse(AuthenticationProperties properties, AccessTokenResponse response, string? audience, string? mergedScope, bool matchesPrimaryToken) + { + if (matchesPrimaryToken) + { + properties.UpdateTokenValue("access_token", response.AccessToken); + properties.UpdateTokenValue("expires_at", DateTimeOffset.Now.AddSeconds(response.ExpiresIn).ToString("o")); + } + else + { + var sets = ReadAccessTokenSets(properties); + var updated = TokenSetHelpers.UpsertAccessTokenSet(sets, audience!, mergedScope, response); + WriteAccessTokenSets(properties, updated); + } + + // Rotation + id_token refresh apply regardless of which slot was updated. + if (!string.IsNullOrEmpty(response.RefreshToken)) + { + properties.UpdateTokenValue("refresh_token", response.RefreshToken); + } + + if (!string.IsNullOrEmpty(response.IdToken)) + { + properties.UpdateTokenValue("id_token", response.IdToken); + } + } + + private static List ReadAccessTokenSets(AuthenticationProperties properties) + { + if (properties.Items.TryGetValue(AccessTokensItemKey, out var json) && !string.IsNullOrEmpty(json)) + { + return JsonSerializer.Deserialize>(json) ?? new List(); + } + + return new List(); + } + + private static void WriteAccessTokenSets(AuthenticationProperties properties, List sets) + { + properties.Items[AccessTokensItemKey] = JsonSerializer.Serialize(sets); + } } } diff --git a/src/Auth0.AspNetCore.Authentication/TokenClient.cs b/src/Auth0.AspNetCore.Authentication/TokenClient.cs index a3316fb4..bbd0fdfd 100644 --- a/src/Auth0.AspNetCore.Authentication/TokenClient.cs +++ b/src/Auth0.AspNetCore.Authentication/TokenClient.cs @@ -22,7 +22,7 @@ public TokenClient(HttpClient httpClient) _httpClient = httpClient; } - public async Task Refresh(Auth0WebAppOptions options, string refreshToken, string? domain = null) + public async Task Refresh(Auth0WebAppOptions options, string refreshToken, string? domain = null, string? audience = null, string? scope = null) { var body = new Dictionary { { "grant_type", "refresh_token" }, @@ -30,6 +30,16 @@ public TokenClient(HttpClient httpClient) { "refresh_token", refreshToken } }; + if (!string.IsNullOrWhiteSpace(audience)) + { + body.Add("audience", audience); + } + + if (!string.IsNullOrWhiteSpace(scope)) + { + body.Add("scope", scope); + } + // Use provided domain for dynamic resolution, fallback to options.Domain var tokenEndpointDomain = domain ?? options.Domain; diff --git a/src/Auth0.AspNetCore.Authentication/TokenSetHelpers.cs b/src/Auth0.AspNetCore.Authentication/TokenSetHelpers.cs new file mode 100644 index 00000000..4053a436 --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/TokenSetHelpers.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Auth0.AspNetCore.Authentication +{ + /// + /// Selects how compares scopes. + /// + internal enum ScopeMatchMode + { + /// + /// Superset membership against the requested scope (falling back to the granted + /// scope for older entries that never recorded it). Used on the read path so a + /// request for "read" can be served by a cached "read write" token. + /// + RequestedScope, + + /// + /// Strict set-equality against the granted scope. Used when deciding whether a + /// freshly granted token should replace an existing entry. + /// + Granted + } + + /// + /// Pure helper functions for scope handling and access-token-set matching/merging. + /// + internal static class TokenSetHelpers + { + /// + /// Splits a space-separated scope string into individual scopes, dropping empties. + /// + public static string[] ParseScopes(string? scopes) + { + if (string.IsNullOrWhiteSpace(scopes)) + { + return Array.Empty(); + } + + return scopes.Trim().Split(' ').Where(s => !string.IsNullOrEmpty(s)).ToArray(); + } + + /// + /// Merges two scope strings into an order-preserving union (no sorting). + /// + public static string MergeScopes(string? baseScopes, string? additionalScopes) + { + var ordered = ParseScopes(baseScopes).Concat(ParseScopes(additionalScopes)).Distinct().ToList(); + return string.Join(" ", ordered); + } + + /// + /// Determines whether all are present in + /// (set membership). When is true, + /// the two scope sets must be exactly equal. + /// + public static bool CompareScopes(string? scopes, string? requiredScopes, bool strict = false) + { + if (scopes == requiredScopes) + { + return true; + } + + if (string.IsNullOrEmpty(scopes) || string.IsNullOrEmpty(requiredScopes)) + { + return false; + } + + var scopesSet = new HashSet(ParseScopes(scopes)); + var requiredSet = new HashSet(ParseScopes(requiredScopes)); + + var hasAll = requiredSet.All(scopesSet.Contains); + + if (strict) + { + return hasAll && scopesSet.Count == requiredSet.Count; + } + + return hasAll; + } + + /// + /// Returns the default scope configured for an audience: the per-audience entry when + /// present, otherwise the global default. + /// + public static string? GetScopeForAudience(string? scope, IReadOnlyDictionary? scopeByAudience, string? audience) + { + if (scopeByAudience != null && audience != null && scopeByAudience.TryGetValue(audience, out var audienceScope)) + { + return audienceScope; + } + + return scope; + } + + /// + /// Merges the requested scope with the configured defaults for the audience. + /// Order-preserving union of default scopes followed by requested scopes. + /// + public static string? MergeScopeWithDefaults(string? requestScope, string? audience, string? scope, IReadOnlyDictionary? scopeByAudience) + { + var defaultScope = GetScopeForAudience(scope, scopeByAudience, audience); + var merged = MergeScopes(defaultScope, requestScope); + + return string.IsNullOrEmpty(merged) ? null : merged; + } + + /// + /// Finds the best matching for an audience and scope: + /// an exact match is preferred, otherwise the smallest superset. + /// + /// + /// compares against the requested scope + /// (falling back to granted); compares strictly + /// against the granted scope. + /// + public static AccessTokenSet? FindAccessTokenSet(IEnumerable? sets, string audience, string? scope, ScopeMatchMode matchMode = ScopeMatchMode.RequestedScope) + { + if (sets == null) + { + return null; + } + + // Audience must match exactly; scope comparison depends on the mode: + // - Granted: strict set-equality against the granted scope (used when deciding + // whether a freshly granted token should replace an existing entry). + // - RequestedScope (default): superset membership against the requested scope, + // falling back to granted for older entries that never recorded it (used on the + // read path, so a request for "read" can be served by a cached "read write" token). + var strict = matchMode == ScopeMatchMode.Granted; + var matches = sets.Where(set => + set.Audience == audience && + CompareScopes( + strict ? set.Scope : (set.RequestedScope ?? set.Scope), + scope, + strict)) + .ToList(); + + if (matches.Count == 0) + { + return null; + } + + // When several tokens qualify, prefer the least-scoped one (smallest superset) so we + // never hand back a more privileged token than the request needs. + return matches + .OrderBy(set => new HashSet(ParseScopes(set.Scope)).Count) + .First(); + } + + /// + /// Applies a freshly retrieved token to the additional-token collection: + /// + /// Match by requested scope; if found, replace when the access token changed. + /// Else match strictly by granted scope and merge the requested scopes (union). + /// Else append a new entry. + /// + /// Returns the updated list (a new list instance). + /// + public static List UpsertAccessTokenSet(IEnumerable? sets, string audience, string? requestedScope, AccessTokenResponse response) + { + var result = sets?.ToList() ?? new List(); + + // Case 1: we've served this request before. Refresh the token in place, + // skipping the write when the access token is unchanged. + var sameRequest = FindAccessTokenSet(result, audience, requestedScope, ScopeMatchMode.RequestedScope); + if (sameRequest != null) + { + if (sameRequest.AccessToken != response.AccessToken) + { + Replace(result, sameRequest, Build(audience, requestedScope, response)); + } + + return result; + } + + // Case 2: a different request resolved to a token we already hold (Auth0 + // granted the same scope set). Reuse that entry and widen its RequestedScope + // so both requests now resolve here. + var sameGrant = FindAccessTokenSet(result, audience, response.Scope, ScopeMatchMode.Granted); + if (sameGrant != null) + { + var mergedRequested = MergeScopes(sameGrant.RequestedScope, requestedScope); + Replace(result, sameGrant, Build(audience, mergedRequested, response)); + + return result; + } + + // Case 3: a brand-new audience/scope combination. + result.Add(Build(audience, requestedScope, response)); + + return result; + } + + private static void Replace(List sets, AccessTokenSet existing, AccessTokenSet updated) + { + var index = sets.IndexOf(existing); + sets[index] = updated; + } + + private static AccessTokenSet Build(string audience, string? requestedScope, AccessTokenResponse response) + { + return new AccessTokenSet + { + AccessToken = response.AccessToken, + ExpiresAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + response.ExpiresIn, + Audience = audience, + Scope = response.Scope, + RequestedScope = string.IsNullOrEmpty(requestedScope) ? null : requestedScope + }; + } + } +} diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs index 9446b061..cfceee67 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs @@ -127,6 +127,79 @@ public async Task Refresh_WithoutCustomDomain_UsesDefaultDomain() requestedDomain.Should().Be(defaultDomain); } + [Fact] + public async Task Refresh_WithAudienceAndScope_IncludesThemInBody() + { + var capturedBody = string.Empty; + + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .Callback((req, _) => + { + if (req.Content != null) + capturedBody = req.Content.ReadAsStringAsync().Result; + }) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"access_token\":\"new_token\",\"token_type\":\"Bearer\",\"expires_in\":86400,\"scope\":\"read:orders\"}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + var result = await client.Refresh( + new Auth0WebAppOptions { Domain = "default.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123", + null, + "api://orders", + "read:orders" + ); + + result.Should().NotBeNull(); + result?.Scope.Should().Be("read:orders"); + capturedBody.Should().Contain("audience=api%3A%2F%2Forders"); + capturedBody.Should().Contain("scope=read%3Aorders"); + } + + [Fact] + public async Task Refresh_WithoutAudienceAndScope_OmitsThemFromBody() + { + var capturedBody = string.Empty; + + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .Callback((req, _) => + { + if (req.Content != null) + capturedBody = req.Content.ReadAsStringAsync().Result; + }) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"access_token\":\"new_token\",\"token_type\":\"Bearer\",\"expires_in\":86400}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + await client.Refresh( + new Auth0WebAppOptions { Domain = "default.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123" + ); + + capturedBody.Should().NotContain("audience="); + capturedBody.Should().NotContain("scope="); + } + [Fact] public async Task Refresh_WithNullDomain_ThrowsInvalidOperationException() { diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs new file mode 100644 index 00000000..3d39e516 --- /dev/null +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs @@ -0,0 +1,139 @@ +using FluentAssertions; +using System; +using System.Collections.Generic; +using Xunit; + +namespace Auth0.AspNetCore.Authentication.IntegrationTests +{ + public class TokenSetHelpersTests + { + [Theory] + [InlineData(null, new string[0])] + [InlineData("", new string[0])] + [InlineData(" ", new string[0])] + [InlineData("a", new[] { "a" })] + [InlineData("a b c", new[] { "a", "b", "c" })] + [InlineData(" a b ", new[] { "a", "b" })] + public void ParseScopes_SplitsAndFilters(string? input, string[] expected) + { + TokenSetHelpers.ParseScopes(input).Should().Equal(expected); + } + + [Fact] + public void MergeScopes_IsOrderPreservingUnion_NotSorted() + { + // default scopes first, request scopes appended, dedup, original order kept + TokenSetHelpers.MergeScopes("c b", "b a").Should().Be("c b a"); + } + + [Fact] + public void MergeScopes_HandlesNulls() + { + TokenSetHelpers.MergeScopes(null, "a b").Should().Be("a b"); + TokenSetHelpers.MergeScopes("a b", null).Should().Be("a b"); + TokenSetHelpers.MergeScopes(null, null).Should().Be(""); + } + + [Fact] + public void CompareScopes_NonStrict_IsSupersetMembership() + { + TokenSetHelpers.CompareScopes("a b c", "a b").Should().BeTrue(); + TokenSetHelpers.CompareScopes("a b", "a b c").Should().BeFalse(); + } + + [Fact] + public void CompareScopes_Strict_RequiresEqualSets() + { + TokenSetHelpers.CompareScopes("a b", "b a", strict: true).Should().BeTrue(); + TokenSetHelpers.CompareScopes("a b c", "a b", strict: true).Should().BeFalse(); + } + + [Fact] + public void GetScopeForAudience_PrefersPerAudienceMap() + { + var map = new Dictionary { ["api://orders"] = "read:orders" }; + TokenSetHelpers.GetScopeForAudience("default", map, "api://orders").Should().Be("read:orders"); + TokenSetHelpers.GetScopeForAudience("default", map, "api://other").Should().Be("default"); + TokenSetHelpers.GetScopeForAudience("default", null, "api://orders").Should().Be("default"); + } + + [Fact] + public void MergeScopeWithDefaults_UnionsDefaultsWithRequest() + { + var map = new Dictionary { ["api://orders"] = "read:orders" }; + TokenSetHelpers.MergeScopeWithDefaults("write:orders", "api://orders", "openid", map) + .Should().Be("read:orders write:orders"); + } + + [Fact] + public void MergeScopeWithDefaults_ReturnsNullWhenEmpty() + { + TokenSetHelpers.MergeScopeWithDefaults(null, "api://x", null, null).Should().BeNull(); + } + + [Fact] + public void FindAccessTokenSet_PrefersExactThenSmallestSuperset() + { + var sets = new List + { + new AccessTokenSet { Audience = "api", AccessToken = "big", Scope = "a b c", RequestedScope = "a b c" }, + new AccessTokenSet { Audience = "api", AccessToken = "small", Scope = "a b", RequestedScope = "a b" }, + }; + + // smallest superset for "a" + TokenSetHelpers.FindAccessTokenSet(sets, "api", "a", ScopeMatchMode.RequestedScope)!.AccessToken.Should().Be("small"); + // exact match + TokenSetHelpers.FindAccessTokenSet(sets, "api", "a b c", ScopeMatchMode.RequestedScope)!.AccessToken.Should().Be("big"); + // no match for different audience + TokenSetHelpers.FindAccessTokenSet(sets, "other", "a", ScopeMatchMode.RequestedScope).Should().BeNull(); + } + + [Fact] + public void UpsertAccessTokenSet_AppendsNewEntry() + { + var response = new AccessTokenResponse { AccessToken = "tok", ExpiresIn = 3600, Scope = "read:x" }; + var result = TokenSetHelpers.UpsertAccessTokenSet(null, "api", "read:x", response); + + result.Should().HaveCount(1); + result[0].AccessToken.Should().Be("tok"); + result[0].Audience.Should().Be("api"); + result[0].Scope.Should().Be("read:x"); + result[0].RequestedScope.Should().Be("read:x"); + result[0].ExpiresAt.Should().BeGreaterThan(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + } + + [Fact] + public void UpsertAccessTokenSet_ReplacesExistingByRequestedScope() + { + var existing = new List + { + new AccessTokenSet { Audience = "api", AccessToken = "old", Scope = "a", RequestedScope = "a" } + }; + var response = new AccessTokenResponse { AccessToken = "new", ExpiresIn = 3600, Scope = "a" }; + + var result = TokenSetHelpers.UpsertAccessTokenSet(existing, "api", "a", response); + + result.Should().HaveCount(1); + result[0].AccessToken.Should().Be("new"); + } + + [Fact] + public void UpsertAccessTokenSet_MergesRequestedScopeWhenGrantedScopeMatches() + { + // cached entry: granted "a", requested "a b". New request "a c" -> granted "a". + // Should merge requestedScope to "a b c" on the same entry rather than append. + var existing = new List + { + new AccessTokenSet { Audience = "api", AccessToken = "old", Scope = "a", RequestedScope = "a b" } + }; + var response = new AccessTokenResponse { AccessToken = "new", ExpiresIn = 3600, Scope = "a" }; + + var result = TokenSetHelpers.UpsertAccessTokenSet(existing, "api", "a c", response); + + result.Should().HaveCount(1); + result[0].RequestedScope.Should().Be("a b c"); + result[0].Scope.Should().Be("a"); + result[0].AccessToken.Should().Be("new"); + } + } +} From dc671dc42b44401dd4ef2ae99bca7107b847c772 Mon Sep 17 00:00:00 2001 From: Kailash B Date: Wed, 10 Jun 2026 18:24:05 +0530 Subject: [PATCH 2/7] feat: Allow ForceRefresh to by-pass cache per-call --- .../AccessTokenRequest.cs | 7 + .../HttpContextExtensions.cs | 30 +-- .../HttpContextExtensionsTests.cs | 172 ++++++++++++++++++ 3 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs diff --git a/src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs b/src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs index 7385183c..fb16466f 100644 --- a/src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs +++ b/src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs @@ -17,5 +17,12 @@ public class AccessTokenRequest /// default scopes for the resolved audience. /// public string? Scope { get; set; } + + /// + /// When true, always exchanges the refresh token for a new access token, + /// ignoring any cached token. The freshly retrieved token still replaces the + /// cached entry. Defaults to false. + /// + public bool ForceRefresh { get; set; } } } diff --git a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs index f9f5c3c4..9ddf35ce 100644 --- a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs +++ b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs @@ -47,23 +47,27 @@ public static class HttpContextExtensions var properties = authenticateResult.Properties; var matchesPrimaryToken = MatchesPrimaryToken(audience, mergedScope, optionsWithAccessToken); - // 1. Try to satisfy the request from what is already stored in the session. - if (matchesPrimaryToken) + // 1. Try to satisfy the request from what is already stored in the session, + // unless the caller explicitly asked to bypass the cache. + if (!request.ForceRefresh) { - if (properties.Items.TryGetValue(".Token.access_token", out var primaryToken) && - !string.IsNullOrEmpty(primaryToken) && - !IsPrimaryExpired(properties)) + if (matchesPrimaryToken) { - return primaryToken; + if (properties.Items.TryGetValue(".Token.access_token", out var primaryToken) && + !string.IsNullOrEmpty(primaryToken) && + !IsPrimaryExpired(properties)) + { + return primaryToken; + } } - } - else - { - var sets = ReadAccessTokenSets(properties); - var match = TokenSetHelpers.FindAccessTokenSet(sets, audience!, mergedScope, ScopeMatchMode.RequestedScope); - if (match != null && match.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(ExpiryLeewaySeconds).ToUnixTimeSeconds()) + else { - return match.AccessToken; + var sets = ReadAccessTokenSets(properties); + var match = TokenSetHelpers.FindAccessTokenSet(sets, audience!, mergedScope, ScopeMatchMode.RequestedScope); + if (match != null && match.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(ExpiryLeewaySeconds).ToUnixTimeSeconds()) + { + return match.AccessToken; + } } } diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs new file mode 100644 index 00000000..ec6f0017 --- /dev/null +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs @@ -0,0 +1,172 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using System; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Auth0.AspNetCore.Authentication.IntegrationTests +{ + public class HttpContextExtensionsTests + { + private const string CookieScheme = "Cookies"; + private const string Domain = "test.auth0.com"; + private const string PrimaryAudience = "https://api"; + + [Fact] + public async Task GetAccessTokenAsync_WithoutForceRefresh_ReturnsCachedTokenWithoutCallingBackchannel() + { + var handler = CreateTokenHandler("fresh"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "cached"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + + var context = BuildContext(handler.Object, properties, out var authService); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = PrimaryAudience, ForceRefresh = false }); + + result.Should().Be("cached"); + handler.Protected().Verify("SendAsync", Times.Never(), + ItExpr.IsAny(), ItExpr.IsAny()); + authService.Verify(s => s.SignInAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task GetAccessTokenAsync_WithForceRefresh_BypassesCacheAndPersistsNewToken() + { + var handler = CreateTokenHandler("fresh"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "cached"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + + AuthenticationProperties? persisted = null; + var context = BuildContext(handler.Object, properties, out var authService); + authService + .Setup(s => s.SignInAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, p) => persisted = p) + .Returns(Task.CompletedTask); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = PrimaryAudience, ForceRefresh = true }); + + result.Should().Be("fresh"); + handler.Protected().Verify("SendAsync", Times.Once(), + ItExpr.IsAny(), ItExpr.IsAny()); + persisted.Should().NotBeNull(); + persisted!.Items[".Token.access_token"].Should().Be("fresh"); + } + + [Fact] + public async Task GetAccessTokenAsync_WithForceRefresh_NoRefreshToken_FiresEventAndReturnsNull() + { + var handler = CreateTokenHandler("fresh"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "cached"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + // No refresh token stored. + + var missingRefreshTokenFired = false; + var context = BuildContext(handler.Object, properties, out _, withAccessTokenOptions => + { + withAccessTokenOptions.Events = new Auth0WebAppWithAccessTokenEvents + { + OnMissingRefreshToken = _ => + { + missingRefreshTokenFired = true; + return Task.CompletedTask; + } + }; + }); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = PrimaryAudience, ForceRefresh = true }); + + result.Should().BeNull(); + missingRefreshTokenFired.Should().BeTrue(); + handler.Protected().Verify("SendAsync", Times.Never(), + ItExpr.IsAny(), ItExpr.IsAny()); + } + + private static Mock CreateTokenHandler(string accessToken) + { + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + $"{{\"access_token\":\"{accessToken}\",\"token_type\":\"Bearer\",\"expires_in\":86400}}") + }); + return handler; + } + + private static HttpContext BuildContext( + HttpMessageHandler backchannelHandler, + AuthenticationProperties properties, + out Mock authService, + Action? configureWithAccessToken = null) + { + var webAppOptions = new Auth0WebAppOptions + { + Domain = Domain, + ClientId = "cid", + ClientSecret = "secret", + Backchannel = new HttpClient(backchannelHandler), + CookieAuthenticationScheme = CookieScheme + }; + + var withAccessTokenOptions = new Auth0WebAppWithAccessTokenOptions + { + Audience = PrimaryAudience + }; + configureWithAccessToken?.Invoke(withAccessTokenOptions); + + var principal = new ClaimsPrincipal(new ClaimsIdentity("Cookies")); + var ticket = new AuthenticationTicket(principal, properties, CookieScheme); + + authService = new Mock(); + authService + .Setup(s => s.AuthenticateAsync(It.IsAny(), CookieScheme)) + .ReturnsAsync(AuthenticateResult.Success(ticket)); + authService + .Setup(s => s.SignInAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var webAppSnapshot = new Mock>(); + webAppSnapshot.Setup(s => s.Get(It.IsAny())).Returns(webAppOptions); + var withAccessTokenSnapshot = new Mock>(); + withAccessTokenSnapshot.Setup(s => s.Get(It.IsAny())).Returns(withAccessTokenOptions); + + var services = new ServiceCollection(); + services.AddSingleton(authService.Object); + services.AddSingleton(webAppSnapshot.Object); + services.AddSingleton(withAccessTokenSnapshot.Object); + + return new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + } + } +} From d8402bbad1fa69b9e87413def0affa15f9106c44 Mon Sep 17 00:00:00 2001 From: Kailash B Date: Fri, 12 Jun 2026 12:21:15 +0530 Subject: [PATCH 3/7] feat: Handles exceptions in the Refresh Token flow --- .../AccessTokenRefreshFailedContext.cs | 69 +++++++++ .../Auth0WebAppWithAccessTokenEvents.cs | 33 +++++ .../AuthenticationBuilderExtensions.cs | 7 +- .../HttpContextExtensions.cs | 48 ++++++- .../TokenClient.cs | 62 +++++++- .../TokenRefreshResult.cs | 25 ++++ .../HttpContextExtensionsTests.cs | 134 ++++++++++++++++++ .../TokenClientTests.cs | 71 +++++++++- 8 files changed, 432 insertions(+), 17 deletions(-) create mode 100644 src/Auth0.AspNetCore.Authentication/AccessTokenRefreshFailedContext.cs create mode 100644 src/Auth0.AspNetCore.Authentication/TokenRefreshResult.cs diff --git a/src/Auth0.AspNetCore.Authentication/AccessTokenRefreshFailedContext.cs b/src/Auth0.AspNetCore.Authentication/AccessTokenRefreshFailedContext.cs new file mode 100644 index 00000000..9091beb8 --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/AccessTokenRefreshFailedContext.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Http; +using System; + +namespace Auth0.AspNetCore.Authentication +{ + /// + /// Provides the details of a failed access-token refresh to subscribers of + /// , so they can + /// distinguish a terminal failure (such as an invalid_grant for a revoked or expired + /// refresh token, which warrants a re-login) from a transient one (such as a timeout or a + /// rate-limit, which may be retried). + /// + public class AccessTokenRefreshFailedContext + { + internal AccessTokenRefreshFailedContext(HttpContext httpContext, string? audience, string? scope, int? statusCode, string? error, string? errorDescription, Exception? exception) + { + HttpContext = httpContext; + Audience = audience; + Scope = scope; + StatusCode = statusCode; + Error = error; + ErrorDescription = errorDescription; + Exception = exception; + } + + /// + /// The current , allowing you to sign the user out, challenge + /// for re-login, or resolve services to react to the failure. + /// + public HttpContext HttpContext { get; } + + /// + /// The audience the token was being requested for, when one was resolved. + /// + public string? Audience { get; } + + /// + /// The scope the token was being requested for, when one was resolved. + /// + public string? Scope { get; } + + /// + /// The HTTP status code returned by the token endpoint, when the failure was an HTTP + /// rejection. null when the request never produced a response (for example a + /// transport failure — see ). + /// + public int? StatusCode { get; } + + /// + /// The error code from the token endpoint's error response (for example + /// invalid_grant), when present. + /// + public string? Error { get; } + + /// + /// The error_description from the token endpoint's error response, when present. + /// + public string? ErrorDescription { get; } + + /// + /// The exception that caused the failure, when the refresh threw rather than returning + /// an HTTP error (for example a transport failure, timeout, or misconfiguration). + /// null when the failure was an HTTP rejection — see , + /// , and . + /// May contain transport/diagnostic detail; log server-side only, do not surface to end users. + /// + public Exception? Exception { get; } + } +} diff --git a/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenEvents.cs b/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenEvents.cs index 48342c19..84be1ad9 100644 --- a/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenEvents.cs +++ b/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenEvents.cs @@ -54,5 +54,38 @@ public class Auth0WebAppWithAccessTokenEvents /// /// public Func? OnMissingRefreshToken { get; set; } + + /// + /// Executed when an attempt to exchange the refresh token for an access token failed, + /// allowing you to react accordingly (e.g. force a re-login). This fires when a refresh + /// token was present but the token endpoint rejected the request (such as an + /// invalid_grant for a revoked or expired refresh token) or could not be reached. + /// The supplied carries the failure details + /// (status code, error/error_description, or the thrown exception) so you can + /// distinguish a terminal failure from a transient one. + /// + /// + /// + /// services + /// .AddAuth0WebAppAuthentication(options => {}) + /// .WithAccessToken(options => + /// { + /// options.Events = new Auth0WebAppWithAccessTokenEvents + /// { + /// OnAccessTokenRefreshFailed = async (context) => + /// { + /// // A revoked or expired refresh token is terminal — force a re-login. + /// if (context.Error == "invalid_grant") + /// { + /// await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + /// var authenticationProperties = new AuthenticationPropertiesBuilder().WithRedirectUri("/").Build(); + /// await context.HttpContext.ChallengeAsync(Auth0Constants.AuthenticationScheme, authenticationProperties); + /// } + /// } + /// }; + /// }); + /// + /// + public Func? OnAccessTokenRefreshFailed { get; set; } } } diff --git a/src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs b/src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs index 7b384435..e8e86947 100644 --- a/src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs +++ b/src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs @@ -251,11 +251,12 @@ private static async Task RefreshTokenIfNeccesary(CookieValidatePrincipalContext private static async Task RefreshTokens(HttpContext httpContext, Auth0WebAppOptions options, string refreshToken, HttpClient httpClient) { var tokenClient = new TokenClient(httpClient); - + // Get the resolved domain from HttpContext if available (for multiple custom domains) var resolvedDomain = httpContext.GetResolvedDomain(); - - return await tokenClient.Refresh(options, refreshToken, resolvedDomain); + + var result = await tokenClient.Refresh(options, refreshToken, resolvedDomain); + return result.Response; } private static async Task VerifyBackchannelLogoutSupport(HttpContext context, OpenIdConnectOptions oidcOptions) diff --git a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs index 9ddf35ce..c464e20c 100644 --- a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs +++ b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs @@ -86,12 +86,28 @@ public static class HttpContextExtensions var tokenClient = new TokenClient(httpClient); var resolvedDomain = context.GetResolvedDomain(); - var response = await tokenClient.Refresh(options, refreshToken, resolvedDomain, audience, mergedScope).ConfigureAwait(false); - if (response == null) + TokenRefreshResult result; + try { + result = await tokenClient.Refresh(options, refreshToken, resolvedDomain, audience, mergedScope).ConfigureAwait(false); + } + catch (Exception ex) + { + // Any refresh failure — transport error, malformed response, or misconfiguration — + // is folded into the same failure path as a token-endpoint rejection, so callers + // have a single failure protocol and nothing escapes this method. + await FireRefreshFailed(context, optionsWithAccessToken, audience, mergedScope, statusCode: null, error: null, errorDescription: null, exception: ex).ConfigureAwait(false); + return null; + } + + if (!result.IsSuccess) + { + await FireRefreshFailed(context, optionsWithAccessToken, audience, mergedScope, result.StatusCode, result.Error, result.ErrorDescription, exception: null).ConfigureAwait(false); return null; } + var response = result.Response!; + // 3. Merge the new token into the session (primary slot or additional array) and persist. ApplyTokenResponse(properties, response, audience, mergedScope, matchesPrimaryToken); @@ -119,6 +135,15 @@ public static class HttpContextExtensions /// (login-time) token — the one stored in the .Token.access_token slot — rather /// than an additional MRRT audience/scope kept in the access-token sets. /// + private static async Task FireRefreshFailed(HttpContext context, Auth0WebAppWithAccessTokenOptions optionsWithAccessToken, string? audience, string? scope, int? statusCode, string? error, string? errorDescription, Exception? exception) + { + if (optionsWithAccessToken.Events?.OnAccessTokenRefreshFailed != null) + { + var failedContext = new AccessTokenRefreshFailedContext(context, audience, scope, statusCode, error, errorDescription, exception); + await optionsWithAccessToken.Events.OnAccessTokenRefreshFailed(failedContext).ConfigureAwait(false); + } + } + private static bool MatchesPrimaryToken(string? audience, string? mergedScope, Auth0WebAppWithAccessTokenOptions options) { var matchesPrimaryAudience = audience == null || audience == options.Audience; @@ -136,7 +161,13 @@ private static bool IsPrimaryExpired(AuthenticationProperties properties) return true; } - var expiresAt = DateTimeOffset.Parse(expiresAtRaw); + // Treat an unparseable timestamp as expired so the token is re-fetched rather + // than throwing out of a cache check on a malformed session value. + if (!DateTimeOffset.TryParse(expiresAtRaw, out var expiresAt)) + { + return true; + } + return DateTimeOffset.Compare(expiresAt, DateTimeOffset.Now.AddSeconds(ExpiryLeewaySeconds)) <= 0; } @@ -170,7 +201,16 @@ private static List ReadAccessTokenSets(AuthenticationProperties { if (properties.Items.TryGetValue(AccessTokensItemKey, out var json) && !string.IsNullOrEmpty(json)) { - return JsonSerializer.Deserialize>(json) ?? new List(); + // Corrupted or version-skewed session data is treated as a cache miss: the + // token gets re-fetched, which is preferable to throwing out of a public method. + try + { + return JsonSerializer.Deserialize>(json) ?? new List(); + } + catch (JsonException) + { + return new List(); + } } return new List(); diff --git a/src/Auth0.AspNetCore.Authentication/TokenClient.cs b/src/Auth0.AspNetCore.Authentication/TokenClient.cs index bbd0fdfd..cb39c25d 100644 --- a/src/Auth0.AspNetCore.Authentication/TokenClient.cs +++ b/src/Auth0.AspNetCore.Authentication/TokenClient.cs @@ -22,7 +22,7 @@ public TokenClient(HttpClient httpClient) _httpClient = httpClient; } - public async Task Refresh(Auth0WebAppOptions options, string refreshToken, string? domain = null, string? audience = null, string? scope = null) + public async Task Refresh(Auth0WebAppOptions options, string refreshToken, string? domain = null, string? audience = null, string? scope = null) { var body = new Dictionary { { "grant_type", "refresh_token" }, @@ -60,16 +60,72 @@ public TokenClient(HttpClient httpClient) { if (!response.IsSuccessStatusCode) { - return null; + return await BuildFailure(response).ConfigureAwait(false); } var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync(contentStream, _jsonSerializerOptions).ConfigureAwait(false); + AccessTokenResponse? accessTokenResponse; + try + { + accessTokenResponse = await JsonSerializer.DeserializeAsync(contentStream, _jsonSerializerOptions).ConfigureAwait(false); + } + catch (JsonException) + { + // The body is over token-bearing bytes (id_token is a JWT of user claims). + // Swallow the parse error so it never surfaces as an exposed Exception on the + // failure event; report a status-code-only failure with a static, payload-free + // message instead. + return new TokenRefreshResult + { + StatusCode = (int)response.StatusCode, + Error = "invalid_token_response", + ErrorDescription = "The token endpoint returned a response that could not be parsed." + }; + } + + return accessTokenResponse != null + ? TokenRefreshResult.Success(accessTokenResponse) + : new TokenRefreshResult { StatusCode = (int)response.StatusCode }; } } } + private static async Task BuildFailure(HttpResponseMessage response) + { + var result = new TokenRefreshResult { StatusCode = (int)response.StatusCode }; + + try + { + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(body)) + { + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + if (root.ValueKind == JsonValueKind.Object) + { + // An "mfa_required" error also carries "mfa_token" and "mfa_requirements"; + // surfacing those (and completing the MFA flow) is deferred to a subsequent PR. + if (root.TryGetProperty("error", out var errorElement)) + { + result.Error = errorElement.GetString(); + } + + if (root.TryGetProperty("error_description", out var descriptionElement)) + { + result.ErrorDescription = descriptionElement.GetString(); + } + } + } + } + catch (Exception) + { + // A non-JSON or unreadable error body still yields a result carrying the status code. + } + + return result; + } + private void ApplyClientAuthentication(Auth0WebAppOptions options, Dictionary body, string domain) { if (options.ClientAssertionSecurityKey != null) diff --git a/src/Auth0.AspNetCore.Authentication/TokenRefreshResult.cs b/src/Auth0.AspNetCore.Authentication/TokenRefreshResult.cs new file mode 100644 index 00000000..059bc4a6 --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/TokenRefreshResult.cs @@ -0,0 +1,25 @@ +namespace Auth0.AspNetCore.Authentication +{ + /// + /// The outcome of a refresh-token exchange: either a successful + /// or, on an HTTP rejection, the status code and parsed error details from the body. + /// + internal class TokenRefreshResult + { + /// The successful token response, or null when the exchange failed. + public AccessTokenResponse? Response { get; set; } + + /// The HTTP status code returned by the token endpoint when the exchange failed. + public int? StatusCode { get; set; } + + /// The error code from the token endpoint's error body, when present. + public string? Error { get; set; } + + /// The error_description from the token endpoint's error body, when present. + public string? ErrorDescription { get; set; } + + public bool IsSuccess => Response != null; + + public static TokenRefreshResult Success(AccessTokenResponse response) => new TokenRefreshResult { Response = response }; + } +} diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs index ec6f0017..22f13355 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs @@ -102,6 +102,140 @@ public async Task GetAccessTokenAsync_WithForceRefresh_NoRefreshToken_FiresEvent ItExpr.IsAny(), ItExpr.IsAny()); } + [Fact] + public async Task GetAccessTokenAsync_WithCorruptedAccessTokenSets_TreatsAsCacheMissAndRefreshes() + { + var handler = CreateTokenHandler("fresh"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "primary"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + // Corrupted/version-skewed additional-token store. + properties.Items[".Token.access_tokens"] = "{ this is not valid json"; + + var context = BuildContext(handler.Object, properties, out _); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = "https://other-api" }); + + // Corruption is swallowed: the request falls through to a refresh rather than throwing. + result.Should().Be("fresh"); + } + + [Fact] + public async Task GetAccessTokenAsync_WithMalformedPrimaryExpiry_TreatsAsExpiredAndRefreshes() + { + var handler = CreateTokenHandler("fresh"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "cached"; + properties.Items[".Token.expires_at"] = "not-a-date"; + properties.Items[".Token.refresh_token"] = "rt"; + + var context = BuildContext(handler.Object, properties, out _); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = PrimaryAudience }); + + result.Should().Be("fresh"); + handler.Protected().Verify("SendAsync", Times.Once(), + ItExpr.IsAny(), ItExpr.IsAny()); + } + + [Fact] + public async Task GetAccessTokenAsync_WhenRefreshRejected_FiresRefreshFailedEventAndReturnsNull() + { + var handler = CreateFailingHandler(HttpStatusCode.BadRequest, + "{\"error\":\"invalid_grant\",\"error_description\":\"refresh token revoked\"}"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "cached"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + + AccessTokenRefreshFailedContext? captured = null; + var context = BuildContext(handler.Object, properties, out _, opts => + { + opts.Events = new Auth0WebAppWithAccessTokenEvents + { + OnAccessTokenRefreshFailed = ctx => + { + captured = ctx; + return Task.CompletedTask; + } + }; + }); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = PrimaryAudience, ForceRefresh = true }); + + result.Should().BeNull(); + captured.Should().NotBeNull(); + captured!.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + captured.Error.Should().Be("invalid_grant"); + captured.ErrorDescription.Should().Be("refresh token revoked"); + captured.Exception.Should().BeNull(); + captured.Audience.Should().Be(PrimaryAudience); + captured.HttpContext.Should().BeSameAs(context); + } + + [Fact] + public async Task GetAccessTokenAsync_WhenTransportFails_FiresRefreshFailedEventAndReturnsNull() + { + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("network down")); + + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "cached"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + + AccessTokenRefreshFailedContext? captured = null; + var context = BuildContext(handler.Object, properties, out _, opts => + { + opts.Events = new Auth0WebAppWithAccessTokenEvents + { + OnAccessTokenRefreshFailed = ctx => + { + captured = ctx; + return Task.CompletedTask; + } + }; + }); + + // A transport failure must not propagate out of the public method. + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = PrimaryAudience, ForceRefresh = true }); + + result.Should().BeNull(); + captured.Should().NotBeNull(); + // Transport failures surface as the thrown exception, with no HTTP status/error detail. + captured!.Exception.Should().BeOfType(); + captured.StatusCode.Should().BeNull(); + captured.Error.Should().BeNull(); + } + + private static Mock CreateFailingHandler(HttpStatusCode statusCode, string body) + { + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(body) + }); + return handler; + } + private static Mock CreateTokenHandler(string accessToken) { var handler = new Mock(); diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs index cfceee67..c8ea860c 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs @@ -32,7 +32,34 @@ public async Task Returns_Null_When_No_Success_StatusCode() var client = new TokenClient(new HttpClient(mockHandler.Object)); var result = await client.Refresh(new Auth0WebAppOptions { Domain = "local.auth0.com", ClientId = "cid", ClientSecret = "secret" }, "123"); - result.Should().BeNull(); + result.IsSuccess.Should().BeFalse(); + result.Response.Should().BeNull(); + } + + [Fact] + public async Task Returns_Failure_With_Error_Details_When_Rejected() + { + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.Forbidden, + Content = new StringContent("{\"error\":\"invalid_grant\",\"error_description\":\"token revoked\"}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + var result = await client.Refresh(new Auth0WebAppOptions { Domain = "local.auth0.com", ClientId = "cid", ClientSecret = "secret" }, "123"); + + result.IsSuccess.Should().BeFalse(); + result.StatusCode.Should().Be((int)HttpStatusCode.Forbidden); + result.Error.Should().Be("invalid_grant"); + result.ErrorDescription.Should().Be("token revoked"); } [Fact] @@ -76,8 +103,8 @@ public async Task Refresh_WithCustomDomain_UsesCorrectTokenEndpoint() customDomain // Pass custom domain ); - result.Should().NotBeNull(); - result?.AccessToken.Should().Be("new_token"); + result.IsSuccess.Should().BeTrue(); + result.Response?.AccessToken.Should().Be("new_token"); requestedDomain.Should().Be(customDomain); } @@ -122,8 +149,8 @@ public async Task Refresh_WithoutCustomDomain_UsesDefaultDomain() // No custom domain passed, should use default ); - result.Should().NotBeNull(); - result?.AccessToken.Should().Be("new_token"); + result.IsSuccess.Should().BeTrue(); + result.Response?.AccessToken.Should().Be("new_token"); requestedDomain.Should().Be(defaultDomain); } @@ -160,8 +187,8 @@ public async Task Refresh_WithAudienceAndScope_IncludesThemInBody() "read:orders" ); - result.Should().NotBeNull(); - result?.Scope.Should().Be("read:orders"); + result.IsSuccess.Should().BeTrue(); + result.Response?.Scope.Should().Be("read:orders"); capturedBody.Should().Contain("audience=api%3A%2F%2Forders"); capturedBody.Should().Contain("scope=read%3Aorders"); } @@ -200,6 +227,36 @@ await client.Refresh( capturedBody.Should().NotContain("scope="); } + [Fact] + public async Task Refresh_WithMalformedSuccessBody_ReturnsFailureWithoutThrowing() + { + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{ this is not valid json") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + var result = await client.Refresh( + new Auth0WebAppOptions { Domain = "default.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123" + ); + + result.IsSuccess.Should().BeFalse(); + result.Response.Should().BeNull(); + result.StatusCode.Should().Be((int)HttpStatusCode.OK); + result.Error.Should().Be("invalid_token_response"); + result.ErrorDescription.Should().Be("The token endpoint returned a response that could not be parsed."); + } + [Fact] public async Task Refresh_WithNullDomain_ThrowsInvalidOperationException() { From d82b5fa2064bd6dc937e20dca1657bb6582c81c5 Mon Sep 17 00:00:00 2001 From: Kailash B Date: Fri, 12 Jun 2026 15:25:52 +0530 Subject: [PATCH 4/7] chore: Removes hard-coded leeway and uses AccessTokenExpirationLeeway instead --- .../HttpContextExtensions.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs index c464e20c..5163e062 100644 --- a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs +++ b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs @@ -17,7 +17,6 @@ namespace Auth0.AspNetCore.Authentication public static class HttpContextExtensions { internal const string AccessTokensItemKey = ".Token.access_tokens"; - private const int ExpiryLeewaySeconds = 60; /// /// Retrieves an access token for the audience/scope described by . @@ -55,7 +54,7 @@ public static class HttpContextExtensions { if (properties.Items.TryGetValue(".Token.access_token", out var primaryToken) && !string.IsNullOrEmpty(primaryToken) && - !IsPrimaryExpired(properties)) + !IsPrimaryExpired(properties, optionsWithAccessToken.AccessTokenExpirationLeeway)) { return primaryToken; } @@ -64,7 +63,7 @@ public static class HttpContextExtensions { var sets = ReadAccessTokenSets(properties); var match = TokenSetHelpers.FindAccessTokenSet(sets, audience!, mergedScope, ScopeMatchMode.RequestedScope); - if (match != null && match.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(ExpiryLeewaySeconds).ToUnixTimeSeconds()) + if (match != null && match.ExpiresAt > DateTimeOffset.UtcNow.Add(optionsWithAccessToken.AccessTokenExpirationLeeway).ToUnixTimeSeconds()) { return match.AccessToken; } @@ -154,7 +153,7 @@ private static bool MatchesPrimaryToken(string? audience, string? mergedScope, A return matchesPrimaryAudience && matchesPrimaryScope; } - private static bool IsPrimaryExpired(AuthenticationProperties properties) + private static bool IsPrimaryExpired(AuthenticationProperties properties, TimeSpan leeway) { if (!properties.Items.TryGetValue(".Token.expires_at", out var expiresAtRaw) || string.IsNullOrEmpty(expiresAtRaw)) { @@ -168,7 +167,7 @@ private static bool IsPrimaryExpired(AuthenticationProperties properties) return true; } - return DateTimeOffset.Compare(expiresAt, DateTimeOffset.Now.AddSeconds(ExpiryLeewaySeconds)) <= 0; + return DateTimeOffset.Compare(expiresAt, DateTimeOffset.Now.Add(leeway)) <= 0; } private static void ApplyTokenResponse(AuthenticationProperties properties, AccessTokenResponse response, string? audience, string? mergedScope, bool matchesPrimaryToken) From b526f74102692338d3cc9935b043bf71fd581b78 Mon Sep 17 00:00:00 2001 From: Kailash B Date: Fri, 12 Jun 2026 16:11:26 +0530 Subject: [PATCH 5/7] chore: Update documentation --- EXAMPLES.md | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 14 ++++++ 2 files changed, 149 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index 68f963cf..f39fffc5 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -5,6 +5,11 @@ - [Calling an API](#calling-an-api) - [Configuring the refresh leeway](#configuring-the-refresh-leeway) - [Server-side session storage](#server-side-session-storage) +- [Multi-Resource Refresh Tokens (MRRT)](#multi-resource-refresh-tokens-mrrt) + - [Requesting a token for another audience](#requesting-a-token-for-another-audience) + - [Configuring default scopes per audience](#configuring-default-scopes-per-audience) + - [Forcing a refresh](#forcing-a-refresh) + - [Handling refresh failures](#handling-refresh-failures) - [Organizations](#organizations) - [Extra parameters](#extra-parameters) - [Roles](#roles) @@ -165,6 +170,8 @@ services A larger leeway refreshes more eagerly (fewer near-expiry tokens, more refresh calls); a smaller leeway refreshes later. The leeway only takes effect when `UseRefreshTokens` is enabled. +> :information_source: To obtain access tokens for **additional** audiences or scopes on demand (without a second login), see [Multi-Resource Refresh Tokens (MRRT)](#multi-resource-refresh-tokens-mrrt). + #### Detecting the absense of a refresh token In the event where the API, defined in your Auth0 dashboard, isn't configured to [allow offline access](https://auth0.com/docs/get-started/dashboard/api-settings), or the user was already logged in before the use of refresh tokens was enabled (e.g. a user logs in a few minutes before the use of refresh tokens is deployed), it might be useful to detect the absense of a refresh token in order to react accordingly (e.g. log the user out locally and force them to re-login). @@ -276,6 +283,134 @@ public class RedisTicketStore : ITicketStore > > :warning: **Protecting the ticket:** it holds sensitive data (claims, access and refresh tokens). Encrypting the payload with `IDataProtector` as shown above means an attacker who gains read access to the cache cannot recover those tokens. Treat the cache backend itself as sensitive too: restrict access and enable encryption in transit (e.g. Redis AUTH + TLS) and at rest. +## Multi-Resource Refresh Tokens (MRRT) + +[Multi-Resource Refresh Tokens (MRRT)](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) let a single session obtain access tokens for *additional* audiences and scopes on demand, by exchanging the session's refresh token — without forcing the user through another interactive login. + +A typical use case: the user logs in once, and your web app then needs to call a downstream API that expects a token for a *different* audience than the one requested at login. Instead of re-authenticating, you exchange the existing refresh token for a token scoped to that API. + +> :information_source: MRRT requires refresh tokens. Configure `UseRefreshTokens = true` and a `ClientSecret`, and ensure MRRT is enabled for your client/APIs in the Auth0 Dashboard. Tokens obtained for additional audiences are cached in the session alongside the login-time ("primary") token and reused until they near expiry. + +> :warning: **Token storage and cookie size.** Each additional audience/scope you obtain a token for adds another entry to the cached token set, which by default is serialized into the encrypted **authentication cookie** along with the rest of the session. Cookies cannot grow indefinitely — browsers cap them at around 4 KB each, and request-header limits apply on top of that. An application that fans out across several audiences can therefore accumulate enough tokens to exceed those limits and have the session rejected. If you expect to hold tokens for more than a couple of audiences, move the session **server-side** so only a small session key rides in the cookie while the token set lives in a store you control — see [Server-side session storage](#server-side-session-storage) above. The MRRT API is identical either way; only where the token set is persisted changes. + +### Requesting a token for another audience + +Use `HttpContext.GetAccessTokenAsync` with an `AccessTokenRequest` describing the `Audience` and/or `Scope` you need. The SDK first tries to satisfy the request from the session (the primary token or a previously cached additional token), and only exchanges the refresh token when no usable cached token exists. Newly obtained tokens are persisted back into the session automatically. + +```csharp +[Authorize] +public async Task CallMessagesApi() +{ + var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest + { + Audience = "https://messages.example.com", + Scope = "read:messages" + }); + + if (accessToken == null) + { + // No refresh token available, or the refresh failed — see "Handling refresh failures" below. + return Challenge(); + } + + var request = new HttpRequestMessage(HttpMethod.Get, "https://messages.example.com/api/messages"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await _httpClient.SendAsync(request); + return Content(await response.Content.ReadAsStringAsync()); +} +``` + +The requested `Scope` is merged (order-preserving union) with the configured default scopes for the resolved audience. Omitting `Audience` falls back to the globally configured `Audience`; omitting `Scope` falls back to the configured default for that audience. + +> :information_source: `GetAccessTokenAsync` returns `null` rather than throwing when no refresh token is available or the refresh fails. Always check for `null` before using the token. + +When called with no arguments matching the primary audience/scope, `GetAccessTokenAsync` is equivalent to retrieving the login-time access token (and refreshing it when expired). + +### Configuring default scopes per audience + +You can configure default scopes for each additional audience using `ScopeByAudience`. When an audience is present in this map, its value is used as the default scope for that audience; otherwise the global `Scope` is used as the fallback. + +```csharp +services + .AddAuth0WebAppAuthentication(options => + { + options.Domain = Configuration["Auth0:Domain"]; + options.ClientId = Configuration["Auth0:ClientId"]; + options.ClientSecret = Configuration["Auth0:ClientSecret"]; + }) + .WithAccessToken(options => + { + options.Audience = Configuration["Auth0:Audience"]; + options.UseRefreshTokens = true; + options.ScopeByAudience = new Dictionary + { + ["https://messages.example.com"] = "read:messages write:messages", + ["https://billing.example.com"] = "read:invoices" + }; + }); +``` + +With this configured, a request for the `https://messages.example.com` audience defaults to the `read:messages write:messages` scopes, so callers can omit `Scope` for that audience: + +```csharp +var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest +{ + Audience = "https://messages.example.com" +}); +``` + +### Forcing a refresh + +Set `ForceRefresh = true` to bypass the cache and always exchange the refresh token for a new access token. The freshly retrieved token still replaces the cached entry. + +```csharp +var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest +{ + Audience = "https://messages.example.com", + Scope = "read:messages", + ForceRefresh = true +}); +``` + +### Handling refresh failures + +When a refresh token is present but the exchange fails, the `OnAccessTokenRefreshFailed` event fires and `GetAccessTokenAsync` returns `null`. The supplied `AccessTokenRefreshFailedContext` carries the failure details so you can distinguish a **terminal** failure (such as an `invalid_grant` for a revoked or expired refresh token, which warrants a re-login) from a **transient** one (such as a timeout or rate-limit, which may be retried). + +All refresh failures — token-endpoint rejections, malformed responses, and transport/misconfiguration errors — flow through this single event. For HTTP rejections, `StatusCode`, `Error`, and `ErrorDescription` are populated; for transport failures, `Exception` is populated instead. + +```csharp +services + .AddAuth0WebAppAuthentication(options => + { + options.Domain = Configuration["Auth0:Domain"]; + options.ClientId = Configuration["Auth0:ClientId"]; + options.ClientSecret = Configuration["Auth0:ClientSecret"]; + }) + .WithAccessToken(options => + { + options.Audience = Configuration["Auth0:Audience"]; + options.UseRefreshTokens = true; + options.Events = new Auth0WebAppWithAccessTokenEvents + { + OnAccessTokenRefreshFailed = async (context) => + { + // A revoked or expired refresh token is terminal — force a re-login. + if (context.Error == "invalid_grant") + { + await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + var authenticationProperties = new LogoutAuthenticationPropertiesBuilder().WithRedirectUri("/").Build(); + await context.HttpContext.ChallengeAsync(Auth0Constants.AuthenticationScheme, authenticationProperties); + } + // Otherwise (e.g. a timeout surfaced via context.Exception, or a 429), you may + // choose to log and let the caller retry. + } + }; + }); +``` + +> :warning: `AccessTokenRefreshFailedContext.Exception` may contain transport/diagnostic detail — log it server-side only, do not surface it to end users. + ## Organizations [Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications. diff --git a/README.md b/README.md index 20b78a1f..df2a16d4 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,20 @@ services.AddAuth0WebAppAuthentication(options => For detailed configuration options, caching strategies, security requirements, and more examples, see the [Multiple Custom Domain (MCD) Examples](EXAMPLES.md#multiple-custom-domain-mcd-support). +## Multi-Resource Refresh Tokens (MRRT) + +Multi-Resource Refresh Tokens (MRRT) let a single session obtain access tokens for additional audiences and scopes on demand, by exchanging the session's refresh token — without forcing the user through another interactive login. + +```csharp +var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest +{ + Audience = "https://messages.example.com", + Scope = "read:messages" +}); +``` + +For configuring per-audience default scopes, forcing a refresh, and handling refresh failures, see the [Multi-Resource Refresh Tokens (MRRT) Examples](EXAMPLES.md#multi-resource-refresh-tokens-mrrt). + ## API reference Explore public API's available in auth0-aspnetcore-authentication. From b2e05eaa110a2300877fff68adf46f9ba5b5681d Mon Sep 17 00:00:00 2001 From: Kailash B Date: Fri, 12 Jun 2026 16:27:13 +0530 Subject: [PATCH 6/7] chore: Increase coverage --- .../AccessTokenRefreshFailedContext.cs | 17 +- .../Auth0WebAppAuthenticationBuilder.cs | 5 + .../Auth0WebAppWithAccessTokenOptions.cs | 4 +- .../HttpContextExtensions.cs | 21 +- .../TokenClient.cs | 23 +- .../TokenRefreshResult.cs | 26 ++- .../HttpContextExtensionsTests.cs | 219 ++++++++++++++++++ .../TokenSetHelpersTests.cs | 15 ++ 8 files changed, 300 insertions(+), 30 deletions(-) diff --git a/src/Auth0.AspNetCore.Authentication/AccessTokenRefreshFailedContext.cs b/src/Auth0.AspNetCore.Authentication/AccessTokenRefreshFailedContext.cs index 9091beb8..5afaa26a 100644 --- a/src/Auth0.AspNetCore.Authentication/AccessTokenRefreshFailedContext.cs +++ b/src/Auth0.AspNetCore.Authentication/AccessTokenRefreshFailedContext.cs @@ -12,7 +12,7 @@ namespace Auth0.AspNetCore.Authentication /// public class AccessTokenRefreshFailedContext { - internal AccessTokenRefreshFailedContext(HttpContext httpContext, string? audience, string? scope, int? statusCode, string? error, string? errorDescription, Exception? exception) + private AccessTokenRefreshFailedContext(HttpContext httpContext, string? audience, string? scope, int? statusCode, string? error, string? errorDescription, Exception? exception) { HttpContext = httpContext; Audience = audience; @@ -23,6 +23,21 @@ internal AccessTokenRefreshFailedContext(HttpContext httpContext, string? audien Exception = exception; } + /// + /// Creates a context for a failure where the token endpoint returned a non-success + /// HTTP response. is left null. + /// + internal static AccessTokenRefreshFailedContext FromHttpRejection(HttpContext httpContext, string? audience, string? scope, int? statusCode, string? error, string? errorDescription) => + new AccessTokenRefreshFailedContext(httpContext, audience, scope, statusCode, error, errorDescription, exception: null); + + /// + /// Creates a context for a failure where the refresh threw before producing an HTTP + /// response (for example a transport failure, timeout, or misconfiguration). + /// , , and are left null. + /// + internal static AccessTokenRefreshFailedContext FromException(HttpContext httpContext, string? audience, string? scope, Exception exception) => + new AccessTokenRefreshFailedContext(httpContext, audience, scope, statusCode: null, error: null, errorDescription: null, exception: exception); + /// /// The current , allowing you to sign the user out, challenge /// for re-login, or resolve services to react to the failure. diff --git a/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs b/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs index 31852f11..01a953f3 100644 --- a/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs +++ b/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs @@ -169,6 +169,11 @@ private void EnableWithAccessToken(Action con ValidateOptions(_options); + // GetAccessTokenAsync (MRRT) resolves an IHttpClientFactory to exchange the refresh + // token when no Backchannel is configured. Register it here so the factory is always + // available. + _services.AddHttpClient(); + _services.Configure(_authenticationScheme, configureOptions); _services.AddOptions(_authenticationScheme) .Configure(options => diff --git a/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenOptions.cs b/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenOptions.cs index 3a962ffe..6fb4cdbd 100644 --- a/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenOptions.cs +++ b/src/Auth0.AspNetCore.Authentication/Auth0WebAppWithAccessTokenOptions.cs @@ -33,8 +33,8 @@ public class Auth0WebAppWithAccessTokenOptions /// /// The amount of time before an access token expires during which it is treated as /// already expired, so that a refresh is triggered proactively rather than the token - /// lapsing mid-request. Only applies when is enabled. - /// Defaults to 60 seconds. + /// lapsing mid-request. Applies to both the primary (login-time) token and additional + /// audience/scope tokens retrieved on demand (MRRT). Defaults to 60 seconds. /// public TimeSpan AccessTokenExpirationLeeway { get; set; } = TimeSpan.FromSeconds(60); diff --git a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs index 5163e062..a039cd05 100644 --- a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs +++ b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs @@ -81,7 +81,7 @@ public static class HttpContextExtensions return null; } - var httpClient = options.Backchannel ?? new HttpClient(); + var httpClient = options.Backchannel ?? context.RequestServices.GetRequiredService().CreateClient(); var tokenClient = new TokenClient(httpClient); var resolvedDomain = context.GetResolvedDomain(); @@ -95,13 +95,15 @@ public static class HttpContextExtensions // Any refresh failure — transport error, malformed response, or misconfiguration — // is folded into the same failure path as a token-endpoint rejection, so callers // have a single failure protocol and nothing escapes this method. - await FireRefreshFailed(context, optionsWithAccessToken, audience, mergedScope, statusCode: null, error: null, errorDescription: null, exception: ex).ConfigureAwait(false); + await FireRefreshFailed(optionsWithAccessToken, + AccessTokenRefreshFailedContext.FromException(context, audience, mergedScope, ex)).ConfigureAwait(false); return null; } if (!result.IsSuccess) { - await FireRefreshFailed(context, optionsWithAccessToken, audience, mergedScope, result.StatusCode, result.Error, result.ErrorDescription, exception: null).ConfigureAwait(false); + await FireRefreshFailed(optionsWithAccessToken, + AccessTokenRefreshFailedContext.FromHttpRejection(context, audience, mergedScope, result.StatusCode, result.Error, result.ErrorDescription)).ConfigureAwait(false); return null; } @@ -130,19 +132,22 @@ public static class HttpContextExtensions } /// - /// Determines whether the requested audience/scope matches the application's primary - /// (login-time) token — the one stored in the .Token.access_token slot — rather - /// than an additional MRRT audience/scope kept in the access-token sets. + /// Fires the event + /// with the details of a failed refresh, when a subscriber is configured. /// - private static async Task FireRefreshFailed(HttpContext context, Auth0WebAppWithAccessTokenOptions optionsWithAccessToken, string? audience, string? scope, int? statusCode, string? error, string? errorDescription, Exception? exception) + private static async Task FireRefreshFailed(Auth0WebAppWithAccessTokenOptions optionsWithAccessToken, AccessTokenRefreshFailedContext failedContext) { if (optionsWithAccessToken.Events?.OnAccessTokenRefreshFailed != null) { - var failedContext = new AccessTokenRefreshFailedContext(context, audience, scope, statusCode, error, errorDescription, exception); await optionsWithAccessToken.Events.OnAccessTokenRefreshFailed(failedContext).ConfigureAwait(false); } } + /// + /// Determines whether the requested audience/scope matches the application's primary + /// (login-time) token — the one stored in the .Token.access_token slot — rather + /// than an additional MRRT audience/scope kept in the access-token sets. + /// private static bool MatchesPrimaryToken(string? audience, string? mergedScope, Auth0WebAppWithAccessTokenOptions options) { var matchesPrimaryAudience = audience == null || audience == options.Audience; diff --git a/src/Auth0.AspNetCore.Authentication/TokenClient.cs b/src/Auth0.AspNetCore.Authentication/TokenClient.cs index cb39c25d..5ca54f94 100644 --- a/src/Auth0.AspNetCore.Authentication/TokenClient.cs +++ b/src/Auth0.AspNetCore.Authentication/TokenClient.cs @@ -76,24 +76,23 @@ public async Task Refresh(Auth0WebAppOptions options, string // Swallow the parse error so it never surfaces as an exposed Exception on the // failure event; report a status-code-only failure with a static, payload-free // message instead. - return new TokenRefreshResult - { - StatusCode = (int)response.StatusCode, - Error = "invalid_token_response", - ErrorDescription = "The token endpoint returned a response that could not be parsed." - }; + return TokenRefreshResult.Failure( + (int)response.StatusCode, + "invalid_token_response", + "The token endpoint returned a response that could not be parsed."); } return accessTokenResponse != null ? TokenRefreshResult.Success(accessTokenResponse) - : new TokenRefreshResult { StatusCode = (int)response.StatusCode }; + : TokenRefreshResult.Failure((int)response.StatusCode); } } } private static async Task BuildFailure(HttpResponseMessage response) { - var result = new TokenRefreshResult { StatusCode = (int)response.StatusCode }; + string? error = null; + string? errorDescription = null; try { @@ -104,16 +103,14 @@ private static async Task BuildFailure(HttpResponseMessage r var root = document.RootElement; if (root.ValueKind == JsonValueKind.Object) { - // An "mfa_required" error also carries "mfa_token" and "mfa_requirements"; - // surfacing those (and completing the MFA flow) is deferred to a subsequent PR. if (root.TryGetProperty("error", out var errorElement)) { - result.Error = errorElement.GetString(); + error = errorElement.GetString(); } if (root.TryGetProperty("error_description", out var descriptionElement)) { - result.ErrorDescription = descriptionElement.GetString(); + errorDescription = descriptionElement.GetString(); } } } @@ -123,7 +120,7 @@ private static async Task BuildFailure(HttpResponseMessage r // A non-JSON or unreadable error body still yields a result carrying the status code. } - return result; + return TokenRefreshResult.Failure((int)response.StatusCode, error, errorDescription); } private void ApplyClientAuthentication(Auth0WebAppOptions options, Dictionary body, string domain) diff --git a/src/Auth0.AspNetCore.Authentication/TokenRefreshResult.cs b/src/Auth0.AspNetCore.Authentication/TokenRefreshResult.cs index 059bc4a6..d262d841 100644 --- a/src/Auth0.AspNetCore.Authentication/TokenRefreshResult.cs +++ b/src/Auth0.AspNetCore.Authentication/TokenRefreshResult.cs @@ -2,24 +2,38 @@ namespace Auth0.AspNetCore.Authentication { /// /// The outcome of a refresh-token exchange: either a successful - /// or, on an HTTP rejection, the status code and parsed error details from the body. + /// or, on a failure, the status code and parsed error details from the body. Construct + /// via or so the two states stay mutually exclusive. /// internal class TokenRefreshResult { + private TokenRefreshResult() + { + } + /// The successful token response, or null when the exchange failed. - public AccessTokenResponse? Response { get; set; } + public AccessTokenResponse? Response { get; private set; } /// The HTTP status code returned by the token endpoint when the exchange failed. - public int? StatusCode { get; set; } + public int? StatusCode { get; private set; } /// The error code from the token endpoint's error body, when present. - public string? Error { get; set; } + public string? Error { get; private set; } /// The error_description from the token endpoint's error body, when present. - public string? ErrorDescription { get; set; } + public string? ErrorDescription { get; private set; } public bool IsSuccess => Response != null; - public static TokenRefreshResult Success(AccessTokenResponse response) => new TokenRefreshResult { Response = response }; + public static TokenRefreshResult Success(AccessTokenResponse response) => + new TokenRefreshResult { Response = response }; + + public static TokenRefreshResult Failure(int? statusCode, string? error = null, string? errorDescription = null) => + new TokenRefreshResult + { + StatusCode = statusCode, + Error = error, + ErrorDescription = errorDescription + }; } } diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs index 22f13355..90a8f834 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs @@ -6,9 +6,11 @@ using Moq; using Moq.Protected; using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Security.Claims; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -219,6 +221,206 @@ public async Task GetAccessTokenAsync_WhenTransportFails_FiresRefreshFailedEvent captured.Error.Should().BeNull(); } + [Fact] + public async Task GetAccessTokenAsync_AdditionalAudienceCached_ReturnsCachedTokenWithoutCallingBackchannel() + { + var handler = CreateTokenHandler("fresh"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "primary"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + properties.Items[".Token.access_tokens"] = SerializeSets( + new AccessTokenSet + { + Audience = "https://other-api", + AccessToken = "cached-other", + Scope = "read:other", + RequestedScope = "read:other", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var context = BuildContext(handler.Object, properties, out var authService); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = "https://other-api", Scope = "read:other" }); + + result.Should().Be("cached-other"); + handler.Protected().Verify("SendAsync", Times.Never(), + ItExpr.IsAny(), ItExpr.IsAny()); + authService.Verify(s => s.SignInAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task GetAccessTokenAsync_AdditionalAudienceCached_PrefersSmallestSuperset() + { + var handler = CreateTokenHandler("fresh"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "primary"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + properties.Items[".Token.access_tokens"] = SerializeSets( + new AccessTokenSet + { + Audience = "https://other-api", + AccessToken = "broad", + Scope = "read write", + RequestedScope = "read write", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }, + new AccessTokenSet + { + Audience = "https://other-api", + AccessToken = "narrow", + Scope = "read", + RequestedScope = "read", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var context = BuildContext(handler.Object, properties, out _); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = "https://other-api", Scope = "read" }); + + // Both tokens satisfy "read", but the least-scoped one must be returned. + result.Should().Be("narrow"); + handler.Protected().Verify("SendAsync", Times.Never(), + ItExpr.IsAny(), ItExpr.IsAny()); + } + + [Fact] + public async Task GetAccessTokenAsync_AdditionalAudienceWithinLeeway_TreatsAsExpiredAndRefreshes() + { + var handler = CreateTokenHandler("fresh"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "primary"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + // Token still valid on the wall clock (expires in 30s) but inside the 60s leeway window. + properties.Items[".Token.access_tokens"] = SerializeSets( + new AccessTokenSet + { + Audience = "https://other-api", + AccessToken = "almost-expired", + Scope = "read:other", + RequestedScope = "read:other", + ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(30).ToUnixTimeSeconds() + }); + + var context = BuildContext(handler.Object, properties, out _); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = "https://other-api", Scope = "read:other" }); + + result.Should().Be("fresh"); + handler.Protected().Verify("SendAsync", Times.Once(), + ItExpr.IsAny(), ItExpr.IsAny()); + } + + [Fact] + public async Task GetAccessTokenAsync_OnRefresh_PersistsRotatedRefreshToken() + { + var handler = CreateRawTokenHandler( + "{\"access_token\":\"fresh\",\"token_type\":\"Bearer\",\"expires_in\":86400,\"refresh_token\":\"rotated-rt\"}"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "cached"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + + AuthenticationProperties? persisted = null; + var context = BuildContext(handler.Object, properties, out var authService); + authService + .Setup(s => s.SignInAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, p) => persisted = p) + .Returns(Task.CompletedTask); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = PrimaryAudience, ForceRefresh = true }); + + result.Should().Be("fresh"); + persisted.Should().NotBeNull(); + persisted!.Items[".Token.refresh_token"].Should().Be("rotated-rt"); + } + + [Fact] + public async Task GetAccessTokenAsync_OnAdditionalAudienceRefresh_PersistsRotatedIdToken() + { + var handler = CreateRawTokenHandler( + "{\"access_token\":\"fresh\",\"token_type\":\"Bearer\",\"expires_in\":86400,\"id_token\":\"rotated-id\"}"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "primary"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.id_token"] = "old-id"; + properties.Items[".Token.refresh_token"] = "rt"; + + AuthenticationProperties? persisted = null; + var context = BuildContext(handler.Object, properties, out var authService); + authService + .Setup(s => s.SignInAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, p) => persisted = p) + .Returns(Task.CompletedTask); + + // A non-primary audience goes through the additional-token path; id_token refresh + // must still be persisted regardless of which slot was updated. + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = "https://other-api", Scope = "read:other" }); + + result.Should().Be("fresh"); + persisted.Should().NotBeNull(); + persisted!.Items[".Token.id_token"].Should().Be("rotated-id"); + } + + [Fact] + public async Task GetAccessTokenAsync_WithScopeByAudience_AppliesPerAudienceDefaultScope() + { + var capturedBody = string.Empty; + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => + { + if (req.Content != null) + capturedBody = req.Content.ReadAsStringAsync().Result; + }) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + "{\"access_token\":\"fresh\",\"token_type\":\"Bearer\",\"expires_in\":86400,\"scope\":\"read:orders\"}") + }); + + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "primary"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + + var context = BuildContext(handler.Object, properties, out _, opts => + { + opts.ScopeByAudience = new Dictionary { ["https://orders"] = "read:orders" }; + }); + + // No explicit scope on the request — the per-audience default must flow into the exchange. + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = "https://orders" }); + + result.Should().Be("fresh"); + capturedBody.Should().Contain("scope=read%3Aorders"); + } + + private static string SerializeSets(params AccessTokenSet[] sets) + { + return JsonSerializer.Serialize(new List(sets)); + } + private static Mock CreateFailingHandler(HttpStatusCode statusCode, string body) { var handler = new Mock(); @@ -254,6 +456,23 @@ private static Mock CreateTokenHandler(string accessToken) return handler; } + private static Mock CreateRawTokenHandler(string body) + { + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(body) + }); + return handler; + } + private static HttpContext BuildContext( HttpMessageHandler backchannelHandler, AuthenticationProperties properties, diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs index 3d39e516..cbf99e6b 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs @@ -117,6 +117,21 @@ public void UpsertAccessTokenSet_ReplacesExistingByRequestedScope() result[0].AccessToken.Should().Be("new"); } + [Fact] + public void UpsertAccessTokenSet_UnchangedAccessToken_LeavesEntryIntact() + { + // Same request, same access token returned: the entry must be left in place + // (same instance) rather than replaced. + var existing = new AccessTokenSet { Audience = "api", AccessToken = "tok", Scope = "a", RequestedScope = "a" }; + var sets = new List { existing }; + var response = new AccessTokenResponse { AccessToken = "tok", ExpiresIn = 3600, Scope = "a" }; + + var result = TokenSetHelpers.UpsertAccessTokenSet(sets, "api", "a", response); + + result.Should().HaveCount(1); + result[0].Should().BeSameAs(existing); + } + [Fact] public void UpsertAccessTokenSet_MergesRequestedScopeWhenGrantedScopeMatches() { From 087dab30cc11c9f6affc427b9ac390e78711ad5c Mon Sep 17 00:00:00 2001 From: Kailash B Date: Mon, 15 Jun 2026 10:26:35 +0530 Subject: [PATCH 7/7] fix: Address review comments --- .../HttpContextExtensions.cs | 22 +++++ .../TokenClient.cs | 13 ++- .../TokenSetHelpers.cs | 5 +- .../HttpContextExtensionsTests.cs | 39 ++++++++ .../TokenClientTests.cs | 92 +++++++++++++++++++ .../TokenSetHelpersTests.cs | 65 ++++++++++++- 6 files changed, 229 insertions(+), 7 deletions(-) diff --git a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs index a039cd05..9013c5ca 100644 --- a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs +++ b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs @@ -23,10 +23,32 @@ public static class HttpContextExtensions /// Reuses a cached token from the session when one is present and not expired; otherwise /// exchanges the session's refresh token for a new token and persists it. /// + /// + /// This method is not safe to call concurrently for the same session. It reads the session, + /// modifies the stored tokens in memory, and writes them back; concurrent calls each operate on + /// their own snapshot, so the last write wins and a freshly fetched token from another in-flight + /// call can be silently dropped. More importantly, when refresh-token rotation is enabled, + /// concurrent calls exchange the same refresh token: the second exchange presents an + /// already-rotated token, which can trigger refresh-token reuse detection and invalidate the + /// entire session. + /// + /// To avoid this, ensure only one call is in flight per session + /// at a time. Either don't issue parallel requests that each trigger a refresh, or plug in a + /// server-side session store via + /// + /// whose ITicketStore serializes concurrent access per session. + /// + /// /// The current . /// The audience/scope to request a token for. /// The Auth0 authentication scheme. Defaults to . /// The access token, or null when no refresh token is available or the refresh failed. + /// + /// Thrown when a refresh succeeds but the new token cannot be persisted because the response has + /// already started. Persisting the refreshed token calls , + /// which writes the authentication cookie; if the headers have already been sent, this throws and + /// the exception propagates out of this method rather than being folded into the refresh-failed path. + /// public static async Task GetAccessTokenAsync(this HttpContext context, AccessTokenRequest request, string? scheme = null) { scheme ??= Auth0Constants.AuthenticationScheme; diff --git a/src/Auth0.AspNetCore.Authentication/TokenClient.cs b/src/Auth0.AspNetCore.Authentication/TokenClient.cs index 5ca54f94..ec73309b 100644 --- a/src/Auth0.AspNetCore.Authentication/TokenClient.cs +++ b/src/Auth0.AspNetCore.Authentication/TokenClient.cs @@ -82,9 +82,16 @@ public async Task Refresh(Auth0WebAppOptions options, string "The token endpoint returned a response that could not be parsed."); } - return accessTokenResponse != null - ? TokenRefreshResult.Success(accessTokenResponse) - : TokenRefreshResult.Failure((int)response.StatusCode); + // A 200 with no usable access_token (e.g. an empty object or a body missing + // the field) deserializes to a non-null response whose AccessToken is null. + // Treat that as a failure so IsSuccess only ever means "we have a usable token" + // and a useless token is never persisted downstream. + return !string.IsNullOrEmpty(accessTokenResponse?.AccessToken) + ? TokenRefreshResult.Success(accessTokenResponse!) + : TokenRefreshResult.Failure( + (int)response.StatusCode, + "invalid_token_response", + "The token endpoint returned a response without an access token."); } } } diff --git a/src/Auth0.AspNetCore.Authentication/TokenSetHelpers.cs b/src/Auth0.AspNetCore.Authentication/TokenSetHelpers.cs index 4053a436..7a7f4614 100644 --- a/src/Auth0.AspNetCore.Authentication/TokenSetHelpers.cs +++ b/src/Auth0.AspNetCore.Authentication/TokenSetHelpers.cs @@ -156,11 +156,14 @@ public static bool CompareScopes(string? scopes, string? requiredScopes, bool st /// Else match strictly by granted scope and merge the requested scopes (union). /// Else append a new entry. /// + /// Expired entries are pruned first so stale tokens for combinations that are no longer + /// requested don't accumulate in the (cookie-serialized) set indefinitely. /// Returns the updated list (a new list instance). /// public static List UpsertAccessTokenSet(IEnumerable? sets, string audience, string? requestedScope, AccessTokenResponse response) { - var result = sets?.ToList() ?? new List(); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var result = sets?.Where(set => set.ExpiresAt > now).ToList() ?? new List(); // Case 1: we've served this request before. Refresh the token in place, // skipping the write when the access token is unchanged. diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs index 90a8f834..bf8052b7 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs @@ -179,6 +179,45 @@ public async Task GetAccessTokenAsync_WhenRefreshRejected_FiresRefreshFailedEven captured.HttpContext.Should().BeSameAs(context); } + [Fact] + public async Task GetAccessTokenAsync_WhenRefreshReturnsTokenlessBody_FiresRefreshFailedEventAndDoesNotPersist() + { + // 200 OK with no access_token: must be treated as a failure, not a success that + // clobbers the cached primary token with null. + var handler = CreateRawTokenHandler("{}"); + var properties = new AuthenticationProperties(); + properties.Items[".Token.access_token"] = "cached"; + properties.Items[".Token.expires_at"] = DateTimeOffset.Now.AddHours(1).ToString("o"); + properties.Items[".Token.refresh_token"] = "rt"; + + AccessTokenRefreshFailedContext? captured = null; + var context = BuildContext(handler.Object, properties, out var authService, opts => + { + opts.Events = new Auth0WebAppWithAccessTokenEvents + { + OnAccessTokenRefreshFailed = ctx => + { + captured = ctx; + return Task.CompletedTask; + } + }; + }); + + var result = await context.GetAccessTokenAsync( + new AccessTokenRequest { Audience = PrimaryAudience, ForceRefresh = true }); + + result.Should().BeNull(); + captured.Should().NotBeNull(); + captured!.StatusCode.Should().Be((int)HttpStatusCode.OK); + captured.Error.Should().Be("invalid_token_response"); + captured.ErrorDescription.Should().Be("The token endpoint returned a response without an access token."); + // The session must be left untouched — no SignInAsync, so the stale token is preserved. + authService.Verify(s => s.SignInAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never()); + properties.Items[".Token.access_token"].Should().Be("cached"); + } + [Fact] public async Task GetAccessTokenAsync_WhenTransportFails_FiresRefreshFailedEventAndReturnsNull() { diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs index c8ea860c..cc7c54e6 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs @@ -257,6 +257,98 @@ public async Task Refresh_WithMalformedSuccessBody_ReturnsFailureWithoutThrowing result.ErrorDescription.Should().Be("The token endpoint returned a response that could not be parsed."); } + [Fact] + public async Task Refresh_WithEmptyJsonObjectBody_ReturnsFailureWithoutThrowing() + { + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + var result = await client.Refresh( + new Auth0WebAppOptions { Domain = "default.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123" + ); + + // A 200 with no access_token deserializes to a non-null object whose AccessToken is null; + // that must be reported as a failure rather than a success carrying an empty token. + result.IsSuccess.Should().BeFalse(); + result.Response.Should().BeNull(); + result.StatusCode.Should().Be((int)HttpStatusCode.OK); + result.Error.Should().Be("invalid_token_response"); + result.ErrorDescription.Should().Be("The token endpoint returned a response without an access token."); + } + + [Fact] + public async Task Refresh_WithBodyMissingAccessToken_ReturnsFailureWithoutThrowing() + { + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"token_type\":\"Bearer\",\"expires_in\":86400}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + var result = await client.Refresh( + new Auth0WebAppOptions { Domain = "default.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123" + ); + + result.IsSuccess.Should().BeFalse(); + result.Response.Should().BeNull(); + result.StatusCode.Should().Be((int)HttpStatusCode.OK); + result.Error.Should().Be("invalid_token_response"); + result.ErrorDescription.Should().Be("The token endpoint returned a response without an access token."); + } + + [Fact] + public async Task Refresh_WithEmptyAccessToken_ReturnsFailureWithoutThrowing() + { + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"access_token\":\"\",\"token_type\":\"Bearer\",\"expires_in\":86400}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + var result = await client.Refresh( + new Auth0WebAppOptions { Domain = "default.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123" + ); + + result.IsSuccess.Should().BeFalse(); + result.Response.Should().BeNull(); + result.StatusCode.Should().Be((int)HttpStatusCode.OK); + result.Error.Should().Be("invalid_token_response"); + result.ErrorDescription.Should().Be("The token endpoint returned a response without an access token."); + } + [Fact] public async Task Refresh_WithNullDomain_ThrowsInvalidOperationException() { diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs index cbf99e6b..77446b48 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenSetHelpersTests.cs @@ -107,7 +107,7 @@ public void UpsertAccessTokenSet_ReplacesExistingByRequestedScope() { var existing = new List { - new AccessTokenSet { Audience = "api", AccessToken = "old", Scope = "a", RequestedScope = "a" } + new AccessTokenSet { Audience = "api", AccessToken = "old", Scope = "a", RequestedScope = "a", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() } }; var response = new AccessTokenResponse { AccessToken = "new", ExpiresIn = 3600, Scope = "a" }; @@ -122,7 +122,7 @@ public void UpsertAccessTokenSet_UnchangedAccessToken_LeavesEntryIntact() { // Same request, same access token returned: the entry must be left in place // (same instance) rather than replaced. - var existing = new AccessTokenSet { Audience = "api", AccessToken = "tok", Scope = "a", RequestedScope = "a" }; + var existing = new AccessTokenSet { Audience = "api", AccessToken = "tok", Scope = "a", RequestedScope = "a", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() }; var sets = new List { existing }; var response = new AccessTokenResponse { AccessToken = "tok", ExpiresIn = 3600, Scope = "a" }; @@ -132,6 +132,65 @@ public void UpsertAccessTokenSet_UnchangedAccessToken_LeavesEntryIntact() result[0].Should().BeSameAs(existing); } + [Fact] + public void UpsertAccessTokenSet_PrunesExpiredEntriesForOtherCombinations() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var existing = new List + { + // Expired entry for a combination that is no longer being requested. + new AccessTokenSet { Audience = "api", AccessToken = "stale", Scope = "read", RequestedScope = "read", ExpiresAt = now - 60 }, + // Still-valid entry for a different combination. + new AccessTokenSet { Audience = "api", AccessToken = "live", Scope = "write", RequestedScope = "write", ExpiresAt = now + 3600 } + }; + var response = new AccessTokenResponse { AccessToken = "fresh", ExpiresIn = 3600, Scope = "manage" }; + + var result = TokenSetHelpers.UpsertAccessTokenSet(existing, "api", "manage", response); + + // The dead "read" entry is dropped; the live "write" entry and the new "manage" entry remain. + result.Should().HaveCount(2); + result.Should().NotContain(set => set.AccessToken == "stale"); + result.Should().Contain(set => set.AccessToken == "live"); + result.Should().Contain(set => set.AccessToken == "fresh"); + } + + [Fact] + public void UpsertAccessTokenSet_KeepsUnexpiredEntries() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var existing = new List + { + new AccessTokenSet { Audience = "api", AccessToken = "other", Scope = "read", RequestedScope = "read", ExpiresAt = now + 3600 } + }; + var response = new AccessTokenResponse { AccessToken = "fresh", ExpiresIn = 3600, Scope = "write" }; + + var result = TokenSetHelpers.UpsertAccessTokenSet(existing, "api", "write", response); + + result.Should().HaveCount(2); + result.Should().Contain(set => set.AccessToken == "other"); + result.Should().Contain(set => set.AccessToken == "fresh"); + } + + [Fact] + public void UpsertAccessTokenSet_ExpiredUpsertTarget_IsRefreshedNotDropped() + { + // The entry being upserted is itself expired. Pruning must not lose the combination: + // it should end up present with the freshly fetched token. + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var existing = new List + { + new AccessTokenSet { Audience = "api", AccessToken = "old", Scope = "a", RequestedScope = "a", ExpiresAt = now - 60 } + }; + var response = new AccessTokenResponse { AccessToken = "new", ExpiresIn = 3600, Scope = "a" }; + + var result = TokenSetHelpers.UpsertAccessTokenSet(existing, "api", "a", response); + + result.Should().HaveCount(1); + result[0].AccessToken.Should().Be("new"); + result[0].RequestedScope.Should().Be("a"); + result[0].ExpiresAt.Should().BeGreaterThan(now); + } + [Fact] public void UpsertAccessTokenSet_MergesRequestedScopeWhenGrantedScopeMatches() { @@ -139,7 +198,7 @@ public void UpsertAccessTokenSet_MergesRequestedScopeWhenGrantedScopeMatches() // Should merge requestedScope to "a b c" on the same entry rather than append. var existing = new List { - new AccessTokenSet { Audience = "api", AccessToken = "old", Scope = "a", RequestedScope = "a b" } + new AccessTokenSet { Audience = "api", AccessToken = "old", Scope = "a", RequestedScope = "a b", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() } }; var response = new AccessTokenResponse { AccessToken = "new", ExpiresIn = 3600, Scope = "a" };