Skip to content

Commit 9a7482f

Browse files
committed
Improve auth flow to prevent refresh token loops
- Remove refresh trigger from LrmAuthStateProvider (single source principle) - Add ShouldAttemptRefresh() to check coordinator state before refresh - Add permanent failure flag to stop retries after 401 - Add 500ms cache to GetAuthenticationStateAsync to prevent concurrent evaluations - Increase MinRefreshInterval to 10s and add MaxRefreshWaitTime of 15s - Show user-friendly session expired message on login page
1 parent 1553dc5 commit 9a7482f

5 files changed

Lines changed: 143 additions & 39 deletions

File tree

cloud/src/LrmCloud.Web/Pages/Auth/Login.razor

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
@layout AuthLayout
33
@inject AuthService AuthService
44
@inject NavigationManager Navigation
5+
@inject TokenRefreshCoordinator RefreshCoordinator
56

67
<PageTitle>Login - LRM Cloud</PageTitle>
78

@@ -12,6 +13,13 @@
1213
<RadzenText TextStyle="TextStyle.Body2" class="rz-color-secondary">Sign in to your account</RadzenText>
1314
</div>
1415

16+
@if (!string.IsNullOrEmpty(_sessionExpiredMessage))
17+
{
18+
<RadzenAlert AlertStyle="AlertStyle.Warning" Shade="Shade.Lighter" AllowClose="true" Close="@(() => _sessionExpiredMessage = null)">
19+
<RadzenIcon Icon="schedule" class="rz-me-2" />@_sessionExpiredMessage
20+
</RadzenAlert>
21+
}
22+
1523
@if (!string.IsNullOrEmpty(_errorMessage))
1624
{
1725
<RadzenAlert AlertStyle="AlertStyle.Danger" Shade="Shade.Lighter" AllowClose="false">
@@ -79,13 +87,22 @@
7987
private readonly LoginRequest _model = new();
8088
private bool _isLoading;
8189
private string? _errorMessage;
90+
private string? _sessionExpiredMessage;
8291

8392
[Parameter]
8493
[SupplyParameterFromQuery(Name = "returnUrl")]
8594
public string? ReturnUrl { get; set; }
8695

8796
protected override async Task OnInitializedAsync()
8897
{
98+
// Check if we were redirected here due to session expiry
99+
if (RefreshCoordinator.IsRefreshPermanentlyFailed && !string.IsNullOrEmpty(RefreshCoordinator.FailureReason))
100+
{
101+
_sessionExpiredMessage = RefreshCoordinator.FailureReason;
102+
// Reset so the message only shows once
103+
RefreshCoordinator.ResetFailureState();
104+
}
105+
89106
// Use IsTokenValidAsync instead of IsAuthenticatedAsync to check both:
90107
// 1. Token exists
91108
// 2. Token is not expired

cloud/src/LrmCloud.Web/Services/AuthService.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ public async Task<AuthResult> LoginAsync(LoginRequest request)
3535
var result = await response.Content.ReadFromJsonAsync<ApiResponse<LoginResponse>>();
3636
if (result?.Data != null)
3737
{
38+
// Reset any previous auth failure state
39+
_refreshCoordinator.ResetFailureState();
40+
3841
await _tokenStorage.StoreTokensAsync(
3942
result.Data.Token,
4043
result.Data.RefreshToken,
@@ -145,7 +148,7 @@ public async Task<bool> RefreshTokenAsync()
145148
{
146149
// Another refresh is in progress or was recently attempted
147150
// Wait for it to complete and check if tokens are now valid
148-
await _refreshCoordinator.WaitForRefreshAsync(TimeSpan.FromSeconds(5));
151+
await _refreshCoordinator.WaitForRefreshAsync(TokenRefreshCoordinator.MaxRefreshWaitTime);
149152

150153
// Check if we now have valid tokens (from the other refresh)
151154
var token = await _tokenStorage.GetAccessTokenAsync();
@@ -182,6 +185,10 @@ await _tokenStorage.StoreTokensAsync(
182185
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
183186
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
184187
{
188+
// Mark as permanently failed to prevent retry loops
189+
_refreshCoordinator.MarkRefreshPermanentlyFailed(
190+
"Your session has expired. Please log in again.");
191+
185192
await _tokenStorage.ClearTokensAsync();
186193
_authStateProvider.NotifyUserLogout();
187194
}
@@ -246,10 +253,27 @@ public async Task<bool> IsAuthenticatedAsync()
246253
return !string.IsNullOrEmpty(token);
247254
}
248255

256+
/// <summary>
257+
/// Checks if a refresh attempt should be made.
258+
/// Returns false if refresh has permanently failed or is already in progress.
259+
/// </summary>
260+
public bool ShouldAttemptRefresh()
261+
{
262+
// Don't attempt if permanently failed (e.g., token was revoked)
263+
if (_refreshCoordinator.IsRefreshPermanentlyFailed)
264+
return false;
265+
266+
// Don't attempt if already in progress
267+
if (_refreshCoordinator.IsRefreshInProgress)
268+
return false;
269+
270+
return true;
271+
}
272+
249273
/// <summary>
250274
/// Checks if the user has a valid (non-expired) token.
251275
/// Unlike IsAuthenticatedAsync, this also verifies the token hasn't expired
252-
/// and handles the case where tokens are being cleared.
276+
/// and handles the case where tokens are being cleared or auth has permanently failed.
253277
/// </summary>
254278
public async Task<bool> IsTokenValidAsync()
255279
{
@@ -258,6 +282,11 @@ public async Task<bool> IsTokenValidAsync()
258282
if (_tokenStorage.IsClearing)
259283
return false;
260284

285+
// Don't report as authenticated if refresh has permanently failed
286+
// This means the session is invalid and user needs to login again
287+
if (_refreshCoordinator.IsRefreshPermanentlyFailed)
288+
return false;
289+
261290
var token = await _tokenStorage.GetAccessTokenAsync();
262291
if (string.IsNullOrEmpty(token))
263292
return false;

cloud/src/LrmCloud.Web/Services/AuthenticatedHttpHandler.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
3737
var coordinator = _serviceProvider.GetService<TokenRefreshCoordinator>();
3838
if (coordinator?.IsRefreshInProgress == true)
3939
{
40-
await coordinator.WaitForRefreshAsync(TimeSpan.FromSeconds(10), cancellationToken);
40+
await coordinator.WaitForRefreshAsync(TokenRefreshCoordinator.MaxRefreshWaitTime, cancellationToken);
4141
}
4242

4343
// Proactive refresh: if token is expired or about to expire, refresh before sending
44-
if (await _tokenStorage.IsTokenExpiredAsync() && await _tokenStorage.CanRefreshAsync())
44+
// Check coordinator state first to avoid unnecessary attempts
45+
var authService = _serviceProvider.GetService<AuthService>();
46+
if (await _tokenStorage.IsTokenExpiredAsync() &&
47+
await _tokenStorage.CanRefreshAsync() &&
48+
authService?.ShouldAttemptRefresh() == true)
4549
{
4650
await TryRefreshTokenAsync();
4751
}
@@ -51,10 +55,11 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
5155

5256
var response = await base.SendAsync(request, cancellationToken);
5357

54-
// Handle 401 Unauthorized - try to refresh token
58+
// Handle 401 Unauthorized - try to refresh token (if not permanently failed)
5559
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint)
5660
{
57-
if (await TryRefreshTokenAsync())
61+
var authSvc = _serviceProvider.GetService<AuthService>();
62+
if (authSvc?.ShouldAttemptRefresh() == true && await TryRefreshTokenAsync())
5863
{
5964
// Retry the request with new token
6065
request = await CloneRequestAsync(request);

cloud/src/LrmCloud.Web/Services/LrmAuthStateProvider.cs

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ public class LrmAuthStateProvider : AuthenticationStateProvider
1616
private UserDto? _cachedUser;
1717
private bool _isInitialized;
1818

19+
// Cache auth state to prevent multiple simultaneous evaluations
20+
private AuthenticationState? _cachedAuthState;
21+
private DateTime _cacheExpiry = DateTime.MinValue;
22+
private static readonly TimeSpan CacheDuration = TimeSpan.FromMilliseconds(500);
23+
private readonly SemaphoreSlim _cacheLock = new(1, 1);
24+
1925
public LrmAuthStateProvider(TokenStorageService tokenStorage, HttpClient httpClient, IServiceProvider serviceProvider)
2026
{
2127
_tokenStorage = tokenStorage;
@@ -24,6 +30,35 @@ public LrmAuthStateProvider(TokenStorageService tokenStorage, HttpClient httpCli
2430
}
2531

2632
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
33+
{
34+
// Return cached state if still valid (prevents multiple simultaneous evaluations)
35+
if (_cachedAuthState != null && DateTime.UtcNow < _cacheExpiry)
36+
{
37+
return _cachedAuthState;
38+
}
39+
40+
// Use lock to prevent concurrent cache population
41+
await _cacheLock.WaitAsync();
42+
try
43+
{
44+
// Double-check after acquiring lock
45+
if (_cachedAuthState != null && DateTime.UtcNow < _cacheExpiry)
46+
{
47+
return _cachedAuthState;
48+
}
49+
50+
var authState = await GetAuthenticationStateCoreAsync();
51+
_cachedAuthState = authState;
52+
_cacheExpiry = DateTime.UtcNow.Add(CacheDuration);
53+
return authState;
54+
}
55+
finally
56+
{
57+
_cacheLock.Release();
58+
}
59+
}
60+
61+
private async Task<AuthenticationState> GetAuthenticationStateCoreAsync()
2762
{
2863
var token = await _tokenStorage.GetAccessTokenAsync();
2964

@@ -33,39 +68,13 @@ public override async Task<AuthenticationState> GetAuthenticationStateAsync()
3368
}
3469

3570
// Check if token is expired
71+
// NOTE: We do NOT trigger refresh here - that's handled by AuthenticatedHttpHandler
72+
// This prevents multiple refresh attempts from different components
3673
if (await _tokenStorage.IsTokenExpiredAsync())
3774
{
38-
// Token expired, try to refresh if possible
39-
if (await _tokenStorage.CanRefreshAsync())
40-
{
41-
try
42-
{
43-
// Attempt to refresh the token using AuthService
44-
// AuthService uses TokenRefreshCoordinator internally to prevent concurrent refreshes
45-
var authService = _serviceProvider.GetService<AuthService>();
46-
if (authService != null && await authService.RefreshTokenAsync())
47-
{
48-
// Refresh succeeded - the AuthService already notified us
49-
// and updated the cached user, so we can proceed
50-
token = await _tokenStorage.GetAccessTokenAsync();
51-
}
52-
else
53-
{
54-
// Refresh failed - return unauthenticated
55-
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
56-
}
57-
}
58-
catch
59-
{
60-
// Error during refresh - return unauthenticated
61-
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
62-
}
63-
}
64-
else
65-
{
66-
// Can't refresh - return unauthenticated
67-
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
68-
}
75+
// Token expired - return unauthenticated
76+
// The next API call via AuthenticatedHttpHandler will trigger refresh
77+
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
6978
}
7079

7180
// Try to get user info from cache or fetch from API
@@ -87,6 +96,8 @@ public override async Task<AuthenticationState> GetAuthenticationStateAsync()
8796
public void NotifyUserAuthentication(UserDto user)
8897
{
8998
_cachedUser = user;
99+
_cachedAuthState = null; // Invalidate cache
100+
_cacheExpiry = DateTime.MinValue;
90101
var authenticatedUser = CreateClaimsPrincipal(user);
91102
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(authenticatedUser)));
92103
}
@@ -95,6 +106,8 @@ public void NotifyUserLogout()
95106
{
96107
_cachedUser = null;
97108
_isInitialized = false;
109+
_cachedAuthState = null; // Invalidate cache
110+
_cacheExpiry = DateTime.MinValue;
98111
var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
99112
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(anonymousUser)));
100113
}

cloud/src/LrmCloud.Web/Services/TokenRefreshCoordinator.cs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,58 @@ public class TokenRefreshCoordinator
99
private readonly SemaphoreSlim _refreshLock = new(1, 1);
1010
private bool _refreshInProgress;
1111
private DateTime? _lastRefreshAttempt;
12+
private bool _refreshPermanentlyFailed;
13+
private string? _failureReason;
1214

1315
/// <summary>
1416
/// Minimum time between refresh attempts to prevent rapid-fire refreshes.
1517
/// </summary>
16-
private static readonly TimeSpan MinRefreshInterval = TimeSpan.FromSeconds(5);
18+
private static readonly TimeSpan MinRefreshInterval = TimeSpan.FromSeconds(10);
19+
20+
/// <summary>
21+
/// Maximum time to wait for an in-progress refresh to complete.
22+
/// </summary>
23+
public static readonly TimeSpan MaxRefreshWaitTime = TimeSpan.FromSeconds(15);
24+
25+
/// <summary>
26+
/// Indicates if refresh has permanently failed (e.g., token revoked/invalid).
27+
/// When true, no further refresh attempts should be made.
28+
/// </summary>
29+
public bool IsRefreshPermanentlyFailed => _refreshPermanentlyFailed;
30+
31+
/// <summary>
32+
/// The reason for permanent failure, if any.
33+
/// </summary>
34+
public string? FailureReason => _failureReason;
35+
36+
/// <summary>
37+
/// Mark refresh as permanently failed. No further refresh attempts will be made.
38+
/// </summary>
39+
public void MarkRefreshPermanentlyFailed(string reason)
40+
{
41+
_refreshPermanentlyFailed = true;
42+
_failureReason = reason;
43+
}
44+
45+
/// <summary>
46+
/// Reset the permanent failure state (called on successful login).
47+
/// </summary>
48+
public void ResetFailureState()
49+
{
50+
_refreshPermanentlyFailed = false;
51+
_failureReason = null;
52+
}
1753

1854
/// <summary>
1955
/// Try to acquire the refresh lock. Returns true if this caller should perform the refresh.
20-
/// Returns false if another refresh is in progress or was recently attempted.
56+
/// Returns false if another refresh is in progress, was recently attempted, or has permanently failed.
2157
/// </summary>
2258
public async Task<bool> TryAcquireRefreshLockAsync(CancellationToken cancellationToken = default)
2359
{
60+
// Don't attempt refresh if it has permanently failed
61+
if (_refreshPermanentlyFailed)
62+
return false;
63+
2464
// Quick check before waiting for lock
2565
if (_refreshInProgress)
2666
return false;

0 commit comments

Comments
 (0)