Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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 @@ -120,6 +120,25 @@ public IDictionary<string, string> InboundClaimTypeMap
}
}

/// <summary>
/// Determines if the <see cref="ReadOnlyMemory{T}"/> is a well formed JSON Web Token (JWT). See: <see href="https://datatracker.ietf.org/doc/html/rfc7519"/>.
/// </summary>
/// <param name="token"><see cref="ReadOnlyMemory{T}"/> that should represent a valid JWT.</param>
/// <remarks>Uses <see cref="Regex.IsMatch(string, string)"/> matching:
/// <para>JWS: @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$"</para>
/// <para>JWE: (dir): @"^[A-Za-z0-9-_]+\.\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$"</para>
/// <para>JWE: (wrappedkey): @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]$"</para>
/// </remarks>
/// <returns>
/// <para><see langword="false"/> if the token is null or whitespace.</para>
/// <para><see langword="false"/> if token.Length is greater than <see cref="TokenHandler.MaximumTokenSizeInBytes"/>.</para>
/// <para><see langword="true"/> if the token is in JSON Compact Serialization format.</para>
/// </returns>
public virtual bool CanReadToken(ReadOnlyMemory<char> token)
{
return JwtTokenUtilities.CanReadToken(token, MaximumTokenSizeInBytes);
}

/// <summary>
/// Determines if the string is a well formed JSON Web Token (JWT). See: <see href="https://datatracker.ietf.org/doc/html/rfc7519"/>.
/// </summary>
Expand All @@ -139,6 +158,9 @@ public virtual 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))
Expand Down Expand Up @@ -169,6 +191,7 @@ public virtual bool CanReadToken(string token)
LogHelper.LogInformation(LogMessages.IDX14107);
return false;
}
#endif
}

private static StringComparison GetStringComparisonRuleIf509(SecurityKey securityKey) =>
Expand Down
62 changes: 52 additions & 10 deletions src/Microsoft.IdentityModel.JsonWebTokens/JwtTokenUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -580,30 +580,72 @@ internal static SecurityKey ResolveTokenSigningKey(string kid, string x5t, IEnum
return null;
}

/// <summary>
/// Determines if the token can be read.
/// </summary>
/// <param name="token">The token to check.</param>
/// <param name="maximumTokenSizeInBytes">Maximum allowed size of the token, method will return false if exceeded.</param>
internal static bool CanReadToken(ReadOnlyMemory<char> token, 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;
}
}

/// <summary>
/// Counts the number of JWT token segments.
/// </summary>
/// <param name="token">The JWT token.</param>
/// <param name="maxCount">The maximum number of segments to count up to.</param>
/// <returns>The number of segments up to <paramref name="maxCount"/>.</returns>
internal static int CountJwtTokenPart(string token, int maxCount)
internal static int CountJwtTokenPart(ReadOnlySpan<char> token, int maxCount)
Copy link
Contributor

@brentschmaltz brentschmaltz Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this as ReadOnlySpan?

We don't like to remove the old method as that can cause issues when mixed versions are used.
We end up with a MethodNotFound exception

Copy link
Author

@henrikwidlund henrikwidlund Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need it because without it the PR would not make sense as we'd need to work with strings again. If you want I can create a method that takes string and calls this one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@henrikwidlund i was thinking why ReadOnlySpan and not ReadOnlyMemory?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only reasons are passing a smaller object and that that's what the method works with. If that's what you're thinking about I don't understand your comment about method not found as that would happen regardless.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brentschmaltz I believe older versions of .NET does not support regular expressions for ReadOnlySpan, and this library targets older versions of .NET. This is the reason ReadOnlyMemory is used in the CanReadToken method.

ReadOnlySpan is more efficient and what libraries should use/support whenever possible.

I agreee that supporting string would be useful, even if it is basically just a wrapper for the new method.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what's your suggestion, add back the string based one and have that one call the span one, or close the PR?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what's your suggestion, add back the string based one and have that one call the span one, or close the PR?

