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