Skip to content

Fix array handling for claims with multiple values #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
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
21 changes: 18 additions & 3 deletions Abblix.Jwt.UnitTests/JwtEncryptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
// CONTACT: For license inquiries or permissions, contact Abblix LLP at
// info@abblix.com

using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.IdentityModel.Tokens;
using Xunit;
Expand All @@ -35,6 +36,7 @@ public class JwtEncryptionTests
public async Task JwtFullCycleTest()
{
var issuedAt = DateTimeOffset.UtcNow;
var expiresIn = TimeSpan.FromSeconds(10);

var token = new JsonWebToken
{
Expand All @@ -43,17 +45,18 @@ public async Task JwtFullCycleTest()
JwtId = Guid.NewGuid().ToString("N"),
IssuedAt = issuedAt,
NotBefore = issuedAt,
ExpiresAt = issuedAt + TimeSpan.FromDays(1),
ExpiresAt = issuedAt + expiresIn,
Issuer = "abblix.com",
Audiences = new []{ nameof(JwtFullCycleTest) },
Audiences = [nameof(JwtFullCycleTest)],
["test"] = "value",
["address"] = new JsonObject
{
{ "street", "123 Main St" },
{ "city", "Springfield" },
{ "state", "IL" },
{ "zip", "62701" },
}
},
["colors"] = new JsonArray("red", "green", "blue"),
},
};

Expand All @@ -73,6 +76,18 @@ public async Task JwtFullCycleTest()
var expectedClaims = ExtractClaims(token);
var actualClaims = ExtractClaims(result.Token);
Assert.Equal(expectedClaims, actualClaims);

var arrayValues = result.Token.Payload.Json.GetArrayOfStrings("colors");
Assert.Equal(["red", "green", "blue"], arrayValues);

var address = result.Token.Payload.Json["address"]?.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
Assert.Equal("{\"street\":\"123 Main St\",\"city\":\"Springfield\",\"state\":\"IL\",\"zip\":\"62701\"}", address);

await Task.Delay(expiresIn);

var (error, description) = Assert.IsType<JwtValidationError>(await validator.ValidateAsync(jwt, parameters));
Assert.Equal(JwtError.InvalidToken, error);
Assert.Contains("Lifetime validation failed", description);
}

