From 6f0213276ca3c502455cddab7c9be04880ab3e0d Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Thu, 24 Apr 2025 12:24:33 +0300 Subject: [PATCH 1/7] Fixed processing of arrays of claim values in JWT --- Abblix.Jwt.UnitTests/JwtEncryptionTests.cs | 12 ++- Abblix.Jwt/JsonObjectExtensions.cs | 9 +-- Abblix.Jwt/JsonWebTokenValidator.cs | 81 +++++++++++++++---- .../IBackChannelAuthenticationHandler.cs | 3 +- 4 files changed, 82 insertions(+), 23 deletions(-) diff --git a/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs b/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs index 35811abf..8efa5d83 100644 --- a/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs +++ b/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs @@ -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; @@ -45,7 +46,7 @@ public async Task JwtFullCycleTest() NotBefore = issuedAt, ExpiresAt = issuedAt + TimeSpan.FromDays(1), Issuer = "abblix.com", - Audiences = new []{ nameof(JwtFullCycleTest) }, + Audiences = [nameof(JwtFullCycleTest)], ["test"] = "value", ["address"] = new JsonObject { @@ -53,7 +54,8 @@ public async Task JwtFullCycleTest() { "city", "Springfield" }, { "state", "IL" }, { "zip", "62701" }, - } + }, + ["colors"] = new JsonArray("red", "green", "blue"), }, }; @@ -73,6 +75,12 @@ 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); } private static IEnumerable<(string Key, string?)> ExtractClaims(JsonWebToken token) diff --git a/Abblix.Jwt/JsonObjectExtensions.cs b/Abblix.Jwt/JsonObjectExtensions.cs index 5a33fa62..8d86074a 100644 --- a/Abblix.Jwt/JsonObjectExtensions.cs +++ b/Abblix.Jwt/JsonObjectExtensions.cs @@ -70,14 +70,13 @@ public static class JsonObjectExtensions /// 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; diff --git a/Abblix.Jwt/JsonWebTokenValidator.cs b/Abblix.Jwt/JsonWebTokenValidator.cs index ad14f42c..2827f7cb 100644 --- a/Abblix.Jwt/JsonWebTokenValidator.cs +++ b/Abblix.Jwt/JsonWebTokenValidator.cs @@ -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; @@ -142,17 +143,59 @@ 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); } + /// + /// Merges a set of claims into a , excluding specified claim types. + /// + /// + /// Each claim is converted to a using ToJsonNode. + /// - Claims with a single value are stored as a . + /// - Claims with multiple values are stored in a . + /// + /// Excluded claim types will be skipped entirely. + /// Ensure that returned instances are not reused elsewhere in the JSON tree, + /// as System.Text.Json.Nodes does not allow a node to have more than one parent. + /// + /// The collection of claims to merge. + /// The target to populate with merged claims. + /// An array of claim type identifiers that should be excluded from the merge. + /// + /// Thrown if a grouped claim contains no values, + /// which should not occur under normal circumstances. + private static void MergeClaims(IEnumerable 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; + } + } /// /// Creates a representation of a claim value based on its type. @@ -160,16 +203,24 @@ private static JwtValidationResult Validate(string jwt, ValidationParameters par /// The type of the claim value. /// The string representation of the claim value. /// A representing the claim value. - private static JsonNode? ToJsonNode(string valueType, string value) - => valueType switch - { - JsonClaimValueTypes.Json => JsonNode.Parse(value).NotNull(nameof(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 => JsonValue.Create(long.Parse(value)), - ClaimValueTypes.Integer32 => JsonValue.Create(int.Parse(value)), - ClaimValueTypes.Integer64 => JsonValue.Create(long.Parse(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)), + ClaimValueTypes.Time => JsonValue.Create(TimeSpan.Parse(value)), - _ => value, - }; + 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), + }; } diff --git a/Abblix.Oidc.Server/Endpoints/BackChannelAuthentication/Interfaces/IBackChannelAuthenticationHandler.cs b/Abblix.Oidc.Server/Endpoints/BackChannelAuthentication/Interfaces/IBackChannelAuthenticationHandler.cs index e1aec194..448ff257 100644 --- a/Abblix.Oidc.Server/Endpoints/BackChannelAuthentication/Interfaces/IBackChannelAuthenticationHandler.cs +++ b/Abblix.Oidc.Server/Endpoints/BackChannelAuthentication/Interfaces/IBackChannelAuthenticationHandler.cs @@ -42,6 +42,7 @@ public interface IBackChannelAuthenticationHandler /// /// A task that represents the asynchronous operation, containing the backchannel authentication response. /// - Task HandleAsync(BackChannelAuthenticationRequest request, + Task HandleAsync( + BackChannelAuthenticationRequest request, ClientRequest clientRequest); } From a49de0545990433822050834cbe8ebc77aefbf6b Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Thu, 24 Apr 2025 15:58:05 +0300 Subject: [PATCH 2/7] Clean issues by Sonarqube --- Abblix.Jwt/JsonWebTokenExtensions.cs | 1 - Abblix.Jwt/JsonWebTokenValidator.cs | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Abblix.Jwt/JsonWebTokenExtensions.cs b/Abblix.Jwt/JsonWebTokenExtensions.cs index 037e36ed..fb18e996 100644 --- a/Abblix.Jwt/JsonWebTokenExtensions.cs +++ b/Abblix.Jwt/JsonWebTokenExtensions.cs @@ -221,5 +221,4 @@ private static JsonElement ToJsonElement(this string jsonString) /// public static JsonNode? ToJsonNode(this JsonElement jsonElement) => JsonNode.Parse(jsonElement.GetRawText()); - } diff --git a/Abblix.Jwt/JsonWebTokenValidator.cs b/Abblix.Jwt/JsonWebTokenValidator.cs index 2827f7cb..66f121c9 100644 --- a/Abblix.Jwt/JsonWebTokenValidator.cs +++ b/Abblix.Jwt/JsonWebTokenValidator.cs @@ -210,12 +210,14 @@ private static void MergeClaims(IEnumerable claims, JsonObject json, stri 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)), - ClaimValueTypes.Time => JsonValue.Create(TimeSpan.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.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)), From 33146013abed4f8bba1dbafbd9f158ed0d2cc967 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Thu, 24 Apr 2025 19:54:07 +0300 Subject: [PATCH 3/7] Introduced ClockSkew in Abblix.Jwt.ValidationOptions and set it to TimeSpan.Zero by default --- Abblix.Jwt.UnitTests/JwtEncryptionTests.cs | 9 ++++- Abblix.Jwt/JsonWebKeyFactory.cs | 2 +- Abblix.Jwt/JsonWebTokenValidator.cs | 20 +++++----- Abblix.Jwt/ValidationOptions.cs | 2 +- Abblix.Jwt/ValidationParameters.cs | 43 ++++++++++++---------- 5 files changed, 43 insertions(+), 33 deletions(-) diff --git a/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs b/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs index 8efa5d83..561f75a5 100644 --- a/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs +++ b/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs @@ -36,6 +36,7 @@ public class JwtEncryptionTests public async Task JwtFullCycleTest() { var issuedAt = DateTimeOffset.UtcNow; + var expiresIn = TimeSpan.FromSeconds(10); var token = new JsonWebToken { @@ -44,7 +45,7 @@ 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 = [nameof(JwtFullCycleTest)], ["test"] = "value", @@ -81,6 +82,12 @@ public async Task JwtFullCycleTest() 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(await validator.ValidateAsync(jwt, parameters)); + Assert.Equal(JwtError.InvalidToken, error); + Assert.Equal("", description); } private static IEnumerable<(string Key, string?)> ExtractClaims(JsonWebToken token) diff --git a/Abblix.Jwt/JsonWebKeyFactory.cs b/Abblix.Jwt/JsonWebKeyFactory.cs index 2b3e964f..05308015 100644 --- a/Abblix.Jwt/JsonWebKeyFactory.cs +++ b/Abblix.Jwt/JsonWebKeyFactory.cs @@ -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)) diff --git a/Abblix.Jwt/JsonWebTokenValidator.cs b/Abblix.Jwt/JsonWebTokenValidator.cs index 66f121c9..fc15a22f 100644 --- a/Abblix.Jwt/JsonWebTokenValidator.cs +++ b/Abblix.Jwt/JsonWebTokenValidator.cs @@ -58,41 +58,41 @@ public Task ValidateAsync(string jwt, ValidationParameters /// The result of the JWT validation process, either indicating success or detailing any validation errors. 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); @@ -105,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); @@ -119,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) { diff --git a/Abblix.Jwt/ValidationOptions.cs b/Abblix.Jwt/ValidationOptions.cs index 403b9193..fcdbcd28 100644 --- a/Abblix.Jwt/ValidationOptions.cs +++ b/Abblix.Jwt/ValidationOptions.cs @@ -23,7 +23,7 @@ namespace Abblix.Jwt; /// -/// 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. /// [Flags] diff --git a/Abblix.Jwt/ValidationParameters.cs b/Abblix.Jwt/ValidationParameters.cs index 777c864d..5f870943 100644 --- a/Abblix.Jwt/ValidationParameters.cs +++ b/Abblix.Jwt/ValidationParameters.cs @@ -23,62 +23,65 @@ namespace Abblix.Jwt; /// -/// Represents the parameters used for validating a JSON Web Token (JWT). +/// Defines parameters used during the validation of a JSON Web Token (JWT). /// public record ValidationParameters { /// - /// Gets or sets the validation options. + /// Options that control various aspects of JWT validation. /// public ValidationOptions Options { get; init; } = ValidationOptions.Default; /// - /// Gets or sets the delegate for issuer validation. + /// Delegate used to verify the validity of a token issuer. /// public ValidateIssuersDelegate? ValidateIssuer { get; set; } /// - /// Gets or sets the delegate for audience validation. + /// Delegate used to validate one or more token audiences. /// public ValidateAudienceDelegate? ValidateAudience { get; set; } /// - /// 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. /// public ResolveIssuerSigningKeysDelegate? ResolveIssuerSigningKeys { get; set; } /// - /// Gets or sets the delegate for resolving token decryption keys. + /// Delegate that resolves decryption keys for a given issuer, used during token decryption. /// public ResolveTokenDecryptionKeysDelegate? ResolveTokenDecryptionKeys { get; set; } /// - /// 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. /// - /// The issuer for which to resolve the signing keys. - /// An asynchronous enumerable of JSON Web Keys. + public TimeSpan ClockSkew { get; set; } = TimeSpan.Zero; + + /// + /// Resolves signing keys (JWKs) asynchronously for a specified issuer. + /// + /// Issuer whose signing keys are to be resolved. + /// An asynchronous stream of JSON Web Keys. public delegate IAsyncEnumerable ResolveIssuerSigningKeysDelegate(string issuer); /// - /// 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. /// - /// The issuer for which to resolve the decryption keys. - /// An asynchronous enumerable of JSON Web Keys. + /// Issuer whose decryption keys are to be resolved. + /// An asynchronous stream of JSON Web Keys. public delegate IAsyncEnumerable ResolveTokenDecryptionKeysDelegate(string issuer); /// - /// Represents a delegate that validates a set of audiences against a specific criterion. + /// Validates a collection of audiences against expected values. /// - /// The audiences to validate. - /// A task that represents the asynchronous validation operation. The task result contains the validation outcome. + /// Audiences to be validated. + /// A task that returns true if validation succeeds. public delegate Task ValidateAudienceDelegate(IEnumerable audiences); /// - /// Represents a delegate that validates an issuer against a specific criterion. + /// Validates a token issuer against expected values. /// - /// The issuer to validate. - /// A task that represents the asynchronous validation operation. The task result contains the validation outcome. + /// Issuer to be validated. + /// A task that returns true if validation succeeds. public delegate Task ValidateIssuersDelegate(string issuer); }; From d661aa99b7afe2cc1d8007335fd321b6eaec8c13 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Thu, 1 May 2025 13:08:44 +0300 Subject: [PATCH 4/7] Fixed assert for description --- Abblix.Jwt.UnitTests/JwtEncryptionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs b/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs index 561f75a5..e8d88030 100644 --- a/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs +++ b/Abblix.Jwt.UnitTests/JwtEncryptionTests.cs @@ -87,7 +87,7 @@ public async Task JwtFullCycleTest() var (error, description) = Assert.IsType(await validator.ValidateAsync(jwt, parameters)); Assert.Equal(JwtError.InvalidToken, error); - Assert.Equal("", description); + Assert.Contains("Lifetime validation failed", description); } private static IEnumerable<(string Key, string?)> ExtractClaims(JsonWebToken token) From 5800db75170e43d3ed3e4cb6d0d7b898f258a592 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Wed, 7 May 2025 19:28:50 +0300 Subject: [PATCH 5/7] Fix regex and add unit tests for it --- .../Abblix.Oidc.Server.Mvc.UnitTests.csproj | 32 +++++++++++++++ .../ConfigurableRouteConventionTests.cs | 39 +++++++++++++++++++ .../appsettings.json | 20 ++++++++++ .../ConfigurableRouteConvention.cs | 19 ++++----- Abblix.Oidc.Server.Mvc/Path.cs | 2 +- .../ClientSecretAuthenticator.cs | 10 ++--- .../PrivateKeyJwtAuthenticator.cs | 5 +++ 7 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 Abblix.Oidc.Server.Mvc.UnitTests/Abblix.Oidc.Server.Mvc.UnitTests.csproj create mode 100644 Abblix.Oidc.Server.Mvc.UnitTests/ConfigurableRouteConventionTests.cs create mode 100644 Abblix.Oidc.Server.Mvc.UnitTests/appsettings.json diff --git a/Abblix.Oidc.Server.Mvc.UnitTests/Abblix.Oidc.Server.Mvc.UnitTests.csproj b/Abblix.Oidc.Server.Mvc.UnitTests/Abblix.Oidc.Server.Mvc.UnitTests.csproj new file mode 100644 index 00000000..272706b9 --- /dev/null +++ b/Abblix.Oidc.Server.Mvc.UnitTests/Abblix.Oidc.Server.Mvc.UnitTests.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/Abblix.Oidc.Server.Mvc.UnitTests/ConfigurableRouteConventionTests.cs b/Abblix.Oidc.Server.Mvc.UnitTests/ConfigurableRouteConventionTests.cs new file mode 100644 index 00000000..01ec58be --- /dev/null +++ b/Abblix.Oidc.Server.Mvc.UnitTests/ConfigurableRouteConventionTests.cs @@ -0,0 +1,39 @@ +using Abblix.Oidc.Server.Mvc.Features.ConfigurableRoutes; +using Microsoft.Extensions.Configuration; + +namespace Abblix.Oidc.Server.Mvc.UnitTests; + +public class ConfigurableRouteConventionTests +{ + public ConfigurableRouteConventionTests() + { + var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .Build(); + + var routesSection = configuration.GetSection("Routes"); + _convention = new ConfigurableRouteConvention(Path.RoutePrefix, routesSection); + } + + private readonly ConfigurableRouteConvention _convention; + + [Theory] + [InlineData(Path.Authorize, "~/connect/authorize")] + [InlineData(Path.PushAuthorizationRequest, "~/connect/par")] + [InlineData(Path.UserInfo, "~/connect/userinfo")] + [InlineData(Path.EndSession, "~/connect/endsession")] + [InlineData(Path.CheckSession, "~/connect/checksession")] + [InlineData(Path.Token, "~/connect/token")] + [InlineData(Path.Revocation, "~/connect/revoke")] + [InlineData(Path.Introspection, "~/connect/introspect")] + [InlineData(Path.BackChannelAuthentication, "~/connect/bc-authorize")] + [InlineData(Path.DeviceAuthorization, "~/connect/deviceauthorization")] + [InlineData(Path.Register, "~/connect/register")] + [InlineData(Path.Configuration, "~/.well-known/openid-configuration")] + [InlineData(Path.Keys, "~/.well-known/jwks")] + public void Resolve_AllPathConstants_ReturnExpected(string template, string expected) + { + Assert.Equal(expected, _convention.Resolve(template)); + } +} diff --git a/Abblix.Oidc.Server.Mvc.UnitTests/appsettings.json b/Abblix.Oidc.Server.Mvc.UnitTests/appsettings.json new file mode 100644 index 00000000..4cee63bc --- /dev/null +++ b/Abblix.Oidc.Server.Mvc.UnitTests/appsettings.json @@ -0,0 +1,20 @@ +{ + "Routes": { + "base": "~/connect", + "authorize": "[route:base]/authorize", + "par": "[route:base]/par", + "userinfo": "[route:base]/userinfo", + "endsession": "[route:base]/endsession", + "checksession": "[route:base]/checksession", + "token": "[route:base]/token", + "revoke": "[route:base]/revoke", + "introspect": "[route:base]/introspect", + "bc_authorize": "[route:base]/bc-authorize", + "deviceauthorization": "[route:base]/deviceauthorization", + "register": "[route:base]/register", + + "well_known": "~/.well-known", + "configuration": "[route:well_known]/openid-configuration", + "jwks": "[route:well_known]/jwks" + } +} diff --git a/Abblix.Oidc.Server.Mvc/Features/ConfigurableRoutes/ConfigurableRouteConvention.cs b/Abblix.Oidc.Server.Mvc/Features/ConfigurableRoutes/ConfigurableRouteConvention.cs index 930d333e..bc615f6a 100644 --- a/Abblix.Oidc.Server.Mvc/Features/ConfigurableRoutes/ConfigurableRouteConvention.cs +++ b/Abblix.Oidc.Server.Mvc/Features/ConfigurableRoutes/ConfigurableRouteConvention.cs @@ -48,7 +48,7 @@ public ConfigurableRouteConvention(string prefix = "route", IConfigurationSectio { _configSection = configSection; _routeRegex = new Regex( - $@"\[{prefix}:(?<{TokenGroup}>\w+)(\?(?<{FallbackGroup}>[^\]]+))?\]", + $@"\[{prefix}:(?<{TokenGroup}>\w+)(\?(?<{FallbackGroup}>.+))?\]", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); } @@ -79,7 +79,7 @@ public void Apply(ApplicationModel application) /// Applies token replacement to a single selector's route template. /// /// The whose route template may contain tokens. - private void Apply(SelectorModel selector) + public void Apply(SelectorModel selector) { var model = selector.AttributeRouteModel; if (!string.IsNullOrEmpty(model?.Template)) @@ -94,22 +94,17 @@ private void Apply(SelectorModel selector) /// /// Thrown if a token cannot be resolved and no fallback is provided. /// - private string Resolve(string template) + public string Resolve(string template) { var resolvedTokens = new HashSet(StringComparer.Ordinal); - bool tokenFound; - do + while (true) { - tokenFound = false; - var originalTemplate = template; template = _routeRegex.Replace( originalTemplate, match => { - tokenFound = true; - var token = match.Groups[TokenGroup].Value; if (!resolvedTokens.Add(token)) throw new InvalidOperationException($"Circular dependency for token '{token}' in route '{originalTemplate}'."); @@ -125,9 +120,11 @@ private string Resolve(string template) throw new InvalidOperationException($"Can't resolve the route {token}"); }); - Debug.WriteLine($"Intermediate resolved template: {template}"); + if (string.Equals(originalTemplate, template, StringComparison.Ordinal)) + break; - } while (tokenFound); + Debug.WriteLine($"The template {originalTemplate} resolved to {template}"); + } return template; } diff --git a/Abblix.Oidc.Server.Mvc/Path.cs b/Abblix.Oidc.Server.Mvc/Path.cs index 2010154e..db58f71d 100644 --- a/Abblix.Oidc.Server.Mvc/Path.cs +++ b/Abblix.Oidc.Server.Mvc/Path.cs @@ -28,7 +28,7 @@ namespace Abblix.Oidc.Server.Mvc; /// public static class Path { - internal const string RoutePrefix = "route"; + public const string RoutePrefix = "route"; private const string Base = "[" + RoutePrefix + ":base?~/connect]"; diff --git a/Abblix.Oidc.Server/Features/ClientAuthentication/ClientSecretAuthenticator.cs b/Abblix.Oidc.Server/Features/ClientAuthentication/ClientSecretAuthenticator.cs index c3e22187..d77f68e4 100644 --- a/Abblix.Oidc.Server/Features/ClientAuthentication/ClientSecretAuthenticator.cs +++ b/Abblix.Oidc.Server/Features/ClientAuthentication/ClientSecretAuthenticator.cs @@ -115,13 +115,13 @@ protected ClientSecretAuthenticator( private bool TryValidateClientSecret(ClientInfo client, string secret) { // We store only client secret hashes, so we have to hash the raw secret to compare. And we do it lazy. - var matchingSha256Secrets = FindMatchingSecrets(client, - clientSecret => clientSecret.Sha512Hash, HashAlgorithm.Sha512, secret); + var matchingSha512Secrets = FindMatchingSecrets( + client, clientSecret => clientSecret.Sha512Hash, HashAlgorithm.Sha512, secret); - var matchingSha512Secrets = FindMatchingSecrets(client, - clientSecret => clientSecret.Sha256Hash, HashAlgorithm.Sha256, secret); + var matchingSha256Secrets = FindMatchingSecrets( + client, clientSecret => clientSecret.Sha256Hash, HashAlgorithm.Sha256, secret); - var matchingSecret = matchingSha256Secrets.Concat(matchingSha512Secrets) + var matchingSecret = matchingSha512Secrets.Concat(matchingSha256Secrets) .MaxBy(item => item.ExpiresAt); if (matchingSecret == null) diff --git a/Abblix.Oidc.Server/Features/ClientAuthentication/PrivateKeyJwtAuthenticator.cs b/Abblix.Oidc.Server/Features/ClientAuthentication/PrivateKeyJwtAuthenticator.cs index 4d6b551e..128f5a1b 100644 --- a/Abblix.Oidc.Server/Features/ClientAuthentication/PrivateKeyJwtAuthenticator.cs +++ b/Abblix.Oidc.Server/Features/ClientAuthentication/PrivateKeyJwtAuthenticator.cs @@ -81,6 +81,11 @@ public IEnumerable ClientAuthenticationMethodsSupported /// The authenticated , or null if authentication fails. public async Task TryAuthenticateClientAsync(ClientRequest request) { + if (request.ClientAssertionType is null) + { + return null; + } + if (request.ClientAssertionType != ClientAssertionTypes.JwtBearer) { _logger.LogWarning( From 7052de7fc480cbd832f9ba7c88105eee37eff76b Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Sun, 25 May 2025 09:20:08 +0300 Subject: [PATCH 6/7] Pinned Microsoft.Extensions.Configuration.Binder version to 9.0.5 --- Abblix.Oidc.Server.Tests/Abblix.Oidc.Server.Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Abblix.Oidc.Server.Tests/Abblix.Oidc.Server.Tests.csproj b/Abblix.Oidc.Server.Tests/Abblix.Oidc.Server.Tests.csproj index 3dfaecc9..ba1bd288 100644 --- a/Abblix.Oidc.Server.Tests/Abblix.Oidc.Server.Tests.csproj +++ b/Abblix.Oidc.Server.Tests/Abblix.Oidc.Server.Tests.csproj @@ -5,8 +5,8 @@ - - + + From 5a7e45ffbbba62648a2ca12cfead61cfbadd5359 Mon Sep 17 00:00:00 2001 From: Kirill Kovalev Date: Sun, 25 May 2025 09:20:20 +0300 Subject: [PATCH 7/7] Minor cleanups --- .../ConfigurableRouteConventionTests.cs | 4 +--- .../AuthorizationContextExtensionsTests.cs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Abblix.Oidc.Server.Mvc.UnitTests/ConfigurableRouteConventionTests.cs b/Abblix.Oidc.Server.Mvc.UnitTests/ConfigurableRouteConventionTests.cs index 01ec58be..b7b7ab21 100644 --- a/Abblix.Oidc.Server.Mvc.UnitTests/ConfigurableRouteConventionTests.cs +++ b/Abblix.Oidc.Server.Mvc.UnitTests/ConfigurableRouteConventionTests.cs @@ -33,7 +33,5 @@ public ConfigurableRouteConventionTests() [InlineData(Path.Configuration, "~/.well-known/openid-configuration")] [InlineData(Path.Keys, "~/.well-known/jwks")] public void Resolve_AllPathConstants_ReturnExpected(string template, string expected) - { - Assert.Equal(expected, _convention.Resolve(template)); - } + => Assert.Equal(expected, _convention.Resolve(template)); } diff --git a/Abblix.Oidc.Server.Tests/AuthorizationContextExtensionsTests.cs b/Abblix.Oidc.Server.Tests/AuthorizationContextExtensionsTests.cs index 4d79da1b..192fa8cc 100644 --- a/Abblix.Oidc.Server.Tests/AuthorizationContextExtensionsTests.cs +++ b/Abblix.Oidc.Server.Tests/AuthorizationContextExtensionsTests.cs @@ -36,7 +36,7 @@ public void SerializeDeserializeTest() { var ac = new AuthorizationContext( "clientId", - new []{ "scope1", "scope2" }, + ["scope1", "scope2"], new RequestedClaims { UserInfo = new Dictionary { { "abc", new RequestedClaimDetails { Essential = true } },