Browse Source

Make user codes fully configurable and remove token length assumptions

pull/2044/head
Kévin Chalet 2 years ago
parent
commit
b32eb8c0a2
  1. 1
      Directory.Build.targets
  2. 29
      sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs
  3. 53
      shared/OpenIddict.Extensions/OpenIddictHelpers.cs
  4. 18
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  5. 13
      src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs
  6. 57
      src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs
  7. 83
      src/OpenIddict.Server/OpenIddictServerBuilder.cs
  8. 82
      src/OpenIddict.Server/OpenIddictServerConfiguration.cs
  9. 10
      src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs
  10. 144
      src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
  11. 142
      src/OpenIddict.Server/OpenIddictServerHandlers.cs
  12. 29
      src/OpenIddict.Server/OpenIddictServerOptions.cs
  13. 10
      src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs
  14. 57
      src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs
  15. 141
      test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs

1
Directory.Build.targets

@ -96,6 +96,7 @@
<DefineConstants>$(DefineConstants);SUPPORTS_ONE_SHOT_HASHING_METHODS</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_OPERATING_SYSTEM_VERSIONS_COMPARISON</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_PEM_ENCODED_KEY_IMPORT</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_TEXT_ELEMENT_ENUMERATOR</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_WINFORMS_TASK_DIALOG</DefineConstants>
</PropertyGroup>

29
sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs

@ -316,16 +316,6 @@ public class AuthorizationController : Controller
[Authorize, HttpGet("~/connect/verify"), IgnoreAntiforgeryToken]
public async Task<IActionResult> Verify()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
// If the user code was not specified in the query string (e.g as part of the verification_uri_complete),
// render a form to ask the user to enter the user code manually (non-digit chars are automatically ignored).
if (string.IsNullOrEmpty(request.UserCode))
{
return View(new VerifyViewModel());
}
// Retrieve the claims principal associated with the user code.
var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
if (result.Succeeded && !string.IsNullOrEmpty(result.Principal.GetClaim(Claims.ClientId)))
@ -339,16 +329,23 @@ public class AuthorizationController : Controller
{
ApplicationName = await _applicationManager.GetLocalizedDisplayNameAsync(application),
Scope = string.Join(" ", result.Principal.GetScopes()),
UserCode = request.UserCode
UserCode = result.Properties.GetTokenValue(OpenIddictServerAspNetCoreConstants.Tokens.UserCode)
});
}
// Redisplay the form when the user code is not valid.
return View(new VerifyViewModel
// If a user code was specified (e.g as part of the verification_uri_complete)
// but is not valid, render a form asking the user to enter the user code manually.
else if (!string.IsNullOrEmpty(result.Properties.GetTokenValue(OpenIddictServerAspNetCoreConstants.Tokens.UserCode)))
{
Error = Errors.InvalidToken,
ErrorDescription = "The specified user code is not valid. Please make sure you typed it correctly."
});
return View(new VerifyViewModel
{
Error = Errors.InvalidToken,
ErrorDescription = "The specified user code is not valid. Please make sure you typed it correctly."
});
}
// Otherwise, render a form asking the user to enter the user code manually.
return View(new VerifyViewModel());
}
[Authorize, FormValueRequired("submit.Accept")]

53
shared/OpenIddict.Extensions/OpenIddictHelpers.cs

