feat: implement native AOT-friendly JwtTokenValidator to prevent sending expired bearer tokens in auth handlers

This commit is contained in:
2026-05-27 11:55:12 +02:00
parent e0c64c4c82
commit 816bf48d15
6 changed files with 139 additions and 3 deletions
@@ -3,6 +3,7 @@ using System.Threading;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Maui.Infrastructure.Identity; namespace NexusReader.Maui.Infrastructure.Identity;
@@ -55,7 +56,12 @@ public class MobileAuthenticationHeaderHandler : DelegatingHandler
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{ {
originalToken = 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; var token = tokenResult.IsSuccess ? tokenResult.Value : null;
// 1. Try Token-based auth // 1. Try Token-based auth
if (!string.IsNullOrWhiteSpace(token)) if (!string.IsNullOrWhiteSpace(token) && !JwtTokenValidator.IsExpired(token))
{ {
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant); var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Http; using Microsoft.AspNetCore.Components.WebAssembly.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Client.Handlers; namespace NexusReader.Web.Client.Handlers;
@@ -48,7 +49,12 @@ public class AuthenticationHeaderHandler : DelegatingHandler
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{ {
originalToken = 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> <ItemGroup>
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" /> <ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" /> <ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
</ItemGroup> </ItemGroup>
</Project> </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();
}
}