feat(maui): resolve 401 load error by registering MobileAuthenticationHeaderHandler with configuration-based API host
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Infrastructure.Mobile.Services;
|
using NexusReader.Infrastructure.Mobile.Services;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
using NexusReader.Application;
|
using NexusReader.Application;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using NexusReader.Maui.Infrastructure.Logging;
|
using NexusReader.Maui.Infrastructure.Logging;
|
||||||
|
using NexusReader.Maui.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace NexusReader.Maui;
|
namespace NexusReader.Maui;
|
||||||
|
|
||||||
@@ -50,8 +52,15 @@ public static class MauiProgram
|
|||||||
sp.GetRequiredService<NexusAuthenticationStateProvider>());
|
sp.GetRequiredService<NexusAuthenticationStateProvider>());
|
||||||
builder.Services.AddAuthorizationCore();
|
builder.Services.AddAuthorizationCore();
|
||||||
|
|
||||||
// Basic Network
|
// Basic Network with Secure Token Handler
|
||||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://10.0.2.2:5000") });
|
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
|
// UI State
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"ApiSettings": {
|
||||||
|
"BaseUrl": "https://localhost:5000"
|
||||||
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"Using": [
|
"Using": [
|
||||||
"Serilog.Sinks.File",
|
"Serilog.Sinks.File",
|
||||||
@@ -42,4 +45,4 @@
|
|||||||
"FromLogContext"
|
"FromLogContext"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user