feat: implement native AOT-friendly JwtTokenValidator to prevent sending expired bearer tokens in auth handlers
This commit is contained in:
@@ -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,9 +56,14 @@ public class MobileAuthenticationHeaderHandler : DelegatingHandler
|
|||||||
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
||||||
{
|
{
|
||||||
originalToken = tokenResult.Value;
|
originalToken = tokenResult.Value;
|
||||||
|
|
||||||
|
// Only attach the Bearer token if it is not expired
|
||||||
|
if (!JwtTokenValidator.IsExpired(originalToken))
|
||||||
|
{
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var response = await base.SendAsync(request, cancellationToken);
|
var response = await base.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +49,14 @@ public class AuthenticationHeaderHandler : DelegatingHandler
|
|||||||
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
||||||
{
|
{
|
||||||
originalToken = tokenResult.Value;
|
originalToken = tokenResult.Value;
|
||||||
|
|
||||||
|
// Only attach the Bearer token if it is not expired
|
||||||
|
if (!JwtTokenValidator.IsExpired(originalToken))
|
||||||
|
{
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var response = await base.SendAsync(request, cancellationToken);
|
var response = await base.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user