Yes, that is what we are thinking.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which of them? I can do both, but it would be a bit of a waste 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add back the string based one and have that one call the span one,

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, done :)

{
var count = 1;
var index = 0;
while (index < token.Length)
int count = 1;
ReadOnlySpan<char> localToken = token;
while (localToken.Length > 0)
{
var dotIndex = token.IndexOf('.', index);
int dotIndex = localToken.IndexOf('.');
if (dotIndex < 0)
{
break;
}
count++;
index = dotIndex + 1;
if (count == maxCount)
{
break;
}
localToken = localToken.Slice(dotIndex + 1);
}

return count;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.DecryptTokenWithConfigurationAsync(Microsoft.IdentityModel.JsonWebTokens.JsonWebToken jwtToken, Microsoft.IdentityModel.Tokens.TokenValidationParameters validationParameters, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<string>
Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.DecryptTokenWithConfigurationAsync(Microsoft.IdentityModel.JsonWebTokens.JsonWebToken jwtToken, Microsoft.IdentityModel.Tokens.TokenValidationParameters validationParameters, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<string>
virtual Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.CanReadToken(System.ReadOnlyMemory<char> token) -> bool
28 changes: 26 additions & 2 deletions src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,26 @@ public override Type TokenType
get { return typeof(JwtSecurityToken); }
}

/// <summary>
/// Determines if the <see cref="ReadOnlyMemory{T}"/> is a well formed Json Web Token (JWT).
/// <para>See: https://datatracker.ietf.org/doc/html/rfc7519 </para>
/// </summary>
/// <param name="token"><see cref="ReadOnlyMemory{T}"/> that should represent a valid JWT.</param>
/// <remarks>Uses <see cref="Regex.IsMatch(string, string)"/> matching one of:
/// <para>JWS: @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$"</para>
/// <para>JWE: (dir): @"^[A-Za-z0-9-_]+\.\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$"</para>
/// <para>JWE: (wrappedkey): @"^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]$"</para>
/// </remarks>
/// <returns>
/// <para>'false' if the token is null or whitespace.</para>
/// <para>'false' if token.Length is greater than <see cref="TokenHandler.MaximumTokenSizeInBytes"/>.</para>
/// <para>'true' if the token is in JSON compact serialization format.</para>
/// </returns>
public virtual bool CanReadToken(ReadOnlyMemory<char> token)
{
return JwtTokenUtilities.CanReadToken(token, MaximumTokenSizeInBytes);
}

/// <summary>
/// Determines if the string is a well formed Json Web Token (JWT).
/// <para>See: https://datatracker.ietf.org/doc/html/rfc7519 </para>
Expand All @@ -283,6 +303,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))
Expand All @@ -294,7 +317,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);
Expand All @@ -306,6 +329,7 @@ public override bool CanReadToken(string token)

LogHelper.LogInformation(LogMessages.IDX12720);
return false;
#endif
}

/// <summary>
Expand Down Expand Up @@ -823,7 +847,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));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
virtual System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.CanReadToken(System.ReadOnlyMemory<char> token) -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,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<JwtTheoryData> SegmentTheoryData()
{
var theoryData = new TheoryData<JwtTheoryData>();
Expand Down Expand Up @@ -540,7 +552,7 @@ public static TheoryData<CreateTokenTheoryData> 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)
Expand Down Expand Up @@ -868,7 +880,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)]
Expand Down Expand Up @@ -1259,7 +1271,7 @@ public static TheoryData<CreateTokenTheoryData> 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)
Expand Down Expand Up @@ -2054,7 +2066,7 @@ public static TheoryData<CreateTokenTheoryData> 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")
Expand Down Expand Up @@ -2514,7 +2526,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()
{
Expand Down Expand Up @@ -3211,7 +3223,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()
Expand Down
Loading