Skip to content

Commit ddebbc3

Browse files
authored
Merge pull request #2350 from DuendeSoftware/jmdc/plus-jwks-serialization-7.4
Backport "Do not escape '+' character in x5c of jwks" to 7.4.x
2 parents 27d0d4a + 2e704d9 commit ddebbc3

File tree

4 files changed

+97
-1
lines changed

4 files changed

+97
-1
lines changed

identity-server/src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ public Task WriteHttpResponse(JsonWebKeysResult result, HttpContext context)
5252
context.Response.SetCache(result.MaxAge.Value, "Origin");
5353
}
5454

55-
return context.Response.WriteJsonAsync(new { keys = result.WebKeys }, "application/json; charset=UTF-8");
55+
var json = ObjectSerializer.ToUnescapedString(new { keys = result.WebKeys });
56+
57+
return context.Response.WriteJsonAsync(json, "application/json; charset=UTF-8");
5658
}
5759
}

identity-server/src/IdentityServer/Infrastructure/ObjectSerializer.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// See LICENSE in the project root for license information.
33

44

5+
using System.Text.Encodings.Web;
56
using System.Text.Json;
67
using System.Text.Json.Serialization;
78

@@ -14,7 +15,28 @@ internal static class ObjectSerializer
1415
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
1516
};
1617

18+
private static readonly JsonSerializerOptions OptionsWithoutEscaping = new()
19+
{
20+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
21+
// Use UnsafeRelaxedJsonEscaping to avoid escaping '+' as '\u002B' in base64-encoded
22+
// values like x5c certificates. The '+' character is valid in JSON strings and does
23+
// not need to be escaped. The default encoder escapes it for HTML safety, but our
24+
// JSON responses are served with application/json content type.
25+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
26+
};
27+
28+
/// <summary>
29+
/// Serializes an object to a JSON string using default encoding, which escapes
30+
/// certain characters (such as '+') for HTML safety.
31+
/// </summary>
1732
public static string ToString(object o) => JsonSerializer.Serialize(o, Options);
1833

34+
/// <summary>
35+
/// Serializes an object to a JSON string using relaxed encoding that does not
36+
/// escape characters like '+'. This is useful for producing JSON where
37+
/// base64-encoded values (e.g., x5c certificates) should remain unescaped.
38+
/// </summary>
39+
public static string ToUnescapedString(object o) => JsonSerializer.Serialize(o, OptionsWithoutEscaping);
40+
1941
public static T FromString<T>(string value) => JsonSerializer.Deserialize<T>(value, Options);
2042
}

identity-server/test/IdentityServer.IntegrationTests/Endpoints/Discovery/DiscoveryEndpointTests.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,62 @@ public async Task Jwks_with_two_key_using_different_algs_expect_different_alg_va
286286
jwks.Keys.ShouldContain(x => x.KeyId == rsaKey.KeyId && x.Alg == "RS256");
287287
}
288288

289+
[Fact]
290+
[Trait("Category", Category)]
291+
public async Task Jwks_x5c_should_not_escape_plus_character()
292+
{
293+
var cert = TestCert.Load();
294+
295+
var pipeline = new IdentityServerPipeline();
296+
pipeline.OnPostConfigureServices += services =>
297+
{
298+
services.AddIdentityServerBuilder()
299+
.AddSigningCredential(cert);
300+
};
301+
pipeline.Initialize();
302+
303+
var result = await pipeline.BackChannelClient.GetAsync("https://server/.well-known/openid-configuration/jwks");
304+
var json = await result.Content.ReadAsStringAsync();
305+
306+
// The x5c property contains base64-encoded certificate data which commonly has '+' characters.
307+
// These should not be escaped as \u002B in the JSON response.
308+
json.ShouldNotContain("\\u002B");
309+
json.ShouldContain('+');
310+
}
311+
312+
[Fact]
313+
[Trait("Category", Category)]
314+
public async Task Jwks_x5t_should_not_escape_base64url_encoded_characters()
315+
{
316+
var cert = TestCert.Load();
317+
318+
var pipeline = new IdentityServerPipeline();
319+
pipeline.OnPostConfigureServices += services =>
320+
{
321+
services.AddIdentityServerBuilder()
322+
.AddSigningCredential(cert);
323+
};
324+
pipeline.Initialize();
325+
326+
var result = await pipeline.BackChannelClient.GetAsync("https://server/.well-known/openid-configuration/jwks");
327+
var json = await result.Content.ReadAsStringAsync();
328+
var data = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
329+
330+
var keys = data["keys"].EnumerateArray().ToList();
331+
var keyWithX5t = keys.First(k => k.TryGetProperty("x5t", out _));
332+
var x5t = keyWithX5t.GetProperty("x5t").GetString();
333+
334+
// The x5t property is a base64url-encoded SHA-1 thumbprint (per RFC 7517).
335+
// Base64url encoding uses '-' and '_' instead of '+' and '/', so '+' and '/' must not appear.
336+
x5t.ShouldNotContain("+");
337+
x5t.ShouldNotContain("/");
338+
x5t.ShouldContain("_"); // The cert we are using happens to contain '_' but not '-' in its thumbprint
339+
340+
// Verify the value matches the expected base64url-encoded thumbprint
341+
var expectedThumbprint = Base64UrlEncoder.Encode(cert.GetCertHash());
342+
x5t.ShouldBe(expectedThumbprint);
343+
}
344+
289345
[Fact]
290346
[Trait("Category", Category)]
291347
public async Task Unicode_values_in_url_should_be_processed_correctly()

identity-server/test/IdentityServer.UnitTests/Infrastructure/ObjectSerializerTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,20 @@ public void Can_serialize_and_deserialize_dictionary()
3737

3838
result.ShouldNotBeNull();
3939
}
40+
41+
[Fact]
42+
public void Can_serialize_jwk_with_plus_character_in_x5c()
43+
{
44+
var jwk = new Dictionary<string, object>
45+
{
46+
{ "kty", "RSA" },
47+
{ "x5c", new List<string> { "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+test+value+with+plus" } }
48+
};
49+
50+
var json = Duende.IdentityServer.ObjectSerializer.ToUnescapedString(jwk);
51+
52+
// The '+' character should not be escaped as \u002B
53+
json.ShouldNotContain("\\u002B");
54+
json.ShouldContain("+");
55+
}
4056
}

0 commit comments

Comments
 (0)