diff --git a/Rusty.Jwt.Abstractions/Caching/ITokenCache.cs b/Rusty.Jwt.Abstractions/Caching/ITokenCache.cs new file mode 100644 index 0000000..32fe77f --- /dev/null +++ b/Rusty.Jwt.Abstractions/Caching/ITokenCache.cs @@ -0,0 +1,25 @@ +namespace Rusty.Jwt.Caching; + +/// +/// Used to cache key token verification data, to ease the load on third-part APIs. +/// +public interface ITokenCache +{ + /// + /// Used to retrieve a cached token value, containing the verification result. + /// + /// The token to fetch the result of. + /// A token used to cancel the operation. + /// + /// Returns a cached token value containing the verification result. + /// + Task GetAsync(string token, CancellationToken cancellationToken); + + /// + /// Used to set a token value in the cache. + /// + /// The token to cache a value of. + /// The value to cache. + /// A token used to cancel the operation. + Task SetAsync(string token, TokenCacheValue value, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Rusty.Jwt.Abstractions/Caching/InMemoryTokenCache.cs b/Rusty.Jwt.Abstractions/Caching/InMemoryTokenCache.cs new file mode 100644 index 0000000..4d8fa29 --- /dev/null +++ b/Rusty.Jwt.Abstractions/Caching/InMemoryTokenCache.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; + +namespace Rusty.Jwt.Caching; + +public class InMemoryTokenCache : ITokenCache +{ + private readonly ISystemClock _systemClock; + private readonly ConcurrentDictionary _items; + + public InMemoryTokenCache(ISystemClock systemClock) + { + _systemClock = systemClock; + _items = new ConcurrentDictionary(); + } + + public Task GetAsync(string token, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!_items.TryGetValue(token, out var result)) + { + return Task.FromResult(null); + } + + if (result.Expiry < _systemClock.Now) + { + _ = _items.TryRemove(token, out _); + + return Task.FromResult(null); + } + + return Task.FromResult(result); + } + + public Task SetAsync(string token, TokenCacheValue value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + _ = _items.TryAdd(token, value); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Rusty.Jwt.Abstractions/Caching/TokenCacheValue.cs b/Rusty.Jwt.Abstractions/Caching/TokenCacheValue.cs new file mode 100644 index 0000000..91ded3a --- /dev/null +++ b/Rusty.Jwt.Abstractions/Caching/TokenCacheValue.cs @@ -0,0 +1,7 @@ +namespace Rusty.Jwt.Caching; + +public record struct TokenCacheValue +{ + public bool Valid { get; set; } + public DateTime Expiry { get; set; } +} \ No newline at end of file diff --git a/Rusty.Jwt.Abstractions/ISystemClock.cs b/Rusty.Jwt.Abstractions/ISystemClock.cs new file mode 100644 index 0000000..9afaeb0 --- /dev/null +++ b/Rusty.Jwt.Abstractions/ISystemClock.cs @@ -0,0 +1,12 @@ +namespace Rusty.Jwt; + +/// +/// Used to abstract logic to get the current time. +/// +public interface ISystemClock +{ + /// + /// Gets the current date time. + /// + DateTime Now { get; } +} \ No newline at end of file diff --git a/Rusty.Jwt.Abstractions/Rusty.Jwt.Abstractions.csproj b/Rusty.Jwt.Abstractions/Rusty.Jwt.Abstractions.csproj index 4005dcf..a275652 100644 --- a/Rusty.Jwt.Abstractions/Rusty.Jwt.Abstractions.csproj +++ b/Rusty.Jwt.Abstractions/Rusty.Jwt.Abstractions.csproj @@ -13,7 +13,7 @@ https://github.com/reecerussell/rusty-jwt.git git jwt - 1.0.2 + 1.1.0 diff --git a/Rusty.Jwt.Azure.Tests/AzureEndToEndTests.cs b/Rusty.Jwt.Azure.Tests/AzureEndToEndTests.cs index a62c608..f606205 100644 --- a/Rusty.Jwt.Azure.Tests/AzureEndToEndTests.cs +++ b/Rusty.Jwt.Azure.Tests/AzureEndToEndTests.cs @@ -1,4 +1,3 @@ -using Azure.Identity; using Base64Extensions; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; diff --git a/Rusty.Jwt.Tests/Caching/InMemoryTokenCacheTests.cs b/Rusty.Jwt.Tests/Caching/InMemoryTokenCacheTests.cs new file mode 100644 index 0000000..e904308 --- /dev/null +++ b/Rusty.Jwt.Tests/Caching/InMemoryTokenCacheTests.cs @@ -0,0 +1,142 @@ +using System.Collections.Concurrent; +using System.Reflection; +using FluentAssertions; +using Moq; +using Rusty.Jwt.Caching; + +namespace Rusty.Jwt.Tests.Caching; + +public class InMemoryTokenCacheTests +{ + [Fact] + public async Task GetAsync_WhereCacheHits_ReturnsResult() + { + var now = DateTime.UtcNow; + var token = "320742304"; + var value = new TokenCacheValue + { + Valid = true, + Expiry = now.AddMinutes(5) + }; + var cancellationToken = new CancellationToken(); + + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); + + var cache = new InMemoryTokenCache(clock.Object); + + var items = new ConcurrentDictionary + { + [token] = value + }; + typeof(InMemoryTokenCache).GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(cache, items); + + var result = await cache.GetAsync(token, cancellationToken); + result.Should().Be(value); + } + + [Fact] + public async Task GetAsync_WhereCacheMisses_ReturnsNull() + { + var now = DateTime.UtcNow; + var token = "320742304"; + var cancellationToken = new CancellationToken(); + + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); + + var cache = new InMemoryTokenCache(clock.Object); + + var result = await cache.GetAsync(token, cancellationToken); + result.Should().BeNull(); + } + + [Fact] + public async Task GetAsync_WhereCacheHitsByValueHasExpired_ReturnsNull() + { + var now = DateTime.UtcNow; + var token = "320742304"; + var value = new TokenCacheValue + { + Valid = true, + Expiry = now.AddMinutes(-5) + }; + var cancellationToken = new CancellationToken(); + + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); + + var cache = new InMemoryTokenCache(clock.Object); + + var items = new ConcurrentDictionary + { + [token] = value + }; + typeof(InMemoryTokenCache).GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(cache, items); + + var result = await cache.GetAsync(token, cancellationToken); + result.Should().BeNull(); + + // Item has been cleared from cache. + items.Count.Should().Be(0); + } + + [Fact] + public async Task GetAsync_WhereCancellationHasBeenRequested_Throws() + { + var token = "320742304"; + var cancellationToken = new CancellationToken(true); + + var clock = new Mock(); + var cache = new InMemoryTokenCache(clock.Object); + + await Assert.ThrowsAsync( + () => cache.GetAsync(token, cancellationToken)); + } + + [Fact] + public async Task SetAsync_GivenValidInput_SetsInCache() + { + var now = DateTime.UtcNow; + var token = "320742304"; + var value = new TokenCacheValue + { + Valid = true, + Expiry = now.AddMinutes(-5) + }; + var cancellationToken = new CancellationToken(); + + var clock = new Mock(); + var cache = new InMemoryTokenCache(clock.Object); + + await cache.SetAsync(token, value, cancellationToken); + + var items = (ConcurrentDictionary)typeof(InMemoryTokenCache) + .GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(cache)!; + + items.Count.Should().Be(1); + items[token].Should().Be(value); + } + + [Fact] + public async Task SetAsync_WhereCancellationHasBeenRequested_Throws() + { + var now = DateTime.UtcNow; + var token = "320742304"; + var value = new TokenCacheValue + { + Valid = true, + Expiry = now.AddMinutes(-5) + }; + var cancellationToken = new CancellationToken(true); + + var clock = new Mock(); + var cache = new InMemoryTokenCache(clock.Object); + + await Assert.ThrowsAsync( + () => cache.SetAsync(token, value, cancellationToken)); + } +} \ No newline at end of file diff --git a/Rusty.Jwt.Tests/Caching/NoopTokenCacheTests.cs b/Rusty.Jwt.Tests/Caching/NoopTokenCacheTests.cs new file mode 100644 index 0000000..0915fbe --- /dev/null +++ b/Rusty.Jwt.Tests/Caching/NoopTokenCacheTests.cs @@ -0,0 +1,25 @@ +using FluentAssertions; +using Rusty.Jwt.Caching; + +namespace Rusty.Jwt.Tests.Caching; + +public class NoopTokenCacheTests +{ + [Fact] + public async Task GetAsync_GivenAnyInput_ReturnsNull() + { + var cache = new NoopTokenCache(); + var result = await cache.GetAsync("token", new CancellationToken()); + + result.Should().BeNull(); + } + + [Fact] + public void SetAsync_GivenAnyInput_ReturnsCompletedTask() + { + var cache = new NoopTokenCache(); + var task = cache.SetAsync("token", new TokenCacheValue(), new CancellationToken()); + + task.IsCompleted.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/Rusty.Jwt.Tests/HmacEndToEndTests.cs b/Rusty.Jwt.Tests/HmacEndToEndTests.cs index fa1da04..9e83120 100644 --- a/Rusty.Jwt.Tests/HmacEndToEndTests.cs +++ b/Rusty.Jwt.Tests/HmacEndToEndTests.cs @@ -19,7 +19,8 @@ public async Task InitializeAsync() { var serviceCollection = new ServiceCollection(); serviceCollection.AddRustyJwt() - .AddHmacSigningKey("foo", HashAlgorithm.SHA256, "Key1", SigningKeyMode.SignAndVerify); + .AddHmacSigningKey("foo", HashAlgorithm.SHA256, "Key1", SigningKeyMode.SignAndVerify) + .UseInMemoryTokenCache(); var services = serviceCollection.BuildServiceProvider(); diff --git a/Rusty.Jwt.Tests/JwtVerifierTests.cs b/Rusty.Jwt.Tests/JwtVerifierTests.cs index 9b9a736..26fa849 100644 --- a/Rusty.Jwt.Tests/JwtVerifierTests.cs +++ b/Rusty.Jwt.Tests/JwtVerifierTests.cs @@ -2,6 +2,7 @@ using Base64Extensions; using FluentAssertions; using Moq; +using Rusty.Jwt.Caching; namespace Rusty.Jwt.Tests; @@ -13,7 +14,7 @@ public class JwtVerifierTests public async Task VerifyAsync_GivenEmptyToken_ThrowsInvalidToken(string token) { var keyRing = new Mock(); - var verifier = new JwtVerifier(keyRing.Object); + var verifier = CreateService(keyRing: keyRing.Object); await Assert.ThrowsAsync( () => verifier.VerifyAsync(token, CancellationToken.None)); @@ -26,7 +27,7 @@ await Assert.ThrowsAsync( public async Task VerifyAsync_GivenTokenWithInvalidStructure_ThrowsInvalidToken(string token) { var keyRing = new Mock(); - var verifier = new JwtVerifier(keyRing.Object); + var verifier = CreateService(keyRing: keyRing.Object); await Assert.ThrowsAsync( () => verifier.VerifyAsync(token, CancellationToken.None)); @@ -38,7 +39,7 @@ await Assert.ThrowsAsync( public async Task VerifyAsync_GivenTokenWithInvalidHeader_ThrowsInvalidToken(string token) { var keyRing = new Mock(); - var verifier = new JwtVerifier(keyRing.Object); + var verifier = CreateService(keyRing: keyRing.Object); await Assert.ThrowsAsync( () => verifier.VerifyAsync(token, CancellationToken.None)); @@ -51,6 +52,7 @@ public async Task VerifyAsync_GivenTokenWithKeyId_VerifiesWithTheCorrectKey() const string signatureData = "LoE9f0HmUNvJ9td_O0327K6yWgUqGp4GrRYLpH6ca1c"; const string keyId = "123"; var cancellationToken = new CancellationToken(); + var now = DateTime.Now; var dataBytes = Encoding.UTF8.GetBytes(tokenData); var signatureBytes = Base64Convert.Decode(Encoding.UTF8.GetBytes(signatureData)); @@ -65,13 +67,56 @@ public async Task VerifyAsync_GivenTokenWithKeyId_VerifiesWithTheCorrectKey() .Returns(key.Object) .Verifiable(); - var verifier = new JwtVerifier(keyRing.Object); + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); + + var cache = new Mock(); + cache.Setup(x => x.GetAsync($"{tokenData}.{signatureData}", cancellationToken)) + .ReturnsAsync((TokenCacheValue?) null) + .Verifiable(); + cache.Setup(x => x.SetAsync($"{tokenData}.{signatureData}", It.Is(d => + d.Valid == true && + d.Expiry == now.AddMinutes(JwtVerifier.DefaultCacheMinutes)), cancellationToken)) + .Verifiable(); + + var verifier = CreateService( + keyRing: keyRing.Object, + tokenCache: cache.Object, + systemClock: clock.Object); var claims = await verifier.VerifyAsync($"{tokenData}.{signatureData}", cancellationToken); claims["sub"].Should().Be("1234567890"); key.VerifyAll(); keyRing.VerifyAll(); + cache.VerifyAll(); + } + + [Fact] + public async Task VerifyAsync_GivenCachedToken_BypassesVerification() + { + const string tokenData = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"; + const string signatureData = "LoE9f0HmUNvJ9td_O0327K6yWgUqGp4GrRYLpH6ca1c"; + const string keyId = "123"; + var cancellationToken = new CancellationToken(); + var now = DateTime.Now; + + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); + + var cache = new Mock(); + cache.Setup(x => x.GetAsync($"{tokenData}.{signatureData}", cancellationToken)) + .ReturnsAsync(new TokenCacheValue{Valid = true}) + .Verifiable(); + + var verifier = CreateService( + tokenCache: cache.Object, + systemClock: clock.Object); + var claims = await verifier.VerifyAsync($"{tokenData}.{signatureData}", cancellationToken); + + claims["sub"].Should().Be("1234567890"); + + cache.VerifyAll(); } [Fact] @@ -81,6 +126,7 @@ public async Task VerifyAsync_WhereTokensKeyIsNotFound_VerifiesWithDefaultKey() const string signatureData = "LoE9f0HmUNvJ9td_O0327K6yWgUqGp4GrRYLpH6ca1c"; const string keyId = "123"; var cancellationToken = new CancellationToken(); + var now = DateTime.Now; var dataBytes = Encoding.UTF8.GetBytes(tokenData); var signatureBytes = Base64Convert.Decode(Encoding.UTF8.GetBytes(signatureData)); @@ -98,13 +144,29 @@ public async Task VerifyAsync_WhereTokensKeyIsNotFound_VerifiesWithDefaultKey() .Returns(key.Object) .Verifiable(); - var verifier = new JwtVerifier(keyRing.Object); + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); + + var cache = new Mock(); + cache.Setup(x => x.GetAsync($"{tokenData}.{signatureData}", cancellationToken)) + .ReturnsAsync((TokenCacheValue?) null) + .Verifiable(); + cache.Setup(x => x.SetAsync($"{tokenData}.{signatureData}", It.Is(d => + d.Valid == true && + d.Expiry == now.AddMinutes(JwtVerifier.DefaultCacheMinutes)), cancellationToken)) + .Verifiable(); + + var verifier = CreateService( + keyRing: keyRing.Object, + tokenCache: cache.Object, + systemClock: clock.Object); var claims = await verifier.VerifyAsync($"{tokenData}.{signatureData}", cancellationToken); claims["sub"].Should().Be("1234567890"); key.VerifyAll(); keyRing.VerifyAll(); + cache.VerifyAll(); } [Theory] @@ -117,6 +179,7 @@ public async Task VerifyAsync_WhereTokenHasNoKeyId_VerifiesWithDefaultKey(string var tokenData = header + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"; const string signatureData = "LoE9f0HmUNvJ9td_O0327K6yWgUqGp4GrRYLpH6ca1c"; var cancellationToken = new CancellationToken(); + var now = DateTime.Now; var dataBytes = Encoding.UTF8.GetBytes(tokenData); var signatureBytes = Base64Convert.Decode(Encoding.UTF8.GetBytes(signatureData)); @@ -130,14 +193,31 @@ public async Task VerifyAsync_WhereTokenHasNoKeyId_VerifiesWithDefaultKey(string keyRing.Setup(x => x.GetVerificationKey(algorithm, hashAlgorithm)) .Returns(key.Object) .Verifiable(); + + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); + + var cache = new Mock(); + cache.Setup(x => x.GetAsync($"{tokenData}.{signatureData}", cancellationToken)) + .ReturnsAsync((TokenCacheValue?) null) + .Verifiable(); + cache.Setup(x => x.SetAsync($"{tokenData}.{signatureData}", It.Is(d => + d.Valid == true && + d.Expiry == now.AddMinutes(JwtVerifier.DefaultCacheMinutes)), cancellationToken)) + .Verifiable(); - var verifier = new JwtVerifier(keyRing.Object); + var verifier = CreateService( + keyRing: keyRing.Object, + tokenCache: cache.Object, + systemClock: clock.Object); + var claims = await verifier.VerifyAsync($"{tokenData}.{signatureData}", cancellationToken); claims["sub"].Should().Be("1234567890"); key.VerifyAll(); keyRing.VerifyAll(); + cache.VerifyAll(); } [Fact] @@ -146,6 +226,7 @@ public async Task VerifyAsync_WhereSignatureIsInvalid_ThrowsInvalidToken() const string tokenData = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"; const string signatureData = "LoE9f0HmUNvJ9td_O0327K6yWgUqGp4GrRYLpH6ca1c"; var cancellationToken = new CancellationToken(); + var now = DateTime.Now; var dataBytes = Encoding.UTF8.GetBytes(tokenData); var signatureBytes = Encoding.UTF8.GetBytes(Base64Convert.Decode(signatureData)); @@ -159,11 +240,28 @@ public async Task VerifyAsync_WhereSignatureIsInvalid_ThrowsInvalidToken() keyRing.Setup(x => x.GetVerificationKey(SigningKeyAlgorithm.Hmac, HashAlgorithm.SHA256)) .Returns(key.Object) .Verifiable(); + + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); + + var cache = new Mock(); + cache.Setup(x => x.GetAsync($"{tokenData}.{signatureData}", cancellationToken)) + .ReturnsAsync((TokenCacheValue?) null) + .Verifiable(); + cache.Setup(x => x.SetAsync($"{tokenData}.{signatureData}", It.Is(d => + d.Valid == false && + d.Expiry == now.AddMinutes(JwtVerifier.DefaultCacheMinutes)), cancellationToken)) + .Verifiable(); - var verifier = new JwtVerifier(keyRing.Object); + var verifier = CreateService( + keyRing: keyRing.Object, + tokenCache: cache.Object, + systemClock: clock.Object); await Assert.ThrowsAsync( () => verifier.VerifyAsync($"{tokenData}.{signatureData}", cancellationToken)); + + cache.VerifyAll(); } [Theory] @@ -174,6 +272,7 @@ public async Task VerifyAsync_WherePayloadIsNotValid_ThrowsInvalidToken(string p var tokenData = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + payload; const string signatureData = "LoE9f0HmUNvJ9td_O0327K6yWgUqGp4GrRYLpH6ca1c"; var cancellationToken = new CancellationToken(); + var now = DateTime.Now; var dataBytes = Encoding.UTF8.GetBytes(tokenData); var signatureBytes = Encoding.UTF8.GetBytes(Base64Convert.Decode(signatureData)); @@ -187,11 +286,28 @@ public async Task VerifyAsync_WherePayloadIsNotValid_ThrowsInvalidToken(string p keyRing.Setup(x => x.GetVerificationKey(SigningKeyAlgorithm.Hmac, HashAlgorithm.SHA256)) .Returns(key.Object) .Verifiable(); + + var clock = new Mock(); + clock.SetupGet(x => x.Now).Returns(now); - var verifier = new JwtVerifier(keyRing.Object); + var cache = new Mock(); + cache.Setup(x => x.GetAsync($"{tokenData}.{signatureData}", cancellationToken)) + .ReturnsAsync((TokenCacheValue?) null) + .Verifiable(); + cache.Setup(x => x.SetAsync($"{tokenData}.{signatureData}", It.Is(d => + d.Valid == false && + d.Expiry == now.AddMinutes(JwtVerifier.DefaultCacheMinutes)), cancellationToken)) + .Verifiable(); + + var verifier = CreateService( + keyRing: keyRing.Object, + tokenCache: cache.Object, + systemClock: clock.Object); await Assert.ThrowsAsync( () => verifier.VerifyAsync($"{tokenData}.{signatureData}", cancellationToken)); + + cache.VerifyAll(); } [Fact] @@ -201,22 +317,21 @@ public async Task VerifyAsync_WhereAlgorithmIsNotSupported_ThrowsInvalidToken() const string token = "eyd0eXAnOidqd3QnLCdhbGcnOidYUzI1Nid9Cg.eyJzdWIiOiIxMjM0NTY3ODkwIn0.LoE9f0HmUNvJ9td_O0327K6yWgUqGp4GrRYLpH6ca1c"; var cancellationToken = new CancellationToken(); - var verifier = new JwtVerifier(Mock.Of()); + var verifier = CreateService(); await Assert.ThrowsAsync( () => verifier.VerifyAsync(token, cancellationToken)); } - - [Fact] - public async Task VerifyAsync_WhereHashAlgorithmIsNotSupported_ThrowsInvalidToken() - { - // {"alg":"XS256"} - const string token = "eyd0eXAnOidqd3QnLCdhbGcnOidSWDI3Mid9Cg.eyJzdWIiOiIxMjM0NTY3ODkwIn0.LoE9f0HmUNvJ9td_O0327K6yWgUqGp4GrRYLpH6ca1c"; - var cancellationToken = new CancellationToken(); - var verifier = new JwtVerifier(Mock.Of()); + private static JwtVerifier CreateService( + IKeyRing? keyRing = null, + ITokenCache? tokenCache = null, + ISystemClock? systemClock = null) + { + keyRing ??= Mock.Of(); + tokenCache ??= Mock.Of(); + systemClock ??= Mock.Of(); - await Assert.ThrowsAsync( - () => verifier.VerifyAsync(token, cancellationToken)); + return new JwtVerifier(keyRing, tokenCache, systemClock); } } \ No newline at end of file diff --git a/Rusty.Jwt.Tests/UtcSystemClockTests.cs b/Rusty.Jwt.Tests/UtcSystemClockTests.cs new file mode 100644 index 0000000..60c34bb --- /dev/null +++ b/Rusty.Jwt.Tests/UtcSystemClockTests.cs @@ -0,0 +1,14 @@ +using FluentAssertions; + +namespace Rusty.Jwt.Tests; + +public class UtcSystemClockTests +{ + [Fact] + public void Now_ReturnsCurrent_UtcTime() + { + var clock = new UtcSystemClock(); + + clock.Now.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMilliseconds(1)); + } +} \ No newline at end of file diff --git a/Rusty.Jwt/Caching/NoopTokenCache.cs b/Rusty.Jwt/Caching/NoopTokenCache.cs new file mode 100644 index 0000000..c415a44 --- /dev/null +++ b/Rusty.Jwt/Caching/NoopTokenCache.cs @@ -0,0 +1,14 @@ +namespace Rusty.Jwt.Caching; + +public class NoopTokenCache : ITokenCache +{ + public Task GetAsync(string token, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task SetAsync(string token, TokenCacheValue value, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Rusty.Jwt/Extensions/JwtServiceBuilderExtensions.cs b/Rusty.Jwt/Extensions/JwtServiceBuilderExtensions.cs index b9f6eba..f17dddc 100644 --- a/Rusty.Jwt/Extensions/JwtServiceBuilderExtensions.cs +++ b/Rusty.Jwt/Extensions/JwtServiceBuilderExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Rusty.Jwt.Caching; using Rusty.Jwt.Keys; namespace Rusty.Jwt; @@ -75,4 +76,14 @@ public static IJwtServiceBuilder AddHmacSigningKey(this IJwtServiceBuilder build return builder; } + + /// + /// Used to configure the token caching functionality to use an in-memory cache. + /// + public static IJwtServiceBuilder UseInMemoryTokenCache(this IJwtServiceBuilder builder) + { + builder.Services.AddSingleton(); + + return builder; + } } \ No newline at end of file diff --git a/Rusty.Jwt/Extensions/ServiceCollectionExtensions.cs b/Rusty.Jwt/Extensions/ServiceCollectionExtensions.cs index 0124f20..659b953 100644 --- a/Rusty.Jwt/Extensions/ServiceCollectionExtensions.cs +++ b/Rusty.Jwt/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Rusty.Jwt.Caching; using Rusty.Jwt.Keys; namespace Rusty.Jwt; @@ -14,6 +15,8 @@ public static IJwtServiceBuilder AddRustyJwt(this IServiceCollection services) services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); + services.TryAddSingleton(); + services.TryAddSingleton(); return new JwtServiceBuilder(services); } diff --git a/Rusty.Jwt/JwtVerifier.cs b/Rusty.Jwt/JwtVerifier.cs index 9952fe9..734ee27 100644 --- a/Rusty.Jwt/JwtVerifier.cs +++ b/Rusty.Jwt/JwtVerifier.cs @@ -1,17 +1,29 @@ using System.Text; using Base64Extensions; using Newtonsoft.Json; +using Rusty.Jwt.Caching; using Rusty.Jwt.Keys; namespace Rusty.Jwt; public class JwtVerifier : IJwtVerifier { + /// + /// The default cache timing in minutes. + /// + public const int DefaultCacheMinutes = 5; + private readonly IKeyRing _keyRing; + private readonly ITokenCache _tokenCache; + private readonly ISystemClock _systemClock; - public JwtVerifier(IKeyRing keyRing) + public JwtVerifier(IKeyRing keyRing, + ITokenCache tokenCache, + ISystemClock systemClock) { _keyRing = keyRing; + _tokenCache = tokenCache; + _systemClock = systemClock; } public async Task VerifyAsync(string token, CancellationToken cancellationToken = default) @@ -30,6 +42,17 @@ public async Task VerifyAsync(string token, CancellationToken cancellati var header = Deserialize
(parts[0]); var (algorithm, hashAlgorithm) = ReadTokenAlgorithm(header.Algorithm!); + var cacheResult = await _tokenCache.GetAsync(token, cancellationToken); + if (cacheResult is { } result) + { + if (!result.Valid) + { + throw new InvalidTokenException("token is invalid"); + } + + return Deserialize(parts[1]); + } + IVerificationKey key; if (header.KeyId != null) { @@ -46,10 +69,44 @@ public async Task VerifyAsync(string token, CancellationToken cancellati var valid = await key.VerifyAsync(data, signature, cancellationToken); if (!valid) { + await CacheInvalidTokenAsync(token, cancellationToken); + throw new InvalidTokenException("token is invalid"); } + + try + { + var claims = Deserialize(parts[1]); + await CacheValidTokenAsync(token, claims, cancellationToken); + + return claims; + } + catch (InvalidTokenException) + { + await CacheInvalidTokenAsync(token, cancellationToken); + + throw; + } + } + + private Task CacheValidTokenAsync(string token, Claims claims, CancellationToken cancellationToken) + { + var expiry = claims.Expiry ?? _systemClock.Now.AddMinutes(DefaultCacheMinutes); - return Deserialize(parts[1]); + return _tokenCache.SetAsync(token, new TokenCacheValue + { + Valid = true, + Expiry = expiry.DateTime + }, cancellationToken); + } + + private Task CacheInvalidTokenAsync(string token, CancellationToken cancellationToken) + { + return _tokenCache.SetAsync(token, new TokenCacheValue + { + Valid = false, + Expiry = _systemClock.Now.AddMinutes(DefaultCacheMinutes) + }, cancellationToken); } private static T Deserialize(string base64Json) diff --git a/Rusty.Jwt/Rusty.Jwt.csproj b/Rusty.Jwt/Rusty.Jwt.csproj index ddf98da..1791a07 100644 --- a/Rusty.Jwt/Rusty.Jwt.csproj +++ b/Rusty.Jwt/Rusty.Jwt.csproj @@ -12,7 +12,7 @@ jwt https://github.com/reecerussell/rusty-jwt/blob/master/LICENSE 10 - 1.0.3 + 1.1.0 diff --git a/Rusty.Jwt/UtcSystemClock.cs b/Rusty.Jwt/UtcSystemClock.cs new file mode 100644 index 0000000..6cf1818 --- /dev/null +++ b/Rusty.Jwt/UtcSystemClock.cs @@ -0,0 +1,12 @@ +namespace Rusty.Jwt; + +/// +/// An implementation of used to return the UTC time. +/// +public class UtcSystemClock : ISystemClock +{ + /// + /// Gets the current UTC time. + /// + public DateTime Now => DateTime.UtcNow; +} \ No newline at end of file