Skip to content

Commit e12336b

Browse files
committed
fix(blazor): prevent token refresh loop with proactive JWT renewal (#1231)
- Add proactive token refresh in AuthorizationHeaderHandler: check JWT expiry before sending requests and refresh 30s before it expires. This prevents the 401 → refresh → retry cycle that caused infinite loops. - Increase default AccessTokenMinutes from 2 to 15 — 2 minutes was too aggressive and guaranteed refresh storms during normal Blazor usage.
1 parent a631f8b commit e12336b

2 files changed

Lines changed: 52 additions & 73 deletions

File tree

src/Playground/FSH.Api/appsettings.json

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
"Exporter": {
1212
"Otlp": {
13-
"Enabled": true,
13+
"Enabled": false,
1414
"Endpoint": "http://localhost:4317",
1515
"Protocol": "grpc"
1616
}
@@ -29,8 +29,7 @@
2929
},
3030
"Serilog": {
3131
"Using": [
32-
"Serilog.Sinks.Console",
33-
"Serilog.Sinks.OpenTelemetry"
32+
"Serilog.Sinks.Console"
3433
],
3534
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId", "WithCorrelationId", "WithProcessId", "WithProcessName" ],
3635
"MinimumLevel": {
@@ -42,16 +41,6 @@
4241
"Args": {
4342
"restrictedToMinimumLevel": "Information"
4443
}
45-
},
46-
{
47-
"Name": "OpenTelemetry",
48-
"Args": {
49-
"endpoint": "http://localhost:4317",
50-
"protocol": "grpc",
51-
"resourceAttributes": {
52-
"service.name": "FSH.Api"
53-
}
54-
}
5544
}
5645
]
5746
},
@@ -114,7 +103,7 @@
114103
"Issuer": "fsh.local",
115104
"Audience": "fsh.clients",
116105
"SigningKey": "replace-with-256-bit-secret-min-32-chars",
117-
"AccessTokenMinutes": 2,
106+
"AccessTokenMinutes": 15,
118107
"RefreshTokenDays": 7
119108
},
120109
"SecurityHeadersOptions": {
@@ -154,7 +143,8 @@
154143
}
155144
},
156145
"MultitenancyOptions": {
157-
"RunTenantMigrationsOnStartup": true
146+
"RunTenantMigrationsOnStartup": false,
147+
"AutoProvisionOnStartup": false
158148
},
159149
"Storage": {
160150
"Provider": "local"
Lines changed: 47 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using FSH.Playground.Blazor.Services;
22
using Microsoft.AspNetCore.Authentication;
3+
using System.IdentityModel.Tokens.Jwt;
34
using System.Net;
45

56
namespace FSH.Playground.Blazor.Services.Api;
@@ -8,18 +9,21 @@ namespace FSH.Playground.Blazor.Services.Api;
89
/// Delegating handler that adds the JWT token to API requests and handles 401 responses
910
/// by attempting to refresh the access token. If refresh fails, signs out the user and
1011
/// notifies Blazor components via IAuthStateNotifier.
12+
///
13+
/// Proactively refreshes tokens that are near expiry to avoid 401-driven refresh loops.
1114
/// </summary>
1215
internal sealed class AuthorizationHeaderHandler : DelegatingHandler
1316
{
17+
/// <summary>
18+
/// Refresh the token if it expires within this window.
19+
/// Prevents 401 → refresh → retry cycles by refreshing before expiry.
20+
/// </summary>
21+
private static readonly TimeSpan ExpiryBuffer = TimeSpan.FromSeconds(30);
22+
1423
private readonly IHttpContextAccessor _httpContextAccessor;
1524
private readonly IServiceProvider _serviceProvider;
1625
private readonly ICircuitTokenCache _circuitTokenCache;
1726
private readonly ILogger<AuthorizationHeaderHandler> _logger;
18-
19-
/// <summary>
20-
/// Track if sign-out has already been initiated to prevent multiple sign-out attempts.
21-
/// This is scoped per circuit (instance field, not static).
22-
/// </summary>
2327
private bool _signOutInitiated;
2428

2529
public AuthorizationHeaderHandler(
@@ -38,104 +42,102 @@ protected override async Task<HttpResponseMessage> SendAsync(
3842
HttpRequestMessage request,
3943
CancellationToken cancellationToken)
4044
{
41-
// Get current access token from circuit cache or claims
4245
var accessToken = await GetAccessTokenAsync();
4346

44-
// Attach access token to request
47+
// Proactive refresh: if the token is near expiry, refresh before sending
48+
if (!string.IsNullOrEmpty(accessToken) && IsTokenNearExpiry(accessToken))
49+
{
50+
_logger.LogDebug("Access token near expiry, proactively refreshing");
51+
var refreshed = await TryRefreshTokenAsync(cancellationToken);
52+
if (!string.IsNullOrEmpty(refreshed))
53+
{
54+
accessToken = refreshed;
55+
}
56+
}
57+
4558
if (!string.IsNullOrEmpty(accessToken))
4659
{
4760
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
4861
}
4962

50-
// Send the request
5163
var response = await base.SendAsync(request, cancellationToken);
5264

53-
// If we get a 401, try to refresh the token and retry once
65+
// Reactive refresh: handle unexpected 401 (e.g., token revoked server-side)
5466
if (response.StatusCode == HttpStatusCode.Unauthorized)
5567
{
56-
// If sign-out already initiated, don't attempt refresh or sign-out again
57-
if (_signOutInitiated)
68+
if (_signOutInitiated || string.IsNullOrEmpty(accessToken))
5869
{
5970
return response;
6071
}
6172

62-
if (string.IsNullOrEmpty(accessToken))
63-
{
64-
_logger.LogDebug("Received 401 but no access token available - cannot refresh");
65-
return response;
66-
}
67-
6873
_logger.LogInformation("Received 401, attempting token refresh");
69-
7074
var newAccessToken = await TryRefreshTokenAsync(cancellationToken);
7175

7276
if (!string.IsNullOrEmpty(newAccessToken))
7377
{
7478
_logger.LogInformation("Token refresh successful, retrying request");
75-
76-
// Clone the request with new token
7779
using var retryRequest = await CloneHttpRequestMessageAsync(request);
7880
retryRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", newAccessToken);
79-
80-
// Dispose the original response before retrying
8181
response.Dispose();
82-
83-
// Retry the request with the new token
8482
response = await base.SendAsync(retryRequest, cancellationToken);
8583
}
8684
else
8785
{
8886
_logger.LogWarning("Token refresh failed, signing out user");
89-
90-
// Mark sign-out as initiated to prevent multiple sign-out attempts
9187
_signOutInitiated = true;
92-
93-
// Sign out the user since refresh token is also invalid/expired
9488
await SignOutUserAsync();
9589
}
9690
}
9791

9892
return response;
9993
}
10094

95+
private static bool IsTokenNearExpiry(string accessToken)
96+
{
97+
try
98+
{
99+
var handler = new JwtSecurityTokenHandler();
100+
if (!handler.CanReadToken(accessToken))
101+
{
102+
return false;
103+
}
104+
105+
var jwt = handler.ReadJwtToken(accessToken);
106+
return jwt.ValidTo <= DateTime.UtcNow.Add(ExpiryBuffer);
107+
}
108+
catch
109+
{
110+
return false;
111+
}
112+
}
113+
101114
private async Task SignOutUserAsync()
102115
{
103116
try
104117
{
105118
var httpContext = _httpContextAccessor.HttpContext;
106119
if (httpContext is not null)
107120
{
108-
// Try to sign out via cookies, but this may fail in Blazor Server's
109-
// SignalR context where the response has already started
110121
try
111122
{
112123
if (!httpContext.Response.HasStarted)
113124
{
114125
await httpContext.SignOutAsync("Cookies");
115126
_logger.LogInformation("User signed out due to expired refresh token");
116127
}
117-
else
118-
{
119-
_logger.LogDebug("Response already started, skipping cookie sign-out");
120-
}
121128
}
122129
catch (InvalidOperationException ex)
123130
{
124-
// Expected in Blazor Server SignalR context - headers are read-only
125-
_logger.LogDebug(ex, "Could not sign out via cookies (response started), using navigation redirect");
131+
_logger.LogDebug(ex, "Could not sign out via cookies (response started)");
126132
}
127133

128-
// Notify Blazor components that session has expired
129-
// This will trigger navigation to login page with forceLoad:true,
130-
// which will create a new HTTP request where cookies can be cleared
131134
var authStateNotifier = _serviceProvider.GetService<IAuthStateNotifier>();
132135
authStateNotifier?.NotifySessionExpired();
133136
}
134137
}
135-
catch (Microsoft.AspNetCore.Components.NavigationException ex)
138+
catch (Microsoft.AspNetCore.Components.NavigationException)
136139
{
137-
// Expected - NavigateTo with forceLoad throws this to interrupt execution
138-
_logger.LogDebug(ex, "Navigation to login triggered (NavigationException is expected)");
140+
// Expected — NavigateTo with forceLoad throws this to interrupt execution
139141
}
140142
catch (Exception ex)
141143
{
@@ -147,21 +149,15 @@ private async Task SignOutUserAsync()
147149
{
148150
try
149151
{
150-
// First, check circuit-scoped cache for refreshed tokens
151-
// This is critical because httpContext.User claims are cached per circuit
152-
// and don't update even after SignInAsync
153152
if (!string.IsNullOrEmpty(_circuitTokenCache.AccessToken))
154153
{
155154
return Task.FromResult<string?>(_circuitTokenCache.AccessToken);
156155
}
157156

158-
// Fall back to claims (initial token from cookie)
159157
var httpContext = _httpContextAccessor.HttpContext;
160-
var user = httpContext?.User;
161-
162-
if (user?.Identity?.IsAuthenticated == true)
158+
if (httpContext?.User?.Identity?.IsAuthenticated == true)
163159
{
164-
return Task.FromResult(user.FindFirst("access_token")?.Value);
160+
return Task.FromResult(httpContext.User.FindFirst("access_token")?.Value);
165161
}
166162
}
167163
catch (Exception ex)
@@ -176,8 +172,6 @@ private async Task SignOutUserAsync()
176172
{
177173
try
178174
{
179-
// Resolve the token refresh service from the service provider
180-
// We use IServiceProvider to avoid circular dependency issues
181175
var tokenRefreshService = _serviceProvider.GetService<ITokenRefreshService>();
182176
if (tokenRefreshService is null)
183177
{
@@ -201,31 +195,26 @@ private static async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpR
201195
Version = request.Version
202196
};
203197

204-
// Copy headers (except Authorization which we'll set separately)
205198
foreach (var header in request.Headers.Where(h => !string.Equals(h.Key, "Authorization", StringComparison.OrdinalIgnoreCase)))
206199
{
207200
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
208201
}
209202

210-
// Copy content if present
211203
if (request.Content != null)
212204
{
213205
var contentBytes = await request.Content.ReadAsByteArrayAsync();
214206
clone.Content = new ByteArrayContent(contentBytes);
215-
216-
// Copy content headers
217207
foreach (var header in request.Content.Headers)
218208
{
219209
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
220210
}
221211
}
222212

223-
// Copy options
224213
foreach (var option in request.Options)
225214
{
226215
clone.Options.TryAdd(option.Key, option.Value);
227216
}
228217

229218
return clone;
230219
}
231-
}
220+
}

0 commit comments

Comments
 (0)