diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Program.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Program.cs
index 74a87152..1891abf6 100644
--- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Program.cs
+++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Program.cs
@@ -97,6 +97,8 @@ builder.Services.AddOpenIddict()
RedirectUri = new Uri("callback/login/local", UriKind.Relative),
PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative),
+ // ClientSecret = "emCimpdc9SeOaZzN5jzm4_eek-STF6VenfVlKO1_qt0",
+ //
// On supported platforms, this application can authenticate using 3 different client
// authentication methods that all offer a higher security level than shared client secrets:
//
diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx
index bf0fb02e..eb44e11f 100644
--- a/src/OpenIddict.Abstractions/OpenIddictResources.resx
+++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx
@@ -849,10 +849,10 @@ To register the validation services, use 'services.AddOpenIddict().AddValidation
The secret cannot be null or empty.
- The specified hash algorithm is not valid.
+ The specified hash algorithm ({0}) is not valid or is not supported by the platform.
- The comparand cannot be null or empty.
+ The client secret payload stored in the database is malformed, uses an unsupported version or doesn't meet the minimum security requirements and may have been tampered with.One or more validation error(s) occurred while trying to create a new authorization:
@@ -1869,6 +1869,15 @@ To use a custom policy relying on the system store, set 'OpenIddictServerOptions
The '{0}' grant type is already assigned to a standard grant type and cannot be used for custom flows.
+
+ The number of iterations for derivation of client secrets cannot be lower than {0} or higher than {1}.
+
+
+ The length of salts used for derivation of client secrets cannot be lower than {0} or higher than {1} bits.
+
+
+ The output length used for derivation of client secrets cannot be lower than {0} or higher than {1} bits.
+
The security token is missing.
@@ -2549,6 +2558,9 @@ To use a custom policy relying on the system store, set 'OpenIddictServerOptions
The X.509 client certificate shouldn't be null at this point.
+
+ The length of the memory span ({0}) doesn't match the expected value ({1}).
+
An error occurred while validating the token '{Token}'.
@@ -3332,6 +3344,12 @@ This may indicate that the hashed entry is corrupted or malformed.
Certificate validation failed because the token binding certificate provided by an anonymous client was not valid: {Errors}.
+
+ The client secret attached to the client application {ClientId} was automatically re-hashed to match the new settings.
+
+
+ The client secret attached to the client application {ClientId} couldn't be automatically re-hashed due to a concurency exception.
+
https://documentation.openiddict.com/errors/{0}
diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs
index 151f62f3..1c6c3e0d 100644
--- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs
+++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs
@@ -4,9 +4,11 @@
* the license and the contributors participating to this project.
*/
+using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
@@ -894,7 +896,7 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
ArgumentNullException.ThrowIfNull(application);
ArgumentException.ThrowIfNullOrEmpty(type);
- return string.Equals(await GetApplicationTypeAsync(application, cancellationToken), type, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(await GetApplicationTypeAsync(application, cancellationToken), type, StringComparison.Ordinal);
}
///
@@ -910,7 +912,7 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
ArgumentNullException.ThrowIfNull(application);
ArgumentException.ThrowIfNullOrEmpty(type);
- return string.Equals(await GetClientTypeAsync(application, cancellationToken), type, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(await GetClientTypeAsync(application, cancellationToken), type, StringComparison.Ordinal);
}
///
@@ -926,7 +928,7 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
ArgumentNullException.ThrowIfNull(application);
ArgumentException.ThrowIfNullOrEmpty(type);
- return string.Equals(await GetConsentTypeAsync(application, cancellationToken), type, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(await GetConsentTypeAsync(application, cancellationToken), type, StringComparison.Ordinal);
}
///
@@ -1284,22 +1286,21 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
else
{
// Ensure the application type is supported by the manager.
- if (!string.Equals(type, ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase) &&
- !string.Equals(type, ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
+ if (type is not (ClientTypes.Confidential or ClientTypes.Public))
{
yield return new ValidationResult(SR.GetResourceString(SR.ID2112));
}
// Ensure no client secret was specified if the client is a public application.
var secret = await Store.GetClientSecretAsync(application, cancellationToken);
- if (!string.IsNullOrEmpty(secret) && string.Equals(type, ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
+ if (!string.IsNullOrEmpty(secret) && type is ClientTypes.Public)
{
yield return new ValidationResult(SR.GetResourceString(SR.ID2114));
}
// Ensure a client secret or a JSON Web Key suitable for signing
// was specified if the client is a confidential application.
- if (string.IsNullOrEmpty(secret) && string.Equals(type, ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrEmpty(secret) && type is ClientTypes.Confidential)
{
var set = await Store.GetJsonWebKeySetAsync(application, cancellationToken);
if (set?.Keys is null || !set.Keys.Any(static key =>
@@ -1395,13 +1396,35 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
return false;
}
- if (!await ValidateClientSecretAsync(secret, value, cancellationToken))
+ var result = await ValidateClientSecretAsync(secret, value, cancellationToken);
+ if (!result.IsValid)
{
Logger.LogInformation(6161, SR.GetResourceString(SR.ID6161), await GetClientIdAsync(application, cancellationToken));
return false;
}
+ // If the client secret was valid but a rehash is required, update the stored client secret with the new hash.
+ if (result.IsRehashRequired && !Options.CurrentValue.DisableAutomaticClientSecretRehashing)
+ {
+ try
+ {
+ await UpdateAsync(application, secret, cancellationToken);
+ }
+
+ catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception))
+ {
+ // If a non-fatal exception is thrown, ignore it: the client secret will be updated the next time it is validated again.
+ Logger.LogDebug(6295, exception, SR.GetResourceString(SR.ID6295), await GetClientIdAsync(application, cancellationToken));
+
+ return true;
+ }
+
+ Logger.LogInformation(6294, SR.GetResourceString(SR.ID6294), await GetClientIdAsync(application, cancellationToken));
+
+ return true;
+ }
+
return true;
}
@@ -1729,30 +1752,37 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
{
ArgumentException.ThrowIfNullOrEmpty(secret);
- // Note: the PRF, iteration count, salt length and key length currently all match the default values
- // used by CryptoHelper and ASP.NET Core Identity but this may change in the future, if necessary.
-
- var salt = RandomNumberGenerator.GetBytes(count: 128 / 8);
- var hash = HashSecret(secret, salt, HashAlgorithmName.SHA256, iterations: 10_000, length: 256 / 8);
-
- return new(Convert.ToBase64String(hash));
-
// Note: the following logic deliberately uses the same format as CryptoHelper (used in OpenIddict 1.x/2.x),
// which was itself based on ASP.NET Core Identity's latest hashed password format. This guarantees that
// secrets hashed using a recent OpenIddict version can still be read by older packages (and vice versa).
- static byte[] HashSecret(string secret, byte[] salt, HashAlgorithmName algorithm, int iterations, int length)
- {
- var key = Rfc2898DeriveBytes.Pbkdf2(secret, salt, iterations, algorithm, length);
- var payload = new byte[13 + salt.Length + key.Length];
+ var options = Options.CurrentValue;
+
+ var salt = RandomNumberGenerator.GetBytes(options.ClientSecretKeyDerivationSaltLength / 8);
+ var key = Rfc2898DeriveBytes.Pbkdf2(secret, salt,
+ options.ClientSecretKeyDerivationIterations,
+ options.ClientSecretKeyDerivationHashAlgorithm,
+ options.ClientSecretKeyDerivationOutputLength / 8);
+
+ var length = 1 + sizeof(uint) * 3 + salt.Length + key.Length;
+ // To avoid unnecessary allocations on the heap, use a stack-allocated buffer when the total length is less
+ // than 256 bytes. Otherwise, rent a buffer from the shared array pool and return it to the pool after use.
+ byte[]? array = null;
+ Span payload = (length is <= 256
+ ? stackalloc byte[256]
+ : (array = ArrayPool.Shared.Rent(minimumLength: length)))[..length];
+ Debug.Assert(payload.Length == length, SR.FormatID4021(payload.Length, length));
+
+ try
+ {
// Write the format marker.
payload[0] = 0x01;
// Write the hashing algorithm version.
- BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(1, sizeof(uint)), algorithm switch
+ BinaryPrimitives.WriteUInt32BigEndian(payload.Slice(1, sizeof(uint)), options.ClientSecretKeyDerivationHashAlgorithm switch
{
- var name when name == HashAlgorithmName.SHA1 => 0,
+ var name when name == HashAlgorithmName.SHA1 => 0,
var name when name == HashAlgorithmName.SHA256 => 1,
var name when name == HashAlgorithmName.SHA512 => 2,
@@ -1760,18 +1790,27 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
});
// Write the iteration count of the algorithm.
- BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(5, sizeof(uint)), (uint) iterations);
+ BinaryPrimitives.WriteUInt32BigEndian(payload.Slice(5, sizeof(uint)), (uint) options.ClientSecretKeyDerivationIterations);
// Write the size of the salt.
- BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(9, sizeof(uint)), (uint) salt.Length);
+ BinaryPrimitives.WriteUInt32BigEndian(payload.Slice(9, sizeof(uint)), (uint) salt.Length);
// Write the salt.
- salt.CopyTo(payload.AsSpan(13));
+ salt.CopyTo(payload.Slice(13, salt.Length));
// Write the subkey.
- key.CopyTo(payload.AsSpan(13 + salt.Length));
+ key.CopyTo(payload.Slice(13 + salt.Length, key.Length));
- return payload;
+ return new(Convert.ToBase64String(payload, Base64FormattingOptions.None));
+ }
+
+ finally
+ {
+ // Return the rented buffer to the pool if one was used.
+ if (array is not null)
+ {
+ ArrayPool.Shared.Return(array, clearArray: true);
+ }
}
}
@@ -1783,43 +1822,31 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
/// The value stored in the database, which is usually a hashed representation of the secret.
/// The that can be used to abort the operation.
///
- /// A that can be used to monitor the asynchronous operation,
- /// whose result returns a boolean indicating whether the specified value was valid.
+ /// A that can be used to monitor the asynchronous operation, whose result returns
+ /// a tuple indicating whether the client secret was valid and whether the client secret should be re-hashed.
///
- protected virtual ValueTask ValidateClientSecretAsync(
+ protected virtual ValueTask<(bool IsValid, bool IsRehashRequired)> ValidateClientSecretAsync(
string secret, string comparand, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(secret);
ArgumentException.ThrowIfNullOrEmpty(comparand);
- try
- {
- return new(VerifyHashedSecret(comparand, secret));
- }
-
- catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception))
- {
- Logger.LogWarning(6163, exception, SR.GetResourceString(SR.ID6163));
-
- return new(false);
- }
-
// Note: the following logic deliberately uses the same format as CryptoHelper (used in OpenIddict 1.x/2.x),
// which was itself based on ASP.NET Core Identity's latest hashed password format. This guarantees that
// secrets hashed using a recent OpenIddict version can still be read by older packages (and vice versa).
- static bool VerifyHashedSecret(string hash, string secret)
+ try
{
- var payload = new ReadOnlySpan(Convert.FromBase64String(hash));
- if (payload.Length is 0)
+ ReadOnlySpan payload = Convert.FromBase64String(comparand);
+ if (payload is [])
{
- return false;
+ throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand));
}
// Verify the hashing format version.
if (payload[0] is not 0x01)
{
- return false;
+ throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand));
}
// Read the hashing algorithm version.
@@ -1829,32 +1856,67 @@ public class OpenIddictApplicationManager : IOpenIddictApplication
1 => HashAlgorithmName.SHA256,
2 => HashAlgorithmName.SHA512,
- _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0217))
+ _ => throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand))
};
- // Read the iteration count of the algorithm.
+ // Read the iteration count of the algorithm and ensure it's more than
+ // 10 000 iterations, which is the value used in previous OpenIddict versions.
var iterations = (int) BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(5, sizeof(uint)));
+ if (iterations is not (>= 10_000 and <= 10_000_000))
+ {
+ throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand));
+ }
- // Read the size of the salt and ensure it's more than 128 bits.
- var saltLength = (int) BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(9, sizeof(uint)));
- if (saltLength is < 128 / 8)
+ // Read the size of the salt and ensure it's more than 128 bits,
+ // which is the value used in previous OpenIddict versions.
+ var length = (int) BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(9, sizeof(uint)));
+ if (length is not (>= 128 / 8 and <= 1024 / 8))
{
- return false;
+ throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand));
}
// Read the salt.
- var salt = payload.Slice(13, saltLength);
+ var salt = payload.Slice(13, length);
- // Ensure the derived key length is more than 128 bits.
- var keyLength = payload.Length - 13 - salt.Length;
- if (keyLength is < 128 / 8)
+ // Ensure the derived key length is more than 128 bits,
+ // which is the value used in previous OpenIddict versions.
+ length = payload.Length - 13 - salt.Length;
+ if (length is not (>= 128 / 8 and <= 2048 / 8))
{
- return false;
+ throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand));
}
- return CryptographicOperations.FixedTimeEquals(
- left : payload.Slice(13 + salt.Length, keyLength),
- right: Rfc2898DeriveBytes.Pbkdf2(secret, salt.ToArray(), iterations, algorithm, keyLength));
+ // Read the derived key.
+ var key = payload.Slice(13 + salt.Length, length);
+
+ // Hash the specified client secret with the same salt, iteration count and algorithm as the
+ // stored value, and compare the results: if they don't match, the client secret is invalid.
+ if (!CryptographicOperations.FixedTimeEquals(key, Rfc2898DeriveBytes.Pbkdf2(
+ secret, salt, iterations, algorithm, key.Length)))
+ {
+ return new((IsValid: false, IsRehashRequired: false));
+ }
+
+ var options = Options.CurrentValue;
+
+ // Note: if the client secret is valid but one of the key derivation options is not strictly identical (even
+ // when the application is now configured to use a lower security level), indicate that a rehash is required.
+ //
+ // This deliberately differs from ASP.NET Core Identity's logic, which only considers that a rehash is required
+ // when the iteration count configured in the password options is higher than the one extracted from the payload:
+ // doing that allows developers to select a lower security level (e.g fewer iterations or a less expensive hash
+ // algorithm) if the previous settings used in production proved to be too slow for their needs.
+ return new((IsValid: true, IsRehashRequired: algorithm != options.ClientSecretKeyDerivationHashAlgorithm ||
+ iterations != options.ClientSecretKeyDerivationIterations ||
+ salt.Length != options.ClientSecretKeyDerivationSaltLength / 8 ||
+ key.Length != options.ClientSecretKeyDerivationOutputLength / 8));
+ }
+
+ catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception))
+ {
+ Logger.LogWarning(6163, exception, SR.GetResourceString(SR.ID6163));
+
+ return new((IsValid: false, IsRehashRequired: false));
}
}
diff --git a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs
index 6b5c7c36..c8979b7b 100644
--- a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs
+++ b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs
@@ -630,7 +630,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori
ArgumentNullException.ThrowIfNull(authorization);
ArgumentException.ThrowIfNullOrEmpty(status);
- return string.Equals(await GetStatusAsync(authorization, cancellationToken), status, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(await GetStatusAsync(authorization, cancellationToken), status, StringComparison.Ordinal);
}
///
@@ -646,7 +646,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori
ArgumentNullException.ThrowIfNull(authorization);
ArgumentException.ThrowIfNullOrEmpty(type);
- return string.Equals(await GetTypeAsync(authorization, cancellationToken), type, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(await GetTypeAsync(authorization, cancellationToken), type, StringComparison.Ordinal);
}
///
@@ -811,7 +811,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori
ArgumentNullException.ThrowIfNull(authorization);
var status = await Store.GetStatusAsync(authorization, cancellationToken);
- if (string.Equals(status, Statuses.Revoked, StringComparison.OrdinalIgnoreCase))
+ if (status is Statuses.Revoked)
{
return true;
}
@@ -935,8 +935,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori
yield return new ValidationResult(SR.GetResourceString(SR.ID2116));
}
- else if (!string.Equals(type, AuthorizationTypes.AdHoc, StringComparison.OrdinalIgnoreCase) &&
- !string.Equals(type, AuthorizationTypes.Permanent, StringComparison.OrdinalIgnoreCase))
+ else if (type is not (AuthorizationTypes.AdHoc or AuthorizationTypes.Permanent))
{
yield return new ValidationResult(SR.GetResourceString(SR.ID2117));
}
diff --git a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs
index d1741a9e..f334ed8c 100644
--- a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs
+++ b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs
@@ -708,7 +708,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok
ArgumentNullException.ThrowIfNull(token);
ArgumentException.ThrowIfNullOrEmpty(status);
- return string.Equals(await GetStatusAsync(token, cancellationToken), status, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(await GetStatusAsync(token, cancellationToken), status, StringComparison.Ordinal);
}
///
@@ -723,7 +723,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok
ArgumentNullException.ThrowIfNull(token);
ArgumentException.ThrowIfNullOrEmpty(type);
- return string.Equals(await GetTypeAsync(token, cancellationToken), type, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(await GetTypeAsync(token, cancellationToken), type, StringComparison.Ordinal);
}
///
@@ -745,7 +745,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok
for (var index = 0; index < types.Length; index++)
{
- if (string.Equals(type, types[index], StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(type, types[index], StringComparison.Ordinal))
{
return true;
}
diff --git a/src/OpenIddict.Core/OpenIddictCoreBuilder.cs b/src/OpenIddict.Core/OpenIddictCoreBuilder.cs
index a468b7e7..191727cc 100644
--- a/src/OpenIddict.Core/OpenIddictCoreBuilder.cs
+++ b/src/OpenIddict.Core/OpenIddictCoreBuilder.cs
@@ -6,6 +6,7 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
+using System.Security.Cryptography;
using Microsoft.Extensions.DependencyInjection.Extensions;
using OpenIddict.Core;
@@ -372,6 +373,7 @@ public sealed class OpenIddictCoreBuilder
/// Disabling this feature MAY result in security vulnerabilities in the other cases.
///
/// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
public OpenIddictCoreBuilder DisableAdditionalFiltering()
=> Configure(options => options.DisableAdditionalFiltering = true);
@@ -381,9 +383,79 @@ public sealed class OpenIddictCoreBuilder
/// of your application and result in multiple queries being sent by the stores.
///
/// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
public OpenIddictCoreBuilder DisableEntityCaching()
=> Configure(options => options.DisableEntityCaching = true);
+ ///
+ /// Disables the automatic client secret rehashing applied by the application
+ /// manager when a client secret is validated and needs to be rehashed.
+ ///
+ /// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public OpenIddictCoreBuilder DisableAutomaticClientSecretRehashing()
+ => Configure(options => options.DisableAutomaticClientSecretRehashing = true);
+
+ ///
+ /// Configures OpenIddict to use the specified hash algorithm to protect the client secrets.
+ ///
+ /// The hash algorithm to use.
+ /// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public OpenIddictCoreBuilder SetClientSecretKeyDerivationHashAlgorithm(HashAlgorithmName algorithm)
+ {
+ if (algorithm != HashAlgorithmName.SHA1 &&
+ algorithm != HashAlgorithmName.SHA256 &&
+ algorithm != HashAlgorithmName.SHA512)
+ {
+ throw new ArgumentException(SR.FormatID0217(algorithm.Name), nameof(algorithm));
+ }
+
+ return Configure(options => options.ClientSecretKeyDerivationHashAlgorithm = algorithm);
+ }
+
+ ///
+ /// Configures OpenIddict to use the specified number of iterations to protect the client secrets.
+ ///
+ /// The number of iterations to use.
+ /// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public OpenIddictCoreBuilder SetClientSecretKeyDerivationIterations(int iterations)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(iterations, 10_000);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(iterations, 10_000_000);
+
+ return Configure(options => options.ClientSecretKeyDerivationIterations = iterations);
+ }
+
+ ///
+ /// Configures OpenIddict to use the specified output length (in bits) to protect the client secrets.
+ ///
+ /// The output length to use.
+ /// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public OpenIddictCoreBuilder SetClientSecretKeyDerivationOutputLength(int length)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(length, 256);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(length, 2048);
+
+ return Configure(options => options.ClientSecretKeyDerivationOutputLength = length);
+ }
+
+ ///
+ /// Configures OpenIddict to use the specified salt length (in bits) to protect the client secrets.
+ ///
+ /// The salt length to use.
+ /// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public OpenIddictCoreBuilder SetClientSecretKeyDerivationSaltLength(int length)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(length, 128);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(length, 1024);
+
+ return Configure(options => options.ClientSecretKeyDerivationSaltLength = length);
+ }
+
///
/// Configures OpenIddict to use the specified entity as the default application entity.
///
diff --git a/src/OpenIddict.Core/OpenIddictCoreConfiguration.cs b/src/OpenIddict.Core/OpenIddictCoreConfiguration.cs
index c1e2d5a1..e83b79fd 100644
--- a/src/OpenIddict.Core/OpenIddictCoreConfiguration.cs
+++ b/src/OpenIddict.Core/OpenIddictCoreConfiguration.cs
@@ -4,6 +4,7 @@
* the license and the contributors participating to this project.
*/
+using System.Security.Cryptography;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@@ -12,7 +13,7 @@ namespace OpenIddict.Core;
///
/// Contains the methods required to ensure that the OpenIddict core configuration is valid.
///
-public class OpenIddictCoreConfiguration : IPostConfigureOptions
+public class OpenIddictCoreConfiguration : IPostConfigureOptions, IValidateOptions
{
private readonly IServiceProvider _provider;
@@ -26,6 +27,44 @@ public class OpenIddictCoreConfiguration : IPostConfigureOptions
public void PostConfigure(string? name, OpenIddictCoreOptions options)
{
+ ArgumentNullException.ThrowIfNull(options);
+
options.TimeProvider ??= _provider.GetService() ?? TimeProvider.System;
}
+
+ ///
+ public ValidateOptionsResult Validate(string? name, OpenIddictCoreOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var builder = new ValidateOptionsResultBuilder();
+
+ // Ensure the options used to feed the PBKDF-based client secret protector respect
+ // the minimum security requirements used in previous versions of OpenIddict.
+ //
+ // Note: the values used here MUST be kept in sync with the values in the application manager.
+ if (options.ClientSecretKeyDerivationHashAlgorithm != HashAlgorithmName.SHA1 &&
+ options.ClientSecretKeyDerivationHashAlgorithm != HashAlgorithmName.SHA256 &&
+ options.ClientSecretKeyDerivationHashAlgorithm != HashAlgorithmName.SHA512)
+ {
+ builder.AddError(SR.FormatID0217(options.ClientSecretKeyDerivationHashAlgorithm.Name));
+ }
+
+ if (options.ClientSecretKeyDerivationIterations is not (>= 10_000 and <= 10_000_000))
+ {
+ builder.AddError(SR.FormatID0518(10_000, 10_000_000));
+ }
+
+ if (options.ClientSecretKeyDerivationSaltLength is not (>= 128 and <= 1024))
+ {
+ builder.AddError(SR.FormatID0519(128, 1024));
+ }
+
+ if (options.ClientSecretKeyDerivationOutputLength is not (>= 256 and <= 2048))
+ {
+ builder.AddError(SR.FormatID0520(256, 2048));
+ }
+
+ return builder.Build();
+ }
}
diff --git a/src/OpenIddict.Core/OpenIddictCoreExtensions.cs b/src/OpenIddict.Core/OpenIddictCoreExtensions.cs
index 4a045114..a20e97ea 100644
--- a/src/OpenIddict.Core/OpenIddictCoreExtensions.cs
+++ b/src/OpenIddict.Core/OpenIddictCoreExtensions.cs
@@ -51,9 +51,11 @@ public static class OpenIddictCoreExtensions
builder.Services.TryAddScoped(static provider =>
throw new InvalidOperationException(SR.GetResourceString(SR.ID0472)));
- // Note: TryAddEnumerable() is used here to ensure the initializer is registered only once.
+ // Note: TryAddEnumerable() is used here to ensure the initializers are registered only once.
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
IPostConfigureOptions, OpenIddictCoreConfiguration>());
+ builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
+ IValidateOptions, OpenIddictCoreConfiguration>());
return new OpenIddictCoreBuilder(builder.Services);
}
diff --git a/src/OpenIddict.Core/OpenIddictCoreOptions.cs b/src/OpenIddict.Core/OpenIddictCoreOptions.cs
index a3129da5..20a35b4b 100644
--- a/src/OpenIddict.Core/OpenIddictCoreOptions.cs
+++ b/src/OpenIddict.Core/OpenIddictCoreOptions.cs
@@ -4,6 +4,9 @@
* the license and the contributors participating to this project.
*/
+using System.ComponentModel;
+using System.Security.Cryptography;
+
namespace OpenIddict.Core;
///
@@ -11,6 +14,30 @@ namespace OpenIddict.Core;
///
public sealed class OpenIddictCoreOptions
{
+ ///
+ /// Gets or sets the hash algorithm used to protect the client secrets (by default, SHA512).
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public HashAlgorithmName ClientSecretKeyDerivationHashAlgorithm { get; set; } = HashAlgorithmName.SHA512;
+
+ ///
+ /// Gets or sets the number of iterations used to protect the client secrets (by default, 100 000).
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public int ClientSecretKeyDerivationIterations { get; set; } = 100_000;
+
+ ///
+ /// Gets or sets the length (in bits) of the PBKDF2 key used to protect the client secrets (by default, 512 bits).
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public int ClientSecretKeyDerivationOutputLength { get; set; } = 512;
+
+ ///
+ /// Gets or sets the length (in bits) of the salt used to protect the client secrets (by default, 256 bits).
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public int ClientSecretKeyDerivationSaltLength { get; set; } = 256;
+
///
/// Gets or sets a boolean indicating whether additional filtering should be disabled,
/// so that the OpenIddict managers don't execute a second check to ensure the results
@@ -19,13 +46,21 @@ public sealed class OpenIddictCoreOptions
/// are guaranteed to execute case-sensitive filtering at the database level.
/// Disabling this feature MAY result in security vulnerabilities in the other cases.
///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
public bool DisableAdditionalFiltering { get; set; }
+ ///
+ /// Gets or sets a boolean indicating whether automatic client secret rehashing should be disabled.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public bool DisableAutomaticClientSecretRehashing { get; set; }
+
///
/// Gets or sets a boolean indicating whether entity caching should be disabled.
/// Disabling entity caching may have a noticeable impact on the performance
/// of your application and result in multiple queries being sent by the stores.
///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
public bool DisableEntityCaching { get; set; }
///
@@ -34,6 +69,7 @@ public sealed class OpenIddictCoreOptions
/// abnormally and doesn't cause a memory starvation or out-of-memory exceptions.
/// This property is not used when is .
///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
public int EntityCacheLimit { get; set; } = 250;
///
diff --git a/test/OpenIddict.Client.IntegrationTests/OpenIddict.Client.IntegrationTests.csproj b/test/OpenIddict.Client.IntegrationTests/OpenIddict.Client.IntegrationTests.csproj
index 902e4012..3021eed0 100644
--- a/test/OpenIddict.Client.IntegrationTests/OpenIddict.Client.IntegrationTests.csproj
+++ b/test/OpenIddict.Client.IntegrationTests/OpenIddict.Client.IntegrationTests.csproj
@@ -16,17 +16,12 @@
-
-
-
-
+
diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs
new file mode 100644
index 00000000..d61c08c1
--- /dev/null
+++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs
@@ -0,0 +1,437 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace OpenIddict.Core.Tests;
+
+public class OpenIddictApplicationCacheTests
+{
+ [Fact]
+ public void Constructor_ThrowsAnExceptionForNullOptions()
+ {
+ // Arrange
+ var options = (IOptionsMonitor) null!;
+ var store = Mock.Of>();
+
+ // Act and assert
+ var exception = Assert.Throws(() => new OpenIddictApplicationCache