Merge pull request #2299 from DuendeSoftware/jmdc/hybrid

Use hybridcache for replay detection in JwtBearer Extensions
This commit is contained in:
Joe DeCock 2025-12-10 12:50:57 -06:00 committed by GitHub
commit 79eeec9e22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 41 additions and 15 deletions

View file

@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Duende.IdentityModel" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
</ItemGroup>
<ItemGroup>

View file

@ -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);
}
/// <summary>

View file

@ -11,7 +11,7 @@ public interface IReplayCache
/// <summary>
/// Adds a hashed jti to the cache.
/// </summary>
Task Add(string jtiHash, DateTimeOffset expiration, CancellationToken cancellationToken = default);
Task Add(string jtiHash, TimeSpan expiration, CancellationToken cancellationToken = default);
/// <summary>

View file

@ -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;
/// <summary>
/// Default implementation of the replay cache using IDistributedCache
/// Default implementation of the replay cache using Hybrid Cache
/// </summary>
internal class ReplayCache : IReplayCache
{
private const string Prefix = "DPoPJwtBearerEvents-DPoPReplay-jti-";
private readonly IDistributedCache _cache;
private readonly HybridCache _cache;
/// <summary>
/// Constructs new instances of <see cref="ReplayCache"/>.
/// </summary>
public ReplayCache(IDistributedCache cache) => _cache = cache;
public ReplayCache(HybridCache cache) => _cache = cache;
/// <inheritdoc />
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);
}
/// <inheritdoc />
public async Task<bool> Exists(string handle, CancellationToken cancellationToken) => await _cache.GetAsync(Prefix + handle, cancellationToken) != null;
public async Task<bool> Exists(string handle, CancellationToken ct) =>
(await _cache.GetOrDefaultAsync<bool?>(Prefix + handle, ct)) != null;
}
/// <summary>
/// Extension methods for HybridCache. This is needed because HybridCache does not have a GetOrDefaultAsync method.
/// https://github.com/dotnet/extensions/issues/5688#issuecomment-2692247434
/// </summary>
internal static class HybridCacheExtensions
{
private static readonly HybridCacheEntryOptions ReadOnlyEntryOptions = new()
{
Flags = HybridCacheEntryFlags.DisableLocalCacheWrite
| HybridCacheEntryFlags.DisableDistributedCacheWrite
| HybridCacheEntryFlags.DisableUnderlyingData
};
extension(HybridCache cache)
{
internal async ValueTask<T?> GetOrDefaultAsync<T>(string key, CancellationToken ct = default) =>
await cache.GetOrCreateAsync<T?>(
key,
// The factory will never be invoked because the ReadOnlyEntryOptions set the DisableUnderlyingData flag
cancel => throw new InvalidOperationException("Can't Happen"),
ReadOnlyEntryOptions,
cancellationToken: ct);
}
}

View file

@ -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<string>(), Arg.Any<DateTimeOffset>());
public static void ReplayCacheShouldNotBeCalled(this TestDPoPProofValidator validator) => validator.TestReplayCache.DidNotReceive().Add(Arg.Any<string>(), Arg.Any<TimeSpan>());
}

View file

@ -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);
}

View file

@ -28,6 +28,7 @@ public class ApiHost : GenericHost
private void ConfigureServices(IServiceCollection services)
{
services.AddHybridCache();
services.AddRouting();
services.AddAuthorization();