diff --git a/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs b/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs
index 871dbd6..19473ee 100644
--- a/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs
+++ b/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs
@@ -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);
+ }
}
}
diff --git a/src/NexusReader.UI.Shared/Services/JwtTokenValidator.cs b/src/NexusReader.UI.Shared/Services/JwtTokenValidator.cs
new file mode 100644
index 0000000..ac41dee
--- /dev/null
+++ b/src/NexusReader.UI.Shared/Services/JwtTokenValidator.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Text.Json;
+
+namespace NexusReader.UI.Shared.Services;
+
+///
+/// A lightweight, Native AOT-friendly JWT validator that decodes the payload of a JWT token
+/// to verify expiration without standard library dependencies.
+///
+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;
+ }
+}
diff --git a/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs
index c5f4f94..d0d4542 100644
--- a/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs
+++ b/src/NexusReader.UI.Shared/Services/NexusAuthenticationStateProvider.cs
@@ -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);
diff --git a/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs b/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs
index ed07ce9..58650a3 100644
--- a/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs
+++ b/src/NexusReader.Web.Client/Handlers/AuthenticationHeaderHandler.cs
@@ -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);
+ }
}
}
diff --git a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj
index d4d54a5..9ed3835 100644
--- a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj
+++ b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj
@@ -16,5 +16,6 @@
+
diff --git a/tests/NexusReader.Application.Tests/Services/JwtTokenValidatorTests.cs b/tests/NexusReader.Application.Tests/Services/JwtTokenValidatorTests.cs
new file mode 100644
index 0000000..41d1554
--- /dev/null
+++ b/tests/NexusReader.Application.Tests/Services/JwtTokenValidatorTests.cs
@@ -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();
+ }
+}