Skip to content
Closed
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
4 changes: 3 additions & 1 deletion src/IdentityServer/Endpoints/Results/JsonWebKeysResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
27 changes: 24 additions & 3 deletions src/IdentityServer/Infrastructure/ObjectSerializer.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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);
}

/// <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)
{
return JsonSerializer.Serialize(o, OptionsWithoutEscaping);
}

public static T FromString<T>(string value)
{
return JsonSerializer.Deserialize<T>(value, Options);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@


using System;
using System.Collections.Generic;
using Duende.IdentityServer.Models;
using FluentAssertions;
using Xunit;
Expand All @@ -21,4 +22,20 @@ public void Can_be_deserialize_message()
Action a = () => Duende.IdentityServer.ObjectSerializer.FromString<Message<ErrorMessage>>("{\"created\":0, \"data\": {\"error\": \"error\"}}");
a.Should().NotThrow();
}

[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.Should().NotContain("\\u002B");
json.Should().Contain("+");
}
}