From 844ffae374d8b8e95bc3a5d7b1c00ec010250fb9 Mon Sep 17 00:00:00 2001 From: Brett Hazen <2651260+bhazen@users.noreply.github.com> Date: Thu, 8 May 2025 12:26:08 -0500 Subject: [PATCH] Adjust validation of htu for FAPI 2.0 profile conformance --- .../DPoP/DPoPProofValidator.cs | 27 +++++++- .../DPoP/PayloadTests.cs | 67 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) 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 3a5316f7e..238ffb35a 100644 --- a/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs +++ b/aspnetcore-authentication-jwtbearer/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidator.cs @@ -293,7 +293,7 @@ internal class DPoPProofValidator : IDPoPProofValidator return; } - if (!result.Payload.TryGetValue(JwtClaimTypes.DPoPHttpUrl, out var htu) || !context.ExpectedUrl.Equals(htu)) + if (!result.Payload.TryGetValue(JwtClaimTypes.DPoPHttpUrl, out var htu) || !HtuValueIsValid(context.ExpectedUrl, htu as string)) { result.SetError("Invalid 'htu' value."); return; @@ -328,6 +328,31 @@ internal class DPoPProofValidator : IDPoPProofValidator } } + private bool HtuValueIsValid(string requestedUri, string? htuValue) + { + if (string.IsNullOrEmpty(requestedUri) || string.IsNullOrEmpty(htuValue)) + { + return false; + } + + try + { + var uri1 = new Uri(requestedUri); + var uri2 = new Uri(htuValue); + + return Uri.Compare( + uri1, + uri2, + UriComponents.Scheme | UriComponents.HostAndPort | UriComponents.Path, + UriFormat.SafeUnescaped, + StringComparison.OrdinalIgnoreCase) == 0; + } + catch (UriFormatException) + { + return false; + } + } + /// /// Validates if the token has been replayed. /// diff --git a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/PayloadTests.cs b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/PayloadTests.cs index c96c88c3d..d6ed82da6 100644 --- a/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/PayloadTests.cs +++ b/aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/PayloadTests.cs @@ -94,6 +94,73 @@ public class PayloadTests : DPoPProofValidatorTestBase Result.ShouldBeInvalidProofWithDescription("Invalid 'htu' value."); ProofValidator.ReplayCacheShouldNotBeCalled(); } + + [Theory] + [InlineData("https://example.com?query=1#fragment")] + [InlineData("https://example.com/#fragment")] + [InlineData("https://example.com/?query=1")] + [Trait("Category", "Unit")] + public void htu_ignores_query_and_fragment_parts_in_comparison_against_requested_url(string payloadUrl) + { + Result.Payload = new Dictionary + { + { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash }, + { JwtClaimTypes.JwtId, TokenId }, + { JwtClaimTypes.DPoPHttpMethod, HttpMethod }, + { JwtClaimTypes.DPoPHttpUrl, payloadUrl }, + { JwtClaimTypes.IssuedAt, IssuedAt } + }; + + ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt)); + ProofValidator.ValidatePayload(Context, Result); + + Result.IsError.ShouldBeFalse(Result.ErrorDescription); + } + + [Theory] + [InlineData("https://example.com")] + [InlineData("HTTPS://EXAMPLE.COM")] + [InlineData("https://EXAMPLE.com")] + [InlineData("HtTpS://eXaMpLe.CoM")] + [Trait("Category", "Unit")] + public void htu_ignores_casing_in_comparison_against_requested_url(string payloadUrl) + { + Result.Payload = new Dictionary + { + { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash }, + { JwtClaimTypes.JwtId, TokenId }, + { JwtClaimTypes.DPoPHttpMethod, HttpMethod }, + { JwtClaimTypes.DPoPHttpUrl, payloadUrl }, + { JwtClaimTypes.IssuedAt, IssuedAt } + }; + + ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt)); + ProofValidator.ValidatePayload(Context, Result); + + Result.IsError.ShouldBeFalse(Result.ErrorDescription); + } + + [Theory] + [InlineData("https://example.com", "https://example.com:443")] + [InlineData("http://example.com", "http://example.com:80")] + [Trait("Category", "Unit")] + public void htu_uses_scheme_based_normalization_in_comparison_against_requested_url(string expectedUrl, string payloadUrl) + { + Context = Context with { ExpectedUrl = expectedUrl }; + Result.Payload = new Dictionary + { + { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash }, + { JwtClaimTypes.JwtId, TokenId }, + { JwtClaimTypes.DPoPHttpMethod, HttpMethod }, + { JwtClaimTypes.DPoPHttpUrl, payloadUrl }, + { JwtClaimTypes.IssuedAt, IssuedAt } + }; + + ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt)); + ProofValidator.ValidatePayload(Context, Result); + + Result.IsError.ShouldBeFalse(Result.ErrorDescription); + } [Fact] [Trait("Category", "Unit")]