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("+");
+ }
}