private static IEnumerable<(string Key, string?)> ExtractClaims(JsonWebToken token)
Expand Down
9 changes: 4 additions & 5 deletions Abblix.Jwt/JsonObjectExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,13 @@ public static class JsonObjectExtensions
/// </remarks>
public static JsonObject SetProperty(this JsonObject json, string name, JsonNode? value)
{
if (json.TryGetPropertyValue(name, out _))
if (value == null)
{
if (value == null)
json.Remove(name);
json.Remove(name);
}
else if (value != null)
else
{
json.Add(name, value);
json[name] = value;
}

return json;
Expand Down
2 changes: 1 addition & 1 deletion Abblix.Jwt/JsonWebKeyFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public static class JsonWebKeyFactory
{
var algorithm = usage switch
{
JsonWebKeyUseNames.Sig or JsonWebKeyUseNames.Enc => "RS256",
JsonWebKeyUseNames.Sig or JsonWebKeyUseNames.Enc => SecurityAlgorithms.RsaSha256,
_ => throw new ArgumentException(
$"Invalid usage specified. Valid options are '{JsonWebKeyUseNames.Sig}' for signing or '{JsonWebKeyUseNames.Enc}' for encryption.",
nameof(usage))
Expand Down
1 change: 0 additions & 1 deletion Abblix.Jwt/JsonWebTokenExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,5 +221,4 @@ private static JsonElement ToJsonElement(this string jsonString)
/// </remarks>
public static JsonNode? ToJsonNode(this JsonElement jsonElement)
=> JsonNode.Parse(jsonElement.GetRawText());

}
107 changes: 80 additions & 27 deletions Abblix.Jwt/JsonWebTokenValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
// CONTACT: For license inquiries or permissions, contact Abblix LLP at
// info@abblix.com

using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -57,41 +58,41 @@ public Task<JwtValidationResult> ValidateAsync(string jwt, ValidationParameters
/// <returns>The result of the JWT validation process, either indicating success or detailing any validation errors.</returns>
private static JwtValidationResult Validate(string jwt, ValidationParameters parameters)
{
var tokenValidationParameters = new TokenValidationParameters
var validationParameters = new TokenValidationParameters
{
NameClaimType = JwtClaimTypes.Subject,

ValidateIssuer = parameters.Options.HasFlag(ValidationOptions.ValidateIssuer),
ValidateAudience = parameters.Options.HasFlag(ValidationOptions.ValidateAudience),
RequireSignedTokens = parameters.Options.HasFlag(ValidationOptions.RequireSignedTokens),
ValidateIssuerSigningKey = parameters.Options.HasFlag(ValidationOptions.ValidateIssuerSigningKey),
ValidateLifetime = parameters.Options.HasFlag(ValidationOptions.ValidateLifetime),
ClockSkew = parameters.ClockSkew,
};

if (tokenValidationParameters.ValidateIssuer)
if (validationParameters.ValidateIssuer)
{
var validateIssuer = parameters.ValidateIssuer
.NotNull(nameof(parameters.ValidateIssuer));

tokenValidationParameters.IssuerValidator = (issuer, _, _) =>
validationParameters.IssuerValidator = (issuer, _, _) =>
validateIssuer(issuer).Result ? issuer : null;
}

if (tokenValidationParameters.ValidateAudience)
if (validationParameters.ValidateAudience)
{
var validateAudience = parameters.ValidateAudience
.NotNull(nameof(parameters.ValidateAudience));

tokenValidationParameters.AudienceValidator = (audiences, _, _) =>
validationParameters.AudienceValidator = (audiences, _, _) =>
validateAudience(audiences).Result;
}

if (tokenValidationParameters.ValidateIssuerSigningKey)
if (validationParameters.ValidateIssuerSigningKey)
{
var resolveIssuerSigningKeys = parameters.ResolveIssuerSigningKeys
.NotNull(nameof(parameters.ResolveIssuerSigningKeys));

tokenValidationParameters.IssuerSigningKeyResolver = (_, securityToken, keyId, _) =>
validationParameters.IssuerSigningKeyResolver = (_, securityToken, keyId, _) =>
{
var signingKeys = resolveIssuerSigningKeys(securityToken.Issuer);

Expand All @@ -104,7 +105,7 @@ private static JwtValidationResult Validate(string jwt, ValidationParameters par

var resolveTokenDecryptionKeys = parameters.ResolveTokenDecryptionKeys;
if (resolveTokenDecryptionKeys != null)
tokenValidationParameters.TokenDecryptionKeyResolver = (_, securityToken, keyId, _) =>
validationParameters.TokenDecryptionKeyResolver = (_, securityToken, keyId, _) =>
{
var decryptionKeys = resolveTokenDecryptionKeys(securityToken.Issuer);

Expand All @@ -118,7 +119,7 @@ private static JwtValidationResult Validate(string jwt, ValidationParameters par
SecurityToken token;
try
{
handler.ValidateToken(jwt, tokenValidationParameters, out token);
handler.ValidateToken(jwt, validationParameters, out token);
}
catch (Exception ex)
{
Expand All @@ -142,34 +143,86 @@ private static JwtValidationResult Validate(string jwt, ValidationParameters par
ExpiresAt = jwToken.ValidTo,
Issuer = jwToken.Issuer,
Audiences = jwToken.Audiences,
}
},
};

foreach (var claim in jwToken.Claims.ExceptBy(JwtSecurityTokenHandlerConstants.ClaimTypesToExclude, claim => claim.Type))
{
result.Payload[claim.Type] = ToJsonNode(claim.ValueType, claim.Value);
}
MergeClaims(jwToken.Claims, result.Payload.Json, JwtSecurityTokenHandlerConstants.ClaimTypesToExclude);

return new ValidJsonWebToken(result);
}

/// <summary>
/// Merges a set of claims into a <see cref="JsonObject"/>, excluding specified claim types.
/// </summary>
/// <remarks>
/// Each claim is converted to a <see cref="JsonNode"/> using <c>ToJsonNode</c>.
/// - Claims with a single value are stored as a <see cref="JsonValue"/>.
/// - Claims with multiple values are stored in a <see cref="JsonArray"/>.
///
/// Excluded claim types will be skipped entirely.
/// Ensure that returned <see cref="JsonNode"/> instances are not reused elsewhere in the JSON tree,
/// as <c>System.Text.Json.Nodes</c> does not allow a node to have more than one parent.
/// </remarks>
/// <param name="claims">The collection of claims to merge.</param>
/// <param name="json">The target <see cref="JsonObject"/> to populate with merged claims.</param>
/// <param name="claimTypesToExclude">An array of claim type identifiers that should be excluded from the merge.
/// </param>
/// <exception cref="InvalidOperationException">Thrown if a grouped claim contains no values,
/// which should not occur under normal circumstances.</exception>
private static void MergeClaims(IEnumerable<Claim> claims, JsonObject json, string[] claimTypesToExclude)
{
var claimGroups = claims
.Where(claim => !claimTypesToExclude.Contains(claim.Type))
.GroupBy(claim => claim.Type, claim => ToJsonNode(claim.ValueType, claim.Value));

foreach (var claimGroup in claimGroups)
{
using var enumerator = claimGroup.GetEnumerator();
if (!enumerator.MoveNext())
throw new InvalidOperationException("Claim group contains no claims.");

var claimValue = enumerator.Current;
if (enumerator.MoveNext())
{
// convert values to array
var jsonArray = new JsonArray { claimValue };
do
{
jsonArray.Add(enumerator.Current);
} while (enumerator.MoveNext());

claimValue = jsonArray;
}
json[claimGroup.Key] = claimValue;
}
}

/// <summary>
/// Creates a <see cref="JsonNode"/> representation of a claim value based on its type.
/// </summary>
/// <param name="valueType">The type of the claim value.</param>
/// <param name="value">The string representation of the claim value.</param>
/// <returns>A <see cref="JsonNode"/> representing the claim value.</returns>
private static JsonNode? ToJsonNode(string valueType, string value)
=> valueType switch
{
JsonClaimValueTypes.Json => JsonNode.Parse(value).NotNull(nameof(value)),

ClaimValueTypes.Boolean => JsonValue.Create(bool.Parse(value)),
ClaimValueTypes.Integer => JsonValue.Create(long.Parse(value)),
ClaimValueTypes.Integer32 => JsonValue.Create(int.Parse(value)),
ClaimValueTypes.Integer64 => JsonValue.Create(long.Parse(value)),

_ => value,
};
private static JsonNode? ToJsonNode(string valueType, string value) => valueType switch
{
JsonClaimValueTypes.Json => JsonNode.Parse(value).NotNull(nameof(value)),

ClaimValueTypes.Boolean => JsonValue.Create(bool.Parse(value)),
ClaimValueTypes.Integer or ClaimValueTypes.Integer64 => JsonValue.Create(long.Parse(value)),
ClaimValueTypes.Integer32 => JsonValue.Create(int.Parse(value)),
ClaimValueTypes.Date or ClaimValueTypes.DateTime
=> JsonValue.Create(DateTimeOffset.Parse(value, CultureInfo.InvariantCulture)),
ClaimValueTypes.Time => JsonValue.Create(TimeSpan.Parse(value, CultureInfo.InvariantCulture)),

ClaimValueTypes.Double => JsonValue.Create(double.Parse(value, CultureInfo.InvariantCulture)),
ClaimValueTypes.HexBinary => JsonValue.Create(Convert.FromHexString(value)),
ClaimValueTypes.Base64Binary or ClaimValueTypes.Base64Octet
=> JsonValue.Create(Convert.FromBase64String(value)),

ClaimValueTypes.UInteger32 => JsonValue.Create(uint.Parse(value, CultureInfo.InvariantCulture)),
ClaimValueTypes.UInteger64 => JsonValue.Create(ulong.Parse(value, CultureInfo.InvariantCulture)),

// Default fallback: treat all other unknown types as string
_ => JsonValue.Create(value),
};
}
2 changes: 1 addition & 1 deletion Abblix.Jwt/ValidationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
namespace Abblix.Jwt;

/// <summary>
/// Enumeration for specifying various validation options for JWT tokens.
/// Set of flags for specifying various validation options for JWT tokens.
/// These options can be combined using bitwise operations to create a customized set of validation rules.
/// </summary>
[Flags]
Expand Down
43 changes: 23 additions & 20 deletions Abblix.Jwt/ValidationParameters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,62 +23,65 @@
namespace Abblix.Jwt;

/// <summary>
/// Represents the parameters used for validating a JSON Web Token (JWT).
/// Defines parameters used during the validation of a JSON Web Token (JWT).
/// </summary>
public record ValidationParameters
{
/// <summary>
/// Gets or sets the validation options.
/// Options that control various aspects of JWT validation.
/// </summary>
public ValidationOptions Options { get; init; } = ValidationOptions.Default;

/// <summary>
/// Gets or sets the delegate for issuer validation.
/// Delegate used to verify the validity of a token issuer.
/// </summary>
public ValidateIssuersDelegate? ValidateIssuer { get; set; }

/// <summary>
/// Gets or sets the delegate for audience validation.
/// Delegate used to validate one or more token audiences.
/// </summary>
public ValidateAudienceDelegate? ValidateAudience { get; set; }

/// <summary>
/// Gets or sets the delegate for resolving issuer signing keys.
/// Delegate that resolves the signing keys for a given issuer, used during token signature validation.
/// </summary>
public ResolveIssuerSigningKeysDelegate? ResolveIssuerSigningKeys { get; set; }

/// <summary>
/// Gets or sets the delegate for resolving token decryption keys.
/// Delegate that resolves decryption keys for a given issuer, used during token decryption.
/// </summary>
public ResolveTokenDecryptionKeysDelegate? ResolveTokenDecryptionKeys { get; set; }

/// <summary>
/// Represents a delegate that asynchronously resolves a collection of JSON Web Keys (JWKs) for a given issuer,
/// used for validating the signing of a JWT.
/// Time window applied to accommodate clock discrepancies when validating timestamps.
/// </summary>
/// <param name="issuer">The issuer for which to resolve the signing keys.</param>
/// <returns>An asynchronous enumerable of JSON Web Keys.</returns>
public TimeSpan ClockSkew { get; set; } = TimeSpan.Zero;

/// <summary>
/// Resolves signing keys (JWKs) asynchronously for a specified issuer.
/// </summary>
/// <param name="issuer">Issuer whose signing keys are to be resolved.</param>
/// <returns>An asynchronous stream of JSON Web Keys.</returns>
public delegate IAsyncEnumerable<JsonWebKey> ResolveIssuerSigningKeysDelegate(string issuer);

/// <summary>
/// Represents a delegate that asynchronously resolves a collection of JSON Web Keys (JWKs) for a given issuer,
/// used for token decryption.
/// Resolves decryption keys (JWKs) asynchronously for a specified issuer.
/// </summary>
/// <param name="issuer">The issuer for which to resolve the decryption keys.</param>
/// <returns>An asynchronous enumerable of JSON Web Keys.</returns>
/// <param name="issuer">Issuer whose decryption keys are to be resolved.</param>
/// <returns>An asynchronous stream of JSON Web Keys.</returns>
public delegate IAsyncEnumerable<JsonWebKey> ResolveTokenDecryptionKeysDelegate(string issuer);

/// <summary>
/// Represents a delegate that validates a set of audiences against a specific criterion.
/// Validates a collection of audiences against expected values.
/// </summary>
/// <param name="audiences">The audiences to validate.</param>
/// <returns>A task that represents the asynchronous validation operation. The task result contains the validation outcome.</returns>
/// <param name="audiences">Audiences to be validated.</param>
/// <returns>A task that returns true if validation succeeds.</returns>
public delegate Task<bool> ValidateAudienceDelegate(IEnumerable<string> audiences);

/// <summary>
/// Represents a delegate that validates an issuer against a specific criterion.
/// Validates a token issuer against expected values.
/// </summary>
/// <param name="issuer">The issuer to validate.</param>
/// <returns>A task that represents the asynchronous validation operation. The task result contains the validation outcome.</returns>
/// <param name="issuer">Issuer to be validated.</param>
/// <returns>A task that returns true if validation succeeds.</returns>
public delegate Task<bool> ValidateIssuersDelegate(string issuer);
};
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public interface IBackChannelAuthenticationHandler
/// <returns>
/// A task that represents the asynchronous operation, containing the backchannel authentication response.
/// </returns>
Task<BackChannelAuthenticationResponse> HandleAsync(BackChannelAuthenticationRequest request,
Task<BackChannelAuthenticationResponse> HandleAsync(
BackChannelAuthenticationRequest request,
ClientRequest clientRequest);
}