diff --git a/README.md b/README.md index 12aa10c..e1454a7 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,31 @@ var options = Options.Create(new MiniJwtOptions ExpirationMinutes = 60 }); -var svc = new MiniJwtService(options, NullLogger.Instance); +var svc = new MiniJwtService(options, NullLogger.Instance, new JwtSecurityTokenHandler()); +``` + +### Testing with TimeProvider + +For testable time-dependent behavior, the library supports `TimeProvider` (built-in for .NET 8+ or via `Microsoft.Bcl.TimeProvider` for earlier versions). You can inject a `FakeTimeProvider` for deterministic testing: + +```csharp +using Microsoft.Extensions.Time.Testing; + +var fakeTimeProvider = new FakeTimeProvider(); +fakeTimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero)); + +var svc = new MiniJwtService( + options, + NullLogger.Instance, + new JwtSecurityTokenHandler(), + fakeTimeProvider +); + +// Generate token at the fixed time +var token = svc.GenerateToken(user); + +// Advance time for further testing +fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); ``` ## Debugging tips diff --git a/docs/examples.md b/docs/examples.md index 6b2897b..663d9a6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -420,6 +420,74 @@ public class JwtServiceTests } ``` +### Testing with TimeProvider for Deterministic Time + +```csharp +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using System.IdentityModel.Tokens.Jwt; +using Xunit; + +public class JwtServiceTimeTests +{ + // Simple test helper for IOptionsMonitor + private class SimpleOptionsMonitor(T value) : IOptionsMonitor + { + public T CurrentValue => value; + public T Get(string? name) => value; + public IDisposable OnChange(Action listener) => new NoOpDisposable(); + } + + private class NoOpDisposable : IDisposable + { + public void Dispose() { } + } + + [Fact] + public void GenerateToken_WithFakeTimeProvider_UsesProvidedTime() + { + // Arrange: Set up a fake time provider at a specific time + var fakeTimeProvider = new FakeTimeProvider(); + var fixedTime = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero); + fakeTimeProvider.SetUtcNow(fixedTime); + + var options = new SimpleOptionsMonitor(new MiniJwtOptions + { + SecretKey = "test-secret-key-at-least-32-bytes-long", + Issuer = "TestApp", + Audience = "TestClient", + ExpirationMinutes = 60 + }); + + // Create service with fake time provider + var service = new MiniJwtService( + options, + NullLogger.Instance, + new JwtSecurityTokenHandler { MapInboundClaims = false }, + fakeTimeProvider + ); + + // Act: Generate a token + var token = service.GenerateToken(new { sub = "test-user" }); + + // Assert: Verify the token uses the fake time + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + + Assert.Equal(fixedTime.UtcDateTime, jwtToken.ValidFrom); + Assert.Equal(fixedTime.AddMinutes(60).UtcDateTime, jwtToken.ValidTo); + + // Advance time and generate another token + fakeTimeProvider.Advance(TimeSpan.FromMinutes(10)); + var token2 = service.GenerateToken(new { sub = "test-user2" }); + + var jwtToken2 = handler.ReadJwtToken(token2); + Assert.Equal(fixedTime.AddMinutes(10).UtcDateTime, jwtToken2.ValidFrom); + } +} +``` + ## Runnable Sample Applications We provide complete, runnable sample applications in the repository: diff --git a/docs/faq.md b/docs/faq.md index 98d079d..7f0d78f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -304,6 +304,9 @@ See the [examples documentation](examples.md#unit-testing) for unit testing patt - Create test instances with `NullLogger.Instance` - Use `Options.Create()` for test configurations - Mock `IMiniJwtService` in your tests for isolation +- Use `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing` for deterministic time-dependent testing + +For time-dependent testing, inject a `FakeTimeProvider` into the service constructor to control token generation timestamps. See [Testing with TimeProvider](examples.md#testing-with-timeprovider-for-deterministic-time) for examples. ## Troubleshooting diff --git a/src/MiniJwt.Core/MiniJwt.Core.csproj b/src/MiniJwt.Core/MiniJwt.Core.csproj index 3ecf9da..4c2bf90 100644 --- a/src/MiniJwt.Core/MiniJwt.Core.csproj +++ b/src/MiniJwt.Core/MiniJwt.Core.csproj @@ -22,6 +22,11 @@ + + + + + diff --git a/src/MiniJwt.Core/Services/MiniJwtService.cs b/src/MiniJwt.Core/Services/MiniJwtService.cs index a51c370..5217b83 100644 --- a/src/MiniJwt.Core/Services/MiniJwtService.cs +++ b/src/MiniJwt.Core/Services/MiniJwtService.cs @@ -15,6 +15,7 @@ public class MiniJwtService : IMiniJwtService, IDisposable { private readonly ILogger _logger; private readonly JwtSecurityTokenHandler _tokenHandler; + private readonly TimeProvider _timeProvider; private readonly IDisposable? _optionsChangeRegistration; private readonly object _sync = new object(); @@ -26,9 +27,15 @@ public class MiniJwtService : IMiniJwtService, IDisposable private const int MinimumKeyLengthBytes = 32; // 256 bits for HS256 public MiniJwtService(IOptionsMonitor optionsMonitor, ILogger logger, JwtSecurityTokenHandler tokenHandler) + : this(optionsMonitor, logger, tokenHandler, TimeProvider.System) + { + } + + public MiniJwtService(IOptionsMonitor optionsMonitor, ILogger logger, JwtSecurityTokenHandler tokenHandler, TimeProvider timeProvider) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _tokenHandler = tokenHandler ?? throw new ArgumentNullException(nameof(tokenHandler)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _options = optionsMonitor.CurrentValue ?? throw new ArgumentNullException(nameof(optionsMonitor)); RefreshFromOptions(_options); @@ -109,7 +116,7 @@ private void RefreshFromOptions(MiniJwtOptions opts) claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())); - var now = DateTime.UtcNow; + var now = _timeProvider.GetUtcNow().UtcDateTime; var expires = now.AddMinutes(currentOptions.ExpirationMinutes); var jwt = new JwtSecurityToken( diff --git a/src/MiniJwt.Tests/MiniJwt.Tests.csproj b/src/MiniJwt.Tests/MiniJwt.Tests.csproj index 426e6aa..e8a2726 100644 --- a/src/MiniJwt.Tests/MiniJwt.Tests.csproj +++ b/src/MiniJwt.Tests/MiniJwt.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/src/MiniJwt.Tests/MiniJwtTests.Service.cs b/src/MiniJwt.Tests/MiniJwtTests.Service.cs index afb8eed..1b139e9 100644 --- a/src/MiniJwt.Tests/MiniJwtTests.Service.cs +++ b/src/MiniJwt.Tests/MiniJwtTests.Service.cs @@ -2,6 +2,7 @@ using System.Linq; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; using MiniJwt.Core.Attributes; using MiniJwt.Core.Models; using MiniJwt.Core.Services; @@ -285,4 +286,97 @@ public void Dispose_ShouldCleanupOptionsChangeSubscription() // Assert Assert.True(trackableDisposable.IsDisposed, "The options change subscription should be disposed when the service is disposed"); } + + [Fact] + public void GenerateToken_WithFakeTimeProvider_ShouldUseProvidedTime() + { + // Arrange + var fakeTimeProvider = new FakeTimeProvider(); + var fixedTime = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero); + fakeTimeProvider.SetUtcNow(fixedTime); + + var options = new SimpleOptionsMonitor(new MiniJwtOptions + { + SecretKey = "IntegrationTestSecretKey_LongEnough_For_HS256_0123456789", + Issuer = "MiniJwt.Tests", + Audience = "MiniJwt.Tests.Client", + ExpirationMinutes = 60 + }); + + using var loggerFactory = new LoggerFactory(); + var service = new MiniJwtService( + options, + loggerFactory.CreateLogger(), + new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(), + fakeTimeProvider + ); + + var user = new TestUser { Id = 1, Email = "test@test.com", Name = "User Test" }; + + // Act + var token = service.GenerateToken(user); + + // Assert + Assert.NotNull(token); + + // Decode the token to verify it uses the fake time + var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + + // The token's NotBefore should match the fixed time + Assert.Equal(fixedTime.UtcDateTime, jwtToken.ValidFrom); + + // The token's expiration should be 60 minutes after the fixed time + var expectedExpiry = fixedTime.AddMinutes(60).UtcDateTime; + Assert.Equal(expectedExpiry, jwtToken.ValidTo); + } + + [Fact] + public void GenerateToken_WithAdvancedTime_UsesUpdatedTime() + { + // Arrange + var fakeTimeProvider = new FakeTimeProvider(); + var initialTime = new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero); + fakeTimeProvider.SetUtcNow(initialTime); + + var options = new SimpleOptionsMonitor(new MiniJwtOptions + { + SecretKey = "IntegrationTestSecretKey_LongEnough_For_HS256_0123456789", + Issuer = "MiniJwt.Tests", + Audience = "MiniJwt.Tests.Client", + ExpirationMinutes = 10 // 10 minutes + }); + + using var loggerFactory = new LoggerFactory(); + var service = new MiniJwtService( + options, + loggerFactory.CreateLogger(), + new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(), + fakeTimeProvider + ); + + var user = new TestUser { Id = 1, Email = "test@test.com", Name = "User Test" }; + + // Generate token at initial time + var token = service.GenerateToken(user); + Assert.NotNull(token); + + // Verify the token was generated with the fake time + var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + Assert.Equal(initialTime.UtcDateTime, jwtToken.ValidFrom); + Assert.Equal(initialTime.AddMinutes(10).UtcDateTime, jwtToken.ValidTo); + + // Advance the fake time by 5 minutes + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + // Generate another token - it should use the new advanced time + var token2 = service.GenerateToken(user); + Assert.NotNull(token2); + + var jwtToken2 = handler.ReadJwtToken(token2); + var expectedTime = initialTime.AddMinutes(5).UtcDateTime; + Assert.Equal(expectedTime, jwtToken2.ValidFrom); + Assert.Equal(expectedTime.AddMinutes(10), jwtToken2.ValidTo); + } } \ No newline at end of file