@ -2,6 +2,7 @@
using System.Data;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
@ -755,12 +756,12 @@ internal static class OpenIddictHelpers
/// randomly selected in the specified <paramref name="charset"/>.
/// </summary>
/// <param name="charset">The characters allowed to be included in the <see cref="string"/>.</param>
/// <param name="length">The desired length of the <see cref="string"/>.</param>
/// <param name="count">The number of characters.</param>
/// <returns>A new <see cref="string"/> containing random data.</returns>
/// <exception cref="CryptographicException">
/// The implementation resolved from <see cref="CryptoConfig.CreateFromName(string)"/> is not valid.
/// </exception>
public static string CreateRandomString(ReadOnlySpan<char> charset, int length)
public static string CreateRandomString(ReadOnlySpan<string> charset, int count)
{
var algorithm = CryptoConfig.CreateFromName("OpenIddict RNG Cryptographic Provider") switch
{
@ -771,12 +772,12 @@ internal static class OpenIddictHelpers
try
{
var buffer = new char[length];
var builder = new StringBuilder();
for (var index = 0; index < buffer.Length; index++)
for (var index = 0; index < count; index++)
{
// Pick a character in the specified charset by generating a random index.
buffer[index] = charset[index: algorithm switch
builder.Append(charset[index: algorithm switch
{
#if SUPPORTS_INTEGER32_RANDOM_NUMBER_GENERATOR_METHODS
// If no custom random number generator was registered, use
@ -786,10 +787,10 @@ internal static class OpenIddictHelpers
// Otherwise, create a default implementation if necessary
// and use the local function that achieves the same result.
_ => GetInt32(algorithm ??= RandomNumberGenerator.Create(), 0..charset.Length)
}];
}]);
}
return new string(buffer);
return builder.ToString();
}
finally
@ -904,6 +905,44 @@ internal static class OpenIddictHelpers
#endif
}
/// <summary>
/// Removes the characters that are not part of <paramref name="charset"/>
/// from the specified <paramref name="value"/> string.
/// </summary>
/// <remarks>
/// Note: if no character is present in <paramref name="charset"/>, all characters are considered valid.
/// </remarks>
/// <param name="value">The original string.</param>
/// <param name="charset">The list of allowed characters.</param>
/// <returns>The original string with the disallowed characters removed.</returns>
/// <exception cref="ArgumentNullException"><paramref name="charset"/> is <see langword="null"/>.</exception>
public static string? RemoveDisallowedCharacters(string? value, IReadOnlyCollection<string> charset)
{
if (charset is null)
{
throw new ArgumentNullException(nameof(charset));
}
if (charset.Count is 0 || string.IsNullOrEmpty(value))
{
return value;
}
var builder = new StringBuilder();
var enumerator = StringInfo.GetTextElementEnumerator(value);
while (enumerator.MoveNext())
{
var element = enumerator.GetTextElement();
if (charset.Contains(element))
{
builder.Append(element);
}
}
return builder.ToString();
}
#if SUPPORTS_KEY_DERIVATION_WITH_SPECIFIED_HASH_ALGORITHM
/// <summary>
/// Creates a derived key based on the specified <paramref name="secret"/> using PBKDF2.

18
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1641,6 +1641,24 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0436" xml:space="preserve">
<value>The specified charset contains a duplicate character.</value>
</data>
<data name="ID0437" xml:space="preserve">
<value>The specified charset contains a character that cannot be represented as a single text element.</value>
</data>
<data name="ID0438" xml:space="preserve">
<value>The specified charset contains non-ASCII characters. Characters outside the Basic Latin Unicode block are only supported on .NET 5.0 and higher.</value>
</data>
<data name="ID0439" xml:space="preserve">
<value>The specified characters count is too low. Use a value equal to or higher than {0}.</value>
</data>
<data name="ID0440" xml:space="preserve">
<value>The specified charset doesn't include enough characters. Ensure at least {0} characters are included in the charset.</value>
</data>
<data name="ID0441" xml:space="preserve">
<value>The specified format string cannot contain a '{0}' character when it is included as an allowed character in the charset.</value>
</data>
<data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value>
</data>

13
src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs

@ -151,7 +151,18 @@ public static partial class OpenIddictClientEvents
public ClaimsPrincipal? Principal { get; set; }
/// <summary>
/// Gets the token types that are considered valid.
/// Gets the characters that are allowed to be present in tokens.
/// If no character was added, all characters are considered valid.
/// </summary>
/// <remarks>
/// Characters that are not present in this set are automatically ignored
/// when validating a self-contained token or making a database lookup.
/// </remarks>
public HashSet<string> AllowedCharset { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets the token types that are considered valid. If no value is
/// explicitly specified, all supported tokens are considered valid.
/// </summary>
public HashSet<string> ValidTokenTypes { get; } = new(StringComparer.OrdinalIgnoreCase);
}

57
src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs

@ -24,6 +24,7 @@ public static partial class OpenIddictClientHandlers
* Token validation:
*/
ResolveTokenValidationParameters.Descriptor,
RemoveDisallowedCharacters.Descriptor,
ValidateReferenceTokenIdentifier.Descriptor,
ValidateIdentityModelToken.Descriptor,
MapInternalClaims.Descriptor,
@ -182,6 +183,54 @@ public static partial class OpenIddictClientHandlers
}
}
/// <summary>
/// Contains the logic responsible for removing the disallowed characters from the token string, if applicable.
/// </summary>
public sealed class RemoveDisallowedCharacters : IOpenIddictClientHandler<ValidateTokenContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
.UseSingletonHandler<RemoveDisallowedCharacters>()
.SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ValidateTokenContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// If no character was explicitly added, all characters are considered valid.
if (context.AllowedCharset.Count is 0)
{
return default;
}
// Remove the disallowed characters from the token string. If the token is
// empty after removing all the unwanted characters, return a generic error.
var token = OpenIddictHelpers.RemoveDisallowedCharacters(context.Token, context.AllowedCharset);
if (string.IsNullOrEmpty(token))
{
context.Reject(
error: Errors.InvalidToken,
description: SR.GetResourceString(SR.ID2004),
uri: SR.FormatID8000(SR.ID2004));
return default;
}
context.Token = token;
return default;
}
}
/// <summary>
/// Contains the logic responsible for validating reference token identifiers.
/// Note: this handler is not used when token storage is disabled.
@ -202,7 +251,7 @@ public static partial class OpenIddictClientHandlers
= OpenIddictClientHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
.AddFilter<RequireTokenStorageEnabled>()
.UseScopedHandler<ValidateReferenceTokenIdentifier>()
.SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
.SetOrder(RemoveDisallowedCharacters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
@ -220,6 +269,12 @@ public static partial class OpenIddictClientHandlers
return;
}
// If the provided token is a JWT token, avoid making a database lookup.
if (context.SecurityTokenHandler.CanReadToken(context.Token))
{
return;
}
// If the reference token cannot be found, don't return an error to allow another handler to validate it.
var token = await _tokenManager.FindByReferenceIdAsync(context.Token);
if (token is null)

