From 2949efa74d3b2b2ecc045e3b3f08c0d4ce65fccc Mon Sep 17 00:00:00 2001 From: Zhenya Polyvanyi Date: Fri, 10 Jul 2026 18:17:52 +0100 Subject: [PATCH 1/2] Add claims dictionary presizing --- .../Json/JsonWebToken.HeaderClaimSet.cs | 2 +- .../Json/JsonWebToken.PayloadClaimSet.cs | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/Json/JsonWebToken.HeaderClaimSet.cs b/src/Microsoft.IdentityModel.JsonWebTokens/Json/JsonWebToken.HeaderClaimSet.cs index 28dc4bf4f2..174c1c3112 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/Json/JsonWebToken.HeaderClaimSet.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/Json/JsonWebToken.HeaderClaimSet.cs @@ -37,7 +37,7 @@ internal JsonClaimSet CreateHeaderClaimSet(ReadOnlySpan byteSpan) LogHelper.MarkAsNonPII(reader.CurrentDepth), LogHelper.MarkAsNonPII(reader.BytesConsumed)))); - Dictionary claims = new(); + Dictionary claims = new(byteSpan.Length / AverageJsonClaimLengthInBytes); while (true) { if (reader.TokenType == JsonTokenType.PropertyName) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/Json/JsonWebToken.PayloadClaimSet.cs b/src/Microsoft.IdentityModel.JsonWebTokens/Json/JsonWebToken.PayloadClaimSet.cs index 2ab0ca3bf4..b7ac08b26a 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/Json/JsonWebToken.PayloadClaimSet.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/Json/JsonWebToken.PayloadClaimSet.cs @@ -12,6 +12,12 @@ namespace Microsoft.IdentityModel.JsonWebTokens { public partial class JsonWebToken { + // Average number of UTF-8 bytes per JSON claim (property name + value + structural + // characters) observed in typical JWTs. Used only to seed the initial capacity of the + // claims dictionary so it can be sized close to what it would grow to anyway, avoiding + // the intermediate reallocations/rehashes of the default 0 -> 3 -> 7 -> 17 growth path. + internal const int AverageJsonClaimLengthInBytes = 32; + internal JsonClaimSet CreatePayloadClaimSet(byte[] bytes, int length) { return CreatePayloadClaimSet(bytes.AsSpan(0, length)); @@ -35,7 +41,7 @@ internal JsonClaimSet CreatePayloadClaimSet(ReadOnlySpan byteSpan) LogHelper.MarkAsNonPII(reader.CurrentDepth), LogHelper.MarkAsNonPII(reader.BytesConsumed)))); - Dictionary claims = []; + Dictionary claims = new(byteSpan.Length / AverageJsonClaimLengthInBytes); while (true) { if (reader.TokenType == JsonTokenType.PropertyName) From 7fe03256de08105773c264c491b8b7cc1c2f0914 Mon Sep 17 00:00:00 2001 From: Zhenya Polyvanyi Date: Wed, 15 Jul 2026 17:10:16 +0100 Subject: [PATCH 2/2] Add unit tests for edge cases --- .../JsonClaimSetTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonClaimSetTests.cs b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonClaimSetTests.cs index 549f66b7e7..97d27b548c 100644 --- a/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonClaimSetTests.cs +++ b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonClaimSetTests.cs @@ -80,6 +80,90 @@ public static TheoryData DirectClaimSetTestCases() return theoryData; } + // Exercises the claims dictionary presizing (capacity == byteSpan.Length / AverageJsonClaimLengthInBytes). + // The seeded capacity is a performance hint only and must never change the parsed result, so these cases + // cover the different capacity regimes: capacity 0 (payload smaller than one average claim), the boundary + // around AverageJsonClaimLengthInBytes, an under-estimate (many small claims forcing growth beyond the seed) + // and an over-estimate (few large claims). Each case asserts every expected claim is parsed. + [Theory, MemberData(nameof(PresizedClaimSetTestCases), DisableDiscoveryEnumeration = true)] + public void PresizedClaimSet_ParsesAllClaims(JsonClaimSetTheoryData theoryData) + { + CompareContext context = TestUtilities.WriteHeader($"{this}.PresizedClaimSet_ParsesAllClaims", theoryData); + + JsonWebToken jwt = new JsonWebToken("{}", $@"{{""true"":true}}"); + + // Act + JsonClaimSet claimSet = jwt.CreatePayloadClaimSet(theoryData.Utf8Bytes, theoryData.Utf8Bytes.Length); + + // Assert + if (claimSet._jsonClaims.Count != theoryData.ExpectedClaimNames.Count) + context.AddDiff($"Expected {theoryData.ExpectedClaimNames.Count} claims, found {claimSet._jsonClaims.Count}."); + + foreach (string claimName in theoryData.ExpectedClaimNames) + { + if (!claimSet._jsonClaims.ContainsKey(claimName)) + context.AddDiff($"Expected claim '{claimName}' was not found in the parsed claim set."); + } + + TestUtilities.AssertFailIfErrors(context); + } + + public static TheoryData PresizedClaimSetTestCases() + { + var theoryData = new TheoryData(); + + // Empty object: length 2 => capacity 0, no claims. + theoryData.Add(new JsonClaimSetTheoryData("EmptyObject") + { + Utf8Bytes = Encoding.UTF8.GetBytes("{}"), + ExpectedClaimNames = new List() + }); + + // Tiny single claim, well under AverageJsonClaimLengthInBytes => capacity 0. + theoryData.Add(new JsonClaimSetTheoryData("SingleClaimCapacityZero") + { + Utf8Bytes = Encoding.UTF8.GetBytes(@"{""a"":""b""}"), + ExpectedClaimNames = new List { "a" } + }); + + // Payload sized right around AverageJsonClaimLengthInBytes (32 bytes) => capacity ~1. + theoryData.Add(new JsonClaimSetTheoryData("PayloadAtCapacityBoundary") + { + Utf8Bytes = Encoding.UTF8.GetBytes(@"{""iss"":""abc"",""sub"":""1234567""}"), + ExpectedClaimNames = new List { "iss", "sub" } + }); + + // Many small claims: the byte-length estimate under-counts the claims, forcing growth + // beyond the seeded capacity. + var manyClaims = new List(); + var manyClaimsBuilder = new StringBuilder("{"); + for (int i = 0; i < 20; i++) + { + string name = "c" + i; + manyClaims.Add(name); + if (i > 0) + manyClaimsBuilder.Append(','); + + manyClaimsBuilder.Append($@"""{name}"":{i}"); + } + + manyClaimsBuilder.Append('}'); + theoryData.Add(new JsonClaimSetTheoryData("ManySmallClaimsUnderEstimate") + { + Utf8Bytes = Encoding.UTF8.GetBytes(manyClaimsBuilder.ToString()), + ExpectedClaimNames = manyClaims + }); + + // Few large claims: the byte-length estimate over-counts the claims, over-sizing the dictionary. + theoryData.Add(new JsonClaimSetTheoryData("FewLargeClaimsOverEstimate") + { + Utf8Bytes = Encoding.UTF8.GetBytes($@"{{""big"":""{new string('x', 512)}""}}"), + ExpectedClaimNames = new List { "big" } + }); + + return theoryData; + } + [Theory, MemberData(nameof(GetClaimAsTypeTheoryData), DisableDiscoveryEnumeration = true)] public void GetClaimAsType(JsonClaimSetTheoryData theoryData) { @@ -199,6 +283,8 @@ public class JsonClaimSetTheoryData : TheoryDataBase { public JsonClaimSetTheoryData(string id) : base(id) { } + public List ExpectedClaimNames { get; set; } + public string Json { get; set; } public JsonWebToken JsonWebToken { get; set; }