11using FSH . Playground . Blazor . Services ;
22using Microsoft . AspNetCore . Authentication ;
3+ using System . IdentityModel . Tokens . Jwt ;
34using System . Net ;
45
56namespace 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>
1215internal 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