mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 09:28:24 +00:00
Merge branch 'main' into jmdc/aaj-custom-nonces
This commit is contained in:
commit
bfc0920f41
11 changed files with 132 additions and 45 deletions
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
11
bff/bff.slnf
11
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()!)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue