feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error #51
@@ -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.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>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"ApiSettings": {
|
||||
"BaseUrl": "https://localhost:5000"
|
||||
},
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.File",
|
||||
@@ -42,4 +45,4 @@
|
||||
"FromLogContext"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user