From ba5536492be40515217dfc5e28b80939fd5a0567 Mon Sep 17 00:00:00 2001 From: Erwin van der Valk Date: Thu, 29 Jan 2026 10:42:31 +0100 Subject: [PATCH] fix issue where yarp proxying doesn't calculate htu correctly --- .../Internal/AccessTokenRequestTransform.cs | 8 +- .../Endpoints/DPoPRemoteEndpointTests.cs | 108 +++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/bff/src/Bff.Yarp/Internal/AccessTokenRequestTransform.cs b/bff/src/Bff.Yarp/Internal/AccessTokenRequestTransform.cs index 13fcdf805..64c5fd719 100644 --- a/bff/src/Bff.Yarp/Internal/AccessTokenRequestTransform.cs +++ b/bff/src/Bff.Yarp/Internal/AccessTokenRequestTransform.cs @@ -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) { diff --git a/bff/test/Bff.Tests/Endpoints/DPoPRemoteEndpointTests.cs b/bff/test/Bff.Tests/Endpoints/DPoPRemoteEndpointTests.cs index 58d8f4fcb..bc54f29a5 100644 --- a/bff/test/Bff.Tests/Endpoints/DPoPRemoteEndpointTests.cs +++ b/bff/test/Bff.Tests/Endpoints/DPoPRemoteEndpointTests.cs @@ -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; } }