diff --git a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj index d0d87a42a..3aa5e0817 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj @@ -9,6 +9,7 @@ + diff --git a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs index d80d7a1e4..82675426a 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs @@ -387,8 +387,7 @@ internal class DPoPProofValidator : IDPoPProofValidator // longer than the likelihood of proof token expiration, which is done before replay skew *= 2; var cacheDuration = dPoPOptions.ProofTokenValidityDuration + skew; - var expiration = TimeProvider.GetUtcNow().Add(cacheDuration); - await ReplayCache.Add(result.TokenIdHash!, expiration, cancellationToken); + await ReplayCache.Add(result.TokenIdHash!, cacheDuration, cancellationToken); } /// diff --git a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/IReplayCache.cs b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/IReplayCache.cs index 96e13086b..94e244a0d 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/IReplayCache.cs +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/IReplayCache.cs @@ -11,7 +11,7 @@ public interface IReplayCache /// /// Adds a hashed jti to the cache. /// - Task Add(string jtiHash, DateTimeOffset expiration, CancellationToken cancellationToken = default); + Task Add(string jtiHash, TimeSpan expiration, CancellationToken cancellationToken = default); /// diff --git a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/ReplayCache.cs b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/ReplayCache.cs index a0f7c32b5..d3eb2deed 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/ReplayCache.cs +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/ReplayCache.cs @@ -1,35 +1,61 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. -using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid; namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP; /// -/// Default implementation of the replay cache using IDistributedCache +/// Default implementation of the replay cache using Hybrid Cache /// internal class ReplayCache : IReplayCache { private const string Prefix = "DPoPJwtBearerEvents-DPoPReplay-jti-"; - private readonly IDistributedCache _cache; + private readonly HybridCache _cache; /// /// Constructs new instances of . /// - public ReplayCache(IDistributedCache cache) => _cache = cache; + public ReplayCache(HybridCache cache) => _cache = cache; /// - public async Task Add(string handle, DateTimeOffset expiration, CancellationToken cancellationToken) + public async Task Add(string handle, TimeSpan expiration, CancellationToken ct) { - var options = new DistributedCacheEntryOptions + var options = new HybridCacheEntryOptions { - AbsoluteExpiration = expiration + Expiration = expiration }; - await _cache.SetAsync(Prefix + handle, [], options, cancellationToken); + await _cache.SetAsync(Prefix + handle, true, options, cancellationToken: ct); } /// - public async Task Exists(string handle, CancellationToken cancellationToken) => await _cache.GetAsync(Prefix + handle, cancellationToken) != null; + public async Task Exists(string handle, CancellationToken ct) => + (await _cache.GetOrDefaultAsync(Prefix + handle, ct)) != null; +} + +/// +/// Extension methods for HybridCache. This is needed because HybridCache does not have a GetOrDefaultAsync method. +/// https://github.com/dotnet/extensions/issues/5688#issuecomment-2692247434 +/// +internal static class HybridCacheExtensions +{ + private static readonly HybridCacheEntryOptions ReadOnlyEntryOptions = new() + { + Flags = HybridCacheEntryFlags.DisableLocalCacheWrite + | HybridCacheEntryFlags.DisableDistributedCacheWrite + | HybridCacheEntryFlags.DisableUnderlyingData + }; + + extension(HybridCache cache) + { + internal async ValueTask GetOrDefaultAsync(string key, CancellationToken ct = default) => + await cache.GetOrCreateAsync( + key, + // The factory will never be invoked because the ReadOnlyEntryOptions set the DisableUnderlyingData flag + cancel => throw new InvalidOperationException("Can't Happen"), + ReadOnlyEntryOptions, + cancellationToken: ct); + } } 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 6bb801b4a..e89c7030f 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 @@ -16,5 +16,5 @@ 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) => validator.TestReplayCache.DidNotReceive().Add(Arg.Any(), Arg.Any()); } 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 0c6c3babe..1e217b212 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 @@ -44,8 +44,7 @@ public class ReplayTests : DPoPProofValidatorTestBase var skew = validateIat && validateNonce ? Math.Max(clientClockSkew, serverClockSkew) : (validateIat ? clientClockSkew : serverClockSkew); - var expectedExpiration = ProofValidator.TestTimeProvider.GetUtcNow() - .Add(TimeSpan.FromSeconds(skew * 2)) + var expectedExpiration = TimeSpan.FromSeconds(skew * 2) .Add(TimeSpan.FromSeconds(ValidFor)); await ReplayCache.Received().Add(TokenIdHash, expectedExpiration); } diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/ApiHost.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/ApiHost.cs index 85d1eaf93..107b74a30 100644 --- a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/ApiHost.cs +++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/TestFramework/ApiHost.cs @@ -28,6 +28,7 @@ public class ApiHost : GenericHost private void ConfigureServices(IServiceCollection services) { + services.AddHybridCache(); services.AddRouting(); services.AddAuthorization();