diff --git a/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs b/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs index 00d4e3464..4f2d056d6 100644 --- a/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs +++ b/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs @@ -55,6 +55,8 @@ public Task WriteHttpResponse(JsonWebKeysResult result, HttpContext context) context.Response.SetCache(result.MaxAge.Value, "Origin"); } - return context.Response.WriteJsonAsync(new { keys = result.WebKeys }, "application/json; charset=UTF-8"); + var json = ObjectSerializer.ToUnescapedString(new { keys = result.WebKeys }); + + return context.Response.WriteJsonAsync(json, "application/json; charset=UTF-8"); } } \ No newline at end of file diff --git a/src/IdentityServer/Infrastructure/ObjectSerializer.cs b/src/IdentityServer/Infrastructure/ObjectSerializer.cs index 23a343da4..e39c7487b 100644 --- a/src/IdentityServer/Infrastructure/ObjectSerializer.cs +++ b/src/IdentityServer/Infrastructure/ObjectSerializer.cs @@ -1,7 +1,8 @@ -// Copyright (c) Duende Software. All rights reserved. +// Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; @@ -13,14 +14,34 @@ internal static class ObjectSerializer { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - + + private static readonly JsonSerializerOptions OptionsWithoutEscaping = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + // Use UnsafeRelaxedJsonEscaping to avoid escaping '+' as '\u002B' in base64-encoded + // values like x5c certificates. The '+' character is valid in JSON strings and does + // not need to be escaped. The default encoder escapes it for HTML safety, but our + // JSON responses are served with application/json content type. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + public static string ToString(object o) { return JsonSerializer.Serialize(o, Options); } + /// + /// Serializes an object to a JSON string using relaxed encoding that does not + /// escape characters like '+'. This is useful for producing JSON where + /// base64-encoded values (e.g., x5c certificates) should remain unescaped. + /// + public static string ToUnescapedString(object o) + { + return JsonSerializer.Serialize(o, OptionsWithoutEscaping); + } + public static T FromString(string value) { return JsonSerializer.Deserialize(value, Options); } -} \ No newline at end of file +} diff --git a/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs b/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs index 8af690a15..c2f0688e9 100644 --- a/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs +++ b/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs @@ -207,6 +207,62 @@ public async Task Jwks_with_two_key_using_different_algs_expect_different_alg_va jwks.Keys.Should().Contain(x => x.KeyId == rsaKey.KeyId && x.Alg == "RS256"); } + [Fact] + [Trait("Category", Category)] + public async Task Jwks_x5c_should_not_escape_plus_character() + { + var cert = TestCert.Load(); + + IdentityServerPipeline pipeline = new IdentityServerPipeline(); + pipeline.OnPostConfigureServices += services => + { + services.AddIdentityServerBuilder() + .AddSigningCredential(cert); + }; + pipeline.Initialize(); + + var result = await pipeline.BackChannelClient.GetAsync("https://server/.well-known/openid-configuration/jwks"); + var json = await result.Content.ReadAsStringAsync(); + + // The x5c property contains base64-encoded certificate data which commonly has '+' characters. + // These should not be escaped as \u002B in the JSON response. + json.Should().NotContain("\\u002B"); + json.Should().Contain("+"); + } + + [Fact] + [Trait("Category", Category)] + public async Task Jwks_x5t_should_not_escape_base64url_encoded_characters() + { + var cert = TestCert.Load(); + + IdentityServerPipeline pipeline = new IdentityServerPipeline(); + pipeline.OnPostConfigureServices += services => + { + services.AddIdentityServerBuilder() + .AddSigningCredential(cert); + }; + pipeline.Initialize(); + + var result = await pipeline.BackChannelClient.GetAsync("https://server/.well-known/openid-configuration/jwks"); + var json = await result.Content.ReadAsStringAsync(); + var data = JsonSerializer.Deserialize>(json); + + var keys = data["keys"].EnumerateArray().ToList(); + var keyWithX5t = keys.First(k => k.TryGetProperty("x5t", out _)); + var x5t = keyWithX5t.GetProperty("x5t").GetString(); + + // The x5t property is a base64url-encoded SHA-1 thumbprint (per RFC 7517). + // Base64url encoding uses '-' and '_' instead of '+' and '/', so '+' and '/' must not appear. + x5t.Should().NotContain("+"); + x5t.Should().NotContain("/"); + x5t.Should().Contain("_"); // The cert we are using happens to contain '_' but not '-' in its thumbprint + + // Verify the value matches the expected base64url-encoded thumbprint + var expectedThumbprint = Base64UrlEncoder.Encode(cert.GetCertHash()); + x5t.Should().Be(expectedThumbprint); + } + [Fact] [Trait("Category", Category)] public async Task Unicode_values_in_url_should_be_processed_correctly() diff --git a/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs b/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs index ea5051ff1..f64d58fcf 100644 --- a/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs +++ b/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs @@ -3,6 +3,7 @@ using System; +using System.Collections.Generic; using Duende.IdentityServer.Models; using FluentAssertions; using Xunit; @@ -21,4 +22,20 @@ public void Can_be_deserialize_message() Action a = () => Duende.IdentityServer.ObjectSerializer.FromString>("{\"created\":0, \"data\": {\"error\": \"error\"}}"); a.Should().NotThrow(); } + + [Fact] + public void Can_serialize_jwk_with_plus_character_in_x5c() + { + var jwk = new Dictionary + { + { "kty", "RSA" }, + { "x5c", new List { "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+test+value+with+plus" } } + }; + + var json = Duende.IdentityServer.ObjectSerializer.ToUnescapedString(jwk); + + // The '+' character should not be escaped as \u002B + json.Should().NotContain("\\u002B"); + json.Should().Contain("+"); + } }