Browse Source

Allow using unique JSON_ARRAY claims to represent arrays of strings

pull/2067/head
Kévin Chalet 2 years ago
parent
commit
38d87b2945
  1. 29
      shared/OpenIddict.Extensions/OpenIddictHelpers.cs
  2. 64
      src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs
  3. 16
      src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs
  4. 17
      src/OpenIddict.Client/OpenIddictClientHandlers.Introspection.cs
  5. 29
      src/OpenIddict.Client/OpenIddictClientHandlers.cs
  6. 16
      src/OpenIddict.Server/OpenIddictServerHandlers.cs
  7. 16
      src/OpenIddict.Validation/OpenIddictValidationHandlers.Discovery.cs
  8. 17
      src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs
  9. 38
      test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs
  10. 41
      test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs

29
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
/// <summary>
/// Determines whether the items contained in <paramref name="element"/>
/// are of the specified <paramref name="kind"/>.
/// </summary>
/// <param name="element">The <see cref="JsonElement"/>.</param>
/// <param name="kind">The expected <see cref="JsonValueKind"/>.</param>
/// <returns>
/// <see langword="true"/> if the array doesn't contain any value or if all the items
/// are of the specified <paramref name="kind"/>, <see langword="false"/> otherwise.
/// </returns>
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;
}
}

64
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<string>();
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<JsonElement>(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();
}
/// <summary>
@ -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<string>();
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<JsonElement>(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();
}
/// <summary>

16
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;
}
}
}

17
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;
}
}
}

29
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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(value) is { ValueKind: JsonValueKind.Array } element &&
OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String)),
// The following claims MUST be represented as unique integers.
Claims.Private.StateTokenLifetime

16
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<JsonElement>(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<JsonElement>(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

16
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;
}
}
}

17
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;
}
}
}

38
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()
{

41
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<HandleTokenRequestContext>(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()
{

Loading…
Cancel
Save