Merge branch 'main' into jmdc/aaj-custom-nonces

This commit is contained in:
Joe DeCock 2025-12-12 13:18:01 -06:00 committed by GitHub
commit bfc0920f41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 132 additions and 45 deletions

View file

@ -40,7 +40,7 @@ that supports the target frameworks our products target (8, 9, 10) -->
<PackageVersion Include="KubernetesClient" Version="17.0.14" />
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.1.0" />
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.1.0" />
<PackageVersion Include="Duende.AspNetCore.Authentication.JwtBearer" Version="0.1.3" />
<PackageVersion Include="Duende.AspNetCore.Authentication.JwtBearer" Version="0.3.0" />
<PackageVersion Include="Duende.IdentityModel" Version="8.0.0" />
<PackageVersion Include="Duende.IdentityModel.OidcClient" Version="7.0.0" />
<PackageVersion Include="Duende.IdentityServer" Version="7.4.0-preview.2" />
@ -137,4 +137,4 @@ that supports the target frameworks our products target (8, 9, 10) -->
<PackageVersion Include="Vogen" Version="7.0.3" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.1.0" />
</ItemGroup>
</Project>
</Project>

View file

@ -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
],
};
/// <summary>
/// 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
/// <see cref="IDistributedCache"/>.
/// </summary>
public bool EnableReplayDetection { get; set; } = false;
}

View file

@ -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.");

View file

@ -22,10 +22,11 @@ public static class DPoPServiceCollectionExtensions
services.AddTransient<DPoPJwtBearerEvents>();
services.TryAddTransient<IDPoPNonceValidator, DefaultDPoPNonceValidator>();
services.AddTransient<IDPoPProofValidator, DPoPProofValidator>();
services.AddTransient<DPoPExpirationValidator>();
services.TryAddTransient<IDPoPProofValidator, DPoPProofValidator>();
services.AddDistributedMemoryCache();
services.AddTransient<IReplayCache, ReplayCache>();
services.AddHybridCache();
services.TryAddTransient<IReplayCache, ReplayCache>();
services.AddSingleton<ConfigureJwtBearerOptions>();
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>>(sp =>

View file

@ -19,7 +19,6 @@ internal class ReplayCache : IReplayCache
/// </summary>
public ReplayCache(HybridCache cache) => _cache = cache;
/// <inheritdoc />
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);
}
/// <inheritdoc />
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
@ -48,14 +36,10 @@ internal static class HybridCacheExtensions
| 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);
}
public async Task<bool> Exists(string handle, CancellationToken ct) => await _cache.GetOrCreateAsync<bool>(
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);
}

View file

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

View file

@ -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<UserToken>();
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<IDPoPProofService>();
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()

View file

@ -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"
]
}
}
}

View file

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

View file

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Duende.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Duende.IdentityServer" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />

View file

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