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(); + } +}