From a0ca3a6c0381b87983ea2bba059057daccb45963 Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Thu, 11 Dec 2025 19:50:58 -0600 Subject: [PATCH 1/2] Replay improvements - Fixed a bug in the replay cache - Made use of replay cache optional - Add more integration test cases of replay scenarios - Use TryAddTransient so that custom implementations of interfaces are possible --- .../DPoP/DPoPOptions.cs | 8 +++ .../DPoP/DPoPProofValidator.cs | 5 ++ .../DPoP/DPoPServiceCollectionExtensions.cs | 6 ++- .../DPoP/ReplayCache.cs | 28 +++------- .../DPoP/ReplayTests.cs | 2 + .../DPoPIntegrationTests.cs | 52 +++++++++++++++++++ 6 files changed, 77 insertions(+), 24 deletions(-) diff --git a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPOptions.cs b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPOptions.cs index 7717ce3fd..0c52d8cf8 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPOptions.cs +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPOptions.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. using Duende.IdentityModel; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.IdentityModel.Tokens; namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP; @@ -70,4 +71,11 @@ public sealed class DPoPOptions SecurityAlgorithms.EcdsaSha512 ], }; + + /// + /// Prevent token replay attacks by caching used DPoP proof token jti values. Defaults to false. By default, an + /// in-memory cache is used. If you enable this, you should consider registering an implementation of + /// . + /// + public bool EnableReplayDetection { get; set; } = false; } 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 82675426a..f2407a2dd 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs @@ -364,6 +364,11 @@ internal class DPoPProofValidator : IDPoPProofValidator { var dPoPOptions = OptionsMonitor.Get(context.Scheme); + if (!dPoPOptions.EnableReplayDetection) + { + return; + } + if (await ReplayCache.Exists(result.TokenIdHash!, cancellationToken)) { result.SetError("Detected DPoP proof token replay."); diff --git a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs index 576b30405..04acf3e69 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP; @@ -20,9 +21,10 @@ public static class DPoPServiceCollectionExtensions services.AddOptions(); services.AddTransient(); - services.AddTransient(); + services.TryAddTransient(); services.AddDistributedMemoryCache(); - services.AddTransient(); + services.AddHybridCache(); + services.TryAddTransient(); services.AddSingleton(); services.AddSingleton>(sp => 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 d3eb2deed..601b2886c 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/ReplayCache.cs +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/ReplayCache.cs @@ -19,7 +19,6 @@ internal class ReplayCache : IReplayCache /// public ReplayCache(HybridCache cache) => _cache = cache; - /// public async Task Add(string handle, TimeSpan expiration, CancellationToken ct) { var options = new HybridCacheEntryOptions @@ -30,17 +29,6 @@ internal class ReplayCache : IReplayCache await _cache.SetAsync(Prefix + handle, true, options, cancellationToken: ct); } - /// - 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 @@ -48,14 +36,10 @@ internal static class HybridCacheExtensions | 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); - } + public async Task Exists(string handle, CancellationToken ct) => await _cache.GetOrCreateAsync( + Prefix + handle, + // 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/ReplayTests.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/ReplayTests.cs index cad03e406..ab589133e 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 @@ -9,6 +9,7 @@ public class ReplayTests : DPoPProofValidatorTestBase [Trait("Category", "Unit")] public async Task replays_detected_in_ValidateReplay_fail() { + Options.EnableReplayDetection = true; ReplayCache.ExistsFunc = jti => jti == TokenIdHash; Result.TokenIdHash = TokenIdHash; @@ -33,6 +34,7 @@ public class ReplayTests : DPoPProofValidatorTestBase Options.ClientClockSkew = TimeSpan.FromSeconds(clientClockSkew); Options.ServerClockSkew = TimeSpan.FromSeconds(serverClockSkew); Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor); + Options.EnableReplayDetection = true; Result.TokenIdHash = TokenIdHash; diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoPIntegrationTests.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoPIntegrationTests.cs index 492b00a5e..6d9a7b27b 100644 --- a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoPIntegrationTests.cs +++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoPIntegrationTests.cs @@ -141,6 +141,58 @@ public class DPoPIntegrationTests(ITestOutputHelper testOutputHelper) result.StatusCode.ShouldBe(HttpStatusCode.OK); } + [Theory] + [InlineData(true)] + [InlineData(false)] + [Trait("Category", "Integration")] + public async Task replayed_proofs_fail_when_replay_detection_is_enabled(bool enableReplayDetection) + { + var identityServer = await CreateIdentityServer(); + identityServer.Clients.Add(DPoPOnlyClient); + var jwk = CreateJwk(); + var api = await CreateDPoPApi(opt => opt.EnableReplayDetection = enableReplayDetection); + + var app = new AppHost(identityServer, api, "client1", testOutputHelper, + configureUserTokenManagementOptions: opt => opt.DPoPJsonWebKey = jwk); + await app.Initialize(); + + // Login and get token for api call + await app.LoginAsync("sub"); + var response = await app.BrowserClient.GetAsync(app.Url("/user_token")); + var token = await response.Content.ReadFromJsonAsync(); + token.ShouldNotBeNull(); + token.AccessToken.ToString().ShouldNotBeNull(); + token.DPoPJsonWebKey.ShouldNotBeNull(); + api.HttpClient.SetToken("DPoP", token.AccessToken); + + // Create proof token for api call + var dpopService = app.Server.Services.GetRequiredService(); + var proof = await dpopService.CreateProofTokenAsync(new DPoPProofRequest + { + AccessToken = token.AccessToken, + DPoPProofKey = jwk, + Method = HttpMethod.Get, + Url = new Uri("http://localhost/") + }); + proof.ShouldNotBeNull(); + api.HttpClient.DefaultRequestHeaders.Add(OidcConstants.HttpHeaders.DPoP, proof.Value); + + var result = await api.HttpClient.GetAsync("/"); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + // Attempt to reuse the proof + var secondResult = await api.HttpClient.GetAsync("/"); + if (enableReplayDetection) + { + secondResult.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + else + { + secondResult.StatusCode.ShouldBe(HttpStatusCode.OK); + } + } + [Fact] [Trait("Category", "Integration")] public async Task access_token_without_proof_token_should_fail() From dd1bbb1acd33b40ce74ba6cf811734707dbe76d3 Mon Sep 17 00:00:00 2001 From: Erwin van der Valk Date: Fri, 12 Dec 2025 14:54:57 +0100 Subject: [PATCH 2/2] fix the issue --- Directory.Packages.props | 4 +- bff/bff.slnf | 11 ++-- bff/src/Bff/HttpContextExtensions.cs | 4 +- bff/test/Bff.Tests/Bff.Tests.csproj | 1 + ...intTests.cs => DPoPRemoteEndpointTests.cs} | 57 +++++++++++++++---- 5 files changed, 56 insertions(+), 21 deletions(-) rename bff/test/Bff.Tests/Endpoints/{DpopRemoteEndpointTests.cs => DPoPRemoteEndpointTests.cs} (51%) diff --git a/Directory.Packages.props b/Directory.Packages.props index d911c69d3..d5c161ffd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,7 +40,7 @@ that supports the target frameworks our products target (8, 9, 10) --> - + @@ -136,4 +136,4 @@ that supports the target frameworks our products target (8, 9, 10) --> - + \ No newline at end of file diff --git a/bff/bff.slnf b/bff/bff.slnf index 1b9761854..dda74c9af 100644 --- a/bff/bff.slnf +++ b/bff/bff.slnf @@ -2,8 +2,6 @@ "solution": { "path": "..\\products.slnx", "projects": [ - "bff\\performance\\Bff.Benchmarks\\Bff.Benchmarks.csproj", - "bff\\performance\\Bff.Performance\\Bff.Performance.csproj", ".github\\workflow-gen\\workflow-gen.csproj", "bff\\hosts\\Blazor\\PerComponent\\Hosts.Bff.Blazor.PerComponent.Client\\Hosts.Bff.Blazor.PerComponent.Client.csproj", "bff\\hosts\\Blazor\\PerComponent\\Hosts.Bff.Blazor.PerComponent\\Hosts.Bff.Blazor.PerComponent.csproj", @@ -13,8 +11,8 @@ "bff\\hosts\\Hosts.Bff.DPoP\\Hosts.Bff.DPoP.csproj", "bff\\hosts\\Hosts.Bff.EF\\Hosts.Bff.EF.csproj", "bff\\hosts\\Hosts.Bff.InMemory\\Hosts.Bff.InMemory.csproj", - "bff\\hosts\\Hosts.Bff.Performance\\Hosts.Bff.Performance.csproj", "bff\\hosts\\Hosts.Bff.MultiFrontend\\Hosts.Bff.MultiFrontend.csproj", + "bff\\hosts\\Hosts.Bff.Performance\\Hosts.Bff.Performance.csproj", "bff\\hosts\\Hosts.IdentityServer\\Hosts.IdentityServer.csproj", "bff\\hosts\\Hosts.ServiceDefaults\\Hosts.ServiceDefaults.csproj", "bff\\hosts\\RemoteApis\\Hosts.RemoteApi.DPoP\\Hosts.RemoteApi.DPoP.csproj", @@ -22,6 +20,7 @@ "bff\\hosts\\RemoteApis\\Hosts.RemoteApi\\Hosts.RemoteApi.csproj", "bff\\migrations\\UserSessionDb\\UserSessionDb.csproj", "bff\\performance\\Bff.Benchmarks\\Bff.Benchmarks.csproj", + "bff\\performance\\Bff.Performance\\Bff.Performance.csproj", "bff\\src\\Bff.Blazor.Client\\Bff.Blazor.Client.csproj", "bff\\src\\Bff.Blazor\\Bff.Blazor.csproj", "bff\\src\\Bff.EntityFramework\\Bff.EntityFramework.csproj", @@ -33,8 +32,8 @@ "bff\\templates\\src\\BffRemoteApi\\BffRemoteApi.csproj", "bff\\test\\Bff.Tests\\Bff.Tests.csproj", "bff\\test\\Hosts.Tests\\Hosts.Tests.csproj", - "shared\\Xunit.Playwright\\Duende.Xunit.Playwright.csproj", - "shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj" + "shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj", + "shared\\Xunit.Playwright\\Duende.Xunit.Playwright.csproj" ] } -} +} \ No newline at end of file diff --git a/bff/src/Bff/HttpContextExtensions.cs b/bff/src/Bff/HttpContextExtensions.cs index 7be3a4da8..f2254abac 100644 --- a/bff/src/Bff/HttpContextExtensions.cs +++ b/bff/src/Bff/HttpContextExtensions.cs @@ -95,7 +95,7 @@ internal static class HttpContextExtensions return new DPoPTokenResult() { - AccessToken = AccessToken.Parse(userToken.ToString()), + AccessToken = AccessToken.Parse(userToken.AccessToken.ToString()), DPoPJsonWebKey = DPoPProofKey.Parse(userToken.DPoPJsonWebKey!.ToString()!) }; } @@ -156,7 +156,7 @@ internal static class HttpContextExtensions return new DPoPTokenResult() { - AccessToken = AccessToken.Parse(clientToken.ToString()), + AccessToken = AccessToken.Parse(clientToken.AccessToken.ToString()), DPoPJsonWebKey = DPoPProofKey.Parse(clientToken.DPoPJsonWebKey!.ToString()!) }; diff --git a/bff/test/Bff.Tests/Bff.Tests.csproj b/bff/test/Bff.Tests/Bff.Tests.csproj index c3374d6b3..a88f859af 100644 --- a/bff/test/Bff.Tests/Bff.Tests.csproj +++ b/bff/test/Bff.Tests/Bff.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/bff/test/Bff.Tests/Endpoints/DpopRemoteEndpointTests.cs b/bff/test/Bff.Tests/Endpoints/DPoPRemoteEndpointTests.cs similarity index 51% rename from bff/test/Bff.Tests/Endpoints/DpopRemoteEndpointTests.cs rename to bff/test/Bff.Tests/Endpoints/DPoPRemoteEndpointTests.cs index bb51785d2..58d8f4fcb 100644 --- a/bff/test/Bff.Tests/Endpoints/DpopRemoteEndpointTests.cs +++ b/bff/test/Bff.Tests/Endpoints/DPoPRemoteEndpointTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.AspNetCore.Authentication.JwtBearer.DPoP; using Duende.Bff.AccessTokenManagement; using Duende.Bff.Tests.TestFramework; using Duende.Bff.Tests.TestInfra; @@ -9,7 +10,7 @@ using Xunit.Abstractions; namespace Duende.Bff.Tests.Endpoints; -public class DpopRemoteEndpointTests(ITestOutputHelper output) : BffTestBase(output), IAsyncLifetime +public class DPoPRemoteEndpointTests(ITestOutputHelper output) : BffTestBase(output) { public override async Task InitializeAsync() { @@ -18,11 +19,7 @@ public class DpopRemoteEndpointTests(ITestOutputHelper output) : BffTestBase(out idSrvClient.RequireDPoP = true; Bff.OnConfigureBff += bff => bff.AddRemoteApis(); - Bff.OnConfigureApp += app => - { - app.MapRemoteBffApiEndpoint(The.Path, Api.Url()) - .WithAccessToken(RequiredTokenType.Client); - }; + await base.InitializeAsync(); Bff.BffOptions.DPoPJsonWebKey = The.DPoPJsonWebKey; @@ -34,17 +31,55 @@ public class DpopRemoteEndpointTests(ITestOutputHelper output) : BffTestBase(out } [Fact] - public async Task Can_login_with_dpop_enabled() => await Bff.BrowserClient.Login() - .CheckHttpStatusCode(); - - [Fact] - public async Task When_calling_api_endpoint_with_dpop_enabled_then_dpop_headers_are_sent() + public async Task Can_call_dpop_protected_api_with_user_token() { + Api.OnConfigureServices += services => + { + services.ConfigureDPoPTokensForScheme("token"); + }; + + Bff.OnConfigureApp += app => + { + app.MapRemoteBffApiEndpoint(The.Path, Api.Url()) + .WithAccessToken(RequiredTokenType.User); + }; + + await InitializeAsync(); + + await Bff.BrowserClient.Login() + .CheckHttpStatusCode(); + ApiCallDetails callToApi = await Bff.BrowserClient.CallBffHostApi( url: Bff.Url(The.PathAndSubPath) ); callToApi.RequestHeaders["DPoP"].First().ShouldNotBeNullOrEmpty(); callToApi.RequestHeaders["Authorization"].First().StartsWith("DPoP ").ShouldBeTrue(); + callToApi.Sub.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task Can_call_dpop_protected_api_with_client_token() + { + Api.OnConfigureServices += services => + { + services.ConfigureDPoPTokensForScheme("token"); + }; + + Bff.OnConfigureApp += app => + { + app.MapRemoteBffApiEndpoint(The.Path, Api.Url()) + .WithAccessToken(RequiredTokenType.Client); + }; + + await InitializeAsync(); + + ApiCallDetails callToApi = await Bff.BrowserClient.CallBffHostApi( + url: Bff.Url(The.PathAndSubPath) + ); + + callToApi.RequestHeaders["DPoP"].First().ShouldNotBeNullOrEmpty(); + callToApi.RequestHeaders["Authorization"].First().StartsWith("DPoP ").ShouldBeTrue(); + callToApi.ClientId.ShouldNotBeNullOrEmpty(); } }