Merge pull request #2341 from DuendeSoftware/ev/bff/fix-htu

fix issue where yarp proxying doesn't calculate htu correctly
This commit is contained in:
Erwin van der Valk 2026-01-29 10:48:03 +01:00 committed by GitHub
commit b8fd3cb8a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 113 additions and 3 deletions

View file

@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Yarp.ReverseProxy.Forwarder;
using Yarp.ReverseProxy.Model;
using Yarp.ReverseProxy.Transforms;
@ -156,14 +157,17 @@ internal class AccessTokenRequestTransform(
private async Task ApplyDPoPToken(RequestTransformContext context, DPoPTokenResult token)
{
var url = RequestUtilities.MakeDestinationAddress(
context.DestinationPrefix,
context.Path,
QueryString.Empty);
var baseUri = new Uri(context.DestinationPrefix);
var proofToken = await proofService.CreateProofTokenAsync(new DPoPProofRequest
{
AccessToken = token.AccessToken,
DPoPProofKey = token.DPoPJsonWebKey,
Method = context.ProxyRequest.Method,
Url = new Uri(baseUri, context.Path)
Url = url
});
if (proofToken != null)
{

View file

@ -80,6 +80,112 @@ public class DPoPRemoteEndpointTests(ITestOutputHelper output) : BffTestBase(out
callToApi.RequestHeaders["DPoP"].First().ShouldNotBeNullOrEmpty();
callToApi.RequestHeaders["Authorization"].First().StartsWith("DPoP ").ShouldBeTrue();
callToApi.ClientId.ShouldNotBeNullOrEmpty();
callToApi.ClientId.ShouldNotBeNullOrEmpty("this clientid would be empty if dpop validation failes");
}
[Theory]
[InlineData("/api/foo", "api/foo", "api/foo/bar")]
[InlineData("/api/foo", "api/foo", "api/foo")]
[InlineData("/api/foo", "foo", "api/foo")]
public async Task HTU_values_are_correctly_verified_for_paths_and_subpaths(string mappedPath, string targetPath, string calledPath)
{
Api.OnConfigureServices += services =>
{
services.ConfigureDPoPTokensForScheme("token");
};
Bff.OnConfigureApp += app =>
{
app.MapRemoteBffApiEndpoint(mappedPath, Api.Url(targetPath))
.WithAccessToken(RequiredTokenType.Client);
};
await InitializeAsync();
ApiCallDetails callToApi = await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url(calledPath)
);
callToApi.RequestHeaders["DPoP"].First().ShouldNotBeNullOrEmpty();
callToApi.RequestHeaders["Authorization"].First().StartsWith("DPoP ").ShouldBeTrue();
callToApi.ClientId.ShouldNotBeNullOrEmpty("this clientid would be empty if dpop validation failes");
}
[Fact]
public async Task DPoP_htu_matches_yarp_destination_when_api_has_path_prefix()
{
// This test reproduces the issue described in https://github.com/orgs/DuendeSoftware/discussions/461
// When mapping /api/foo to https://example.com/api/foo, a request to /api/foo/bar should
// result in DPoP HTU of https://example.com/api/foo/bar (not https://example.com/bar)
string? capturedDPoPHeader = null;
string? capturedRequestPath = null;
Api.OnConfigureServices += services =>
{
services.ConfigureDPoPTokensForScheme("token");
};
Api.OnConfigureApp += app =>
{
app.Use(async (context, next) =>
{
capturedDPoPHeader = context.Request.Headers["DPoP"].FirstOrDefault();
capturedRequestPath = context.Request.Path.Value;
await next();
});
};
Bff.OnConfigureApp += app =>
{
// Map BFF /api/foo to API https://localhost:port/api/foo
app.MapRemoteBffApiEndpoint("/api/foo", Api.Url("api/foo"))
.WithAccessToken(RequiredTokenType.Client);
};
await InitializeAsync();
// Make a request to /api/foo/bar
ApiCallDetails callToApi = await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url("/api/foo/bar")
);
// Verify the API received the request at the correct path
capturedRequestPath.ShouldBe("/api/foo/bar");
// Parse the DPoP proof token to extract the HTU claim
capturedDPoPHeader.ShouldNotBeNullOrEmpty();
var dpopToken = capturedDPoPHeader!;
// The DPoP token is a JWT with format: header.payload.signature
var parts = dpopToken.Split('.');
parts.Length.ShouldBe(3);
// Decode the payload (base64url)
var payload = System.Text.Json.JsonDocument.Parse(
Convert.FromBase64String(Base64UrlDecode(parts[1]))
);
var htu = payload.RootElement.GetProperty("htu").GetString();
// The HTU should match the actual destination URL that YARP sent the request to
// Expected: https://localhost:port/api/foo/bar
// Bug would produce: https://localhost:port/bar
htu.ShouldBe(Api.Url("api/foo/bar").ToString());
}
private static string Base64UrlDecode(string input)
{
var output = input;
output = output.Replace('-', '+').Replace('_', '/');
switch (output.Length % 4)
{
case 2: output += "=="; break;
case 3: output += "="; break;
}
return output;
}
}