diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs index c9869f39..c52bf4b8 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs @@ -230,14 +230,25 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, /// whose result returns the client application corresponding to the identifier. /// - public virtual Task FindByClientIdAsync([NotNull] string identifier, CancellationToken cancellationToken = default) + public virtual async Task FindByClientIdAsync([NotNull] string identifier, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(identifier)) { throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); } - return Store.FindByClientIdAsync(identifier, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var application = await Store.FindByClientIdAsync(identifier, cancellationToken); + if (application == null || + !string.Equals(await Store.GetClientIdAsync(application, cancellationToken), identifier, StringComparison.Ordinal)) + { + return null; + } + + return application; } /// @@ -249,7 +260,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, whose result /// returns the client applications corresponding to the specified post_logout_redirect_uri. /// - public virtual Task> FindByPostLogoutRedirectUriAsync( + public virtual async Task> FindByPostLogoutRedirectUriAsync( [NotNull] string address, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(address)) @@ -257,7 +268,33 @@ namespace OpenIddict.Core throw new ArgumentException("The address cannot be null or empty.", nameof(address)); } - return Store.FindByPostLogoutRedirectUriAsync(address, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var applications = await Store.FindByPostLogoutRedirectUriAsync(address, cancellationToken); + if (applications.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(applications.Length); + + foreach (var application in applications) + { + foreach (var uri in await Store.GetPostLogoutRedirectUrisAsync(application, cancellationToken)) + { + // Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison". + if (string.Equals(uri, address, StringComparison.Ordinal)) + { + builder.Add(application); + } + } + } + + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -269,7 +306,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, whose result /// returns the client applications corresponding to the specified redirect_uri. /// - public virtual Task> FindByRedirectUriAsync( + public virtual async Task> FindByRedirectUriAsync( [NotNull] string address, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(address)) @@ -277,7 +314,33 @@ namespace OpenIddict.Core throw new ArgumentException("The address cannot be null or empty.", nameof(address)); } - return Store.FindByRedirectUriAsync(address, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var applications = await Store.FindByRedirectUriAsync(address, cancellationToken); + if (applications.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(applications.Length); + + foreach (var application in applications) + { + foreach (var uri in await Store.GetRedirectUrisAsync(application, cancellationToken)) + { + // Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison". + if (string.Equals(uri, address, StringComparison.Ordinal)) + { + builder.Add(application); + } + } + } + + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -513,6 +576,8 @@ namespace OpenIddict.Core throw new ArgumentException("The permission name cannot be null or empty.", nameof(permission)); } + // Note: all the string-based comparisons used by this method are ordinal (and thus case-sensitive). + var permissions = await Store.GetPermissionsAsync(application, cancellationToken); bool HasPermission(string name) @@ -897,30 +962,34 @@ namespace OpenIddict.Core throw new ArgumentNullException(nameof(application)); } - var results = ImmutableArray.CreateBuilder(); + var builder = ImmutableArray.CreateBuilder(); + // Ensure the client_id is not null or empty and is not already used for a different application. var identifier = await Store.GetClientIdAsync(application, cancellationToken); if (string.IsNullOrEmpty(identifier)) { - results.Add(new ValidationResult("The client identifier cannot be null or empty.")); + builder.Add(new ValidationResult("The client identifier cannot be null or empty.")); } else { - // Ensure the client_id is not already used for a different application. + // Note: depending on the database/table/query collation used by the store, an application + // whose client_id doesn't exactly match the specified value may be returned (e.g because + // the casing is different). To avoid issues when the client identifier is part of an index + // using the same collation, an error is added even if the two identifiers don't exactly match. var other = await Store.FindByClientIdAsync(identifier, cancellationToken); if (other != null && !string.Equals( await Store.GetIdAsync(other, cancellationToken), await Store.GetIdAsync(application, cancellationToken), StringComparison.Ordinal)) { - results.Add(new ValidationResult("An application with the same client identifier already exists.")); + builder.Add(new ValidationResult("An application with the same client identifier already exists.")); } } var type = await Store.GetClientTypeAsync(application, cancellationToken); if (string.IsNullOrEmpty(type)) { - results.Add(new ValidationResult("The client type cannot be null or empty.")); + builder.Add(new ValidationResult("The client type cannot be null or empty.")); } else @@ -930,7 +999,7 @@ namespace OpenIddict.Core !string.Equals(type, OpenIddictConstants.ClientTypes.Hybrid, StringComparison.OrdinalIgnoreCase) && !string.Equals(type, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase)) { - results.Add(new ValidationResult("Only 'confidential', 'hybrid' or 'public' applications are " + + builder.Add(new ValidationResult("Only 'confidential', 'hybrid' or 'public' applications are " + "supported by the default application manager.")); } @@ -939,14 +1008,14 @@ namespace OpenIddict.Core if (string.IsNullOrEmpty(secret) && string.Equals(type, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) { - results.Add(new ValidationResult("The client secret cannot be null or empty for a confidential application.")); + builder.Add(new ValidationResult("The client secret cannot be null or empty for a confidential application.")); } // Ensure no client secret was specified if the client is a public application. else if (!string.IsNullOrEmpty(secret) && string.Equals(type, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase)) { - results.Add(new ValidationResult("A client secret cannot be associated with a public application.")); + builder.Add(new ValidationResult("A client secret cannot be associated with a public application.")); } } @@ -959,7 +1028,7 @@ namespace OpenIddict.Core // Ensure the address is not null or empty. if (string.IsNullOrEmpty(address)) { - results.Add(new ValidationResult("Callback URLs cannot be null or empty.")); + builder.Add(new ValidationResult("Callback URLs cannot be null or empty.")); break; } @@ -967,7 +1036,7 @@ namespace OpenIddict.Core // Ensure the address is a valid absolute URL. if (!Uri.TryCreate(address, UriKind.Absolute, out Uri uri) || !uri.IsWellFormedOriginalString()) { - results.Add(new ValidationResult("Callback URLs must be valid absolute URLs.")); + builder.Add(new ValidationResult("Callback URLs must be valid absolute URLs.")); break; } @@ -975,7 +1044,7 @@ namespace OpenIddict.Core // Ensure the address doesn't contain a fragment. if (!string.IsNullOrEmpty(uri.Fragment)) { - results.Add(new ValidationResult("Callback URLs cannot contain a fragment.")); + builder.Add(new ValidationResult("Callback URLs cannot contain a fragment.")); break; } @@ -987,14 +1056,14 @@ namespace OpenIddict.Core if (!permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Authorization) && permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint))) { - results.Add(new ValidationResult( + builder.Add(new ValidationResult( "The authorization code flow permission requires adding the authorization endpoint permission.")); } if (!permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Token) && permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint))) { - results.Add(new ValidationResult( + builder.Add(new ValidationResult( "The authorization code flow permission requires adding the token endpoint permission.")); } } @@ -1003,7 +1072,7 @@ namespace OpenIddict.Core !permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Token) && permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint))) { - results.Add(new ValidationResult( + builder.Add(new ValidationResult( "The client credentials flow permission requires adding the token endpoint permission.")); } @@ -1011,7 +1080,7 @@ namespace OpenIddict.Core !permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Authorization) && permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint))) { - results.Add(new ValidationResult( + builder.Add(new ValidationResult( "The implicit flow permission requires adding the authorization endpoint permission.")); } @@ -1019,7 +1088,7 @@ namespace OpenIddict.Core !permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Token) && permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint))) { - results.Add(new ValidationResult( + builder.Add(new ValidationResult( "The password flow permission requires adding the token endpoint permission.")); } @@ -1027,11 +1096,13 @@ namespace OpenIddict.Core !permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Token) && permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint))) { - results.Add(new ValidationResult( + builder.Add(new ValidationResult( "The refresh token flow permission requires adding the token endpoint permission.")); } - return results.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -1097,9 +1168,7 @@ namespace OpenIddict.Core throw new ArgumentException("The address cannot be null or empty.", nameof(address)); } - // Warning: SQL engines like Microsoft SQL Server are known to use case-insensitive lookups by default. - // To ensure a case-sensitive comparison is used, string.Equals(Ordinal) is manually called here. - foreach (var application in await Store.FindByPostLogoutRedirectUriAsync(address, cancellationToken)) + foreach (var application in await FindByPostLogoutRedirectUriAsync(address, cancellationToken)) { // If the application is not allowed to use the logout endpoint, ignore it and keep iterating. if (!await HasPermissionAsync(application, OpenIddictConstants.Permissions.Endpoints.Logout, cancellationToken)) @@ -1107,14 +1176,7 @@ namespace OpenIddict.Core continue; } - foreach (var uri in await Store.GetPostLogoutRedirectUrisAsync(application, cancellationToken)) - { - // Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison". - if (string.Equals(uri, address, StringComparison.Ordinal)) - { - return true; - } - } + return true; } Logger.LogWarning("Client validation failed because '{PostLogoutRedirectUri}' " + diff --git a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs index c944105c..2c6d9116 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs @@ -222,7 +222,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, /// whose result returns the authorizations corresponding to the subject/client. /// - public virtual Task> FindAsync( + public virtual async Task> FindAsync( [NotNull] string subject, [NotNull] string client, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(subject)) @@ -235,7 +235,29 @@ namespace OpenIddict.Core throw new ArgumentException("The client identifier cannot be null or empty.", nameof(client)); } - return Store.FindAsync(subject, client, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var authorizations = await Store.FindAsync(subject, client, cancellationToken); + if (authorizations.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(authorizations.Length); + + foreach (var authorization in authorizations) + { + if (string.Equals(await Store.GetSubjectAsync(authorization, cancellationToken), subject, StringComparison.Ordinal)) + { + builder.Add(authorization); + } + } + + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -249,7 +271,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, /// whose result returns the authorizations corresponding to the criteria. /// - public virtual Task> FindAsync( + public virtual async Task> FindAsync( [NotNull] string subject, [NotNull] string client, [NotNull] string status, CancellationToken cancellationToken) { @@ -268,7 +290,29 @@ namespace OpenIddict.Core throw new ArgumentException("The status cannot be null or empty.", nameof(status)); } - return Store.FindAsync(subject, client, status, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var authorizations = await Store.FindAsync(subject, client, status, cancellationToken); + if (authorizations.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(authorizations.Length); + + foreach (var authorization in authorizations) + { + if (string.Equals(await Store.GetSubjectAsync(authorization, cancellationToken), subject, StringComparison.Ordinal)) + { + builder.Add(authorization); + } + } + + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -283,7 +327,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, /// whose result returns the authorizations corresponding to the criteria. /// - public virtual Task> FindAsync( + public virtual async Task> FindAsync( [NotNull] string subject, [NotNull] string client, [NotNull] string status, [NotNull] string type, CancellationToken cancellationToken = default) { @@ -307,7 +351,29 @@ namespace OpenIddict.Core throw new ArgumentException("The type cannot be null or empty.", nameof(type)); } - return Store.FindAsync(subject, client, status, type, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var authorizations = await Store.FindAsync(subject, client, status, type, cancellationToken); + if (authorizations.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(authorizations.Length); + + foreach (var authorization in authorizations) + { + if (string.Equals(await Store.GetSubjectAsync(authorization, cancellationToken), subject, StringComparison.Ordinal)) + { + builder.Add(authorization); + } + } + + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -328,17 +394,25 @@ namespace OpenIddict.Core [NotNull] string status, [NotNull] string type, ImmutableArray scopes, CancellationToken cancellationToken = default) { - var authorizations = ImmutableArray.CreateBuilder(); + var authorizations = await FindAsync(subject, client, status, type, cancellationToken); + if (authorizations.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(authorizations.Length); - foreach (var authorization in await FindAsync(subject, client, status, type, cancellationToken)) + foreach (var authorization in authorizations) { if (await HasScopesAsync(authorization, scopes, cancellationToken)) { - authorizations.Add(authorization); + builder.Add(authorization); } } - return authorizations.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -369,7 +443,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, /// whose result returns the authorizations corresponding to the specified subject. /// - public virtual Task> FindBySubjectAsync( + public virtual async Task> FindBySubjectAsync( [NotNull] string subject, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(subject)) @@ -377,7 +451,29 @@ namespace OpenIddict.Core throw new ArgumentException("The subject cannot be null or empty.", nameof(subject)); } - return Store.FindBySubjectAsync(subject, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var authorizations = await Store.FindBySubjectAsync(subject, cancellationToken); + if (authorizations.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(authorizations.Length); + + foreach (var authorization in authorizations) + { + if (string.Equals(await Store.GetSubjectAsync(authorization, cancellationToken), subject, StringComparison.Ordinal)) + { + builder.Add(authorization); + } + } + + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -834,28 +930,28 @@ namespace OpenIddict.Core throw new ArgumentNullException(nameof(authorization)); } - var results = ImmutableArray.CreateBuilder(); + var builder = ImmutableArray.CreateBuilder(); var type = await Store.GetTypeAsync(authorization, cancellationToken); if (string.IsNullOrEmpty(type)) { - results.Add(new ValidationResult("The authorization type cannot be null or empty.")); + builder.Add(new ValidationResult("The authorization type cannot be null or empty.")); } else if (!string.Equals(type, OpenIddictConstants.AuthorizationTypes.AdHoc, StringComparison.OrdinalIgnoreCase) && !string.Equals(type, OpenIddictConstants.AuthorizationTypes.Permanent, StringComparison.OrdinalIgnoreCase)) { - results.Add(new ValidationResult("The specified authorization type is not supported by the default token manager.")); + builder.Add(new ValidationResult("The specified authorization type is not supported by the default token manager.")); } if (string.IsNullOrEmpty(await Store.GetStatusAsync(authorization, cancellationToken))) { - results.Add(new ValidationResult("The status cannot be null or empty.")); + builder.Add(new ValidationResult("The status cannot be null or empty.")); } if (string.IsNullOrEmpty(await Store.GetSubjectAsync(authorization, cancellationToken))) { - results.Add(new ValidationResult("The subject cannot be null or empty.")); + builder.Add(new ValidationResult("The subject cannot be null or empty.")); } // Ensure that the scopes are not null or empty and do not contain spaces. @@ -863,20 +959,22 @@ namespace OpenIddict.Core { if (string.IsNullOrEmpty(scope)) { - results.Add(new ValidationResult("Scopes cannot be null or empty.")); + builder.Add(new ValidationResult("Scopes cannot be null or empty.")); break; } if (scope.Contains(OpenIddictConstants.Separators.Space)) { - results.Add(new ValidationResult("Scopes cannot contain spaces.")); + builder.Add(new ValidationResult("Scopes cannot contain spaces.")); break; } } - return results.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// diff --git a/src/OpenIddict.Core/Managers/OpenIddictScopeManager.cs b/src/OpenIddict.Core/Managers/OpenIddictScopeManager.cs index ec4015d4..de70b00c 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictScopeManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictScopeManager.cs @@ -172,14 +172,24 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, /// whose result returns the scope corresponding to the specified name. /// - public virtual Task FindByNameAsync([NotNull] string name, CancellationToken cancellationToken = default) + public virtual async Task FindByNameAsync([NotNull] string name, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(name)) { throw new ArgumentException("The scope name cannot be null or empty.", nameof(name)); } - return Store.FindByNameAsync(name, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var scope = await Store.FindByNameAsync(name, cancellationToken); + if (scope == null || !string.Equals(await Store.GetNameAsync(scope, cancellationToken), name, StringComparison.Ordinal)) + { + return null; + } + + return scope; } /// @@ -191,7 +201,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, /// whose result returns the scopes corresponding to the specified names. /// - public virtual Task> FindByNamesAsync( + public virtual async Task> FindByNamesAsync( ImmutableArray names, CancellationToken cancellationToken = default) { if (names.Any(name => string.IsNullOrEmpty(name))) @@ -199,7 +209,29 @@ namespace OpenIddict.Core throw new ArgumentException("Scope names cannot be null or empty.", nameof(names)); } - return Store.FindByNamesAsync(names, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var scopes = await Store.FindByNamesAsync(names, cancellationToken); + if (scopes.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(scopes.Length); + + foreach (var scope in scopes) + { + if (names.Contains(await Store.GetNameAsync(scope, cancellationToken))) + { + builder.Add(scope); + } + } + + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -499,32 +531,39 @@ namespace OpenIddict.Core throw new ArgumentNullException(nameof(scope)); } - var results = ImmutableArray.CreateBuilder(); + var builder = ImmutableArray.CreateBuilder(); + // Ensure the name is not null or empty, does not contain a + // space and is not already used for a different scope entity. var name = await Store.GetNameAsync(scope, cancellationToken); if (string.IsNullOrEmpty(name)) { - results.Add(new ValidationResult("The scope name cannot be null or empty.")); + builder.Add(new ValidationResult("The scope name cannot be null or empty.")); } else if (name.Contains(OpenIddictConstants.Separators.Space)) { - results.Add(new ValidationResult("The scope name cannot contain spaces.")); + builder.Add(new ValidationResult("The scope name cannot contain spaces.")); } else { - // Ensure the name is not already used for a different name. + // Note: depending on the database/table/query collation used by the store, a scope + // whose name doesn't exactly match the specified value may be returned (e.g because + // the casing is different). To avoid issues when the scope name is part of an index + // using the same collation, an error is added even if the two names don't exactly match. var other = await Store.FindByNameAsync(name, cancellationToken); if (other != null && !string.Equals( await Store.GetIdAsync(other, cancellationToken), await Store.GetIdAsync(scope, cancellationToken), StringComparison.Ordinal)) { - results.Add(new ValidationResult("A scope with the same name already exists.")); + builder.Add(new ValidationResult("A scope with the same name already exists.")); } } - return results.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// diff --git a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs index 2ea62973..0b2a97f3 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs @@ -230,8 +230,20 @@ namespace OpenIddict.Core throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); } + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + identifier = await ObfuscateReferenceIdAsync(identifier, cancellationToken); - return await Store.FindByReferenceIdAsync(identifier, cancellationToken); + + var token = await Store.FindByReferenceIdAsync(identifier, cancellationToken); + if (token == null || + !string.Equals(await Store.GetReferenceIdAsync(token, cancellationToken), identifier, StringComparison.Ordinal)) + { + return null; + } + + return token; } /// @@ -262,7 +274,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, /// whose result returns the tokens corresponding to the specified subject. /// - public virtual Task> FindBySubjectAsync( + public virtual async Task> FindBySubjectAsync( [NotNull] string subject, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(subject)) @@ -270,7 +282,29 @@ namespace OpenIddict.Core throw new ArgumentException("The subject cannot be null or empty.", nameof(subject)); } - return Store.FindBySubjectAsync(subject, cancellationToken); + // SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default. + // To ensure a case-sensitive comparison is enforced independently of the database/table/query collation + // used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here. + + var tokens = await Store.FindBySubjectAsync(subject, cancellationToken); + if (tokens.IsEmpty) + { + return ImmutableArray.Create(); + } + + var builder = ImmutableArray.CreateBuilder(tokens.Length); + + foreach (var token in tokens) + { + if (string.Equals(await Store.GetSubjectAsync(token, cancellationToken), subject, StringComparison.Ordinal)) + { + builder.Add(token); + } + } + + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -821,46 +855,52 @@ namespace OpenIddict.Core throw new ArgumentNullException(nameof(token)); } - var results = ImmutableArray.CreateBuilder(); + var builder = ImmutableArray.CreateBuilder(); // If a reference identifier was associated with the token, // ensure it's not already used for a different token. var identifier = await Store.GetReferenceIdAsync(token, cancellationToken); if (!string.IsNullOrEmpty(identifier)) { + // Note: depending on the database/table/query collation used by the store, a reference token + // whose identifier doesn't exactly match the specified value may be returned (e.g because + // the casing is different). To avoid issues when the reference identifier is part of an index + // using the same collation, an error is added even if the two identifiers don't exactly match. var other = await Store.FindByReferenceIdAsync(identifier, cancellationToken); if (other != null && !string.Equals( await Store.GetIdAsync(other, cancellationToken), await Store.GetIdAsync(token, cancellationToken), StringComparison.Ordinal)) { - results.Add(new ValidationResult("A token with the same reference identifier already exists.")); + builder.Add(new ValidationResult("A token with the same reference identifier already exists.")); } } var type = await Store.GetTokenTypeAsync(token, cancellationToken); if (string.IsNullOrEmpty(type)) { - results.Add(new ValidationResult("The token type cannot be null or empty.")); + builder.Add(new ValidationResult("The token type cannot be null or empty.")); } else if (!string.Equals(type, OpenIddictConstants.TokenTypes.AccessToken, StringComparison.OrdinalIgnoreCase) && !string.Equals(type, OpenIddictConstants.TokenTypes.AuthorizationCode, StringComparison.OrdinalIgnoreCase) && !string.Equals(type, OpenIddictConstants.TokenTypes.RefreshToken, StringComparison.OrdinalIgnoreCase)) { - results.Add(new ValidationResult("The specified token type is not supported by the default token manager.")); + builder.Add(new ValidationResult("The specified token type is not supported by the default token manager.")); } if (string.IsNullOrEmpty(await Store.GetStatusAsync(token, cancellationToken))) { - results.Add(new ValidationResult("The status cannot be null or empty.")); + builder.Add(new ValidationResult("The status cannot be null or empty.")); } if (string.IsNullOrEmpty(await Store.GetSubjectAsync(token, cancellationToken))) { - results.Add(new ValidationResult("The subject cannot be null or empty.")); + builder.Add(new ValidationResult("The subject cannot be null or empty.")); } - return results.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs index c378cfb8..efc2f7e6 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs @@ -346,7 +346,9 @@ namespace OpenIddict.EntityFrameworkCore } } - return builder.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -399,7 +401,9 @@ namespace OpenIddict.EntityFrameworkCore } } - return builder.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// diff --git a/src/OpenIddict.Stores/Stores/OpenIddictApplicationStore.cs b/src/OpenIddict.Stores/Stores/OpenIddictApplicationStore.cs index 9a5b2e1a..c3f4ebb6 100644 --- a/src/OpenIddict.Stores/Stores/OpenIddictApplicationStore.cs +++ b/src/OpenIddict.Stores/Stores/OpenIddictApplicationStore.cs @@ -184,7 +184,9 @@ namespace OpenIddict.Stores } } - return builder.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } /// @@ -230,7 +232,9 @@ namespace OpenIddict.Stores } } - return builder.ToImmutable(); + return builder.Count == builder.Capacity ? + builder.MoveToImmutable() : + builder.ToImmutable(); } ///