83
src/OpenIddict.Server/OpenIddictServerBuilder.cs

@ -6,6 +6,7 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
@ -1709,6 +1710,88 @@ public sealed class OpenIddictServerBuilder
public OpenIddictServerBuilder SetRefreshTokenReuseLeeway(TimeSpan? leeway)
=> Configure(options => options.RefreshTokenReuseLeeway = leeway);
/// <summary>
/// Sets the charset used by OpenIddict to generate random user codes.
/// </summary>
/// <remarks>
/// Note: user codes are meant to be entered manually by users. To ensure
/// they remain easy enough to type even by users with non-Latin keyboards,
/// user codes generated by OpenIddict only include ASCII digits by default.
/// </remarks>
/// <param name="charset">The charset used by OpenIddict to generate random user codes.</param>
/// <returns>The <see cref="OpenIddictServerBuilder"/> instance.</returns>
public OpenIddictServerBuilder SetUserCodeCharset(params string[] charset)
{
if (charset is null)
{
throw new ArgumentNullException(nameof(charset));
}
if (charset.Length is < 9)
{
throw new ArgumentOutOfRangeException(nameof(charset), SR.FormatID0440(9));
}
if (charset.Length != charset.Distinct(StringComparer.Ordinal).Count())
{
throw new ArgumentException(SR.GetResourceString(SR.ID0436), nameof(charset));
}
foreach (var character in charset)
{
#if SUPPORTS_TEXT_ELEMENT_ENUMERATOR
// On supported platforms, ensure each character added to the
// charset represents exactly one grapheme cluster/text element.
var enumerator = StringInfo.GetTextElementEnumerator(character);
if (!enumerator.MoveNext() || enumerator.MoveNext())
{
throw new ArgumentException(SR.GetResourceString(SR.ID0437), nameof(charset));
}
#else
// On unsupported platforms, prevent non-ASCII characters from being used.
if (character.Any(static character => (uint) character > '\x007f'))
{
throw new ArgumentException(SR.GetResourceString(SR.ID0438), nameof(charset));
}
#endif
}
return Configure(options =>
{
options.UserCodeCharset.Clear();
options.UserCodeCharset.UnionWith(charset);
});
}
/// <summary>
/// Sets the format string used by OpenIddict to display user codes. While not recommended,
/// a <see langword="null"/> value can be used to disable the user code formatting logic.
/// </summary>
/// <remarks>
/// Note: if no value is explicitly set, a default format using dash separators
/// is used to make user codes easier to read by the end users.
/// </remarks>
/// <param name="format">The string used by OpenIddict to format user codes.</param>
/// <returns>The <see cref="OpenIddictServerBuilder"/> instance.</returns>
[EditorBrowsable(EditorBrowsableState.Advanced)]
public OpenIddictServerBuilder SetUserCodeDisplayFormat(string? format)
=> Configure(options => options.UserCodeDisplayFormat = format);
/// <summary>
/// Sets the length of the user codes generated by OpenIddict (by default, 12 characters).
/// </summary>
/// <param name="length">The length of the user codes generated by OpenIddict.</param>
/// <returns>The <see cref="OpenIddictServerBuilder"/> instance.</returns>
public OpenIddictServerBuilder SetUserCodeLength(int length)
{
if (length is < 6)
{
throw new ArgumentOutOfRangeException(nameof(length), SR.FormatID0439(6));
}
return Configure(options => options.UserCodeLength = length);
}
/// <summary>
/// Sets the user code lifetime, after which they'll no longer be considered valid.
/// Using short-lived device codes is strongly recommended.

