From 80b6a82e90410a5bd43ad33f91654ef9b893c2a1 Mon Sep 17 00:00:00 2001 From: Henrik Widlund <4659350+henrikwidlund@users.noreply.github.com> Date: Wed, 7 May 2025 10:17:18 +0200 Subject: [PATCH 1/8] Add Span based overload for CanReadToken Fixes #3221 --- .../JsonWebTokenHandler.cs | 77 +++++++++++++++++++ .../PublicAPI.Unshipped.txt | 1 + 2 files changed, 78 insertions(+) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs index 173c06fe80..d8e4f3a30f 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs @@ -118,6 +118,79 @@ public IDictionary InboundClaimTypeMap } } +#if NET8_0_OR_GREATER + /// + /// Determines if the span is a well formed JSON Web Token (JWT). See: . + /// + /// Span that should represent a valid JWT. + /// Uses matching: + /// JWS: @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$" + /// JWE: (dir): @"^[A-Za-z0-9-_]+\.\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$" + /// JWE: (wrappedkey): @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]$" + /// + /// + /// if the token is null or whitespace. + /// if token.Length is greater than . + /// if the token is in JSON Compact Serialization format. + /// + public virtual bool CanReadToken(in ReadOnlySpan token) + { + if (token.IsEmpty || token.IsWhiteSpace()) + return false; + + if (token.Length > MaximumTokenSizeInBytes) + { + if (LogHelper.IsEnabled(EventLogLevel.Informational)) + LogHelper.LogInformation(TokenLogMessages.IDX10209, LogHelper.MarkAsNonPII(token.Length), LogHelper.MarkAsNonPII(MaximumTokenSizeInBytes)); + + return false; + } + + // Count the number of segments, which is the number of periods + 1. We can stop when we've encountered + // more segments than the maximum we know how to handle. + int segmentCount = CountJwtTokenPart(token, JwtConstants.MaxJwtSegmentCount + 1); + + switch (segmentCount) + { + case JwtConstants.JwsSegmentCount: + return JwtTokenUtilities.RegexJws.IsMatch(token); + + case JwtConstants.JweSegmentCount: + return JwtTokenUtilities.RegexJwe.IsMatch(token); + + default: + LogHelper.LogInformation(LogMessages.IDX14107); + return false; + } + + static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) + { + int count = 1; + int index = 0; + ReadOnlySpan localToken = token; + while (index < localToken.Length) + { + int dotIndex = localToken.IndexOf('.'); + if (dotIndex < 0) + { + break; + } + + count++; + index = dotIndex + 1; + if (count == maxCount) + { + break; + } + + localToken = localToken[index..]; + } + + return count; + } + } +#endif + /// /// Determines if the string is a well formed JSON Web Token (JWT). See: . /// @@ -137,6 +210,9 @@ public virtual bool CanReadToken(string token) if (string.IsNullOrWhiteSpace(token)) return false; +#if NET8_0_OR_GREATER + return CanReadToken(token.AsSpan()); +#else if (token.Length > MaximumTokenSizeInBytes) { if (LogHelper.IsEnabled(EventLogLevel.Informational)) @@ -167,6 +243,7 @@ public virtual bool CanReadToken(string token) LogHelper.LogInformation(LogMessages.IDX14107); return false; } +#endif } private static StringComparison GetStringComparisonRuleIf509(SecurityKey securityKey) => diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt index e69de29bb2..30cd9cc7bb 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +virtual Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.CanReadToken(in System.ReadOnlySpan token) -> bool \ No newline at end of file From b7c5319192910654ab9505b8eb2bf1426afec543 Mon Sep 17 00:00:00 2001 From: Henrik Widlund <4659350+henrikwidlund@users.noreply.github.com> Date: Thu, 8 May 2025 09:59:19 +0200 Subject: [PATCH 2/8] Always include memory based overload to make shipped apis work Change to ReadOnlyMemory to reduce allocations when calling ToString --- .../JsonWebTokenHandler.cs | 22 ++++++++++++------- .../PublicAPI.Unshipped.txt | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs index d8e4f3a30f..5ce89d0777 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs @@ -118,7 +118,6 @@ public IDictionary InboundClaimTypeMap } } -#if NET8_0_OR_GREATER /// /// Determines if the span is a well formed JSON Web Token (JWT). See: . /// @@ -133,9 +132,9 @@ public IDictionary InboundClaimTypeMap /// if token.Length is greater than . /// if the token is in JSON Compact Serialization format. /// - public virtual bool CanReadToken(in ReadOnlySpan token) + public virtual bool CanReadToken(in ReadOnlyMemory token) { - if (token.IsEmpty || token.IsWhiteSpace()) + if (token.IsEmpty || token.Span.IsWhiteSpace()) return false; if (token.Length > MaximumTokenSizeInBytes) @@ -148,15 +147,23 @@ public virtual bool CanReadToken(in ReadOnlySpan token) // Count the number of segments, which is the number of periods + 1. We can stop when we've encountered // more segments than the maximum we know how to handle. - int segmentCount = CountJwtTokenPart(token, JwtConstants.MaxJwtSegmentCount + 1); + int segmentCount = CountJwtTokenPart(token.Span, JwtConstants.MaxJwtSegmentCount + 1); switch (segmentCount) { case JwtConstants.JwsSegmentCount: - return JwtTokenUtilities.RegexJws.IsMatch(token); +#if NET8_0_OR_GREATER + return JwtTokenUtilities.RegexJws.IsMatch(token.Span); +#else + return JwtTokenUtilities.RegexJws.IsMatch(token.ToString()); +#endif case JwtConstants.JweSegmentCount: - return JwtTokenUtilities.RegexJwe.IsMatch(token); +#if NET8_0_OR_GREATER + return JwtTokenUtilities.RegexJwe.IsMatch(token.Span); +#else + return JwtTokenUtilities.RegexJwe.IsMatch(token.ToString()); +#endif default: LogHelper.LogInformation(LogMessages.IDX14107); @@ -189,7 +196,6 @@ static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) return count; } } -#endif /// /// Determines if the string is a well formed JSON Web Token (JWT). See: . @@ -211,7 +217,7 @@ public virtual bool CanReadToken(string token) return false; #if NET8_0_OR_GREATER - return CanReadToken(token.AsSpan()); + return CanReadToken(token.AsMemory()); #else if (token.Length > MaximumTokenSizeInBytes) { diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt index 30cd9cc7bb..7a68e88730 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt @@ -1 +1 @@ -virtual Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.CanReadToken(in System.ReadOnlySpan token) -> bool \ No newline at end of file +virtual Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.CanReadToken(in System.ReadOnlyMemory token) -> bool \ No newline at end of file From d6659a4e9c01e860d8bc231b631c7062d9cd5f14 Mon Sep 17 00:00:00 2001 From: Henrik Widlund <4659350+henrikwidlund@users.noreply.github.com> Date: Fri, 9 May 2025 16:16:53 +0200 Subject: [PATCH 3/8] Add to JwtSecurityTokenHandler + tests --- .../JsonWebTokenHandler.cs | 32 +------- .../JwtTokenUtilities.cs | 15 ++-- .../JwtSecurityTokenHandler.cs | 61 ++++++++++++++- .../PublicAPI.Unshipped.txt | 1 + .../JsonWebTokenHandlerTests.cs | 24 ++++-- .../CrossTokenTests.cs | 77 ++++++++++++++++--- 6 files changed, 158 insertions(+), 52 deletions(-) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs index 5ce89d0777..1938170f49 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs @@ -119,9 +119,9 @@ public IDictionary InboundClaimTypeMap } /// - /// Determines if the span is a well formed JSON Web Token (JWT). See: . + /// Determines if the is a well formed JSON Web Token (JWT). See: . /// - /// Span that should represent a valid JWT. + /// that should represent a valid JWT. /// Uses matching: /// JWS: @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$" /// JWE: (dir): @"^[A-Za-z0-9-_]+\.\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$" @@ -147,7 +147,7 @@ public virtual bool CanReadToken(in ReadOnlyMemory token) // Count the number of segments, which is the number of periods + 1. We can stop when we've encountered // more segments than the maximum we know how to handle. - int segmentCount = CountJwtTokenPart(token.Span, JwtConstants.MaxJwtSegmentCount + 1); + int segmentCount = JwtTokenUtilities.CountJwtTokenPart(token.Span, JwtConstants.MaxJwtSegmentCount + 1); switch (segmentCount) { @@ -169,32 +169,6 @@ public virtual bool CanReadToken(in ReadOnlyMemory token) LogHelper.LogInformation(LogMessages.IDX14107); return false; } - - static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) - { - int count = 1; - int index = 0; - ReadOnlySpan localToken = token; - while (index < localToken.Length) - { - int dotIndex = localToken.IndexOf('.'); - if (dotIndex < 0) - { - break; - } - - count++; - index = dotIndex + 1; - if (count == maxCount) - { - break; - } - - localToken = localToken[index..]; - } - - return count; - } } /// diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs index a1d08cdbd5..9dafbba0f8 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs @@ -585,24 +585,29 @@ internal static SecurityKey ResolveTokenSigningKey(string kid, string x5t, IEnum /// The JWT token. /// The maximum number of segments to count up to. /// The number of segments up to . - internal static int CountJwtTokenPart(string token, int maxCount) + internal static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) { - var count = 1; - var index = 0; - while (index < token.Length) + int count = 1; + int index = 0; + ReadOnlySpan localToken = token; + while (index < localToken.Length) { - var dotIndex = token.IndexOf('.', index); + int dotIndex = localToken.IndexOf('.'); if (dotIndex < 0) { break; } + count++; index = dotIndex + 1; if (count == maxCount) { break; } + + localToken = localToken[index..]; } + return count; } diff --git a/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs b/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs index 1ddafdb667..3ab0037498 100644 --- a/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs +++ b/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs @@ -263,6 +263,59 @@ public override Type TokenType get { return typeof(JwtSecurityToken); } } + /// + /// Determines if the is a well formed Json Web Token (JWT). + /// See: https://datatracker.ietf.org/doc/html/rfc7519 + /// + /// that should represent a valid JWT. + /// Uses matching one of: + /// JWS: @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$" + /// JWE: (dir): @"^[A-Za-z0-9-_]+\.\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$" + /// JWE: (wrappedkey): @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]$" + /// + /// + /// 'false' if the token is null or whitespace. + /// 'false' if token.Length is greater than . + /// 'true' if the token is in JSON compact serialization format. + /// + public virtual bool CanReadToken(in ReadOnlyMemory token) + { + if (token.IsEmpty || token.Span.IsWhiteSpace()) + return false; + + if (token.Length > MaximumTokenSizeInBytes) + { + if (LogHelper.IsEnabled(EventLogLevel.Informational)) + LogHelper.LogInformation(TokenLogMessages.IDX10209, LogHelper.MarkAsNonPII(token.Length), LogHelper.MarkAsNonPII(MaximumTokenSizeInBytes)); + + return false; + } + + // Set the maximum number of segments to MaxJwtSegmentCount + 1. This controls the number of splits and allows detecting the number of segments is too large. + // For example: "a.b.c.d.e.f.g.h" => [a], [b], [c], [d], [e], [f.g.h]. 6 segments. + // If just MaxJwtSegmentCount was used, then [a], [b], [c], [d], [e.f.g.h] would be returned. 5 segments. + int tokenPartCount = JwtTokenUtilities.CountJwtTokenPart(token.Span, JwtConstants.MaxJwtSegmentCount + 1); + if (tokenPartCount == JwtConstants.JwsSegmentCount) + { +#if NET8_0_OR_GREATER + return JwtTokenUtilities.RegexJws.IsMatch(token.Span); +#else + return JwtTokenUtilities.RegexJws.IsMatch(token.ToString()); +#endif + } + else if (tokenPartCount == JwtConstants.JweSegmentCount) + { +#if NET8_0_OR_GREATER + return JwtTokenUtilities.RegexJwe.IsMatch(token); +#else + return JwtTokenUtilities.RegexJwe.IsMatch(token.ToString()); +#endif + } + + LogHelper.LogInformation(LogMessages.IDX12720); + return false; + } + /// /// Determines if the string is a well formed Json Web Token (JWT). /// See: https://datatracker.ietf.org/doc/html/rfc7519 @@ -283,6 +336,9 @@ public override bool CanReadToken(string token) if (string.IsNullOrWhiteSpace(token)) return false; +#if NET8_0_OR_GREATER + return CanReadToken(token.AsMemory()); +#else if (token.Length > MaximumTokenSizeInBytes) { if (LogHelper.IsEnabled(EventLogLevel.Informational)) @@ -294,7 +350,7 @@ public override bool CanReadToken(string token) // Set the maximum number of segments to MaxJwtSegmentCount + 1. This controls the number of splits and allows detecting the number of segments is too large. // For example: "a.b.c.d.e.f.g.h" => [a], [b], [c], [d], [e], [f.g.h]. 6 segments. // If just MaxJwtSegmentCount was used, then [a], [b], [c], [d], [e.f.g.h] would be returned. 5 segments. - int tokenPartCount = JwtTokenUtilities.CountJwtTokenPart(token, JwtConstants.MaxJwtSegmentCount + 1); + int tokenPartCount = JwtTokenUtilities.CountJwtTokenPart(token.AsSpan(), JwtConstants.MaxJwtSegmentCount + 1); if (tokenPartCount == JwtConstants.JwsSegmentCount) { return JwtTokenUtilities.RegexJws.IsMatch(token); @@ -306,6 +362,7 @@ public override bool CanReadToken(string token) LogHelper.LogInformation(LogMessages.IDX12720); return false; +#endif } /// @@ -823,7 +880,7 @@ public override ClaimsPrincipal ValidateToken(string token, TokenValidationParam if (token.Length > MaximumTokenSizeInBytes) throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(TokenLogMessages.IDX10209, LogHelper.MarkAsNonPII(token.Length), LogHelper.MarkAsNonPII(MaximumTokenSizeInBytes)))); - int tokenPartCount = JwtTokenUtilities.CountJwtTokenPart(token, JwtConstants.MaxJwtSegmentCount + 1); + int tokenPartCount = JwtTokenUtilities.CountJwtTokenPart(token.AsSpan(), JwtConstants.MaxJwtSegmentCount + 1); if (tokenPartCount != JwtConstants.JwsSegmentCount && tokenPartCount != JwtConstants.JweSegmentCount) throw LogHelper.LogExceptionMessage(new SecurityTokenMalformedException(LogMessages.IDX12741)); diff --git a/src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt b/src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt index e69de29bb2..81f9335cb6 100644 --- a/src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt +++ b/src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +virtual System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.CanReadToken(in System.ReadOnlyMemory token) -> bool \ No newline at end of file diff --git a/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonWebTokenHandlerTests.cs b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonWebTokenHandlerTests.cs index c9a40f8b5e..0beddd9889 100644 --- a/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonWebTokenHandlerTests.cs +++ b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonWebTokenHandlerTests.cs @@ -258,6 +258,18 @@ public void SegmentCanRead(JwtTheoryData theoryData) TestUtilities.AssertFailIfErrors(context); } + [Theory, MemberData(nameof(SegmentTheoryData), DisableDiscoveryEnumeration = true)] + public void SegmentCanReadMemory(JwtTheoryData theoryData) + { + var context = TestUtilities.WriteHeader($"{this}.SegmentCanRead", theoryData); + + var handler = new JsonWebTokenHandler(); + if (theoryData.CanRead != handler.CanReadToken(theoryData.Token.AsMemory())) + context.Diffs.Add("theoryData.CanRead != handler.CanReadToken(theoryData.Token.AsMemory()))"); + + TestUtilities.AssertFailIfErrors(context); + } + public static TheoryData SegmentTheoryData() { var theoryData = new TheoryData(); @@ -539,7 +551,7 @@ public static TheoryData CreateJWEWithAesGcmTheoryData } } - // Tests checks to make sure that the token string created by the JsonWebTokenHandler is consistent with the + // Tests checks to make sure that the token string created by the JsonWebTokenHandler is consistent with the // token string created by the JwtSecurityTokenHandler. [Theory, MemberData(nameof(CreateJWETheoryData), DisableDiscoveryEnumeration = true)] public async Task CreateJWE(CreateTokenTheoryData theoryData) @@ -867,7 +879,7 @@ private static OpenIdConnectConfiguration CreateCustomConfigurationThatThrows(Se return configurationWithCustomCryptoProviderFactory; } - // Tests checks to make sure that the token string (JWE) created by calling + // Tests checks to make sure that the token string (JWE) created by calling // CreateToken(string payload, SigningCredentials signingCredentials, EncryptingCredentials encryptingCredentials) // is equivalent to the token string created by calling CreateToken(SecurityTokenDescriptor tokenDescriptor). [Theory, MemberData(nameof(CreateJWEUsingSecurityTokenDescriptorTheoryData), DisableDiscoveryEnumeration = true)] @@ -1258,7 +1270,7 @@ public static TheoryData CreateJWEUsingSecurityTokenDescr } } - // Tests checks to make sure that the token string created by the JsonWebTokenHandler is consistent with the + // Tests checks to make sure that the token string created by the JsonWebTokenHandler is consistent with the // token string created by the JwtSecurityTokenHandler. [Theory, MemberData(nameof(CreateJWSTheoryData), DisableDiscoveryEnumeration = true)] public async Task CreateJWS(CreateTokenTheoryData theoryData) @@ -2053,7 +2065,7 @@ public static TheoryData CreateJWSUsingSecurityTokenDescr { // Test checks that the values in SecurityTokenDescriptor.Subject.Claims // are properly combined with those specified in SecurityTokenDescriptor.Claims. - // Duplicate values (if present with different case) should not be overridden. + // Duplicate values (if present with different case) should not be overridden. // For example, the 'aud' claim on TokenDescriptor.Claims will not be overridden // by the 'AUD' claim on TokenDescriptor.Subject.Claims, but the 'exp' claim will. new CreateTokenTheoryData("TokenDescriptorWithBothSubjectAndClaims") @@ -2513,7 +2525,7 @@ public async Task AdditionalHeaderValues() // Test checks to make sure that the token payload retrieved from ValidateToken is the same as the payload - // the token was initially created with. + // the token was initially created with. [Fact] public async Task RoundTripJWS() { @@ -3210,7 +3222,7 @@ public async Task ValidateJsonWebTokenClaimMapping() TestUtilities.AssertFailIfErrors(context); } - // Test shows if the JwtSecurityTokenHandler has mapping OFF and + // Test shows if the JwtSecurityTokenHandler has mapping OFF and // the JsonWebTokenHandler has mapping ON,the claims are different. [Fact] public async Task ValidateDifferentClaimsBetweenHandlers() diff --git a/test/Microsoft.IdentityModel.Tokens.Tests/CrossTokenTests.cs b/test/Microsoft.IdentityModel.Tokens.Tests/CrossTokenTests.cs index 2498c9ecf5..9ac9f065fb 100644 --- a/test/Microsoft.IdentityModel.Tokens.Tests/CrossTokenTests.cs +++ b/test/Microsoft.IdentityModel.Tokens.Tests/CrossTokenTests.cs @@ -89,14 +89,25 @@ public void CanReadToken(TokenHandlerTheoryData theoryData) { var tokenHandler = theoryData.TokenHandler; - if (tokenHandler is SecurityTokenHandler securityTokenHandler) + if (tokenHandler is JwtSecurityTokenHandler jwtSecurityTokenHandler) + { + bool canReadToken = theoryData.UseMemoryOverload + ? jwtSecurityTokenHandler.CanReadToken(theoryData.Token.AsMemory()) + : jwtSecurityTokenHandler.CanReadToken(theoryData.Token); + if (canReadToken != theoryData.CanReadToken) + context.AddDiff("jwtSecurityTokenHandler.CanReadToken(theoryData.Token) != theoryData.CanReadToken"); + } + else if (tokenHandler is SecurityTokenHandler securityTokenHandler) { if (securityTokenHandler.CanReadToken(theoryData.Token) != theoryData.CanReadToken) context.AddDiff("securityTokenHandler.CanReadToken(theoryData.Token) != theoryData.CanReadToken"); } else if (tokenHandler is JsonWebTokenHandler jsonWebTokenHandler) { - if (jsonWebTokenHandler.CanReadToken(theoryData.Token) != theoryData.CanReadToken) + bool canReadToken = theoryData.UseMemoryOverload + ? jsonWebTokenHandler.CanReadToken(theoryData.Token.AsMemory()) + : jsonWebTokenHandler.CanReadToken(theoryData.Token); + if (canReadToken != theoryData.CanReadToken) context.AddDiff("jsonWebTokenHandler.CanReadToken(theoryData.Token) != theoryData.CanReadToken"); } else @@ -129,7 +140,18 @@ public static TheoryData CanReadTokenTheoryData Token = Default.AsymmetricJwt, CanReadToken = true, ExpectedException = ExpectedException.NoExceptionExpected, - TestId = "ValidJwt" + TestId = "ValidJwt", + UseMemoryOverload = false + }, + new TokenHandlerTheoryData + { + First = true, + TokenHandler = new JwtSecurityTokenHandler(), + Token = Default.AsymmetricJwt, + CanReadToken = true, + ExpectedException = ExpectedException.NoExceptionExpected, + TestId = "ValidJwt", + UseMemoryOverload = true }, new TokenHandlerTheoryData { @@ -137,7 +159,26 @@ public static TheoryData CanReadTokenTheoryData Token = largeToken, CanReadToken = false, ExpectedException = ExpectedException.NoExceptionExpected, - TestId = "TokenTooLargeJwt" + TestId = "TokenTooLargeJwt", + UseMemoryOverload = false + }, + new TokenHandlerTheoryData + { + TokenHandler = new JwtSecurityTokenHandler(), + Token = largeToken, + CanReadToken = false, + ExpectedException = ExpectedException.NoExceptionExpected, + TestId = "TokenTooLargeJwt", + UseMemoryOverload = true + }, + new TokenHandlerTheoryData + { + TokenHandler = new JsonWebTokenHandler(), + Token = Default.AsymmetricJwt, + CanReadToken = true, + ExpectedException = ExpectedException.NoExceptionExpected, + TestId = "ValidJsonWebToken", + UseMemoryOverload = false }, new TokenHandlerTheoryData { @@ -145,7 +186,17 @@ public static TheoryData CanReadTokenTheoryData Token = Default.AsymmetricJwt, CanReadToken = true, ExpectedException = ExpectedException.NoExceptionExpected, - TestId = "ValidJsonWebToken" + TestId = "ValidJsonWebToken", + UseMemoryOverload = true + }, + new TokenHandlerTheoryData + { + TokenHandler = new JsonWebTokenHandler(), + Token = largeToken, + CanReadToken = false, + ExpectedException = ExpectedException.NoExceptionExpected, + TestId = "TokenTooLargeJsonWebToken", + UseMemoryOverload = false }, new TokenHandlerTheoryData { @@ -153,7 +204,8 @@ public static TheoryData CanReadTokenTheoryData Token = largeToken, CanReadToken = false, ExpectedException = ExpectedException.NoExceptionExpected, - TestId = "TokenTooLargeJsonWebToken" + TestId = "TokenTooLargeJsonWebToken", + UseMemoryOverload = true }, new TokenHandlerTheoryData { @@ -161,7 +213,8 @@ public static TheoryData CanReadTokenTheoryData Token = ReferenceTokens.SamlToken_Valid, CanReadToken = true, ExpectedException = ExpectedException.NoExceptionExpected, - TestId = "ValidSaml1" + TestId = "ValidSaml1", + UseMemoryOverload = false }, new TokenHandlerTheoryData { @@ -169,7 +222,8 @@ public static TheoryData CanReadTokenTheoryData Token = largeToken, CanReadToken = false, ExpectedException = ExpectedException.NoExceptionExpected, - TestId = "TokenTooLargeSaml1" + TestId = "TokenTooLargeSaml1", + UseMemoryOverload = false }, new TokenHandlerTheoryData { @@ -177,7 +231,8 @@ public static TheoryData CanReadTokenTheoryData Token = ReferenceTokens.Saml2Token_Valid, CanReadToken = true, ExpectedException = ExpectedException.NoExceptionExpected, - TestId = "ValidSaml2" + TestId = "ValidSaml2", + UseMemoryOverload = false }, new TokenHandlerTheoryData { @@ -185,7 +240,8 @@ public static TheoryData CanReadTokenTheoryData Token = largeToken, CanReadToken = false, ExpectedException = ExpectedException.NoExceptionExpected, - TestId = "TokenTooLargeSaml2" + TestId = "TokenTooLargeSaml2", + UseMemoryOverload = false }, }; } @@ -216,6 +272,7 @@ public class TokenHandlerTheoryData : TheoryDataBase public TokenHandler TokenHandler { get; set; } public string Token { get; set; } + public bool UseMemoryOverload { get; set; } } } From e5387a23e4d29ee0ea1d5250f3fab24f08796ba8 Mon Sep 17 00:00:00 2001 From: Henrik Widlund <4659350+henrikwidlund@users.noreply.github.com> Date: Mon, 12 May 2025 21:08:36 +0200 Subject: [PATCH 4/8] PR feedback --- .../JsonWebTokenHandler.cs | 36 +------------- .../JwtTokenUtilities.cs | 47 ++++++++++++++++++- .../JwtSecurityTokenHandler.cs | 35 +------------- 3 files changed, 48 insertions(+), 70 deletions(-) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs index 1938170f49..d6b4e89446 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs @@ -134,41 +134,7 @@ public IDictionary InboundClaimTypeMap /// public virtual bool CanReadToken(in ReadOnlyMemory token) { - if (token.IsEmpty || token.Span.IsWhiteSpace()) - return false; - - if (token.Length > MaximumTokenSizeInBytes) - { - if (LogHelper.IsEnabled(EventLogLevel.Informational)) - LogHelper.LogInformation(TokenLogMessages.IDX10209, LogHelper.MarkAsNonPII(token.Length), LogHelper.MarkAsNonPII(MaximumTokenSizeInBytes)); - - return false; - } - - // Count the number of segments, which is the number of periods + 1. We can stop when we've encountered - // more segments than the maximum we know how to handle. - int segmentCount = JwtTokenUtilities.CountJwtTokenPart(token.Span, JwtConstants.MaxJwtSegmentCount + 1); - - switch (segmentCount) - { - case JwtConstants.JwsSegmentCount: -#if NET8_0_OR_GREATER - return JwtTokenUtilities.RegexJws.IsMatch(token.Span); -#else - return JwtTokenUtilities.RegexJws.IsMatch(token.ToString()); -#endif - - case JwtConstants.JweSegmentCount: -#if NET8_0_OR_GREATER - return JwtTokenUtilities.RegexJwe.IsMatch(token.Span); -#else - return JwtTokenUtilities.RegexJwe.IsMatch(token.ToString()); -#endif - - default: - LogHelper.LogInformation(LogMessages.IDX14107); - return false; - } + return JwtTokenUtilities.CanReadToken(token, MaximumTokenSizeInBytes); } /// diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs index 9dafbba0f8..af816ec738 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs @@ -579,13 +579,58 @@ internal static SecurityKey ResolveTokenSigningKey(string kid, string x5t, IEnum return null; } + /// + /// Determines if the token can be read. + /// + /// The token to check. + /// Maximum allowed size of the token, method will return false if exceeded. + internal static bool CanReadToken(in ReadOnlyMemory token, in int maximumTokenSizeInBytes) + { + if (token.IsEmpty || token.Span.IsWhiteSpace()) + return false; + + if (token.Length > maximumTokenSizeInBytes) + { + if (LogHelper.IsEnabled(EventLogLevel.Informational)) + LogHelper.LogInformation(TokenLogMessages.IDX10209, LogHelper.MarkAsNonPII(token.Length), LogHelper.MarkAsNonPII(maximumTokenSizeInBytes)); + + return false; + } + + // Set the maximum number of segments to MaxJwtSegmentCount + 1. This controls the number of splits and allows detecting the number of segments is too large. + // For example: "a.b.c.d.e.f.g.h" => [a], [b], [c], [d], [e], [f.g.h]. 6 segments. + // If just MaxJwtSegmentCount was used, then [a], [b], [c], [d], [e.f.g.h] would be returned. 5 segments. + int segmentCount = CountJwtTokenPart(token.Span, JwtConstants.MaxJwtSegmentCount + 1); + + switch (segmentCount) + { + case JwtConstants.JwsSegmentCount: +#if NET8_0_OR_GREATER + return JwtTokenUtilities.RegexJws.IsMatch(token.Span); +#else + return JwtTokenUtilities.RegexJws.IsMatch(token.ToString()); +#endif + + case JwtConstants.JweSegmentCount: +#if NET8_0_OR_GREATER + return JwtTokenUtilities.RegexJwe.IsMatch(token.Span); +#else + return JwtTokenUtilities.RegexJwe.IsMatch(token.ToString()); +#endif + + default: + LogHelper.LogInformation(LogMessages.IDX14107); + return false; + } + } + /// /// Counts the number of JWT token segments. /// /// The JWT token. /// The maximum number of segments to count up to. /// The number of segments up to . - internal static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) + private static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) { int count = 1; int index = 0; diff --git a/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs b/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs index 3ab0037498..453fe90959 100644 --- a/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs +++ b/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs @@ -280,40 +280,7 @@ public override Type TokenType /// public virtual bool CanReadToken(in ReadOnlyMemory token) { - if (token.IsEmpty || token.Span.IsWhiteSpace()) - return false; - - if (token.Length > MaximumTokenSizeInBytes) - { - if (LogHelper.IsEnabled(EventLogLevel.Informational)) - LogHelper.LogInformation(TokenLogMessages.IDX10209, LogHelper.MarkAsNonPII(token.Length), LogHelper.MarkAsNonPII(MaximumTokenSizeInBytes)); - - return false; - } - - // Set the maximum number of segments to MaxJwtSegmentCount + 1. This controls the number of splits and allows detecting the number of segments is too large. - // For example: "a.b.c.d.e.f.g.h" => [a], [b], [c], [d], [e], [f.g.h]. 6 segments. - // If just MaxJwtSegmentCount was used, then [a], [b], [c], [d], [e.f.g.h] would be returned. 5 segments. - int tokenPartCount = JwtTokenUtilities.CountJwtTokenPart(token.Span, JwtConstants.MaxJwtSegmentCount + 1); - if (tokenPartCount == JwtConstants.JwsSegmentCount) - { -#if NET8_0_OR_GREATER - return JwtTokenUtilities.RegexJws.IsMatch(token.Span); -#else - return JwtTokenUtilities.RegexJws.IsMatch(token.ToString()); -#endif - } - else if (tokenPartCount == JwtConstants.JweSegmentCount) - { -#if NET8_0_OR_GREATER - return JwtTokenUtilities.RegexJwe.IsMatch(token); -#else - return JwtTokenUtilities.RegexJwe.IsMatch(token.ToString()); -#endif - } - - LogHelper.LogInformation(LogMessages.IDX12720); - return false; + return JwtTokenUtilities.CanReadToken(token, MaximumTokenSizeInBytes); } /// From febd8736da268cd9ce1f822a6898f066fe110fe8 Mon Sep 17 00:00:00 2001 From: Henrik Widlund <4659350+henrikwidlund@users.noreply.github.com> Date: Wed, 14 May 2025 09:02:03 +0200 Subject: [PATCH 5/8] Make CountJwtTokenPart internal again as it's used in JwtSecurityTokenHandler --- src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs index af816ec738..00b19edd27 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs @@ -630,7 +630,7 @@ internal static bool CanReadToken(in ReadOnlyMemory token, in int maximumT /// The JWT token. /// The maximum number of segments to count up to. /// The number of segments up to . - private static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) + internal static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) { int count = 1; int index = 0; From 04753588f4adde5aaab76ff23121121532f9d712 Mon Sep 17 00:00:00 2001 From: Henrik Widlund <4659350+henrikwidlund@users.noreply.github.com> Date: Thu, 15 May 2025 09:19:51 +0200 Subject: [PATCH 6/8] Fix compiler issues and remove in keyword (might not work with dotnet framework. --- .../JsonWebTokenHandler.cs | 2 +- .../JwtTokenUtilities.cs | 6 +++--- .../PublicAPI.Unshipped.txt | 2 +- .../JwtSecurityTokenHandler.cs | 2 +- src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs index d6b4e89446..ce07d638aa 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs @@ -132,7 +132,7 @@ public IDictionary InboundClaimTypeMap /// if token.Length is greater than . /// if the token is in JSON Compact Serialization format. /// - public virtual bool CanReadToken(in ReadOnlyMemory token) + public virtual bool CanReadToken(ReadOnlyMemory token) { return JwtTokenUtilities.CanReadToken(token, MaximumTokenSizeInBytes); } diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs index 00b19edd27..7f6cda6688 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs @@ -584,7 +584,7 @@ internal static SecurityKey ResolveTokenSigningKey(string kid, string x5t, IEnum /// /// The token to check. /// Maximum allowed size of the token, method will return false if exceeded. - internal static bool CanReadToken(in ReadOnlyMemory token, in int maximumTokenSizeInBytes) + internal static bool CanReadToken(ReadOnlyMemory token, int maximumTokenSizeInBytes) { if (token.IsEmpty || token.Span.IsWhiteSpace()) return false; @@ -630,7 +630,7 @@ internal static bool CanReadToken(in ReadOnlyMemory token, in int maximumT /// The JWT token. /// The maximum number of segments to count up to. /// The number of segments up to . - internal static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) + internal static int CountJwtTokenPart(ReadOnlySpan token, int maxCount) { int count = 1; int index = 0; @@ -650,7 +650,7 @@ internal static int CountJwtTokenPart(in ReadOnlySpan token, int maxCount) break; } - localToken = localToken[index..]; + localToken = localToken.Slice(index); } return count; diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt index 7a68e88730..acc7a5169a 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt @@ -1 +1 @@ -virtual Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.CanReadToken(in System.ReadOnlyMemory token) -> bool \ No newline at end of file +virtual Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.CanReadToken(System.ReadOnlyMemory token) -> bool \ No newline at end of file diff --git a/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs b/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs index 453fe90959..85b03467df 100644 --- a/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs +++ b/src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs @@ -278,7 +278,7 @@ public override Type TokenType /// 'false' if token.Length is greater than . /// 'true' if the token is in JSON compact serialization format. /// - public virtual bool CanReadToken(in ReadOnlyMemory token) + public virtual bool CanReadToken(ReadOnlyMemory token) { return JwtTokenUtilities.CanReadToken(token, MaximumTokenSizeInBytes); } diff --git a/src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt b/src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt index 81f9335cb6..59fdf9838d 100644 --- a/src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt +++ b/src/System.IdentityModel.Tokens.Jwt/PublicAPI.Unshipped.txt @@ -1 +1 @@ -virtual System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.CanReadToken(in System.ReadOnlyMemory token) -> bool \ No newline at end of file +virtual System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.CanReadToken(System.ReadOnlyMemory token) -> bool \ No newline at end of file From a0fe604a094a4314d9551c9b52134c1d4d97fe64 Mon Sep 17 00:00:00 2001 From: Henrik Widlund <4659350+henrikwidlund@users.noreply.github.com> Date: Thu, 15 May 2025 20:19:56 +0200 Subject: [PATCH 7/8] Simplify logic and fix bug --- .../JwtTokenUtilities.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs index 7f6cda6688..30f84f9e7f 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs @@ -633,24 +633,16 @@ internal static bool CanReadToken(ReadOnlyMemory token, int maximumTokenSi internal static int CountJwtTokenPart(ReadOnlySpan token, int maxCount) { int count = 1; - int index = 0; ReadOnlySpan localToken = token; - while (index < localToken.Length) + while (localToken.Length > 0) { int dotIndex = localToken.IndexOf('.'); if (dotIndex < 0) - { break; - } - count++; - index = dotIndex + 1; if (count == maxCount) - { break; - } - - localToken = localToken.Slice(index); + localToken = localToken.Slice(dotIndex + 1); } return count; From 5446f5ff4d420e2a9f0dc6d328109bdbec8b09b8 Mon Sep 17 00:00:00 2001 From: Henrik Widlund <4659350+henrikwidlund@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:15:00 +0200 Subject: [PATCH 8/8] Add back string based version of CountJwtTokenPart for back compat --- .../JwtTokenUtilities.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs index 89231d30d6..a50935f98f 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs @@ -649,6 +649,18 @@ internal static int CountJwtTokenPart(ReadOnlySpan token, int maxCount) return count; } + /// + /// Counts the number of JWT token segments. + /// + /// + /// This method is kept for backward compatibility when using mixed package versions + /// and must not be called directly in new code. + /// + /// The JWT token. + /// The maximum number of segments to count up to. + /// The number of segments up to . + internal static int CountJwtTokenPart(string token, int maxCount) => CountJwtTokenPart(token.AsSpan(), maxCount); + internal static IEnumerable ConcatSigningKeys(TokenValidationParameters tvp) { if (tvp == null)