diff --git a/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs b/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs
new file mode 100644
index 0000000..871dbd6
--- /dev/null
+++ b/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs
@@ -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;
+
+///
+/// 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.
+///
+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 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();
+ 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();
+ 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 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;
+ }
+}
diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs
index 6024cc6..9f12805 100644
--- a/src/NexusReader.Maui/MauiProgram.cs
+++ b/src/NexusReader.Maui/MauiProgram.cs
@@ -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());
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();
+ builder.Services.AddHttpClient("NexusAPI", client =>
+ {
+ var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000";
+ client.BaseAddress = new Uri(apiBaseUrl);
+ }).AddHttpMessageHandler();
+
+ builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI"));
// UI State
builder.Services.AddScoped();
diff --git a/src/NexusReader.Maui/NexusReader.Maui.csproj b/src/NexusReader.Maui/NexusReader.Maui.csproj
index 32f874c..38f7413 100644
--- a/src/NexusReader.Maui/NexusReader.Maui.csproj
+++ b/src/NexusReader.Maui/NexusReader.Maui.csproj
@@ -34,6 +34,7 @@
+
diff --git a/src/NexusReader.Maui/appsettings.json b/src/NexusReader.Maui/appsettings.json
index 14e9ab2..4d3ef31 100644
--- a/src/NexusReader.Maui/appsettings.json
+++ b/src/NexusReader.Maui/appsettings.json
@@ -1,4 +1,7 @@
{
+ "ApiSettings": {
+ "BaseUrl": "https://localhost:5000"
+ },
"Serilog": {
"Using": [
"Serilog.Sinks.File",
@@ -42,4 +45,4 @@
"FromLogContext"
]
}
-}
+}
\ No newline at end of file