82
src/OpenIddict.Server/OpenIddictServerConfiguration.cs

@ -6,6 +6,9 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Extensions;
@ -282,6 +285,85 @@ public sealed class OpenIddictServerConfiguration : IPostConfigureOptions<OpenId
}
}
// If token storage was disabled, user codes will be returned as-is by OpenIddict instead of being
// automatically converted to reference identifiers (in this case, custom event handlers must be
// registered to manually store the token payload in a database or cache and return a user code
// that can be used and entered by a human user in a web form). Since the default logic is not
// going be used, disable the formatting logic by setting UserCodeDisplayFormat to null here.
if (options.DisableTokenStorage)
{
options.UserCodeLength = 0;
options.UserCodeCharset.Clear();
options.UserCodeDisplayFormat = null;
}
else
{
if (options.UserCodeLength is < 6)
{
throw new InvalidOperationException(SR.FormatID0439(6));
}
if (options.UserCodeCharset.Count is < 9)
{
throw new InvalidOperationException(SR.FormatID0440(9));
}
if (options.UserCodeCharset.Count != options.UserCodeCharset.Distinct(StringComparer.Ordinal).Count())
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0436));
}
foreach (var character in options.UserCodeCharset)
{
#if SUPPORTS_TEXT_ELEMENT_ENUMERATOR
// On supported platforms, ensure each character added to the
// charset represents exactly one grapheme cluster/text element.
var enumerator = StringInfo.GetTextElementEnumerator(character);
if (!enumerator.MoveNext() || enumerator.MoveNext())
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0437));
}
#else
// On unsupported platforms, prevent non-ASCII characters from being used.
if (character.Any(static character => (uint) character > '\x007f'))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0438));
}
#endif
}
if (string.IsNullOrEmpty(options.UserCodeDisplayFormat))
{
var builder = new StringBuilder();
var count = options.UserCodeLength % 5 is 0 ? 5 :
options.UserCodeLength % 4 is 0 ? 4 :
options.UserCodeLength % 3 is 0 ? 3 :
options.UserCodeLength % 2 is 0 ? 2 : 1;
for (var index = 0; index < options.UserCodeLength; index++)
{
if (index is > 0 && index % count is 0)
{
builder.Append(Separators.Dash[0]);
}
builder.Append('{');
builder.Append(index);
builder.Append('}');
}
options.UserCodeDisplayFormat = builder.ToString();
}
if (options.UserCodeCharset.Contains("-", StringComparer.Ordinal) &&
options.UserCodeDisplayFormat.Any(static character => character is '-'))
{
throw new InvalidOperationException(SR.FormatID0441('-'));
}
}
// Sort the handlers collection using the order associated with each handler.
options.Handlers.Sort(static (left, right) => left.Order.CompareTo(right.Order));

10
src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs

