Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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("+");
}
}
Loading