From 2e704d9386862b96756ec2d45c560991a838dcbc Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Mon, 26 Jan 2026 22:37:04 -0600 Subject: [PATCH] Do not escape '+' character in x5c of jwks --- .../Endpoints/Results/JsonWebKeysResult.cs | 4 +- .../Infrastructure/ObjectSerializer.cs | 22 ++++++++ .../Discovery/DiscoveryEndpointTests.cs | 56 +++++++++++++++++++ .../Infrastructure/ObjectSerializerTests.cs | 16 ++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/identity-server/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs b/identity-server/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs index 382265206..d544a09a5 100644 --- a/identity-server/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs +++ b/identity-server/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs @@ -52,6 +52,8 @@ internal class JsonWebKeysHttpWriter : IHttpResponseWriter 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"); } } diff --git a/identity-server/src/IdentityServer/Infrastructure/ObjectSerializer.cs b/identity-server/src/IdentityServer/Infrastructure/ObjectSerializer.cs index 5929f2662..b85b05eea 100644 --- a/identity-server/src/IdentityServer/Infrastructure/ObjectSerializer.cs +++ b/identity-server/src/IdentityServer/Infrastructure/ObjectSerializer.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; @@ -14,7 +15,28 @@ 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 + }; + + /// + /// Serializes an object to a JSON string using default encoding, which escapes + /// certain characters (such as '+') for HTML safety. + /// public static string ToString(object o) => 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) => JsonSerializer.Serialize(o, OptionsWithoutEscaping); + public static T FromString(string value) => JsonSerializer.Deserialize(value, Options); } diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs index 45aa4494b..7e46d9e23 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs @@ -286,6 +286,62 @@ public class DiscoveryEndpointTests jwks.Keys.ShouldContain(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(); + + var 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.ShouldNotContain("\\u002B"); + json.ShouldContain('+'); + } + + [Fact] + [Trait("Category", Category)] + public async Task Jwks_x5t_should_not_escape_base64url_encoded_characters() + { + var cert = TestCert.Load(); + + var 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.ShouldNotContain("+"); + x5t.ShouldNotContain("/"); + x5t.ShouldContain("_"); // 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.ShouldBe(expectedThumbprint); + } + [Fact] [Trait("Category", Category)] public async Task Unicode_values_in_url_should_be_processed_correctly() diff --git a/identity-server/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs b/identity-server/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs index 26562852c..1df285ecd 100644 --- a/identity-server/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs @@ -37,4 +37,20 @@ public class ObjectSerializerTests result.ShouldNotBeNull(); } + + [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.ShouldNotContain("\\u002B"); + json.ShouldContain("+"); + } }