feat(maui): resolve 401 load error by registering MobileAuthenticationHeaderHandler with configuration-based API host

This commit is contained in:
2026-05-21 20:32:11 +02:00
parent f902073bcb
commit 5740d9126a
4 changed files with 160 additions and 3 deletions
@@ -0,0 +1,144 @@
using System.Net.Http.Headers;
using System.Threading;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Maui.Infrastructure.Identity;
/// <summary>
/// A secure HTTP message delegating handler for MAUI that automatically appends JWT tokens
/// to trusted origin requests and transparently refreshes expired tokens in a thread-safe manner.
/// </summary>
public class MobileAuthenticationHeaderHandler : 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 MobileAuthenticationHeaderHandler(INativeStorageService storageService, IServiceProvider serviceProvider)
{
_storageService = storageService;
_serviceProvider = serviceProvider;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? "";
bool isAuthEndpoint = path.Contains("identity/login") ||
path.Contains("identity/register") ||
path.Contains("identity/refresh");
// Resolve configured API host dynamically to avoid hardcoded IP addresses
var config = _serviceProvider.GetRequiredService<IConfiguration>();
var apiBaseUrlString = config["ApiSettings:BaseUrl"];
string? apiHost = null;
if (!string.IsNullOrEmpty(apiBaseUrlString) && Uri.TryCreate(apiBaseUrlString, UriKind.Absolute, out var apiUri))
{
apiHost = apiUri.Host;
}
// In MAUI, since we only call our own local or staging APIs, we trust local IP/localhost/configured API host.
// We ensure we don't accidentally leak tokens to third-party endpoints.
bool isTrustedHost = request.RequestUri != null &&
(request.RequestUri.Host == "localhost" ||
request.RequestUri.Host == "127.0.0.1" ||
(apiHost != null && request.RequestUri.Host == apiHost) ||
request.RequestUri.Host.EndsWith("nexusreader.com")); // Or staging domains
string? originalToken = null;
if (!isAuthEndpoint && isTrustedHost)
{
var tokenResult = await _storageService.GetSecureString(TokenKey);
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{
originalToken = tokenResult.Value;
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
}
}
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
{
using var scope = _serviceProvider.CreateScope();
var identityService = scope.ServiceProvider.GetRequiredService<IIdentityService>();
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)
{
Serilog.Log.Error(ex, "[MobileAuthHandler] Automated token renewal failed");
}
finally
{
_refreshSemaphore.Release();
}
}
return response;
}
private async Task<HttpRequestMessage> 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);
}
return clone;
}
}
+11 -2
View File
@@ -1,11 +1,13 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Mobile.Services;
using NexusReader.UI.Shared.Services;
using NexusReader.Application;
using MediatR;
using NexusReader.Maui.Infrastructure.Logging;
using NexusReader.Maui.Infrastructure.Identity;
namespace NexusReader.Maui;
@@ -50,8 +52,15 @@ public static class MauiProgram
sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddAuthorizationCore();
// Basic Network
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://10.0.2.2:5000") });
// Basic Network with Secure Token Handler
builder.Services.AddTransient<MobileAuthenticationHeaderHandler>();
builder.Services.AddHttpClient("NexusAPI", client =>
{
var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000";
client.BaseAddress = new Uri(apiBaseUrl);
}).AddHttpMessageHandler<MobileAuthenticationHeaderHandler>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
// UI State
builder.Services.AddScoped<IThemeService, ThemeService>();
@@ -34,6 +34,7 @@
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
+4 -1
View File
@@ -1,4 +1,7 @@
{
"ApiSettings": {
"BaseUrl": "https://localhost:5000"
},
"Serilog": {
"Using": [
"Serilog.Sinks.File",
@@ -42,4 +45,4 @@
"FromLogContext"
]
}
}
}