diff --git a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs index 49469fb3..a23c2048 100644 --- a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs +++ b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Primitives; namespace OpenIddict.Extensions; @@ -1032,4 +1033,32 @@ internal static class OpenIddictHelpers return false; } #endif + + /// + /// Determines whether the items contained in + /// are of the specified . + /// + /// The . + /// The expected . + /// + /// if the array doesn't contain any value or if all the items + /// are of the specified , otherwise. + /// + public static bool ValidateArrayElements(JsonElement element, JsonValueKind kind) + { + if (element.ValueKind is not JsonValueKind.Array) + { + throw new ArgumentOutOfRangeException(nameof(element)); + } + + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind != kind) + { + return false; + } + } + + return true; + } } diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs index c81d09bb..cc42c279 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs @@ -1579,7 +1579,37 @@ public static class OpenIddictExtensions throw new ArgumentException(SR.GetResourceString(SR.ID0184), nameof(type)); } - return identity.FindAll(type).Select(claim => claim.Value).Distinct(StringComparer.Ordinal).ToImmutableArray(); + var builder = ImmutableArray.CreateBuilder(); + + foreach (var claim in identity.FindAll(type)) + { + // If the claim uses the special JSON_ARRAY claim value type, parse it to extract its individual + // values. When the individual values are not strings, their string representation is returned. + if (claim.ValueType is "JSON_ARRAY") + { + var element = JsonSerializer.Deserialize(claim.Value); + if (element.ValueKind is not JsonValueKind.Array) + { + continue; + } + + foreach (var item in element.EnumerateArray()) + { + var value = item.ToString(); + if (!builder.Contains(value)) + { + builder.Add(value); + } + } + } + + else if (!builder.Contains(claim.Value)) + { + builder.Add(claim.Value); + } + } + + return builder.ToImmutable(); } /// @@ -1600,7 +1630,37 @@ public static class OpenIddictExtensions throw new ArgumentException(SR.GetResourceString(SR.ID0184), nameof(type)); } - return principal.FindAll(type).Select(claim => claim.Value).Distinct(StringComparer.Ordinal).ToImmutableArray(); + var builder = ImmutableArray.CreateBuilder(); + + foreach (var claim in principal.FindAll(type)) + { + // If the claim uses the special JSON_ARRAY claim value type, parse it to extract its individual + // values. When the individual values are not strings, their string representation is returned. + if (claim.ValueType is "JSON_ARRAY") + { + var element = JsonSerializer.Deserialize(claim.Value); + if (element.ValueKind is not JsonValueKind.Array) + { + continue; + } + + foreach (var item in element.EnumerateArray()) + { + var value = item.ToString(); + if (!builder.Contains(value)) + { + builder.Add(value); + } + } + } + + else if (!builder.Contains(claim.Value)) + { + builder.Add(claim.Value); + } + } + + return builder.ToImmutable(); } /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs index 7bb0fbff..17532908 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs @@ -120,7 +120,8 @@ public static partial class OpenIddictClientHandlers Metadata.ScopesSupported or Metadata.TokenEndpointAuthMethodsSupported => ((JsonElement) value) is JsonElement element && - element.ValueKind is JsonValueKind.Array && ValidateStringArray(element), + element.ValueKind is JsonValueKind.Array && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String), // The following parameters MUST be formatted as booleans: Metadata.AuthorizationResponseIssParameterSupported @@ -129,19 +130,6 @@ public static partial class OpenIddictClientHandlers // Parameters that are not in the well-known list can be of any type. _ => true }; - - static bool ValidateStringArray(JsonElement element) - { - foreach (var item in element.EnumerateArray()) - { - if (item.ValueKind is not JsonValueKind.String) - { - return false; - } - } - - return true; - } } } diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Introspection.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Introspection.cs index 7030313f..76482844 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Introspection.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Introspection.cs @@ -10,6 +10,7 @@ using System.Globalization; using System.Security.Claims; using System.Text.Json; using Microsoft.Extensions.Logging; +using OpenIddict.Extensions; namespace OpenIddict.Client; @@ -94,7 +95,8 @@ public static partial class OpenIddictClientHandlers // Note: empty arrays and arrays that contain a single value are also considered valid. Claims.Audience => ((JsonElement) value) is JsonElement element && element.ValueKind is JsonValueKind.String || - (element.ValueKind is JsonValueKind.Array && ValidateStringArray(element)), + (element.ValueKind is JsonValueKind.Array && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)), // The following claims MUST be formatted as numeric dates: Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore @@ -104,19 +106,6 @@ public static partial class OpenIddictClientHandlers // Claims that are not in the well-known list can be of any type. _ => true }; - - static bool ValidateStringArray(JsonElement element) - { - foreach (var item in element.EnumerateArray()) - { - if (item.ValueKind is not JsonValueKind.String) - { - return false; - } - } - - return true; - } } } diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index 22a6ee05..998f43dc 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -13,6 +13,7 @@ using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using OpenIddict.Extensions; using static OpenIddict.Abstractions.OpenIddictExceptions; @@ -1734,7 +1735,12 @@ public static partial class OpenIddictClientHandlers // The following claims MUST be represented as unique strings or array of strings. Claims.Audience or Claims.AuthenticationMethodReference - => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String) || + // Note: a unique claim using the special JSON_ARRAY claim value type is allowed + // if the individual elements of the parsed JSON array are all string values. + (values is [{ ValueType: JsonClaimValueTypes.JsonArray, Value: string value }] && + JsonSerializer.Deserialize(value) is { ValueKind: JsonValueKind.Array } element && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)), // The following claims MUST be represented as unique numeric dates. Claims.AuthenticationTime or Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore @@ -3079,7 +3085,12 @@ public static partial class OpenIddictClientHandlers // The following claims MUST be represented as unique strings or array of strings. Claims.Audience or Claims.AuthenticationMethodReference - => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String) || + // Note: a unique claim using the special JSON_ARRAY claim value type is allowed + // if the individual elements of the parsed JSON array are all string values. + (values is [{ ValueType: JsonClaimValueTypes.JsonArray, Value: string value }] && + JsonSerializer.Deserialize(value) is { ValueKind: JsonValueKind.Array } element && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)), // The following claims MUST be represented as unique numeric dates. Claims.AuthenticationTime or Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore @@ -4271,7 +4282,12 @@ public static partial class OpenIddictClientHandlers { // The following claims MUST be represented as unique strings or array of strings. Claims.Private.Audience or Claims.Private.Resource or Claims.Private.Presenter - => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String) || + // Note: a unique claim using the special JSON_ARRAY claim value type is allowed + // if the individual elements of the parsed JSON array are all string values. + (values is [{ ValueType: JsonClaimValueTypes.JsonArray, Value: string value }] && + JsonSerializer.Deserialize(value) is { ValueKind: JsonValueKind.Array } element && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)), // The following claims MUST be represented as unique integers. Claims.Private.StateTokenLifetime @@ -7094,7 +7110,12 @@ public static partial class OpenIddictClientHandlers { // The following claims MUST be represented as unique strings or array of strings. Claims.Private.Audience or Claims.Private.Resource or Claims.Private.Presenter - => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String) || + // Note: a unique claim using the special JSON_ARRAY claim value type is allowed + // if the individual elements of the parsed JSON array are all string values. + (values is [{ ValueType: JsonClaimValueTypes.JsonArray, Value: string value }] && + JsonSerializer.Deserialize(value) is { ValueKind: JsonValueKind.Array } element && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)), // The following claims MUST be represented as unique integers. Claims.Private.StateTokenLifetime diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 6e939bc5..9df9fd88 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -14,6 +14,7 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using OpenIddict.Extensions; @@ -698,7 +699,13 @@ public static partial class OpenIddictServerHandlers => values is [{ ValueType: ClaimValueTypes.String }], // The following claims MUST be represented as unique strings or array of strings. - Claims.Audience => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + Claims.Audience + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String) || + // Note: a unique claim using the special JSON_ARRAY claim value type is allowed + // if the individual elements of the parsed JSON array are all string values. + (values is [{ ValueType: JsonClaimValueTypes.JsonArray, Value: string value }] && + JsonSerializer.Deserialize(value) is { ValueKind: JsonValueKind.Array } element && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)), // The following claims MUST be represented as unique numeric dates. Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore @@ -2210,7 +2217,12 @@ public static partial class OpenIddictServerHandlers // The following claims MUST be represented as unique strings or array of strings. Claims.AuthenticationMethodReference or Claims.Private.Audience or Claims.Private.Presenter or Claims.Private.Resource - => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String), + => values.TrueForAll(static value => value.ValueType is ClaimValueTypes.String) || + // Note: a unique claim using the special JSON_ARRAY claim value type is allowed + // if the individual elements of the parsed JSON array are all string values. + (values is [{ ValueType: JsonClaimValueTypes.JsonArray, Value: string value }] && + JsonSerializer.Deserialize(value) is { ValueKind: JsonValueKind.Array } element && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)), // The following claims MUST be represented as unique integers. Claims.Private.AccessTokenLifetime or Claims.Private.AuthorizationCodeLifetime or diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Discovery.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Discovery.cs index 5754e4f7..d6b678ea 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Discovery.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Discovery.cs @@ -94,24 +94,12 @@ public static partial class OpenIddictValidationHandlers // The following parameters MUST be formatted as arrays of strings: Metadata.IntrospectionEndpointAuthMethodsSupported => ((JsonElement) value) is JsonElement element && - element.ValueKind is JsonValueKind.Array && ValidateStringArray(element), + element.ValueKind is JsonValueKind.Array && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String), // Parameters that are not in the well-known list can be of any type. _ => true }; - - static bool ValidateStringArray(JsonElement element) - { - foreach (var item in element.EnumerateArray()) - { - if (item.ValueKind is not JsonValueKind.String) - { - return false; - } - } - - return true; - } } } diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs index 2120c188..6c2b4c53 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs @@ -10,6 +10,7 @@ using System.Globalization; using System.Security.Claims; using System.Text.Json; using Microsoft.Extensions.Logging; +using OpenIddict.Extensions; namespace OpenIddict.Validation; @@ -94,7 +95,8 @@ public static partial class OpenIddictValidationHandlers // Note: empty arrays and arrays that contain a single value are also considered valid. Claims.Audience => ((JsonElement) value) is JsonElement element && element.ValueKind is JsonValueKind.String || - (element.ValueKind is JsonValueKind.Array && ValidateStringArray(element)), + (element.ValueKind is JsonValueKind.Array && + OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)), // The following claims MUST be formatted as numeric dates: Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore @@ -104,19 +106,6 @@ public static partial class OpenIddictValidationHandlers // Claims that are not in the well-known list can be of any type. _ => true }; - - static bool ValidateStringArray(JsonElement element) - { - foreach (var item in element.EnumerateArray()) - { - if (item.ValueKind is not JsonValueKind.String) - { - return false; - } - } - - return true; - } } } diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs index 3785aaaa..c4a7c25d 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs @@ -2592,6 +2592,44 @@ public class OpenIddictExtensionsTests Assert.Equal([Scopes.OpenId, Scopes.Profile], principal.GetClaims(Claims.Scope), StringComparer.Ordinal); } + [Fact] + public void ClaimsIdentity_GetClaims_ReturnsExpectedResultForJsonArrayClaims() + { + // Arrange + var identity = new ClaimsIdentity(); + identity.AddClaim(new Claim("array_claim", + """["value1", "value2", 42, ["value1", "value2"], {"property": "value"}]""", "JSON_ARRAY")); + + // Act and assert + var claims = identity.GetClaims("array_claim"); + Assert.Equal(5, claims.Length); + Assert.Equal("value1", claims[0]); + Assert.Equal("value2", claims[1]); + Assert.Equal("42", claims[2]); + Assert.Equal("""["value1", "value2"]""", claims[3]); + Assert.Equal("""{"property": "value"}""", claims[4]); + } + + [Fact] + public void ClaimsPrincipal_GetClaims_ReturnsExpectedResultForJsonArrayClaims() + { + // Arrange + var identity = new ClaimsIdentity(); + identity.AddClaim(new Claim("array_claim", + """["value1", "value2", 42, ["value1", "value2"], {"property": "value"}]""", "JSON_ARRAY")); + + var principal = new ClaimsPrincipal(identity); + + // Act and assert + var claims = principal.GetClaims("array_claim"); + Assert.Equal(5, claims.Length); + Assert.Equal("value1", claims[0]); + Assert.Equal("value2", claims[1]); + Assert.Equal("42", claims[2]); + Assert.Equal("""["value1", "value2"]""", claims[3]); + Assert.Equal("""{"property": "value"}""", claims[4]); + } + [Fact] public void ClaimsIdentity_HasClaim_ThrowsAnExceptionForNullIdentity() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index d2747fc8..3664c3d0 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -12,6 +12,7 @@ using System.Security.Claims; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; using Moq; using OpenIddict.Core; using Xunit; @@ -1483,6 +1484,46 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.FormatID0424(Claims.Subject), exception.Message); } + [Fact] + public async Task ProcessSignIn_ValidClaimValueTypeDoesNotCauseAnException() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + var identity = new ClaimsIdentity("Bearer") + .SetTokenType(TokenTypeHints.AuthorizationCode) + .SetPresenters("Fabrikam") + .SetClaim(Claims.Subject, "Bob le Bricoleur"); + + identity.AddClaim(new Claim(Claims.AuthenticationMethodReference, + """["value"]""", JsonClaimValueTypes.JsonArray)); + + context.Principal = new ClaimsPrincipal(identity); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w", + Scope = Scopes.OpenId + }); + + // Assert + Assert.NotNull(response.AccessToken); + } + [Fact] public async Task ProcessSignIn_ScopeDefaultsToOpenId() {