Do not escape '+' character in x5c of jwks

This commit is contained in:
Joe DeCock 2026-01-26 22:37:04 -06:00
parent 27d0d4a9ad
commit 2e704d9386
4 changed files with 97 additions and 1 deletions

View file

@ -52,6 +52,8 @@ internal class JsonWebKeysHttpWriter : IHttpResponseWriter<JsonWebKeysResult>
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");
}
}

View file

@ -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
};
/// <summary>
/// Serializes an object to a JSON string using default encoding, which escapes
/// certain characters (such as '+') for HTML safety.
/// </summary>
public static string ToString(object o) => JsonSerializer.Serialize(o, Options);
/// <summary>
/// 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.
/// </summary>
public static string ToUnescapedString(object o) => JsonSerializer.Serialize(o, OptionsWithoutEscaping);
public static T FromString<T>(string value) => JsonSerializer.Deserialize<T>(value, Options);
}

View file

@ -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<Dictionary<string, JsonElement>>(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()

View file

@ -37,4 +37,20 @@ public class ObjectSerializerTests
result.ShouldNotBeNull();
}
[Fact]
public void Can_serialize_jwk_with_plus_character_in_x5c()
{
var jwk = new Dictionary<string, object>
{
{ "kty", "RSA" },
{ "x5c", new List<string> { "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("+");
}
}