Adjust validation of htu for FAPI 2.0 profile conformance

This commit is contained in:
Brett Hazen 2025-05-08 12:26:08 -05:00
parent 163047b238
commit 844ffae374
2 changed files with 93 additions and 1 deletions

View file

@ -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;
}
}
/// <summary>
/// Validates if the token has been replayed.
/// </summary>

View file

@ -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<string, object>
{
{ 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<string, object>
{
{ 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<string, object>
{
{ 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")]