@ -166,6 +166,16 @@ public static partial class OpenIddictServerEvents
/// </summary>
public ClaimsPrincipal? Principal { get; set; }
/// <summary>
/// Gets the characters that are allowed to be present in tokens.
/// If no character was added, all characters are considered valid.
/// </summary>
/// <remarks>
/// Characters that are not present in this set are automatically ignored
/// when validating a self-contained token or making a database lookup.
/// </remarks>
public HashSet<string> AllowedCharset { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets the token types that are considered valid.
/// </summary>

144
src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs

@ -8,7 +8,6 @@ using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@ -27,6 +26,7 @@ public static partial class OpenIddictServerHandlers
* Token validation:
*/
ResolveTokenValidationParameters.Descriptor,
RemoveDisallowedCharacters.Descriptor,
ValidateReferenceTokenIdentifier.Descriptor,
ValidateIdentityModelToken.Descriptor,
NormalizeScopeClaims.Descriptor,
@ -43,8 +43,7 @@ public static partial class OpenIddictServerHandlers
AttachSecurityCredentials.Descriptor,
CreateTokenEntry.Descriptor,
GenerateIdentityModelToken.Descriptor,
AttachTokenPayload.Descriptor,
BeautifyToken.Descriptor
AttachTokenPayload.Descriptor
]);
/// <summary>
@ -230,6 +229,54 @@ public static partial class OpenIddictServerHandlers
}
}
/// <summary>
/// Contains the logic responsible for removing the disallowed characters from the token string, if applicable.
/// </summary>
public sealed class RemoveDisallowedCharacters : IOpenIddictServerHandler<ValidateTokenContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
.UseSingletonHandler<RemoveDisallowedCharacters>()
.SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ValidateTokenContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// If no character was explicitly added, all characters are considered valid.
if (context.AllowedCharset.Count is 0)
{
return default;
}
// Remove the disallowed characters from the token string. If the token is
// empty after removing all the unwanted characters, return a generic error.
var token = OpenIddictHelpers.RemoveDisallowedCharacters(context.Token, context.AllowedCharset);
if (string.IsNullOrEmpty(token))
{
context.Reject(
error: Errors.InvalidToken,
description: SR.GetResourceString(SR.ID2004),
uri: SR.FormatID8000(SR.ID2004));
return default;
}
context.Token = token;
return default;
}
}
/// <summary>
/// Contains the logic responsible for validating reference token identifiers.
/// Note: this handler is not used when the degraded mode is enabled.
@ -251,7 +298,7 @@ public static partial class OpenIddictServerHandlers
.AddFilter<RequireDegradedModeDisabled>()
.AddFilter<RequireTokenStorageEnabled>()
.UseScopedHandler<ValidateReferenceTokenIdentifier>()
.SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
.SetOrder(RemoveDisallowedCharacters.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
@ -269,23 +316,14 @@ public static partial class OpenIddictServerHandlers
return;
}
var token = context.Token.Length switch
// If the provided token is a JWT token, avoid making a database lookup.
if (context.SecurityTokenHandler.CanReadToken(context.Token))
{
// 12 may correspond to a normalized user code and 43 to any
// other base64url-encoded 256-bit reference token identifier.
12 or 43 => await _tokenManager.FindByReferenceIdAsync(context.Token),
// A value higher than 12 (but lower than 50) may correspond to a user code
// containing dashes or any other non-digit character added by the end user.
// In this case, normalize the reference identifier before making the database lookup.
> 12 and < 50 when NormalizeUserCode(context.Token) is { Length: > 0 } value
=> await _tokenManager.FindByReferenceIdAsync(value),
// If the token length differs, the token cannot be a reference token.
_ => null
};
return;
}
// If the reference token cannot be found, don't return an error to allow another handler to validate it.
var token = await _tokenManager.FindByReferenceIdAsync(context.Token);
if (token is null)
{
return;
@ -345,25 +383,6 @@ public static partial class OpenIddictServerHandlers
context.IsReferenceToken = true;
context.Token = payload;
context.TokenId = await _tokenManager.GetIdAsync(token);
// Note: unlike other tokens, user codes may be potentially entered manually by users in a web form.
// To make that easier, user codes are generally "beautified" by adding intermediate dashes to
// make them easier to read and type. Since these additional characters are not part of the original
// user codes, non-digit characters are filtered from the reference identifier using this local method.
static string NormalizeUserCode(string token)
{
var builder = new StringBuilder(token);
for (var index = builder.Length - 1; index >= 0; index--)
{
var character = builder[index];
if (character < '0' || character > '9')
{
builder.Remove(index, 1);
}
}
return builder.ToString();
}
}
}
@ -1496,24 +1515,17 @@ public static partial class OpenIddictServerHandlers
if (context.IsReferenceToken)
{
if (context.TokenType is TokenTypeHints.UserCode)
if (context.TokenType is TokenTypeHints.UserCode &&
context.Options is { UserCodeCharset.Count: > 0, UserCodeLength: > 0 })
{
do
{
// Note: unlike other reference tokens, user codes are meant to be used by humans,
// who may have to enter it in a web form. To ensure they remain easy enough to type
// even by users with non-Latin keyboards, user codes generated by OpenIddict are
// only compound of 12 digits, generated using a crypto-secure random number generator.
// In this case, the resulting user code is estimated to have at most ~40 bits of entropy.
static string CreateRandomNumericCode(int length) => OpenIddictHelpers.CreateRandomString(
charset: stackalloc[] { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' },
length: length);
descriptor.ReferenceId = CreateRandomNumericCode(length: 12);
descriptor.ReferenceId = OpenIddictHelpers.CreateRandomString(
charset: [.. context.Options.UserCodeCharset],
count : context.Options.UserCodeLength);
}
// User codes are relatively short. To help reduce the risks of collisions with
// User codes are generally short. To help reduce the risks of collisions with
// existing entries, a database check is performed here before updating the entry.
while (await _tokenManager.FindByReferenceIdAsync(descriptor.ReferenceId) is not null);
}
@ -1542,6 +1554,7 @@ public static partial class OpenIddictServerHandlers
/// Contains the logic responsible for beautifying user-typed tokens.
/// Note: this handler is not used when the degraded mode is enabled.
/// </summary>
[Obsolete("This class is obsolete and will be removed in a future version.", error: true)]
public sealed class BeautifyToken : IOpenIddictServerHandler<GenerateTokenContext>
{
/// <summary>
@ -1560,36 +1573,7 @@ public static partial class OpenIddictServerHandlers
/// <inheritdoc/>
public ValueTask HandleAsync(GenerateTokenContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// To make user codes easier to read and type by humans, a dash is automatically
// appended before each new block of 4 integers. These dashes are expected to be
// stripped from the user codes when receiving them at the verification endpoint.
if (context.IsReferenceToken && context.TokenType is TokenTypeHints.UserCode)
{
var builder = new StringBuilder(context.Token);
if (builder.Length % 4 != 0)
{
return default;
}
for (var index = builder.Length; index >= 0; index -= 4)
{
if (index != 0 && index != builder.Length)
{
builder.Insert(index, Separators.Dash[0]);
}
}
context.Token = builder.ToString();
}
return default;
}
=> throw new NotSupportedException(SR.GetResourceString(SR.ID0403));
}
}
}

