diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4b469a6ee..a883a658e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -40,7 +40,7 @@ that supports the target frameworks our products target (8, 9, 10) -->
-
+
@@ -137,4 +137,4 @@ that supports the target frameworks our products target (8, 9, 10) -->
-
+
\ No newline at end of file
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 4249a5c9e..88126f585 100644
--- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs
+++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs
@@ -371,6 +371,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 ba8cb29f2..481af4dc8 100644
--- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs
+++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs
@@ -22,10 +22,11 @@ public static class DPoPServiceCollectionExtensions
services.AddTransient();
services.TryAddTransient();
- 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 68dbf51ae..30bbd5ff0 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 7bca7913b..ce7608594 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()
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();
}
}