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")]