diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj
index d0f3afe2b..f5b5e3efd 100644
--- a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj
+++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj
@@ -17,7 +17,6 @@
-
diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AssertionExtensions.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AssertionExtensions.cs
index e89c7030f..8e3289e07 100644
--- a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AssertionExtensions.cs
+++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AssertionExtensions.cs
@@ -2,7 +2,6 @@
// See LICENSE in the project root for license information.
using Duende.IdentityModel;
-using NSubstitute;
namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
@@ -16,5 +15,9 @@ public static class AssertionExtensions
result.Error.ShouldBe(OidcConstants.TokenErrors.InvalidDPoPProof);
}
- public static void ReplayCacheShouldNotBeCalled(this TestDPoPProofValidator validator) => validator.TestReplayCache.DidNotReceive().Add(Arg.Any(), Arg.Any());
+ public static void ReplayCacheShouldNotBeCalled(this TestDPoPProofValidator validator)
+ {
+ var mockCache = (TestReplayCache)validator.TestReplayCache;
+ mockCache.VerifyAddWasNotCalled();
+ }
}
diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/DPoPProofValidatorTestBase.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/DPoPProofValidatorTestBase.cs
index 1947ac9c9..a9c938e7d 100644
--- a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/DPoPProofValidatorTestBase.cs
+++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/DPoPProofValidatorTestBase.cs
@@ -6,11 +6,10 @@ using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using Duende.AspNetCore.Authentication.JwtBearer.DPoP.TestFramework;
using Duende.IdentityModel;
-using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
-using NSubstitute;
namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
@@ -37,12 +36,11 @@ public abstract class DPoPProofValidatorTestBase
protected DPoPProofValidationContext Context;
protected DPoPOptions Options = new();
- protected IReplayCache ReplayCache = Substitute.For();
+ protected TestReplayCache ReplayCache = new();
public TestDPoPProofValidator CreateProofValidator()
{
- var optionsMonitor = Substitute.For>();
- optionsMonitor.Get(Arg.Any()).Returns(Options);
+ var optionsMonitor = new TestOptionsMonitor(Options);
return new TestDPoPProofValidator(
optionsMonitor,
diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/ReplayTests.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/ReplayTests.cs
index 1e217b212..cad03e406 100644
--- a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/ReplayTests.cs
+++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/ReplayTests.cs
@@ -1,8 +1,6 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
-using NSubstitute;
-
namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
public class ReplayTests : DPoPProofValidatorTestBase
@@ -11,7 +9,7 @@ public class ReplayTests : DPoPProofValidatorTestBase
[Trait("Category", "Unit")]
public async Task replays_detected_in_ValidateReplay_fail()
{
- ReplayCache.Exists(TokenIdHash).Returns(true);
+ ReplayCache.ExistsFunc = jti => jti == TokenIdHash;
Result.TokenIdHash = TokenIdHash;
await ProofValidator.ValidateReplay(Context, Result);
@@ -28,7 +26,7 @@ public class ReplayTests : DPoPProofValidatorTestBase
[InlineData(true, true, ClockSkew * 2, ClockSkew * 2)]
public async Task new_proof_tokens_are_added_to_replay_cache(bool validateIat, bool validateNonce, int clientClockSkew, int serverClockSkew)
{
- ReplayCache.Exists(TokenIdHash).Returns(false);
+ ReplayCache.ExistsFunc = _ => false;
Options.ValidationMode = (validateIat && validateNonce) ? ExpirationValidationMode.Both
: validateIat ? ExpirationValidationMode.IssuedAt : ExpirationValidationMode.Nonce;
@@ -46,6 +44,6 @@ public class ReplayTests : DPoPProofValidatorTestBase
: (validateIat ? clientClockSkew : serverClockSkew);
var expectedExpiration = TimeSpan.FromSeconds(skew * 2)
.Add(TimeSpan.FromSeconds(ValidFor));
- await ReplayCache.Received().Add(TokenIdHash, expectedExpiration);
+ ReplayCache.VerifyAddWasCalled(TokenIdHash, expectedExpiration);
}
}
diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/TestOptionsMonitor.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/TestOptionsMonitor.cs
new file mode 100644
index 000000000..a0166b412
--- /dev/null
+++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/TestOptionsMonitor.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.Extensions.Options;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP.TestFramework;
+
+public class TestOptionsMonitor(T options) : IOptionsMonitor
+{
+ private readonly T _options = options;
+
+ public T CurrentValue => _options;
+
+ public T Get(string? name) => _options;
+
+ public IDisposable? OnChange(Action listener) => null;
+}
diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/TestReplayCache.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/TestReplayCache.cs
new file mode 100644
index 000000000..81bb56568
--- /dev/null
+++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/TestReplayCache.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+public class TestReplayCache : IReplayCache
+{
+ private readonly Dictionary _cache = new();
+ private readonly List<(string jtiHash, TimeSpan expiration)> _addCalls = new();
+ private readonly List _existsCalls = new();
+
+ // Configuration for test behavior
+ public Func? ExistsFunc { get; set; }
+
+ public Task Add(string jtiHash, TimeSpan expiration, CancellationToken cancellationToken = default)
+ {
+ _addCalls.Add((jtiHash, expiration));
+ _cache[jtiHash] = (expiration, DateTime.UtcNow);
+ return Task.CompletedTask;
+ }
+
+ public Task Exists(string jtiHash, CancellationToken cancellationToken = default)
+ {
+ _existsCalls.Add(jtiHash);
+
+ if (ExistsFunc != null)
+ {
+ return Task.FromResult(ExistsFunc(jtiHash));
+ }
+
+ return Task.FromResult(_cache.ContainsKey(jtiHash));
+ }
+
+ // Verification methods
+ public void VerifyAddWasCalled(string jtiHash, TimeSpan expectedExpiration)
+ {
+ var call = _addCalls.FirstOrDefault(c => c.jtiHash == jtiHash);
+ if (call == default)
+ {
+ throw new Exception($"Add was not called with jtiHash: {jtiHash}");
+ }
+ if (call.expiration != expectedExpiration)
+ {
+ throw new Exception($"Add was called with wrong expiration. Expected: {expectedExpiration}, Actual: {call.expiration}");
+ }
+ }
+
+ public void VerifyAddWasNotCalled()
+ {
+ if (_addCalls.Count > 0)
+ {
+ throw new Exception($"Add was called {_addCalls.Count} time(s) but should not have been called");
+ }
+ }
+
+ public bool WasAddCalled => _addCalls.Count > 0;
+
+ public IReadOnlyList<(string jtiHash, TimeSpan expiration)> AddCalls => _addCalls;
+
+ public void Clear()
+ {
+ _cache.Clear();
+ _addCalls.Clear();
+ _existsCalls.Clear();
+ }
+}