142
src/OpenIddict.Server/OpenIddictServerHandlers.cs

@ -50,6 +50,7 @@ public static partial class OpenIddictServerHandlers
ValidateRefreshToken.Descriptor,
ValidateUserCode.Descriptor,
ResolveHostAuthenticationProperties.Descriptor,
ReformatValidatedTokens.Descriptor,
/*
* Challenge processing:
@ -92,6 +93,8 @@ public static partial class OpenIddictServerHandlers
GenerateUserCode.Descriptor,
GenerateIdentityToken.Descriptor,
BeautifyGeneratedTokens.Descriptor,
AttachSignInParameters.Descriptor,
AttachCustomSignInParameters.Descriptor,
@ -1711,6 +1714,9 @@ public static partial class OpenIddictServerHandlers
ValidTokenTypes = { TokenTypeHints.UserCode }
};
// Note: restrict the allowed characters to the user code charset set in the options.
notification.AllowedCharset.UnionWith(context.Options.UserCodeCharset);
await _dispatcher.DispatchAsync(notification);
if (notification.IsRequestHandled)
@ -1795,6 +1801,78 @@ public static partial class OpenIddictServerHandlers
}
}
/// <summary>
/// Contains the logic responsible for reformating validated tokens if necessary.
/// Note: this handler is not used when the degraded mode is enabled.
/// </summary>
public sealed class ReformatValidatedTokens : IOpenIddictServerHandler<ProcessAuthenticationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.UseScopedHandler<ReformatValidatedTokens>()
.SetOrder(int.MaxValue - 100_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
public ValueTask HandleAsync(ProcessAuthenticationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Note: unlike other tokens, user codes may be potentially entered manually by users in a web form.
// To make that easier, characters that are not part of the allowed charset are generally ignored.
// Since user codes entered by the user or flowed as a query string parameter can be re-rendered
// (e.g for user confirmation), they are automatically reformatted here to make sure that characters
// that were not part of the allowed charset and ignored when validating them are not included in the
// token string that will be attached to the authentication context and resolved by the application.
if (!string.IsNullOrEmpty(context.UserCode) && !string.IsNullOrEmpty(context.Options.UserCodeDisplayFormat))
{
List<string> arguments = [];
var enumerator = StringInfo.GetTextElementEnumerator(context.UserCode);
while (enumerator.MoveNext())
{
var element = enumerator.GetTextElement();
if (context.Options.UserCodeCharset.Contains(element))
{
arguments.Add(enumerator.GetTextElement());
}
}
if (arguments.Count is 0)
{
context.UserCode = null;
}
else if (arguments.Count == context.Options.UserCodeLength)
{
try
{
context.UserCode = string.Format(CultureInfo.InvariantCulture,
context.Options.UserCodeDisplayFormat, [.. arguments]);
}
catch (FormatException)
{
context.UserCode = string.Join(string.Empty, arguments);
}
}
else
{
context.UserCode = string.Join(string.Empty, arguments);
}
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for rejecting challenge demands made from unsupported endpoints.
/// </summary>
@ -4103,6 +4181,68 @@ public static partial class OpenIddictServerHandlers
}
}
/// <summary>
/// Contains the logic responsible for beautifying user-typed tokens.
/// Note: this handler is not used when the degraded mode is enabled.
/// </summary>
public sealed class BeautifyGeneratedTokens : IOpenIddictServerHandler<ProcessSignInContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ProcessSignInContext>()
.UseSingletonHandler<BeautifyGeneratedTokens>()
.SetOrder(GenerateIdentityToken.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessSignInContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// To make user codes easier to read and type by humans, the user is formatted
// using a display format string specified by the user or created by OpenIddict
// (by default, grouping the user code characters and separating them by dashes).
if (!string.IsNullOrEmpty(context.UserCode) &&
!string.IsNullOrEmpty(context.Options.UserCodeDisplayFormat))
{
List<string> arguments = [];
var enumerator = StringInfo.GetTextElementEnumerator(context.UserCode);
while (enumerator.MoveNext())
{
arguments.Add(enumerator.GetTextElement());
}
if (arguments.Count == context.Options.UserCodeLength)
{
try
{
context.UserCode = string.Format(CultureInfo.InvariantCulture,
context.Options.UserCodeDisplayFormat, [.. arguments]);
}
catch (FormatException)
{
context.UserCode = string.Join(string.Empty, arguments);
}
}
else
{
context.UserCode = string.Join(string.Empty, arguments);
}
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for attaching the appropriate parameters to the sign-in response.
/// </summary>
@ -4114,7 +4254,7 @@ public static partial class OpenIddictServerHandlers
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ProcessSignInContext>()
.UseSingletonHandler<AttachSignInParameters>()
.SetOrder(GenerateIdentityToken.Descriptor.Order + 1_000)
.SetOrder(BeautifyGeneratedTokens.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();

29
src/OpenIddict.Server/OpenIddictServerOptions.cs

@ -218,6 +218,35 @@ public sealed class OpenIddictServerOptions
/// </summary>
public TimeSpan? RefreshTokenReuseLeeway { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets the charset used by OpenIddict to generate random user codes.
/// </summary>
/// <remarks>
/// Note: user codes are meant to be used by humans, who may have to type them manually.
/// To ensure they remain easy enough to type even by users with non-Latin keyboards,
/// user codes generated by OpenIddict only include ASCII digits by default.
/// </remarks>
public HashSet<string> UserCodeCharset { get; } = new(StringComparer.Ordinal)
{
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"
};
/// <summary>
/// Gets or sets the format string used by OpenIddict to display user codes. While not recommended,
/// a <see langword="null"/> value can be used to disable the user code formatting logic.
/// </summary>
/// <remarks>
/// If no value is explicitly set, a default format using dash separators
/// is used to make user codes easier to read by the end users.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Advanced)]
public string? UserCodeDisplayFormat { get; set; }
/// <summary>
/// Gets or sets the length of the user codes generated by OpenIddict (by default, 12 characters).
/// </summary>
public int UserCodeLength { get; set; } = 12;
/// <summary>
/// Gets or sets the period of time user codes remain valid after being issued. The default value is 10 minutes.
/// The client application is expected to start a whole new authentication flow after the user code has expired.

10
src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs

@ -150,6 +150,16 @@ public static partial class OpenIddictValidationEvents
/// </summary>
public ClaimsPrincipal? Principal { get; set; }
/// <summary>
/// Gets the characters that are allowed to be present in tokens.
/// If no character was added, all characters are considered valid.
/// </summary>
/// <remarks>
/// Characters that are not present in this set are automatically ignored
/// when validating a self-contained token or making a database lookup.
/// </remarks>
public HashSet<string> AllowedCharset { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets the token types that are considered valid. If no value is
/// explicitly specified, all supported tokens are considered valid.

57
src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs

@ -10,6 +10,7 @@ using System.Globalization;
using System.Security.Claims;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Extensions;
namespace OpenIddict.Validation;
@ -22,6 +23,7 @@ public static partial class OpenIddictValidationHandlers
* Token validation:
*/
ResolveTokenValidationParameters.Descriptor,
RemoveDisallowedCharacters.Descriptor,
ValidateReferenceTokenIdentifier.Descriptor,
ValidateIdentityModelToken.Descriptor,
NormalizeScopeClaims.Descriptor,
@ -131,6 +133,54 @@ public static partial class OpenIddictValidationHandlers
}
}
/// <summary>
/// Contains the logic responsible for removing the disallowed characters from the token string, if applicable.
/// </summary>
public sealed class RemoveDisallowedCharacters : IOpenIddictValidationHandler<ValidateTokenContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictValidationHandlerDescriptor Descriptor { get; }
= OpenIddictValidationHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
.UseSingletonHandler<RemoveDisallowedCharacters>()
.SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictValidationHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ValidateTokenContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// If no character was explicitly added, all characters are considered valid.
if (context.AllowedCharset.Count is 0)
{
return default;
}
// Remove the disallowed characters from the token string. If the token is
// empty after removing all the unwanted characters, return a generic error.
var token = OpenIddictHelpers.RemoveDisallowedCharacters(context.Token, context.AllowedCharset);
if (string.IsNullOrEmpty(token))
{
context.Reject(
error: Errors.InvalidToken,
description: SR.GetResourceString(SR.ID2004),
uri: SR.FormatID8000(SR.ID2004));
return default;
}
context.Token = token;
return default;
}
}
/// <summary>
/// Contains the logic responsible for validating reference token identifiers.
/// Note: this handler is not used when the degraded mode is enabled.
@ -151,7 +201,7 @@ public static partial class OpenIddictValidationHandlers
= OpenIddictValidationHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
.AddFilter<RequireTokenEntryValidationEnabled>()
.UseScopedHandler<ValidateReferenceTokenIdentifier>()
.SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
.SetOrder(RemoveDisallowedCharacters.Descriptor.Order + 1_000)
.SetType(OpenIddictValidationHandlerType.BuiltIn)
.Build();
@ -163,9 +213,8 @@ public static partial class OpenIddictValidationHandlers
throw new ArgumentNullException(nameof(context));
}
// Reference tokens are base64url-encoded payloads of exactly 256 bits (generated using a
// crypto-secure RNG). If the token length differs, the token cannot be a reference token.
if (context.Token.Length is not 43)
// If the provided token is a JWT token, avoid making a database lookup.
if (context.SecurityTokenHandler.CanReadToken(context.Token))
{
return;
}

