diff --git a/Directory.Build.targets b/Directory.Build.targets
index 8643c038..afd3accc 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -96,6 +96,7 @@
$(DefineConstants);SUPPORTS_ONE_SHOT_HASHING_METHODS
$(DefineConstants);SUPPORTS_OPERATING_SYSTEM_VERSIONS_COMPARISON
$(DefineConstants);SUPPORTS_PEM_ENCODED_KEY_IMPORT
+ $(DefineConstants);SUPPORTS_TEXT_ELEMENT_ENUMERATOR
$(DefineConstants);SUPPORTS_WINFORMS_TASK_DIALOG
diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs
index 56ee5b9d..fddda6ed 100644
--- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs
+++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs
@@ -316,16 +316,6 @@ public class AuthorizationController : Controller
[Authorize, HttpGet("~/connect/verify"), IgnoreAntiforgeryToken]
public async Task 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")]
diff --git a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs
index aadc1dc8..29951a22 100644
--- a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs
+++ b/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 .
///
/// The characters allowed to be included in the .
- /// The desired length of the .
+ /// The number of characters.
/// A new containing random data.
///
/// The implementation resolved from is not valid.
///
- public static string CreateRandomString(ReadOnlySpan charset, int length)
+ public static string CreateRandomString(ReadOnlySpan 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
}
+ ///
+ /// Removes the characters that are not part of
+ /// from the specified string.
+ ///
+ ///
+ /// Note: if no character is present in , all characters are considered valid.
+ ///
+ /// The original string.
+ /// The list of allowed characters.
+ /// The original string with the disallowed characters removed.
+ /// is .
+ public static string? RemoveDisallowedCharacters(string? value, IReadOnlyCollection 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
///
/// Creates a derived key based on the specified using PBKDF2.
diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx
index b1e38e70..1390848f 100644
--- a/src/OpenIddict.Abstractions/OpenIddictResources.resx
+++ b/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}
+
+ The specified charset contains a duplicate character.
+
+
+ The specified charset contains a character that cannot be represented as a single text element.
+
+
+ The specified charset contains non-ASCII characters. Characters outside the Basic Latin Unicode block are only supported on .NET 5.0 and higher.
+
+
+ The specified characters count is too low. Use a value equal to or higher than {0}.
+
+
+ The specified charset doesn't include enough characters. Ensure at least {0} characters are included in the charset.
+
+
+ The specified format string cannot contain a '{0}' character when it is included as an allowed character in the charset.
+
The security token is missing.
diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs b/src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs
index 02e789ad..511a344c 100644
--- a/src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs
+++ b/src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs
@@ -151,7 +151,18 @@ public static partial class OpenIddictClientEvents
public ClaimsPrincipal? Principal { get; set; }
///
- /// 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.
+ ///
+ ///
+ /// Characters that are not present in this set are automatically ignored
+ /// when validating a self-contained token or making a database lookup.
+ ///
+ public HashSet AllowedCharset { get; } = new(StringComparer.Ordinal);
+
+ ///
+ /// Gets the token types that are considered valid. If no value is
+ /// explicitly specified, all supported tokens are considered valid.
///
public HashSet ValidTokenTypes { get; } = new(StringComparer.OrdinalIgnoreCase);
}
diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs
index 51c69d2a..fc4cf664 100644
--- a/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs
+++ b/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
}
}
+ ///
+ /// Contains the logic responsible for removing the disallowed characters from the token string, if applicable.
+ ///
+ public sealed class RemoveDisallowedCharacters : IOpenIddictClientHandler
+ {
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictClientHandlerDescriptor Descriptor { get; }
+ = OpenIddictClientHandlerDescriptor.CreateBuilder()
+ .UseSingletonHandler()
+ .SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
+ .SetType(OpenIddictClientHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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;
+ }
+ }
+
///
/// 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()
.AddFilter()
.UseScopedHandler()
- .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)
diff --git a/src/OpenIddict.Server/OpenIddictServerBuilder.cs b/src/OpenIddict.Server/OpenIddictServerBuilder.cs
index 4a8a1de1..79015799 100644
--- a/src/OpenIddict.Server/OpenIddictServerBuilder.cs
+++ b/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);
+ ///
+ /// Sets the charset used by OpenIddict to generate random user codes.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The charset used by OpenIddict to generate random user codes.
+ /// The instance.
+ 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);
+ });
+ }
+
+ ///
+ /// Sets the format string used by OpenIddict to display user codes. While not recommended,
+ /// a value can be used to disable the user code formatting logic.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The string used by OpenIddict to format user codes.
+ /// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public OpenIddictServerBuilder SetUserCodeDisplayFormat(string? format)
+ => Configure(options => options.UserCodeDisplayFormat = format);
+
+ ///
+ /// Sets the length of the user codes generated by OpenIddict (by default, 12 characters).
+ ///
+ /// The length of the user codes generated by OpenIddict.
+ /// The instance.
+ public OpenIddictServerBuilder SetUserCodeLength(int length)
+ {
+ if (length is < 6)
+ {
+ throw new ArgumentOutOfRangeException(nameof(length), SR.FormatID0439(6));
+ }
+
+ return Configure(options => options.UserCodeLength = length);
+ }
+
///
/// Sets the user code lifetime, after which they'll no longer be considered valid.
/// Using short-lived device codes is strongly recommended.
diff --git a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs
index e6c6552c..cdeac184 100644
--- a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs
+++ b/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 (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));
diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs
index 3276cb84..6d685fe1 100644
--- a/src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs
+++ b/src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs
@@ -166,6 +166,16 @@ public static partial class OpenIddictServerEvents
///
public ClaimsPrincipal? Principal { get; set; }
+ ///
+ /// Gets the characters that are allowed to be present in tokens.
+ /// If no character was added, all characters are considered valid.
+ ///
+ ///
+ /// Characters that are not present in this set are automatically ignored
+ /// when validating a self-contained token or making a database lookup.
+ ///
+ public HashSet AllowedCharset { get; } = new(StringComparer.Ordinal);
+
///
/// Gets the token types that are considered valid.
///
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
index b1eec109..d7f5f668 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
+++ b/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
]);
///
@@ -230,6 +229,54 @@ public static partial class OpenIddictServerHandlers
}
}
+ ///
+ /// Contains the logic responsible for removing the disallowed characters from the token string, if applicable.
+ ///
+ public sealed class RemoveDisallowedCharacters : IOpenIddictServerHandler
+ {
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseSingletonHandler()
+ .SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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;
+ }
+ }
+
///
/// 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()
.AddFilter()
.UseScopedHandler()
- .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.
///
+ [Obsolete("This class is obsolete and will be removed in a future version.", error: true)]
public sealed class BeautifyToken : IOpenIddictServerHandler
{
///
@@ -1560,36 +1573,7 @@ public static partial class OpenIddictServerHandlers
///
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));
}
}
}
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs
index dbf0124f..76b5422c 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs
+++ b/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
}
}
+ ///
+ /// Contains the logic responsible for reformating validated tokens if necessary.
+ /// Note: this handler is not used when the degraded mode is enabled.
+ ///
+ public sealed class ReformatValidatedTokens : IOpenIddictServerHandler
+ {
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseScopedHandler()
+ .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 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;
+ }
+ }
+
///
/// Contains the logic responsible for rejecting challenge demands made from unsupported endpoints.
///
@@ -4103,6 +4181,68 @@ 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.
+ ///
+ public sealed class BeautifyGeneratedTokens : IOpenIddictServerHandler
+ {
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseSingletonHandler()
+ .SetOrder(GenerateIdentityToken.Descriptor.Order + 1_000)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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 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;
+ }
+ }
+
///
/// Contains the logic responsible for attaching the appropriate parameters to the sign-in response.
///
@@ -4114,7 +4254,7 @@ public static partial class OpenIddictServerHandlers
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder()
.UseSingletonHandler()
- .SetOrder(GenerateIdentityToken.Descriptor.Order + 1_000)
+ .SetOrder(BeautifyGeneratedTokens.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs
index c306f31b..ae2a2261 100644
--- a/src/OpenIddict.Server/OpenIddictServerOptions.cs
+++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs
@@ -218,6 +218,35 @@ public sealed class OpenIddictServerOptions
///
public TimeSpan? RefreshTokenReuseLeeway { get; set; } = TimeSpan.FromSeconds(30);
+ ///
+ /// Gets the charset used by OpenIddict to generate random user codes.
+ ///
+ ///
+ /// 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.
+ ///
+ public HashSet UserCodeCharset { get; } = new(StringComparer.Ordinal)
+ {
+ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"
+ };
+
+ ///
+ /// Gets or sets the format string used by OpenIddict to display user codes. While not recommended,
+ /// a value can be used to disable the user code formatting logic.
+ ///
+ ///
+ /// 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.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public string? UserCodeDisplayFormat { get; set; }
+
+ ///
+ /// Gets or sets the length of the user codes generated by OpenIddict (by default, 12 characters).
+ ///
+ public int UserCodeLength { get; set; } = 12;
+
///
/// 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.
diff --git a/src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs b/src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs
index 79cc51a3..b51320c1 100644
--- a/src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs
+++ b/src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs
@@ -150,6 +150,16 @@ public static partial class OpenIddictValidationEvents
///
public ClaimsPrincipal? Principal { get; set; }
+ ///
+ /// Gets the characters that are allowed to be present in tokens.
+ /// If no character was added, all characters are considered valid.
+ ///
+ ///
+ /// Characters that are not present in this set are automatically ignored
+ /// when validating a self-contained token or making a database lookup.
+ ///
+ public HashSet AllowedCharset { get; } = new(StringComparer.Ordinal);
+
///
/// Gets the token types that are considered valid. If no value is
/// explicitly specified, all supported tokens are considered valid.
diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs
index 104e7957..bd09a644 100644
--- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs
+++ b/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
}
}
+ ///
+ /// Contains the logic responsible for removing the disallowed characters from the token string, if applicable.
+ ///
+ public sealed class RemoveDisallowedCharacters : IOpenIddictValidationHandler
+ {
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictValidationHandlerDescriptor Descriptor { get; }
+ = OpenIddictValidationHandlerDescriptor.CreateBuilder()
+ .UseSingletonHandler()
+ .SetOrder(ResolveTokenValidationParameters.Descriptor.Order + 1_000)
+ .SetType(OpenIddictValidationHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ 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;
+ }
+ }
+
///
/// 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()
.AddFilter()
.UseScopedHandler()
- .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;
}
diff --git a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs
index 41c86a73..d4d5a6d7 100644
--- a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs
+++ b/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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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()
{