diff --git a/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs b/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs
index 4a37777..ed07ce9 100644
--- a/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs
+++ b/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs
@@ -1,31 +1,141 @@
using System.Net.Http.Headers;
+using System.Threading;
+using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
+using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Client.Handlers;
+///
+/// A secure HTTP message delegating handler that automatically appends JWT tokens
+/// to trusted origin requests and transparently refreshes expired tokens in a thread-safe manner.
+///
public class AuthenticationHeaderHandler : DelegatingHandler
{
private readonly INativeStorageService _storageService;
+ private readonly IServiceProvider _serviceProvider;
private const string TokenKey = "nexus_auth_token";
+ private static readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
- public AuthenticationHeaderHandler(INativeStorageService storageService)
+ public AuthenticationHeaderHandler(INativeStorageService storageService, IServiceProvider serviceProvider)
{
_storageService = storageService;
+ _serviceProvider = serviceProvider;
}
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
- // Ensure cookies are sent (needed for InteractiveAuto SSR synchronization)
+ // Force browser to forward credentials (cookies) for SSR hydration sync
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
- var tokenResult = await _storageService.GetSecureString(TokenKey);
-
- if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
+ var path = request.RequestUri?.AbsolutePath ?? "";
+ bool isAuthEndpoint = path.Contains("identity/login") ||
+ path.Contains("identity/register") ||
+ path.Contains("identity/refresh");
+
+ // SECURITY FIX (CWE-200): Ensure we only append JWT tokens to local or trusted base origin requests
+ var navigationManager = _serviceProvider.GetRequiredService();
+ bool isTrustedHost = request.RequestUri != null &&
+ (!request.RequestUri.IsAbsoluteUri ||
+ request.RequestUri.ToString().StartsWith(navigationManager.BaseUri, StringComparison.OrdinalIgnoreCase));
+
+ string? originalToken = null;
+
+ if (!isAuthEndpoint && isTrustedHost)
{
- request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.Value);
+ var tokenResult = await _storageService.GetSecureString(TokenKey);
+ if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
+ {
+ originalToken = tokenResult.Value;
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
+ }
}
- return await base.SendAsync(request, cancellationToken);
+ var response = await base.SendAsync(request, cancellationToken);
+
+ // Transparent JWT Auto-Refresh on 401 Unauthorized
+ if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint)
+ {
+ await _refreshSemaphore.WaitAsync(cancellationToken);
+ try
+ {
+ // Re-read token to verify if another concurrent request already refreshed it
+ var tokenResult = await _storageService.GetSecureString(TokenKey);
+ var currentToken = tokenResult.IsSuccess ? tokenResult.Value : null;
+
+ bool refreshed = false;
+
+ if (!string.IsNullOrEmpty(currentToken) && currentToken != originalToken)
+ {
+ refreshed = true;
+ }
+ else
+ {
+ // SECURITY FIX (CWE-400): Resolve scoped services within an explicit using scope to prevent memory leaks
+ using var scope = _serviceProvider.CreateScope();
+ var identityService = scope.ServiceProvider.GetRequiredService();
+ var refreshResult = await identityService.RefreshTokenAsync();
+ if (refreshResult.IsSuccess)
+ {
+ var newTokenResult = await _storageService.GetSecureString(TokenKey);
+ currentToken = newTokenResult.IsSuccess ? newTokenResult.Value : null;
+ refreshed = !string.IsNullOrEmpty(currentToken);
+ }
+ else
+ {
+ await identityService.LogoutAsync();
+ }
+ }
+
+ if (refreshed && !string.IsNullOrEmpty(currentToken))
+ {
+ var newRequest = await CloneHttpRequestMessageAsync(request);
+ newRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", currentToken);
+ return await base.SendAsync(newRequest, cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Write standard security audit safe debug log
+ Console.WriteLine($"[AuthHeaderHandler] Automated token renewal failed: {ex.Message}");
+ }
+ finally
+ {
+ _refreshSemaphore.Release();
+ }
+ }
+
+ return response;
+ }
+
+ private async Task CloneHttpRequestMessageAsync(HttpRequestMessage req)
+ {
+ var clone = new HttpRequestMessage(req.Method, req.RequestUri)
+ {
+ Version = req.Version
+ };
+
+ if (req.Content != null)
+ {
+ var ms = new System.IO.MemoryStream();
+ await req.Content.CopyToAsync(ms);
+ ms.Position = 0;
+ clone.Content = new StreamContent(ms);
+
+ foreach (var h in req.Content.Headers)
+ {
+ clone.Content.Headers.TryAddWithoutValidation(h.Key, h.Value);
+ }
+ }
+
+ foreach (var h in req.Headers)
+ {
+ clone.Headers.TryAddWithoutValidation(h.Key, h.Value);
+ }
+
+ clone.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
+
+ return clone;
}
}