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 @@ 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");
}
}
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 async Task Jwks_with_two_key_using_different_algs_expect_different_alg_va
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 void Can_serialize_and_deserialize_dictionary()
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("+");
+ }
}