From 9037382e47d7f918992875fc79056306ecd9bb8e Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Mon, 8 Dec 2025 20:40:54 -0600 Subject: [PATCH] Remove nsubstitute from JwtBearer extensions --- ...Core.Authentication.JwtBearer.Tests.csproj | 1 - .../DPoP/AssertionExtensions.cs | 7 +- .../DPoP/DPoPProofValidatorTestBase.cs | 8 +-- .../DPoP/ReplayTests.cs | 8 +-- .../TestFramework/TestOptionsMonitor.cs | 17 +++++ .../TestFramework/TestReplayCache.cs | 66 +++++++++++++++++++ 6 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/TestOptionsMonitor.cs create mode 100644 aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/TestReplayCache.cs 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(); + } +}