From 5740d9126a798ce6d4ddccd81ff52f516c94f6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Thu, 21 May 2026 20:32:11 +0200 Subject: [PATCH] feat(maui): resolve 401 load error by registering MobileAuthenticationHeaderHandler with configuration-based API host --- .../MobileAuthenticationHeaderHandler.cs | 144 ++++++++++++++++++ src/NexusReader.Maui/MauiProgram.cs | 13 +- src/NexusReader.Maui/NexusReader.Maui.csproj | 1 + src/NexusReader.Maui/appsettings.json | 5 +- 4 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs 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