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