141
test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs

@ -1736,6 +1736,147 @@ public class OpenIddictServerBuilderTests
Assert.Null(options.DeviceCodeLifetime);
}
[Fact]
public void SetUserCodeCharset_ThrowsAnExceptionForNullCharset()
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act and assert
var exception = Assert.Throws<ArgumentNullException>(() => builder.SetUserCodeCharset(charset: null!));
Assert.Equal("charset", exception.ParamName);
}
[Fact]
public void SetUserCodeCharset_ThrowsAnExceptionForCharsetWithTooFewCharacters()
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act and assert
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => builder.SetUserCodeCharset(["0"]));
Assert.StartsWith(SR.FormatID0440(9), exception.Message);
Assert.Equal("charset", exception.ParamName);
}
[Fact]
public void SetUserCodeCharset_ThrowsAnExceptionForCharsetWithDuplicatedCharacters()
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act and assert
var exception = Assert.Throws<ArgumentException>(() => builder.SetUserCodeCharset(
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "9"]));
Assert.StartsWith(SR.GetResourceString(SR.ID0436), exception.Message);
Assert.Equal("charset", exception.ParamName);
}
#if SUPPORTS_TEXT_ELEMENT_ENUMERATOR
[InlineData("")]
[InlineData("\uD83D\uDE42\uD83D\uDE42")]
[Theory]
public void SetUserCodeCharset_ThrowsAnExceptionForCharsetWithInvalidCharacter(string character)
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act and assert
var exception = Assert.Throws<ArgumentException>(() => builder.SetUserCodeCharset(
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", character]));
Assert.StartsWith(SR.GetResourceString(SR.ID0437), exception.Message);
Assert.Equal("charset", exception.ParamName);
}
#else
[Fact]
public void SetUserCodeCharset_ThrowsAnExceptionForCharsetWithNonAsciiCharacter()
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act and assert
var exception = Assert.Throws<ArgumentException>(() => builder.SetUserCodeCharset(
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "\uD83D\uDE42"]));
Assert.StartsWith(SR.GetResourceString(SR.ID0438), exception.Message);
Assert.Equal("charset", exception.ParamName);
}
#endif
[Fact]
public void SetUserCodeCharset_ReplacesCharset()
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act
builder.SetUserCodeCharset(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]);
var options = GetOptions(services);
// Assert
Assert.Equal(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"], options.UserCodeCharset);
}
[Fact]
public void SetUserCodeDisplayFormat_ReplacesDisplayFormat()
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act
builder.SetUserCodeDisplayFormat("{0}{1}-{2}{3}-{4}{5}-{6}{7}-{8}{9}-{10}{11}");
var options = GetOptions(services);
// Assert
Assert.Equal("{0}{1}-{2}{3}-{4}{5}-{6}{7}-{8}{9}-{10}{11}", options.UserCodeDisplayFormat);
}
[InlineData(-1)]
[InlineData(0)]
[InlineData(5)]
[Theory]
public void SetUserCodeLength_ThrowsAnExceptionForInvalidLength(int length)
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act and assert
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => builder.SetUserCodeLength(length));
Assert.StartsWith(SR.FormatID0439(6), exception.Message);
Assert.Equal("length", exception.ParamName);
}
[Fact]
public void SetUserCodeLength_ReplacesLength()
{
// Arrange
var services = CreateServices();
var builder = CreateBuilder(services);
// Act
builder.SetUserCodeLength(42);
var options = GetOptions(services);
// Assert
Assert.Equal(42, options.UserCodeLength);
}
[Fact]
public void SetUserCodeLifetime_DefaultUserCodeLifetimeIsReplaced()
{

Loading…
Cancel
Save