feat(auth): Stabilize authentication flow and implement premium hydration preloader #60

Merged
mjasin merged 7 commits from feat/mobile-ux-overhaul into infra/beta-deploy-test 2026-05-27 10:29:53 +00:00
6 changed files with 139 additions and 3 deletions
Showing only changes of commit 816bf48d15 - Show all commits
@@ -3,6 +3,7 @@ using System.Threading;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Maui.Infrastructure.Identity;
@@ -55,7 +56,12 @@ public class MobileAuthenticationHeaderHandler : DelegatingHandler
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{
originalToken = tokenResult.Value;
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
// Only attach the Bearer token if it is not expired
if (!JwtTokenValidator.IsExpired(originalToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
}
}
}
@@ -0,0 +1,52 @@
using System;
using System.Text.Json;
namespace NexusReader.UI.Shared.Services;
/// <summary>
/// A lightweight, Native AOT-friendly JWT validator that decodes the payload of a JWT token
/// to verify expiration without standard library dependencies.
/// </summary>
public static class JwtTokenValidator
{
public static bool IsExpired(string? token)
{
if (string.IsNullOrWhiteSpace(token)) return true;
try
{
var parts = token.Split('.');
if (parts.Length != 3) return true;
var payload = parts[1];
// Pad the base64 string
var padLength = 4 - (payload.Length % 4);
if (padLength < 4)
{
payload += new string('=', padLength);
}
// Base64URL to standard Base64 conversion
payload = payload.Replace('-', '+').Replace('_', '/');
var bytes = Convert.FromBase64String(payload);
using var jsonDoc = JsonDocument.Parse(bytes);
if (jsonDoc.RootElement.TryGetProperty("exp", out var expElement))
{
var exp = expElement.GetInt64();
var expTime = DateTimeOffset.FromUnixTimeSeconds(exp);
// Allow a small 10-second clock skew buffer
return expTime <= DateTimeOffset.UtcNow.AddSeconds(10);
}
}
catch
{
return true; // Treat invalid token as expired
}
return true;
}
}
@@ -54,7 +54,7 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
var token = tokenResult.IsSuccess ? tokenResult.Value : null;
// 1. Try Token-based auth
if (!string.IsNullOrWhiteSpace(token))
if (!string.IsNullOrWhiteSpace(token) && !JwtTokenValidator.IsExpired(token))
{
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Client.Handlers;
@@ -48,7 +49,12 @@ public class AuthenticationHeaderHandler : DelegatingHandler
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{
originalToken = tokenResult.Value;
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
// Only attach the Bearer token if it is not expired
if (!JwtTokenValidator.IsExpired(originalToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
}
}
}
@@ -16,5 +16,6 @@
<ItemGroup>
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,71 @@
using System;
using System.Text;
using FluentAssertions;
using NexusReader.UI.Shared.Services;
using Xunit;
namespace NexusReader.Application.Tests.Services;
public class JwtTokenValidatorTests
{
private string CreateMockToken(long exp)
{
// {"alg":"HS256","typ":"JWT"}
var header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
var payloadJson = $"{{\"exp\":{exp}}}";
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
var payload = Convert.ToBase64String(payloadBytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
return $"{header}.{payload}.signature";
}
[Fact]
public void IsExpired_WithNullOrEmptyToken_ShouldReturnTrue()
{
JwtTokenValidator.IsExpired(null).Should().BeTrue();
JwtTokenValidator.IsExpired("").Should().BeTrue();
JwtTokenValidator.IsExpired(" ").Should().BeTrue();
}
[Fact]
public void IsExpired_WithMalformedToken_ShouldReturnTrue()
{
JwtTokenValidator.IsExpired("not.a.valid.token.format.here").Should().BeTrue();
JwtTokenValidator.IsExpired("part1.part2").Should().BeTrue();
JwtTokenValidator.IsExpired("justonestring").Should().BeTrue();
}
[Fact]
public void IsExpired_WithExpiredToken_ShouldReturnTrue()
{
// Expired 1 hour ago
var expiredTime = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
var token = CreateMockToken(expiredTime);
JwtTokenValidator.IsExpired(token).Should().BeTrue();
}
[Fact]
public void IsExpired_WithValidToken_ShouldReturnFalse()
{
// Valid for 1 hour in the future
var futureTime = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
var token = CreateMockToken(futureTime);
JwtTokenValidator.IsExpired(token).Should().BeFalse();
}
[Fact]
public void IsExpired_WithTokenInsideSkewBuffer_ShouldReturnTrue()
{
// Expiring in 5 seconds (within the 10-second skew buffer)
var skewTime = DateTimeOffset.UtcNow.AddSeconds(5).ToUnixTimeSeconds();
var token = CreateMockToken(skewTime);
JwtTokenValidator.IsExpired(token).Should().BeTrue();
}
}