From eeb8deddbfb7c1d52d6d26c521082a99cf679378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Mon, 20 Jul 2026 16:45:15 +0200 Subject: [PATCH] Introduce a new resource entity and support dynamic resources --- .../Properties/launchSettings.json | 2 +- .../Program.cs | 2 +- .../Properties/launchSettings.json | 4 +- .../Caches/IOpenIddictApplicationCache.cs | 2 +- .../Caches/IOpenIddictAuthorizationCache.cs | 2 +- .../Caches/IOpenIddictResourceCache.cs | 62 ++ .../Caches/IOpenIddictScopeCache.cs | 2 +- .../Caches/IOpenIddictTokenCache.cs | 2 +- .../OpenIddictResourceDescriptor.cs | 40 + .../Managers/IOpenIddictResourceManager.cs | 367 +++++++ .../OpenIddictResources.resx | 74 +- .../Stores/IOpenIddictApplicationStore.cs | 2 +- .../Stores/IOpenIddictAuthorizationStore.cs | 2 +- .../Stores/IOpenIddictResourceStore.cs | 280 ++++++ .../Stores/IOpenIddictScopeStore.cs | 2 +- .../Stores/IOpenIddictTokenStore.cs | 2 +- .../Caches/OpenIddictApplicationCache.cs | 2 +- .../Caches/OpenIddictAuthorizationCache.cs | 2 +- .../Caches/OpenIddictResourceCache.cs | 258 +++++ .../Caches/OpenIddictScopeCache.cs | 2 +- .../Caches/OpenIddictTokenCache.cs | 2 +- .../Managers/OpenIddictApplicationManager.cs | 2 +- .../OpenIddictAuthorizationManager.cs | 8 +- .../Managers/OpenIddictResourceManager.cs | 936 ++++++++++++++++++ .../Managers/OpenIddictScopeManager.cs | 8 +- .../Managers/OpenIddictTokenManager.cs | 8 +- src/OpenIddict.Core/OpenIddictCoreBuilder.cs | 93 ++ .../OpenIddictCoreExtensions.cs | 4 + .../OpenIddictEntityFrameworkApplication.cs | 6 +- .../OpenIddictEntityFrameworkAuthorization.cs | 6 +- .../OpenIddictEntityFrameworkResource.cs | 72 ++ .../OpenIddictEntityFrameworkScope.cs | 6 +- .../OpenIddictEntityFrameworkToken.cs | 6 +- ...EntityFrameworkApplicationConfiguration.cs | 10 +- ...tityFrameworkAuthorizationConfiguration.cs | 10 +- ...ictEntityFrameworkResourceConfiguration.cs | 60 ++ ...IddictEntityFrameworkScopeConfiguration.cs | 6 +- ...IddictEntityFrameworkTokenConfiguration.cs | 10 +- .../OpenIddictEntityFrameworkBuilder.cs | 8 +- .../OpenIddictEntityFrameworkExtensions.cs | 2 + .../OpenIddictEntityFrameworkHelpers.cs | 6 +- ...enIddictEntityFrameworkApplicationStore.cs | 6 +- ...IddictEntityFrameworkAuthorizationStore.cs | 12 +- .../OpenIddictEntityFrameworkResourceStore.cs | 635 ++++++++++++ .../OpenIddictEntityFrameworkScopeStore.cs | 11 +- .../OpenIddictEntityFrameworkTokenStore.cs | 12 +- ...penIddictEntityFrameworkCoreApplication.cs | 6 +- ...nIddictEntityFrameworkCoreAuthorization.cs | 6 +- .../OpenIddictEntityFrameworkCoreResource.cs | 72 ++ .../OpenIddictEntityFrameworkCoreScope.cs | 6 +- .../OpenIddictEntityFrameworkCoreToken.cs | 6 +- ...tyFrameworkCoreApplicationConfiguration.cs | 10 +- ...FrameworkCoreAuthorizationConfiguration.cs | 10 +- ...ntityFrameworkCoreResourceConfiguration.cs | 60 ++ ...ctEntityFrameworkCoreScopeConfiguration.cs | 6 +- ...ctEntityFrameworkCoreTokenConfiguration.cs | 10 +- .../OpenIddictEntityFrameworkCoreBuilder.cs | 7 + ...OpenIddictEntityFrameworkCoreCustomizer.cs | 4 +- ...OpenIddictEntityFrameworkCoreExtensions.cs | 2 + .../OpenIddictEntityFrameworkCoreHelpers.cs | 21 +- ...dictEntityFrameworkCoreApplicationStore.cs | 6 +- ...ctEntityFrameworkCoreAuthorizationStore.cs | 14 +- ...nIddictEntityFrameworkCoreResourceStore.cs | 649 ++++++++++++ ...OpenIddictEntityFrameworkCoreScopeStore.cs | 11 +- ...OpenIddictEntityFrameworkCoreTokenStore.cs | 12 +- .../OpenIddictMongoDbResource.cs | 67 ++ .../OpenIddictMongoDbBuilder.cs | 29 + .../OpenIddictMongoDbExtensions.cs | 2 + .../OpenIddictMongoDbOptions.cs | 5 + .../OpenIddictMongoDbApplicationStore.cs | 10 +- .../OpenIddictMongoDbAuthorizationStore.cs | 8 +- .../Stores/OpenIddictMongoDbResourceStore.cs | 437 ++++++++ .../Stores/OpenIddictMongoDbScopeStore.cs | 24 +- .../Stores/OpenIddictMongoDbTokenStore.cs | 8 +- ...OpenIddictServerHandlers.Authentication.cs | 92 +- .../OpenIddictServerHandlers.Exchange.cs | 46 +- .../Caches/OpenIddictApplicationCacheTests.cs | 2 +- .../OpenIddictAuthorizationCacheTests.cs | 2 +- .../Caches/OpenIddictResourceCacheTests.cs | 361 +++++++ .../Caches/OpenIddictScopeCacheTests.cs | 2 +- .../Caches/OpenIddictTokenCacheTests.cs | 2 +- .../OpenIddictApplicationManagerTests.cs | 2 +- .../OpenIddictAuthorizationManagerTests.cs | 2 +- .../OpenIddictResourceManagerTests.cs | 888 +++++++++++++++++ .../Managers/OpenIddictScopeManagerTests.cs | 6 +- .../Managers/OpenIddictTokenManagerTests.cs | 2 +- .../OpenIddictCoreBuilderTests.cs | 96 +- .../OpenIddictCoreExtensionsTests.cs | 20 + .../OpenIddictEntityFrameworkBuilderTests.cs | 15 +- ...penIddictEntityFrameworkExtensionsTests.cs | 8 + ...enIddictEntityFrameworkCoreBuilderTests.cs | 15 +- ...ddictEntityFrameworkCoreExtensionsTests.cs | 8 + ...enIddictEntityFrameworkCoreHelpersTests.cs | 23 +- .../OpenIddictMongoDbBuilderTests.cs | 58 +- .../OpenIddictMongoDbExtensionsTests.cs | 8 + ...ctServerIntegrationTests.Authentication.cs | 153 ++- ...enIddictServerIntegrationTests.Exchange.cs | 68 +- .../OpenIddictServerIntegrationTests.cs | 25 +- .../OpenIddictValidationIntegrationTests.cs | 4 +- 99 files changed, 6174 insertions(+), 298 deletions(-) create mode 100644 src/OpenIddict.Abstractions/Caches/IOpenIddictResourceCache.cs create mode 100644 src/OpenIddict.Abstractions/Descriptors/OpenIddictResourceDescriptor.cs create mode 100644 src/OpenIddict.Abstractions/Managers/IOpenIddictResourceManager.cs create mode 100644 src/OpenIddict.Abstractions/Stores/IOpenIddictResourceStore.cs create mode 100644 src/OpenIddict.Core/Caches/OpenIddictResourceCache.cs create mode 100644 src/OpenIddict.Core/Managers/OpenIddictResourceManager.cs create mode 100644 src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkResource.cs create mode 100644 src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkResourceConfiguration.cs create mode 100644 src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkResourceStore.cs create mode 100644 src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreResource.cs create mode 100644 src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreResourceConfiguration.cs create mode 100644 src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreResourceStore.cs create mode 100644 src/OpenIddict.MongoDb.Models/OpenIddictMongoDbResource.cs create mode 100644 src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbResourceStore.cs create mode 100644 test/OpenIddict.Core.Tests/Caches/OpenIddictResourceCacheTests.cs create mode 100644 test/OpenIddict.Core.Tests/Managers/OpenIddictResourceManagerTests.cs diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Properties/launchSettings.json b/sandbox/OpenIddict.Sandbox.AspNet.Server/Properties/launchSettings.json index 5d22a259..c7f4fbb9 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Properties/launchSettings.json +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Properties/launchSettings.json @@ -10,7 +10,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Program.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Program.cs index 6e983dbf..020ca43a 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Program.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Program.cs @@ -234,7 +234,7 @@ builder.Services.AddOpenIddict() options.EnableSelfSignedTlsClientAuthentication(); - // Note: setting a static issuer is mandatory when using mTLS aliases to ensure it not + // Note: setting a static issuer is mandatory when using mTLS aliases to ensure it is not // dynamically computed based on the request URI, as this would result in two different // issuers being used (one pointing to the mTLS domain and one pointing to the regular one). options.SetIssuer("https://localhost:44395/"); diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Properties/launchSettings.json b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Properties/launchSettings.json index 791075ec..66a9f95d 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Properties/launchSettings.json +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44395/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/OpenIddict.Abstractions/Caches/IOpenIddictApplicationCache.cs b/src/OpenIddict.Abstractions/Caches/IOpenIddictApplicationCache.cs index ba6a8db1..61ab2c99 100644 --- a/src/OpenIddict.Abstractions/Caches/IOpenIddictApplicationCache.cs +++ b/src/OpenIddict.Abstractions/Caches/IOpenIddictApplicationCache.cs @@ -11,7 +11,7 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to cache applications after retrieving them from the store. /// -/// The type of the Application entity. +/// The type of the application entity. public interface IOpenIddictApplicationCache where TApplication : class { /// diff --git a/src/OpenIddict.Abstractions/Caches/IOpenIddictAuthorizationCache.cs b/src/OpenIddict.Abstractions/Caches/IOpenIddictAuthorizationCache.cs index f87fcefd..405ef7a7 100644 --- a/src/OpenIddict.Abstractions/Caches/IOpenIddictAuthorizationCache.cs +++ b/src/OpenIddict.Abstractions/Caches/IOpenIddictAuthorizationCache.cs @@ -11,7 +11,7 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to cache authorizations after retrieving them from the store. /// -/// The type of the Authorization entity. +/// The type of the authorization entity. public interface IOpenIddictAuthorizationCache where TAuthorization : class { /// diff --git a/src/OpenIddict.Abstractions/Caches/IOpenIddictResourceCache.cs b/src/OpenIddict.Abstractions/Caches/IOpenIddictResourceCache.cs new file mode 100644 index 00000000..70e70b2f --- /dev/null +++ b/src/OpenIddict.Abstractions/Caches/IOpenIddictResourceCache.cs @@ -0,0 +1,62 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; + +namespace OpenIddict.Abstractions; + +/// +/// Provides methods allowing to cache resources after retrieving them from the store. +/// +/// The type of the resource entity. +public interface IOpenIddictResourceCache where TResource : class +{ + /// + /// Add the specified resource to the cache. + /// + /// The resource to add to the cache. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask AddAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Retrieves a resource using its unique identifier. + /// + /// The unique identifier associated with the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the resource corresponding to the identifier. + /// + ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken); + + /// + /// Retrieves a resource using its name. + /// + /// The name associated with the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the resource corresponding to the specified name. + /// + ValueTask FindByNameAsync(string name, CancellationToken cancellationToken); + + /// + /// Retrieves a list of resources using their name. + /// + /// The names associated with the resources. + /// The that can be used to abort the operation. + /// The resources corresponding to the specified names. + IAsyncEnumerable FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken); + + /// + /// Removes the specified resource from the cache. + /// + /// The resource to remove from the cache. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask RemoveAsync(TResource resource, CancellationToken cancellationToken); +} diff --git a/src/OpenIddict.Abstractions/Caches/IOpenIddictScopeCache.cs b/src/OpenIddict.Abstractions/Caches/IOpenIddictScopeCache.cs index 48921ef7..5544598e 100644 --- a/src/OpenIddict.Abstractions/Caches/IOpenIddictScopeCache.cs +++ b/src/OpenIddict.Abstractions/Caches/IOpenIddictScopeCache.cs @@ -11,7 +11,7 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to cache scopes after retrieving them from the store. /// -/// The type of the Scope entity. +/// The type of the scope entity. public interface IOpenIddictScopeCache where TScope : class { /// diff --git a/src/OpenIddict.Abstractions/Caches/IOpenIddictTokenCache.cs b/src/OpenIddict.Abstractions/Caches/IOpenIddictTokenCache.cs index bf3364f0..f1c74a41 100644 --- a/src/OpenIddict.Abstractions/Caches/IOpenIddictTokenCache.cs +++ b/src/OpenIddict.Abstractions/Caches/IOpenIddictTokenCache.cs @@ -9,7 +9,7 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to cache tokens after retrieving them from the store. /// -/// The type of the Token entity. +/// The type of the token entity. public interface IOpenIddictTokenCache where TToken : class { /// diff --git a/src/OpenIddict.Abstractions/Descriptors/OpenIddictResourceDescriptor.cs b/src/OpenIddict.Abstractions/Descriptors/OpenIddictResourceDescriptor.cs new file mode 100644 index 00000000..72458f9f --- /dev/null +++ b/src/OpenIddict.Abstractions/Descriptors/OpenIddictResourceDescriptor.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Text.Json; + +namespace OpenIddict.Abstractions; + +/// +/// Represents an OpenIddict resource descriptor. +/// +public class OpenIddictResourceDescriptor +{ + /// + /// Gets or sets the description associated with the resource. + /// + public string? Description { get; set; } + + /// + /// Gets the localized descriptions associated with the resource. + /// + public Dictionary Descriptions { get; } = []; + + /// + /// Gets or sets the display name associated with the resource. + /// + public string? DisplayName { get; set; } + + /// + /// Gets the localized display names associated with the resource. + /// + public Dictionary DisplayNames { get; } = []; + + /// + /// Gets or sets the unique name associated with the resource. + /// + public string? Name { get; set; } + + /// + /// Gets the additional properties associated with the resource. + /// + public Dictionary Properties { get; } = new(StringComparer.Ordinal); +} diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictResourceManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictResourceManager.cs new file mode 100644 index 00000000..03443a03 --- /dev/null +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictResourceManager.cs @@ -0,0 +1,367 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.Json; + +namespace OpenIddict.Abstractions; + +/// +/// Provides methods allowing to manage the Resources stored in the store. +/// +/// +/// Note: this interface is not meant to be implemented by custom managers, +/// that should inherit from the generic OpenIddictResourceManager class. +/// It is primarily intended to be used by services that cannot easily +/// depend on the generic resource manager. The actual resource entity type is +/// automatically determined at runtime based on the OpenIddict core options. +/// +public interface IOpenIddictResourceManager +{ + /// + /// Determines the number of resources that exist in the database. + /// + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the number of resources in the database. + /// + ValueTask CountAsync(CancellationToken cancellationToken = default); + + /// + /// Determines the number of resources that match the specified query. + /// + /// The result type. + /// The query to execute. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the number of resources that match the specified query. + /// + ValueTask CountAsync(Func, IQueryable> query, CancellationToken cancellationToken = default); + + /// + /// Determines the number of resources that match the specified query. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the number of resources that match the specified query. + /// + ValueTask CountAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken = default); + + /// + /// Creates a new resource based on the specified descriptor. + /// + /// The resource descriptor. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose result returns the resource. + /// + ValueTask CreateAsync(OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken = default); + + /// + /// Creates a new resource. + /// + /// The resource to create. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + ValueTask CreateAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Removes an existing resource. + /// + /// The resource to delete. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + ValueTask DeleteAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves a resource using its unique identifier. + /// + /// The unique identifier associated with the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the resource corresponding to the identifier. + /// + ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken = default); + + /// + /// Retrieves a resource using its name. + /// + /// The name associated with the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the resource corresponding to the specified name. + /// + ValueTask FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Retrieves a list of resources using their name. + /// + /// The names associated with the resources. + /// The that can be used to abort the operation. + /// The resources corresponding to the specified names. + IAsyncEnumerable FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken = default); + + /// + /// Executes the specified query and returns the first element. + /// + /// The result type. + /// The query to execute. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the first element returned when executing the query. + /// + ValueTask GetAsync( + Func, IQueryable> query, CancellationToken cancellationToken = default); + + /// + /// Executes the specified query and returns the first element. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the first element returned when executing the query. + /// + ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken = default); + + /// + /// Retrieves the description associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the description associated with the specified resource. + /// + ValueTask GetDescriptionAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves the localized descriptions associated with an resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns all the localized descriptions associated with the resource. + /// + ValueTask> GetDescriptionsAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves the display name associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the display name associated with the resource. + /// + ValueTask GetDisplayNameAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves the localized display names associated with an resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns all the localized display names associated with the resource. + /// + ValueTask> GetDisplayNamesAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves the unique identifier associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the unique identifier associated with the resource. + /// + ValueTask GetIdAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves the localized description associated with an resource + /// and corresponding to the current UI culture or one of its parents. + /// If no matching value can be found, the non-localized value is returned. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the matching localized description associated with the resource. + /// + ValueTask GetLocalizedDescriptionAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves the localized description associated with an resource + /// and corresponding to the specified culture or one of its parents. + /// If no matching value can be found, the non-localized value is returned. + /// + /// The resource. + /// The culture (typically ). + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the matching localized description associated with the resource. + /// + ValueTask GetLocalizedDescriptionAsync(object resource, CultureInfo culture, CancellationToken cancellationToken = default); + + /// + /// Retrieves the localized display name associated with an resource + /// and corresponding to the current UI culture or one of its parents. + /// If no matching value can be found, the non-localized value is returned. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the display name associated with the resource. + /// + ValueTask GetLocalizedDisplayNameAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves the localized display name associated with an resource + /// and corresponding to the specified culture or one of its parents. + /// If no matching value can be found, the non-localized value is returned. + /// + /// The resource. + /// The culture (typically ). + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the display name associated with the resource. + /// + ValueTask GetLocalizedDisplayNameAsync(object resource, CultureInfo culture, CancellationToken cancellationToken = default); + + /// + /// Retrieves the name associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the name associated with the specified resource. + /// + ValueTask GetNameAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Retrieves the additional properties associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns all the additional properties associated with the resource. + /// + ValueTask> GetPropertiesAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Executes the specified query and returns all the corresponding elements. + /// + /// The number of results to return. + /// The number of results to skip. + /// The that can be used to abort the operation. + /// All the elements returned when executing the specified query. + IAsyncEnumerable ListAsync( + int? count = null, int? offset = null, CancellationToken cancellationToken = default); + + /// + /// Executes the specified query and returns all the corresponding elements. + /// + /// The result type. + /// The query to execute. + /// The that can be used to abort the operation. + /// All the elements returned when executing the specified query. + IAsyncEnumerable ListAsync( + Func, IQueryable> query, CancellationToken cancellationToken = default); + + /// + /// Executes the specified query and returns all the corresponding elements. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// All the elements returned when executing the specified query. + IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken = default); + + /// + /// Populates the specified descriptor using the properties exposed by the resource. + /// + /// The descriptor. + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + ValueTask PopulateAsync(OpenIddictResourceDescriptor descriptor, object resource, CancellationToken cancellationToken = default); + + /// + /// Populates the resource using the specified descriptor. + /// + /// The resource. + /// The descriptor. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + ValueTask PopulateAsync(object resource, OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken = default); + + /// + /// Updates an existing resource. + /// + /// The resource to update. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + ValueTask UpdateAsync(object resource, CancellationToken cancellationToken = default); + + /// + /// Updates an existing resource. + /// + /// The resource to update. + /// The descriptor used to update the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + ValueTask UpdateAsync(object resource, OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken = default); + + /// + /// Validates the resource to ensure it's in a consistent state. + /// + /// The resource. + /// The that can be used to abort the operation. + /// The validation error encountered when validating the resource. + IAsyncEnumerable ValidateAsync(object resource, CancellationToken cancellationToken = default); +} diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index ba1eca57..76d81d5b 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -810,10 +810,10 @@ To register the OWIN integration, use 'services.AddOpenIddict().AddValidation(). The client secret hash cannot be set on the application entity. - One or more validation error(s) occurred while trying to create a new application: + One or more validation error(s) occurred while trying to create a new entity: - An error occurred while trying to create a new application. + An error occurred while trying to create a new entity. The client type cannot be null or empty. @@ -834,7 +834,7 @@ To register the OWIN integration, use 'services.AddOpenIddict().AddValidation(). Callback URIs must be valid absolute URIs. - One or more validation error(s) occurred while trying to update an existing application: + One or more validation error(s) occurred while trying to update an existing entity: The secret cannot be null or empty. @@ -845,33 +845,6 @@ To register the OWIN integration, use 'services.AddOpenIddict().AddValidation(). The client secret payload stored in the database is malformed, uses an unsupported version or doesn't meet the minimum security requirements and may have been tampered with. - - One or more validation error(s) occurred while trying to create a new authorization: - - - An error occurred while trying to create a new authorization. - - - One or more validation error(s) occurred while trying to update an existing authorization: - - - One or more validation error(s) occurred while trying to create a new scope: - - - An error occurred while trying to create a new scope. - - - One or more validation error(s) occurred while trying to update an existing scope: - - - One or more validation error(s) occurred while trying to create a new token: - - - An error occurred while trying to create a new token. - - - One or more validation error(s) occurred while trying to update an existing token: - The specified type is invalid. @@ -883,20 +856,12 @@ To register the OWIN integration, use 'services.AddOpenIddict().AddValidation(). To configure the OpenIddict Entity Framework 6.x stores to use a specific 'DbContext', use 'options.UseEntityFramework().UseDbContext<TContext>()'. - The application was concurrently updated and cannot be persisted in its current state. -Reload the application from the database and retry the operation. + The entity was concurrently updated and cannot be persisted in its current state. +Reload the entity from the database and retry the operation. - An error occurred while trying to create a new application instance. -Make sure that the application entity is not abstract and has a public parameterless constructor or create a custom application store that overrides 'InstantiateAsync()' to use a custom factory. - - - The authorization was concurrently updated and cannot be persisted in its current state. -Reload the authorization from the database and retry the operation. - - - An error occurred while trying to create a new authorization instance. -Make sure that the authorization entity is not abstract and has a public parameterless constructor or create a custom authorization store that overrides 'InstantiateAsync()' to use a custom factory. + An error occurred while trying to create a new entity instance. +Make sure that the entity is not abstract and has a public parameterless constructor or create a custom store that overrides 'InstantiateAsync()' to use a custom factory. An error occurred while pruning authorizations. @@ -904,22 +869,6 @@ Make sure that the authorization entity is not abstract and has a public paramet The application associated with the authorization cannot be found. - - The scope was concurrently updated and cannot be persisted in its current state. -Reload the scope from the database and retry the operation. - - - An error occurred while trying to create a new scope instance. -Make sure that the scope entity is not abstract and has a public parameterless constructor or create a custom scope store that overrides 'InstantiateAsync()' to use a custom factory. - - - The token was concurrently updated and cannot be persisted in its current state. -Reload the token from the database and retry the operation. - - - An error occurred while trying to create a new token instance. -Make sure that the token entity is not abstract and has a public parameterless constructor or create a custom token store that overrides 'InstantiateAsync()' to use a custom factory. - An error occurred while pruning tokens. @@ -2490,6 +2439,15 @@ To use a custom policy relying on the system store, set 'OpenIddictServerOptions The specified TLS client certificate is not allowed or valid for this operation. + + The resource name cannot be null or empty. + + + The resource name must be a valid absolute URI. + + + A resource with the same name already exists. + The '{0}' parameter shouldn't be null or empty at this point. diff --git a/src/OpenIddict.Abstractions/Stores/IOpenIddictApplicationStore.cs b/src/OpenIddict.Abstractions/Stores/IOpenIddictApplicationStore.cs index 3eabe594..dabe347e 100644 --- a/src/OpenIddict.Abstractions/Stores/IOpenIddictApplicationStore.cs +++ b/src/OpenIddict.Abstractions/Stores/IOpenIddictApplicationStore.cs @@ -15,7 +15,7 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to manage the applications stored in a database. /// -/// The type of the Application entity. +/// The type of the application entity. public interface IOpenIddictApplicationStore where TApplication : class { /// diff --git a/src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs b/src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs index b33b2b8e..fa178d59 100644 --- a/src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs @@ -12,7 +12,7 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to manage the authorizations stored in a database. /// -/// The type of the Authorization entity. +/// The type of the authorization entity. public interface IOpenIddictAuthorizationStore where TAuthorization : class { /// diff --git a/src/OpenIddict.Abstractions/Stores/IOpenIddictResourceStore.cs b/src/OpenIddict.Abstractions/Stores/IOpenIddictResourceStore.cs new file mode 100644 index 00000000..ff7fd1ea --- /dev/null +++ b/src/OpenIddict.Abstractions/Stores/IOpenIddictResourceStore.cs @@ -0,0 +1,280 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; + +namespace OpenIddict.Abstractions; + +/// +/// Provides methods allowing to manage the resources stored in a database. +/// +/// The type of the resource entity. +public interface IOpenIddictResourceStore where TResource : class +{ + /// + /// Determines the number of resources that exist in the database. + /// + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the number of resources in the database. + /// + ValueTask CountAsync(CancellationToken cancellationToken); + + /// + /// Determines the number of resources that match the specified query. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the number of resources that match the specified query. + /// + ValueTask CountAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken); + + /// + /// Creates a new resource. + /// + /// The resource to create. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask CreateAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Removes an existing resource. + /// + /// The resource to delete. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask DeleteAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Retrieves a resource using its unique identifier. + /// + /// The unique identifier associated with the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the resource corresponding to the identifier. + /// + ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken); + + /// + /// Retrieves a resource using its name. + /// + /// The name associated with the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the resource corresponding to the specified name. + /// + ValueTask FindByNameAsync(string name, CancellationToken cancellationToken); + + /// + /// Retrieves a list of resources using their name. + /// + /// The names associated with the resources. + /// The that can be used to abort the operation. + /// The resources corresponding to the specified names. + IAsyncEnumerable FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken); + + /// + /// Executes the specified query and returns the first element. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the first element returned when executing the query. + /// + ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken); + + /// + /// Retrieves the description associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the description associated with the specified resource. + /// + ValueTask GetDescriptionAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Retrieves the localized descriptions associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns all the localized descriptions associated with the specified resource. + /// + ValueTask> GetDescriptionsAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Retrieves the display name associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the display name associated with the resource. + /// + ValueTask GetDisplayNameAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Retrieves the localized display names associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns all the localized display names associated with the resource. + /// + ValueTask> GetDisplayNamesAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Retrieves the unique identifier associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the unique identifier associated with the resource. + /// + ValueTask GetIdAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Retrieves the name associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the name associated with the specified resource. + /// + ValueTask GetNameAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Retrieves the additional properties associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose + /// result returns all the additional properties associated with the resource. + /// + ValueTask> GetPropertiesAsync(TResource resource, CancellationToken cancellationToken); + + /// + /// Instantiates a new resource. + /// + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the instantiated resource, that can be persisted in the database. + /// + ValueTask InstantiateAsync(CancellationToken cancellationToken); + + /// + /// Executes the specified query and returns all the corresponding elements. + /// + /// The number of results to return. + /// The number of results to skip. + /// The that can be used to abort the operation. + /// All the elements returned when executing the specified query. + IAsyncEnumerable ListAsync(int? count, int? offset, CancellationToken cancellationToken); + + /// + /// Executes the specified query and returns all the corresponding elements. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// All the elements returned when executing the specified query. + IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken); + + /// + /// Sets the description associated with a resource. + /// + /// The resource. + /// The description associated with the authorization. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask SetDescriptionAsync(TResource resource, string? description, CancellationToken cancellationToken); + + /// + /// Sets the localized descriptions associated with a resource. + /// + /// The resource. + /// The localized descriptions associated with the authorization. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask SetDescriptionsAsync(TResource resource, + ImmutableDictionary descriptions, CancellationToken cancellationToken); + + /// + /// Sets the display name associated with a resource. + /// + /// The resource. + /// The display name associated with the resource. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask SetDisplayNameAsync(TResource resource, string? name, CancellationToken cancellationToken); + + /// + /// Sets the localized display names associated with a resource. + /// + /// The resource. + /// The localized display names associated with the resource. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask SetDisplayNamesAsync(TResource resource, + ImmutableDictionary names, CancellationToken cancellationToken); + + /// + /// Sets the name associated with a resource. + /// + /// The resource. + /// The name associated with the authorization. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask SetNameAsync(TResource resource, string? name, CancellationToken cancellationToken); + + /// + /// Sets the additional properties associated with a resource. + /// + /// The resource. + /// The additional properties associated with the resource. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask SetPropertiesAsync(TResource resource, + ImmutableDictionary properties, CancellationToken cancellationToken); + + /// + /// Updates an existing resource. + /// + /// The resource to update. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask UpdateAsync(TResource resource, CancellationToken cancellationToken); +} diff --git a/src/OpenIddict.Abstractions/Stores/IOpenIddictScopeStore.cs b/src/OpenIddict.Abstractions/Stores/IOpenIddictScopeStore.cs index c1e2565f..1a263b26 100644 --- a/src/OpenIddict.Abstractions/Stores/IOpenIddictScopeStore.cs +++ b/src/OpenIddict.Abstractions/Stores/IOpenIddictScopeStore.cs @@ -13,7 +13,7 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to manage the scopes stored in a database. /// -/// The type of the Scope entity. +/// The type of the scope entity. public interface IOpenIddictScopeStore where TScope : class { /// diff --git a/src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs b/src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs index 1ac1ba68..49892a4f 100644 --- a/src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs +++ b/src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs @@ -12,7 +12,7 @@ namespace OpenIddict.Abstractions; /// /// Provides methods allowing to manage the tokens stored in a database. /// -/// The type of the Token entity. +/// The type of the token entity. public interface IOpenIddictTokenStore where TToken : class { /// diff --git a/src/OpenIddict.Core/Caches/OpenIddictApplicationCache.cs b/src/OpenIddict.Core/Caches/OpenIddictApplicationCache.cs index 58cedd0b..22d84100 100644 --- a/src/OpenIddict.Core/Caches/OpenIddictApplicationCache.cs +++ b/src/OpenIddict.Core/Caches/OpenIddictApplicationCache.cs @@ -17,7 +17,7 @@ namespace OpenIddict.Core; /// /// Provides methods allowing to cache applications after retrieving them from the store. /// -/// The type of the Application entity. +/// The type of the application entity. public sealed class OpenIddictApplicationCache : IOpenIddictApplicationCache, IDisposable where TApplication : class { private readonly MemoryCache _cache; diff --git a/src/OpenIddict.Core/Caches/OpenIddictAuthorizationCache.cs b/src/OpenIddict.Core/Caches/OpenIddictAuthorizationCache.cs index be80393a..df9307d5 100644 --- a/src/OpenIddict.Core/Caches/OpenIddictAuthorizationCache.cs +++ b/src/OpenIddict.Core/Caches/OpenIddictAuthorizationCache.cs @@ -16,7 +16,7 @@ namespace OpenIddict.Core; /// /// Provides methods allowing to cache authorizations after retrieving them from the store. /// -/// The type of the Authorization entity. +/// The type of the authorization entity. public sealed class OpenIddictAuthorizationCache : IOpenIddictAuthorizationCache, IDisposable where TAuthorization : class { private readonly MemoryCache _cache; diff --git a/src/OpenIddict.Core/Caches/OpenIddictResourceCache.cs b/src/OpenIddict.Core/Caches/OpenIddictResourceCache.cs new file mode 100644 index 00000000..ae74a9a7 --- /dev/null +++ b/src/OpenIddict.Core/Caches/OpenIddictResourceCache.cs @@ -0,0 +1,258 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace OpenIddict.Core; + +/// +/// Provides methods allowing to cache resources after retrieving them from the store. +/// +/// The type of the Resource entity. +public sealed class OpenIddictResourceCache : IOpenIddictResourceCache, IDisposable where TResource : class +{ + private readonly MemoryCache _cache; + private readonly ConcurrentDictionary _signals; + private readonly IOpenIddictResourceStore _store; + + /// + /// Creates a new instance of the class. + /// + /// The options. + /// The store. + public OpenIddictResourceCache( + IOptionsMonitor options, + IOpenIddictResourceStore store) + { + _cache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = (options ?? throw new ArgumentNullException(nameof(options))).CurrentValue.EntityCacheLimit + }); + + _signals = new ConcurrentDictionary(StringComparer.Ordinal); + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + /// + public async ValueTask AddAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + _cache.Remove(new + { + Method = nameof(FindByIdAsync), + Identifier = await _store.GetIdAsync(resource, cancellationToken) + }); + + _cache.Remove(new + { + Method = nameof(FindByNameAsync), + Name = await _store.GetNameAsync(resource, cancellationToken) + }); + + await CreateEntryAsync(new + { + Method = nameof(FindByIdAsync), + Identifier = await _store.GetIdAsync(resource, cancellationToken) + }, resource, cancellationToken); + + await CreateEntryAsync(new + { + Method = nameof(FindByNameAsync), + Name = await _store.GetNameAsync(resource, cancellationToken) + }, resource, cancellationToken); + } + + /// + public void Dispose() + { + foreach (var signal in _signals) + { + signal.Value.Dispose(); + } + + _cache.Dispose(); + } + + /// + public ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + var parameters = new + { + Method = nameof(FindByIdAsync), + Identifier = identifier + }; + + if (_cache.TryGetValue(parameters, out TResource? resource)) + { + return new(resource); + } + + return new(ExecuteAsync()); + + async Task ExecuteAsync() + { + if ((resource = await _store.FindByIdAsync(identifier, cancellationToken)) is not null) + { + await AddAsync(resource, cancellationToken); + } + + await CreateEntryAsync(parameters, resource, cancellationToken); + + return resource; + } + } + + /// + public ValueTask FindByNameAsync(string name, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var parameters = new + { + Method = nameof(FindByNameAsync), + Name = name + }; + + if (_cache.TryGetValue(parameters, out TResource? resource)) + { + return new(resource); + } + + async Task ExecuteAsync() + { + if ((resource = await _store.FindByNameAsync(name, cancellationToken)) is not null) + { + await AddAsync(resource, cancellationToken); + } + + await CreateEntryAsync(parameters, resource, cancellationToken); + + return resource; + } + + return new(ExecuteAsync()); + } + + /// + public IAsyncEnumerable FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken) + { + if (names.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0203), nameof(names)); + } + + // Note: this method is only partially cached. + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var resource in _store.FindByNamesAsync(names, cancellationToken)) + { + await AddAsync(resource, cancellationToken); + + yield return resource; + } + } + } + + /// + public async ValueTask RemoveAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var identifier = await _store.GetIdAsync(resource, cancellationToken); + if (string.IsNullOrEmpty(identifier)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0196)); + } + + if (_signals.TryRemove(identifier, out CancellationTokenSource? signal)) + { + signal.Cancel(); + signal.Dispose(); + } + } + + /// + /// Creates a cache entry for the specified key. + /// + /// The cache key. + /// The resource to store in the cache entry, if applicable. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + private async ValueTask CreateEntryAsync(object key, TResource? resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(key); + + using var entry = _cache.CreateEntry(key); + + if (resource is not null) + { + entry.AddExpirationToken(await CreateExpirationSignalAsync(resource, cancellationToken) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0197))); + } + + entry.Size = 1L; + entry.Value = resource; + } + + /// + /// Creates a cache entry for the specified key. + /// + /// The cache key. + /// The resources to store in the cache entry. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + private async ValueTask CreateEntryAsync(object key, ImmutableArray resources, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(key); + + using var entry = _cache.CreateEntry(key); + + foreach (var resource in resources) + { + entry.AddExpirationToken(await CreateExpirationSignalAsync(resource, cancellationToken) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0197))); + } + + entry.Size = resources.Length; + entry.Value = resources; + } + + /// + /// Creates an expiration signal allowing to invalidate all the + /// cache entries associated with the specified resource. + /// + /// The resource associated with the expiration signal. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns an expiration signal for the specified resource. + /// + private async ValueTask CreateExpirationSignalAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var identifier = await _store.GetIdAsync(resource, cancellationToken); + if (string.IsNullOrEmpty(identifier)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0204)); + } + + var signal = _signals.GetOrAdd(identifier, _ => new CancellationTokenSource()); + + return new CancellationChangeToken(signal.Token); + } +} diff --git a/src/OpenIddict.Core/Caches/OpenIddictScopeCache.cs b/src/OpenIddict.Core/Caches/OpenIddictScopeCache.cs index 1bf96a42..422e0fe8 100644 --- a/src/OpenIddict.Core/Caches/OpenIddictScopeCache.cs +++ b/src/OpenIddict.Core/Caches/OpenIddictScopeCache.cs @@ -16,7 +16,7 @@ namespace OpenIddict.Core; /// /// Provides methods allowing to cache scopes after retrieving them from the store. /// -/// The type of the Scope entity. +/// The type of the scope entity. public sealed class OpenIddictScopeCache : IOpenIddictScopeCache, IDisposable where TScope : class { private readonly MemoryCache _cache; diff --git a/src/OpenIddict.Core/Caches/OpenIddictTokenCache.cs b/src/OpenIddict.Core/Caches/OpenIddictTokenCache.cs index 71edcf8f..7ce5e54f 100644 --- a/src/OpenIddict.Core/Caches/OpenIddictTokenCache.cs +++ b/src/OpenIddict.Core/Caches/OpenIddictTokenCache.cs @@ -16,7 +16,7 @@ namespace OpenIddict.Core; /// /// Provides methods allowing to cache tokens after retrieving them from the store. /// -/// The type of the Token entity. +/// The type of the token entity. public sealed class OpenIddictTokenCache : IOpenIddictTokenCache, IDisposable where TToken : class { private readonly MemoryCache _cache; diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs index 77ecce6c..b760bacd 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs @@ -30,7 +30,7 @@ namespace OpenIddict.Core; /// Applications that do not want to depend on a specific entity type can use the non-generic /// instead, for which the actual entity type is resolved at runtime. /// -/// The type of the Application entity. +/// The type of the application entity. public class OpenIddictApplicationManager : IOpenIddictApplicationManager where TApplication : class { /// diff --git a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs index c8979b7b..41b23a23 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs @@ -24,7 +24,7 @@ namespace OpenIddict.Core; /// Applications that do not want to depend on a specific entity type can use the non-generic /// instead, for which the actual entity type is resolved at runtime. /// -/// The type of the Authorization entity. +/// The type of the authorization entity. public class OpenIddictAuthorizationManager : IOpenIddictAuthorizationManager where TAuthorization : class { /// @@ -138,7 +138,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori if (results.Any(result => result != ValidationResult.Success)) { var builder = new StringBuilder(); - builder.AppendLine(SR.GetResourceString(SR.ID0219)); + builder.AppendLine(SR.GetResourceString(SR.ID0207)); builder.AppendLine(); foreach (var result in results) @@ -184,7 +184,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori ArgumentNullException.ThrowIfNull(descriptor); var authorization = await Store.InstantiateAsync(cancellationToken) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0220)); + throw new InvalidOperationException(SR.GetResourceString(SR.ID0208)); await PopulateAsync(authorization, descriptor, cancellationToken); await CreateAsync(authorization, cancellationToken); @@ -858,7 +858,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori if (results.Any(result => result != ValidationResult.Success)) { var builder = new StringBuilder(); - builder.AppendLine(SR.GetResourceString(SR.ID0221)); + builder.AppendLine(SR.GetResourceString(SR.ID0215)); builder.AppendLine(); foreach (var result in results) diff --git a/src/OpenIddict.Core/Managers/OpenIddictResourceManager.cs b/src/OpenIddict.Core/Managers/OpenIddictResourceManager.cs new file mode 100644 index 00000000..5a7ee6f4 --- /dev/null +++ b/src/OpenIddict.Core/Managers/OpenIddictResourceManager.cs @@ -0,0 +1,936 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ValidationException = OpenIddict.Abstractions.OpenIddictExceptions.ValidationException; + +namespace OpenIddict.Core; + +/// +/// Provides methods allowing to manage the resources stored in the store. +/// +/// +/// Applications that do not want to depend on a specific entity type can use the non-generic +/// instead, for which the actual entity type is resolved at runtime. +/// +/// The type of the resource entity. +public class OpenIddictResourceManager : IOpenIddictResourceManager where TResource : class +{ + /// + /// Creates a new instance of the class. + /// + /// The cache. + /// The logger. + /// The options. + /// The store. + public OpenIddictResourceManager( + IOpenIddictResourceCache cache, + ILogger> logger, + IOptionsMonitor options, + IOpenIddictResourceStore store) + { + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + Store = store ?? throw new ArgumentNullException(nameof(store)); + } + + /// + /// Gets the cache associated with the current manager. + /// + protected IOpenIddictResourceCache Cache { get; } + + /// + /// Gets the logger associated with the current manager. + /// + protected ILogger Logger { get; } + + /// + /// Gets the options associated with the current manager. + /// + protected IOptionsMonitor Options { get; } + + /// + /// Gets the store associated with the current manager. + /// + protected IOpenIddictResourceStore Store { get; } + + /// + /// Determines the number of resources that exist in the database. + /// + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the number of resources in the database. + /// + public virtual ValueTask CountAsync(CancellationToken cancellationToken = default) + => Store.CountAsync(cancellationToken); + + /// + /// Determines the number of resources that match the specified query. + /// + /// The result type. + /// The query to execute. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the number of resources that match the specified query. + /// + public virtual ValueTask CountAsync( + Func, IQueryable> query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + return CountAsync(static (resources, query) => query(resources), query, cancellationToken); + } + + /// + /// Determines the number of resources that match the specified query. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the number of resources that match the specified query. + /// + public virtual ValueTask CountAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + return Store.CountAsync(query, state, cancellationToken); + } + + /// + /// Creates a new resource. + /// + /// The resource to create. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual async ValueTask CreateAsync(TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + var results = await GetValidationResultsAsync(resource, cancellationToken); + if (results.Any(result => result != ValidationResult.Success)) + { + var builder = new StringBuilder(); + builder.AppendLine(SR.GetResourceString(SR.ID0207)); + builder.AppendLine(); + + foreach (var result in results) + { + builder.AppendLine(result.ErrorMessage); + } + + throw new ValidationException(builder.ToString(), results); + } + + await Store.CreateAsync(resource, cancellationToken); + + if (!Options.CurrentValue.DisableEntityCaching) + { + await Cache.AddAsync(resource, cancellationToken); + } + + async Task> GetValidationResultsAsync( + TResource resource, CancellationToken cancellationToken) + { + var builder = ImmutableArray.CreateBuilder(); + + await foreach (var result in ValidateAsync(resource, cancellationToken)) + { + builder.Add(result); + } + + return builder.ToImmutable(); + } + } + + /// + /// Creates a new resource based on the specified descriptor. + /// + /// The resource descriptor. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose result returns the resource. + /// + public virtual async ValueTask CreateAsync( + OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(descriptor); + + var resource = await Store.InstantiateAsync(cancellationToken) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0208)); + + await PopulateAsync(resource, descriptor, cancellationToken); + await CreateAsync(resource, cancellationToken); + + return resource; + } + + /// + /// Removes an existing resource. + /// + /// The resource to delete. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual async ValueTask DeleteAsync(TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + if (!Options.CurrentValue.DisableEntityCaching) + { + await Cache.RemoveAsync(resource, cancellationToken); + } + + await Store.DeleteAsync(resource, cancellationToken); + } + + /// + /// Retrieves a resource using its unique identifier. + /// + /// The unique identifier associated with the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the resource corresponding to the identifier. + /// + public virtual async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + var resource = Options.CurrentValue.DisableEntityCaching ? + await Store.FindByIdAsync(identifier, cancellationToken) : + await Cache.FindByIdAsync(identifier, cancellationToken); + + if (resource is null) + { + return null; + } + + // 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. + if (!Options.CurrentValue.DisableAdditionalFiltering && + !string.Equals(await Store.GetIdAsync(resource, cancellationToken), identifier, StringComparison.Ordinal)) + { + return null; + } + + return resource; + } + + /// + /// Retrieves a resource using its name. + /// + /// The name associated with the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the resource corresponding to the specified name. + /// + public virtual async ValueTask FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var resource = Options.CurrentValue.DisableEntityCaching ? + await Store.FindByNameAsync(name, cancellationToken) : + await Cache.FindByNameAsync(name, cancellationToken); + + if (resource is null) + { + return null; + } + + // 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. + + if (!Options.CurrentValue.DisableAdditionalFiltering && + !string.Equals(await Store.GetNameAsync(resource, cancellationToken), name, StringComparison.Ordinal)) + { + return null; + } + + return resource; + } + + /// + /// Retrieves a list of resources using their name. + /// + /// The names associated with the resources. + /// The that can be used to abort the operation. + /// The resources corresponding to the specified names. + public virtual IAsyncEnumerable FindByNamesAsync( + ImmutableArray names, CancellationToken cancellationToken = default) + { + if (names.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0203), nameof(names)); + } + + var resources = Options.CurrentValue.DisableEntityCaching ? + Store.FindByNamesAsync(names, cancellationToken) : + Cache.FindByNamesAsync(names, cancellationToken); + + if (Options.CurrentValue.DisableAdditionalFiltering) + { + return resources; + } + + // 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. + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var resource in resources) + { + var name = await Store.GetNameAsync(resource, cancellationToken); + if (!string.IsNullOrEmpty(name) && names.Contains(name, StringComparer.Ordinal)) + { + yield return resource; + } + } + } + } + + /// + /// Executes the specified query and returns the first element. + /// + /// The result type. + /// The query to execute. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the first element returned when executing the query. + /// + public virtual ValueTask GetAsync( + Func, IQueryable> query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + return GetAsync(static (resources, query) => query(resources), query, cancellationToken); + } + + /// + /// Executes the specified query and returns the first element. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the first element returned when executing the query. + /// + public virtual ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + return Store.GetAsync(query, state, cancellationToken); + } + + /// + /// Retrieves the description associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the description associated with the specified resource. + /// + public virtual ValueTask GetDescriptionAsync(TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return Store.GetDescriptionAsync(resource, cancellationToken); + } + + /// + /// Retrieves the localized descriptions associated with an resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns all the localized descriptions associated with the resource. + /// + public virtual async ValueTask> GetDescriptionsAsync( + TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + var descriptions = await Store.GetDescriptionsAsync(resource, cancellationToken); + if (descriptions is not { Count: > 0 }) + { + return ImmutableDictionary.Create(); + } + + return descriptions; + } + + /// + /// Retrieves the display name associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the display name associated with the resource. + /// + public virtual ValueTask GetDisplayNameAsync(TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return Store.GetDisplayNameAsync(resource, cancellationToken); + } + + /// + /// Retrieves the localized display names associated with an resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns all the localized display names associated with the resource. + /// + public virtual async ValueTask> GetDisplayNamesAsync( + TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + var names = await Store.GetDisplayNamesAsync(resource, cancellationToken); + if (names is not { Count: > 0 }) + { + return ImmutableDictionary.Create(); + } + + return names; + } + + /// + /// Retrieves the unique identifier associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the unique identifier associated with the resource. + /// + public virtual ValueTask GetIdAsync(TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return Store.GetIdAsync(resource, cancellationToken); + } + + /// + /// Retrieves the localized display name associated with an resource + /// and corresponding to the current UI culture or one of its parents. + /// If no matching value can be found, the non-localized value is returned. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the matching display name associated with the resource. + /// + public virtual ValueTask GetLocalizedDisplayNameAsync(TResource resource, CancellationToken cancellationToken = default) + => GetLocalizedDisplayNameAsync(resource, CultureInfo.CurrentUICulture, cancellationToken); + + /// + /// Retrieves the localized display name associated with an resource + /// and corresponding to the specified culture or one of its parents. + /// If no matching value can be found, the non-localized value is returned. + /// + /// The resource. + /// The culture (typically ). + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the matching display name associated with the resource. + /// + public virtual async ValueTask GetLocalizedDisplayNameAsync( + TResource resource, CultureInfo culture, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(culture); + + var names = await Store.GetDisplayNamesAsync(resource, cancellationToken); + if (names is not { Count: > 0 }) + { + return await Store.GetDisplayNameAsync(resource, cancellationToken); + } + + do + { + if (names.TryGetValue(culture, out var name)) + { + return name; + } + + culture = culture.Parent; + } + + while (culture != CultureInfo.InvariantCulture); + + return await Store.GetDisplayNameAsync(resource, cancellationToken); + } + + /// + /// Retrieves the localized description associated with an resource + /// and corresponding to the current UI culture or one of its parents. + /// If no matching value can be found, the non-localized value is returned. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the matching localized description associated with the resource. + /// + public virtual ValueTask GetLocalizedDescriptionAsync(TResource resource, CancellationToken cancellationToken = default) + => GetLocalizedDescriptionAsync(resource, CultureInfo.CurrentUICulture, cancellationToken); + + /// + /// Retrieves the localized description associated with an resource + /// and corresponding to the specified culture or one of its parents. + /// If no matching value can be found, the non-localized value is returned. + /// + /// The resource. + /// The culture (typically ). + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the matching localized description associated with the resource. + /// + public virtual async ValueTask GetLocalizedDescriptionAsync( + TResource resource, CultureInfo culture, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(culture); + + var descriptions = await Store.GetDescriptionsAsync(resource, cancellationToken); + if (descriptions is not { Count: > 0 }) + { + return await Store.GetDescriptionAsync(resource, cancellationToken); + } + + do + { + if (descriptions.TryGetValue(culture, out var description)) + { + return description; + } + + culture = culture.Parent; + } + + while (culture != CultureInfo.InvariantCulture); + + return await Store.GetDescriptionAsync(resource, cancellationToken); + } + + /// + /// Retrieves the name associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the name associated with the specified resource. + /// + public virtual ValueTask GetNameAsync(TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return Store.GetNameAsync(resource, cancellationToken); + } + + /// + /// Retrieves the additional properties associated with a resource. + /// + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns all the additional properties associated with the resource. + /// + public virtual ValueTask> GetPropertiesAsync( + TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return Store.GetPropertiesAsync(resource, cancellationToken); + } + + /// + /// Executes the specified query and returns all the corresponding elements. + /// + /// The number of results to return. + /// The number of results to skip. + /// The that can be used to abort the operation. + /// All the elements returned when executing the specified query. + public virtual IAsyncEnumerable ListAsync( + int? count = null, int? offset = null, CancellationToken cancellationToken = default) + => Store.ListAsync(count, offset, cancellationToken); + + /// + /// Executes the specified query and returns all the corresponding elements. + /// + /// The result type. + /// The query to execute. + /// The that can be used to abort the operation. + /// All the elements returned when executing the specified query. + public virtual IAsyncEnumerable ListAsync( + Func, IQueryable> query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + return ListAsync(static (resources, query) => query(resources), query, cancellationToken); + } + + /// + /// Executes the specified query and returns all the corresponding elements. + /// + /// The state type. + /// The result type. + /// The query to execute. + /// The optional state. + /// The that can be used to abort the operation. + /// All the elements returned when executing the specified query. + public virtual IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + return Store.ListAsync(query, state, cancellationToken); + } + + /// + /// Populates the resource using the specified descriptor. + /// + /// The resource. + /// The descriptor. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual async ValueTask PopulateAsync(TResource resource, + OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(descriptor); + + await Store.SetDescriptionAsync(resource, descriptor.Description, cancellationToken); + await Store.SetDescriptionsAsync(resource, descriptor.Descriptions.ToImmutableDictionary(), cancellationToken); + await Store.SetDisplayNameAsync(resource, descriptor.DisplayName, cancellationToken); + await Store.SetDisplayNamesAsync(resource, descriptor.DisplayNames.ToImmutableDictionary(), cancellationToken); + await Store.SetNameAsync(resource, descriptor.Name, cancellationToken); + await Store.SetPropertiesAsync(resource, descriptor.Properties.ToImmutableDictionary(), cancellationToken); + } + + /// + /// Populates the specified descriptor using the properties exposed by the resource. + /// + /// The descriptor. + /// The resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual async ValueTask PopulateAsync( + OpenIddictResourceDescriptor descriptor, + TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(resource); + + descriptor.Description = await Store.GetDescriptionAsync(resource, cancellationToken); + descriptor.DisplayName = await Store.GetDisplayNameAsync(resource, cancellationToken); + descriptor.Name = await Store.GetNameAsync(resource, cancellationToken); + + descriptor.DisplayNames.Clear(); + foreach (var pair in await Store.GetDisplayNamesAsync(resource, cancellationToken)) + { + descriptor.DisplayNames.Add(pair.Key, pair.Value); + } + + descriptor.Descriptions.Clear(); + foreach (var pair in await Store.GetDescriptionsAsync(resource, cancellationToken)) + { + descriptor.Descriptions.Add(pair.Key, pair.Value); + } + + descriptor.Properties.Clear(); + foreach (var pair in await Store.GetPropertiesAsync(resource, cancellationToken)) + { + descriptor.Properties.Add(pair.Key, pair.Value); + } + } + + /// + /// Updates an existing resource. + /// + /// The resource to update. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual async ValueTask UpdateAsync(TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + var results = await GetValidationResultsAsync(resource, cancellationToken); + if (results.Any(result => result != ValidationResult.Success)) + { + var builder = new StringBuilder(); + builder.AppendLine(SR.GetResourceString(SR.ID0215)); + builder.AppendLine(); + + foreach (var result in results) + { + builder.AppendLine(result.ErrorMessage); + } + + throw new ValidationException(builder.ToString(), results); + } + + if (!Options.CurrentValue.DisableEntityCaching) + { + await Cache.RemoveAsync(resource, cancellationToken); + } + + await Store.UpdateAsync(resource, cancellationToken); + + if (!Options.CurrentValue.DisableEntityCaching) + { + await Cache.AddAsync(resource, cancellationToken); + } + + async Task> GetValidationResultsAsync( + TResource resource, CancellationToken cancellationToken) + { + var builder = ImmutableArray.CreateBuilder(); + + await foreach (var result in ValidateAsync(resource, cancellationToken)) + { + builder.Add(result); + } + + return builder.ToImmutable(); + } + } + + /// + /// Updates an existing resource. + /// + /// The resource to update. + /// The descriptor used to update the resource. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual async ValueTask UpdateAsync(TResource resource, + OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(descriptor); + + await PopulateAsync(resource, descriptor, cancellationToken); + await UpdateAsync(resource, cancellationToken); + } + + /// + /// Validates the resource to ensure it's in a consistent state. + /// + /// The resource. + /// The that can be used to abort the operation. + /// The validation error encountered when validating the resource. + public virtual IAsyncEnumerable ValidateAsync(TResource resource, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + // Ensure the name is not null or empty, does not contain a + // space and is not already used for a different resource entity. + var name = await Store.GetNameAsync(resource, cancellationToken); + if (string.IsNullOrEmpty(name)) + { + yield return new ValidationResult(SR.GetResourceString(SR.ID2206)); + } + + // Note: resources MUST be absolute URIs and cannot contain a fragment. + // + // See https://datatracker.ietf.org/doc/html/rfc8693#section-2.1 for more information. + else if (!Uri.TryCreate(name, UriKind.Absolute, out Uri? uri) || + OpenIddictHelpers.IsImplicitFileUri(uri) || !string.IsNullOrEmpty(uri.Fragment)) + { + yield return new ValidationResult(SR.GetResourceString(SR.ID2207)); + } + + else + { + // Note: depending on the database/table/query collation used by the store, a resource + // whose name doesn't exactly match the specified value may be returned (e.g because + // the casing is different). To avoid issues when the resource 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 is not null && !string.Equals( + await Store.GetIdAsync(other, cancellationToken), + await Store.GetIdAsync(resource, cancellationToken), StringComparison.Ordinal)) + { + yield return new ValidationResult(SR.GetResourceString(SR.ID2208)); + } + } + } + } + + /// + ValueTask IOpenIddictResourceManager.CountAsync(CancellationToken cancellationToken) + => CountAsync(cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.CountAsync(Func, IQueryable> query, CancellationToken cancellationToken) + => CountAsync(query, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.CountAsync(Func, TState, IQueryable> query, TState state, CancellationToken cancellationToken) + => CountAsync(query, state, cancellationToken); + + /// + async ValueTask IOpenIddictResourceManager.CreateAsync(OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken) + => await CreateAsync(descriptor, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.CreateAsync(object resource, CancellationToken cancellationToken) + => CreateAsync((TResource) resource, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.DeleteAsync(object resource, CancellationToken cancellationToken) + => DeleteAsync((TResource) resource, cancellationToken); + + /// + async ValueTask IOpenIddictResourceManager.FindByIdAsync(string identifier, CancellationToken cancellationToken) + => await FindByIdAsync(identifier, cancellationToken); + + /// + async ValueTask IOpenIddictResourceManager.FindByNameAsync(string name, CancellationToken cancellationToken) + => await FindByNameAsync(name, cancellationToken); + + /// + IAsyncEnumerable IOpenIddictResourceManager.FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken) + => FindByNamesAsync(names, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetAsync(Func, IQueryable> query, CancellationToken cancellationToken) where TResult : default + => GetAsync(query, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetAsync(Func, TState, IQueryable> query, TState state, CancellationToken cancellationToken) where TResult : default + => GetAsync(query, state, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetDescriptionAsync(object resource, CancellationToken cancellationToken) + => GetDescriptionAsync((TResource) resource, cancellationToken); + + /// + ValueTask> IOpenIddictResourceManager.GetDescriptionsAsync(object resource, CancellationToken cancellationToken) + => GetDescriptionsAsync((TResource) resource, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetDisplayNameAsync(object resource, CancellationToken cancellationToken) + => GetDisplayNameAsync((TResource) resource, cancellationToken); + + /// + ValueTask> IOpenIddictResourceManager.GetDisplayNamesAsync(object resource, CancellationToken cancellationToken) + => GetDisplayNamesAsync((TResource) resource, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetIdAsync(object resource, CancellationToken cancellationToken) + => GetIdAsync((TResource) resource, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetLocalizedDescriptionAsync(object resource, CancellationToken cancellationToken) + => GetLocalizedDescriptionAsync((TResource) resource, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetLocalizedDescriptionAsync(object resource, CultureInfo culture, CancellationToken cancellationToken) + => GetLocalizedDescriptionAsync((TResource) resource, culture, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetLocalizedDisplayNameAsync(object resource, CancellationToken cancellationToken) + => GetLocalizedDisplayNameAsync((TResource) resource, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetLocalizedDisplayNameAsync(object resource, CultureInfo culture, CancellationToken cancellationToken) + => GetLocalizedDisplayNameAsync((TResource) resource, culture, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.GetNameAsync(object resource, CancellationToken cancellationToken) + => GetNameAsync((TResource) resource, cancellationToken); + + /// + ValueTask> IOpenIddictResourceManager.GetPropertiesAsync(object resource, CancellationToken cancellationToken) + => GetPropertiesAsync((TResource) resource, cancellationToken); + + /// + IAsyncEnumerable IOpenIddictResourceManager.ListAsync(int? count, int? offset, CancellationToken cancellationToken) + => ListAsync(count, offset, cancellationToken); + + /// + IAsyncEnumerable IOpenIddictResourceManager.ListAsync(Func, IQueryable> query, CancellationToken cancellationToken) + => ListAsync(query, cancellationToken); + + /// + IAsyncEnumerable IOpenIddictResourceManager.ListAsync(Func, TState, IQueryable> query, TState state, CancellationToken cancellationToken) + => ListAsync(query, state, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.PopulateAsync(OpenIddictResourceDescriptor descriptor, object resource, CancellationToken cancellationToken) + => PopulateAsync(descriptor, (TResource) resource, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.PopulateAsync(object resource, OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken) + => PopulateAsync((TResource) resource, descriptor, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.UpdateAsync(object resource, CancellationToken cancellationToken) + => UpdateAsync((TResource) resource, cancellationToken); + + /// + ValueTask IOpenIddictResourceManager.UpdateAsync(object resource, OpenIddictResourceDescriptor descriptor, CancellationToken cancellationToken) + => UpdateAsync((TResource) resource, descriptor, cancellationToken); + + /// + IAsyncEnumerable IOpenIddictResourceManager.ValidateAsync(object resource, CancellationToken cancellationToken) + => ValidateAsync((TResource) resource, cancellationToken); +} diff --git a/src/OpenIddict.Core/Managers/OpenIddictScopeManager.cs b/src/OpenIddict.Core/Managers/OpenIddictScopeManager.cs index 41d08e44..f523e7cb 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictScopeManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictScopeManager.cs @@ -23,7 +23,7 @@ namespace OpenIddict.Core; /// Applications that do not want to depend on a specific entity type can use the non-generic /// instead, for which the actual entity type is resolved at runtime. /// -/// The type of the Scope entity. +/// The type of the scope entity. public class OpenIddictScopeManager : IOpenIddictScopeManager where TScope : class { /// @@ -131,7 +131,7 @@ public class OpenIddictScopeManager : IOpenIddictScopeManager where TSco if (results.Any(result => result != ValidationResult.Success)) { var builder = new StringBuilder(); - builder.AppendLine(SR.GetResourceString(SR.ID0222)); + builder.AppendLine(SR.GetResourceString(SR.ID0207)); builder.AppendLine(); foreach (var result in results) @@ -177,7 +177,7 @@ public class OpenIddictScopeManager : IOpenIddictScopeManager where TSco ArgumentNullException.ThrowIfNull(descriptor); var scope = await Store.InstantiateAsync(cancellationToken) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0223)); + throw new InvalidOperationException(SR.GetResourceString(SR.ID0208)); await PopulateAsync(scope, descriptor, cancellationToken); await CreateAsync(scope, cancellationToken); @@ -792,7 +792,7 @@ public class OpenIddictScopeManager : IOpenIddictScopeManager where TSco if (results.Any(result => result != ValidationResult.Success)) { var builder = new StringBuilder(); - builder.AppendLine(SR.GetResourceString(SR.ID0224)); + builder.AppendLine(SR.GetResourceString(SR.ID0215)); builder.AppendLine(); foreach (var result in results) diff --git a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs index f334ed8c..00bb2a47 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs @@ -24,7 +24,7 @@ namespace OpenIddict.Core; /// Applications that do not want to depend on a specific entity type can use the non-generic /// instead, for which the actual entity type is resolved at runtime. /// -/// The type of the Token entity. +/// The type of the token entity. public class OpenIddictTokenManager : IOpenIddictTokenManager where TToken : class { /// @@ -146,7 +146,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok if (results.Any(result => result != ValidationResult.Success)) { var builder = new StringBuilder(); - builder.AppendLine(SR.GetResourceString(SR.ID0225)); + builder.AppendLine(SR.GetResourceString(SR.ID0207)); builder.AppendLine(); foreach (var result in results) @@ -192,7 +192,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok ArgumentNullException.ThrowIfNull(descriptor); var token = await Store.InstantiateAsync(cancellationToken) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0226)); + throw new InvalidOperationException(SR.GetResourceString(SR.ID0208)); await PopulateAsync(token, descriptor, cancellationToken); await CreateAsync(token, cancellationToken); @@ -1052,7 +1052,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok if (results.Any(result => result != ValidationResult.Success)) { var builder = new StringBuilder(); - builder.AppendLine(SR.GetResourceString(SR.ID0227)); + builder.AppendLine(SR.GetResourceString(SR.ID0215)); builder.AppendLine(); foreach (var result in results) diff --git a/src/OpenIddict.Core/OpenIddictCoreBuilder.cs b/src/OpenIddict.Core/OpenIddictCoreBuilder.cs index a8438aad..32346135 100644 --- a/src/OpenIddict.Core/OpenIddictCoreBuilder.cs +++ b/src/OpenIddict.Core/OpenIddictCoreBuilder.cs @@ -216,6 +216,86 @@ public sealed class OpenIddictCoreBuilder return this; } + /// + /// Replaces the resource manager by the specified type. + /// + /// The type of the entity. + /// The type of the manager. + /// The instance. + public OpenIddictCoreBuilder ReplaceResourceManager< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TManager>() + where TResource : class + where TManager : OpenIddictResourceManager + { + Services.Replace(ServiceDescriptor.Scoped, TManager>()); + + return this; + } + + /// + /// Replaces the resource manager by the specified type. + /// + /// + /// Note: the specified type MUST be an open generic type definition containing exactly one generic argument. + /// + /// The type of the manager. + /// The instance. + public OpenIddictCoreBuilder ReplaceResourceManager( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) + { + if (!type.IsGenericTypeDefinition || type.GetGenericArguments() is not { Length: 1 }) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0232), nameof(type)); + } + + Services.Replace(ServiceDescriptor.Scoped(typeof(OpenIddictResourceManager<>), type)); + + return this; + } + + /// + /// Replaces the resource store by the specified type. + /// + /// The type of the entity. + /// The type of the store. + /// The lifetime of the store. + /// The instance. + public OpenIddictCoreBuilder ReplaceResourceStore< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>( + ServiceLifetime lifetime = ServiceLifetime.Scoped) + where TResource : class + where TStore : IOpenIddictResourceStore + { + Services.Replace(ServiceDescriptor.Describe(typeof(IOpenIddictResourceStore), typeof(TStore), lifetime)); + + return this; + } + + /// + /// Replaces the resource store by the specified type. + /// + /// + /// Note: the specified type MUST be an open generic type definition containing exactly one generic argument. + /// + /// The type of the store. + /// The lifetime of the store. + /// The instance. + public OpenIddictCoreBuilder ReplaceResourceStore( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, + ServiceLifetime lifetime = ServiceLifetime.Scoped) + { + if (!type.IsGenericTypeDefinition || type.GetGenericArguments() is not { Length: 1 }) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0232), nameof(type)); + } + + Services.Replace(ServiceDescriptor.Describe(typeof(IOpenIddictResourceStore<>), type, lifetime)); + + return this; + } + /// /// Replaces the scope manager by the specified type. /// @@ -493,6 +573,19 @@ public sealed class OpenIddictCoreBuilder return this; } + /// + /// Configures OpenIddict to use the specified entity as the default resource entity. + /// + /// The instance. + public OpenIddictCoreBuilder SetDefaultResourceEntity< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource>() where TResource : class + { + Services.Replace(ServiceDescriptor.Scoped(static provider => + provider.GetRequiredService>())); + + return this; + } + /// /// Configures OpenIddict to use the specified entity as the default scope entity. /// diff --git a/src/OpenIddict.Core/OpenIddictCoreExtensions.cs b/src/OpenIddict.Core/OpenIddictCoreExtensions.cs index a20e97ea..6ae119d0 100644 --- a/src/OpenIddict.Core/OpenIddictCoreExtensions.cs +++ b/src/OpenIddict.Core/OpenIddictCoreExtensions.cs @@ -31,11 +31,13 @@ public static class OpenIddictCoreExtensions builder.Services.TryAddScoped(typeof(IOpenIddictApplicationCache<>), typeof(OpenIddictApplicationCache<>)); builder.Services.TryAddScoped(typeof(IOpenIddictAuthorizationCache<>), typeof(OpenIddictAuthorizationCache<>)); + builder.Services.TryAddScoped(typeof(IOpenIddictResourceCache<>), typeof(OpenIddictResourceCache<>)); builder.Services.TryAddScoped(typeof(IOpenIddictScopeCache<>), typeof(OpenIddictScopeCache<>)); builder.Services.TryAddScoped(typeof(IOpenIddictTokenCache<>), typeof(OpenIddictTokenCache<>)); builder.Services.TryAddScoped(typeof(OpenIddictApplicationManager<>)); builder.Services.TryAddScoped(typeof(OpenIddictAuthorizationManager<>)); + builder.Services.TryAddScoped(typeof(OpenIddictResourceManager<>)); builder.Services.TryAddScoped(typeof(OpenIddictScopeManager<>)); builder.Services.TryAddScoped(typeof(OpenIddictTokenManager<>)); @@ -46,6 +48,8 @@ public static class OpenIddictCoreExtensions throw new InvalidOperationException(SR.GetResourceString(SR.ID0472))); builder.Services.TryAddScoped(static provider => throw new InvalidOperationException(SR.GetResourceString(SR.ID0472))); + builder.Services.TryAddScoped(static provider => + throw new InvalidOperationException(SR.GetResourceString(SR.ID0472))); builder.Services.TryAddScoped(static provider => throw new InvalidOperationException(SR.GetResourceString(SR.ID0472))); builder.Services.TryAddScoped(static provider => diff --git a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkApplication.cs b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkApplication.cs index bdcc8214..1b104811 100644 --- a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkApplication.cs +++ b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkApplication.cs @@ -14,11 +14,7 @@ namespace OpenIddict.EntityFramework.Models; /// public class OpenIddictEntityFrameworkApplication : OpenIddictEntityFrameworkApplication { - public OpenIddictEntityFrameworkApplication() - { - // Generate a new string identifier. - Id = Guid.NewGuid().ToString(); - } + public OpenIddictEntityFrameworkApplication() => Id = Guid.NewGuid().ToString(); } /// diff --git a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkAuthorization.cs b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkAuthorization.cs index 9684f967..b6b74bcd 100644 --- a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkAuthorization.cs +++ b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkAuthorization.cs @@ -14,11 +14,7 @@ namespace OpenIddict.EntityFramework.Models; /// public class OpenIddictEntityFrameworkAuthorization : OpenIddictEntityFrameworkAuthorization { - public OpenIddictEntityFrameworkAuthorization() - { - // Generate a new string identifier. - Id = Guid.NewGuid().ToString(); - } + public OpenIddictEntityFrameworkAuthorization() => Id = Guid.NewGuid().ToString(); } /// diff --git a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkResource.cs b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkResource.cs new file mode 100644 index 00000000..d0989df0 --- /dev/null +++ b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkResource.cs @@ -0,0 +1,72 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace OpenIddict.EntityFramework.Models; + +/// +/// Represents an OpenIddict resource. +/// +public class OpenIddictEntityFrameworkResource : OpenIddictEntityFrameworkResource +{ + public OpenIddictEntityFrameworkResource() => Id = Guid.NewGuid().ToString(); +} + +/// +/// Represents an OpenIddict resource. +/// +[DebuggerDisplay("Id = {Id.ToString(),nq} ; Name = {Name,nq}")] +public class OpenIddictEntityFrameworkResource where TKey : notnull, IEquatable +{ + /// + /// Gets or sets the concurrency token. + /// + public virtual string? ConcurrencyToken { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the public description associated with the current resource. + /// + public virtual string? Description { get; set; } + + /// + /// Gets or sets the localized public descriptions associated + /// with the current resource, serialized as a JSON object. + /// + [StringSyntax(StringSyntaxAttribute.Json)] + public virtual string? Descriptions { get; set; } + + /// + /// Gets or sets the display name associated with the current resource. + /// + public virtual string? DisplayName { get; set; } + + /// + /// Gets or sets the localized display names + /// associated with the current application, + /// serialized as a JSON object. + /// + [StringSyntax(StringSyntaxAttribute.Json)] + public virtual string? DisplayNames { get; set; } + + /// + /// Gets or sets the unique identifier associated with the current resource. + /// + public virtual TKey? Id { get; set; } + + /// + /// Gets or sets the unique name associated with the current resource. + /// + public virtual string? Name { get; set; } + + /// + /// Gets or sets the additional properties serialized as a JSON object, + /// or if no bag was associated with the current resource. + /// + [StringSyntax(StringSyntaxAttribute.Json)] + public virtual string? Properties { get; set; } +} diff --git a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkScope.cs b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkScope.cs index 4d51bab2..14b09fff 100644 --- a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkScope.cs +++ b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkScope.cs @@ -14,11 +14,7 @@ namespace OpenIddict.EntityFramework.Models; /// public class OpenIddictEntityFrameworkScope : OpenIddictEntityFrameworkScope { - public OpenIddictEntityFrameworkScope() - { - // Generate a new string identifier. - Id = Guid.NewGuid().ToString(); - } + public OpenIddictEntityFrameworkScope() => Id = Guid.NewGuid().ToString(); } /// diff --git a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkToken.cs b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkToken.cs index 058d2204..087a454d 100644 --- a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkToken.cs +++ b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkToken.cs @@ -14,11 +14,7 @@ namespace OpenIddict.EntityFramework.Models; /// public class OpenIddictEntityFrameworkToken : OpenIddictEntityFrameworkToken { - public OpenIddictEntityFrameworkToken() - { - // Generate a new string identifier. - Id = Guid.NewGuid().ToString(); - } + public OpenIddictEntityFrameworkToken() => Id = Guid.NewGuid().ToString(); } /// diff --git a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkApplicationConfiguration.cs b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkApplicationConfiguration.cs index b95e358a..0edc45c6 100644 --- a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkApplicationConfiguration.cs +++ b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkApplicationConfiguration.cs @@ -15,12 +15,12 @@ using OpenIddict.EntityFramework.Models; namespace OpenIddict.EntityFramework; /// -/// Defines a relational mapping for the Application entity. +/// Defines a relational mapping for the application entity. /// -/// The type of the Application entity. -/// The type of the Authorization entity. -/// The type of the Token entity. -/// The type of the Key entity. +/// The type of the application entity. +/// The type of the authorization entity. +/// The type of the token entity. +/// The type of the primary key. [EditorBrowsable(EditorBrowsableState.Never)] public sealed class OpenIddictEntityFrameworkApplicationConfiguration< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, diff --git a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkAuthorizationConfiguration.cs b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkAuthorizationConfiguration.cs index eb7f3dd6..2daf0bae 100644 --- a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkAuthorizationConfiguration.cs +++ b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkAuthorizationConfiguration.cs @@ -13,12 +13,12 @@ using OpenIddict.EntityFramework.Models; namespace OpenIddict.EntityFramework; /// -/// Defines a relational mapping for the Authorization entity. +/// Defines a relational mapping for the authorization entity. /// -/// The type of the Authorization entity. -/// The type of the Application entity. -/// The type of the Token entity. -/// The type of the Key entity. +/// The type of the authorization entity. +/// The type of the application entity. +/// The type of the token entity. +/// The type of the primary key. [EditorBrowsable(EditorBrowsableState.Never)] public sealed class OpenIddictEntityFrameworkAuthorizationConfiguration< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, diff --git a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkResourceConfiguration.cs b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkResourceConfiguration.cs new file mode 100644 index 00000000..52c92596 --- /dev/null +++ b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkResourceConfiguration.cs @@ -0,0 +1,60 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; +using System.Data.Entity.Infrastructure.Annotations; +using System.Data.Entity.ModelConfiguration; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using OpenIddict.EntityFramework.Models; + +namespace OpenIddict.EntityFramework; + +/// +/// Defines a relational mapping for the resource entity. +/// +/// The type of the resource entity. +/// The type of the primary key. +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class OpenIddictEntityFrameworkResourceConfiguration< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey> : EntityTypeConfiguration + where TResource : OpenIddictEntityFrameworkResource + where TKey : notnull, IEquatable +{ + public OpenIddictEntityFrameworkResourceConfiguration() + { + // Warning: optional foreign keys MUST NOT be added as CLR properties because + // Entity Framework would throw an exception due to the TKey generic parameter + // being non-nullable when using value types like short, int, long or Guid. + + HasKey(static resource => resource.Id); + + Property(static resource => resource.ConcurrencyToken) + .HasMaxLength(50) + .IsConcurrencyToken(); + + if (typeof(TKey) == typeof(string)) + { + var parameter = Expression.Parameter(typeof(TResource), "resource"); + var property = Expression.Property(parameter, + typeof(TResource).GetProperty(nameof(OpenIddictEntityFrameworkResource.Id))!); + var lambda = Expression.Lambda>(property, parameter); + + Property(lambda).HasMaxLength(100); + } + + Property(static resource => resource.Name) + .HasMaxLength(200) + .HasColumnAnnotation(IndexAnnotation.AnnotationName, new IndexAnnotation(new IndexAttribute + { + IsUnique = true + })); + + ToTable("OpenIddictResources"); + } +} diff --git a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkScopeConfiguration.cs b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkScopeConfiguration.cs index 88ae41a8..36093621 100644 --- a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkScopeConfiguration.cs +++ b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkScopeConfiguration.cs @@ -15,10 +15,10 @@ using OpenIddict.EntityFramework.Models; namespace OpenIddict.EntityFramework; /// -/// Defines a relational mapping for the Scope entity. +/// Defines a relational mapping for the scope entity. /// -/// The type of the Scope entity. -/// The type of the Key entity. +/// The type of the scope entity. +/// The type of the primary key. [EditorBrowsable(EditorBrowsableState.Never)] public sealed class OpenIddictEntityFrameworkScopeConfiguration< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, diff --git a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkTokenConfiguration.cs b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkTokenConfiguration.cs index ecc57577..93ec7b0c 100644 --- a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkTokenConfiguration.cs +++ b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkTokenConfiguration.cs @@ -15,12 +15,12 @@ using OpenIddict.EntityFramework.Models; namespace OpenIddict.EntityFramework; /// -/// Defines a relational mapping for the Token entity. +/// Defines a relational mapping for the token entity. /// -/// The type of the Token entity. -/// The type of the Application entity. -/// The type of the Authorization entity. -/// The type of the Key entity. +/// The type of the token entity. +/// The type of the application entity. +/// The type of the authorization entity. +/// The type of the primary key. [EditorBrowsable(EditorBrowsableState.Never)] public sealed class OpenIddictEntityFrameworkTokenConfiguration< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, diff --git a/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkBuilder.cs b/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkBuilder.cs index ea4ca3d5..1926633c 100644 --- a/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkBuilder.cs +++ b/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkBuilder.cs @@ -65,11 +65,13 @@ public sealed class OpenIddictEntityFrameworkBuilder public OpenIddictEntityFrameworkBuilder ReplaceDefaultEntities< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey>() where TApplication : OpenIddictEntityFrameworkApplication where TAuthorization : OpenIddictEntityFrameworkAuthorization + where TResource : OpenIddictEntityFrameworkResource where TScope : OpenIddictEntityFrameworkScope where TToken : OpenIddictEntityFrameworkToken where TKey : notnull, IEquatable @@ -79,7 +81,7 @@ public sealed class OpenIddictEntityFrameworkBuilder // // To ensure a better exception is thrown, a manual check is made here. if (typeof(TApplication).IsGenericType || typeof(TAuthorization).IsGenericType || - typeof(TScope).IsGenericType || typeof(TToken).IsGenericType) + typeof(TResource).IsGenericType || typeof(TScope).IsGenericType || typeof(TToken).IsGenericType) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0277)); } @@ -97,6 +99,8 @@ public sealed class OpenIddictEntityFrameworkBuilder provider.GetRequiredService>())); Services.Replace(ServiceDescriptor.Scoped(static provider => provider.GetRequiredService>())); + Services.Replace(ServiceDescriptor.Scoped(static provider => + provider.GetRequiredService>())); Services.Replace(ServiceDescriptor.Scoped(static provider => provider.GetRequiredService>())); Services.Replace(ServiceDescriptor.Scoped(static provider => @@ -106,6 +110,8 @@ public sealed class OpenIddictEntityFrameworkBuilder OpenIddictEntityFrameworkApplicationStore>()); Services.Replace(ServiceDescriptor.Scoped, OpenIddictEntityFrameworkAuthorizationStore>()); + Services.Replace(ServiceDescriptor.Scoped, + OpenIddictEntityFrameworkResourceStore>()); Services.Replace(ServiceDescriptor.Scoped, OpenIddictEntityFrameworkScopeStore>()); Services.Replace(ServiceDescriptor.Scoped, diff --git a/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkExtensions.cs b/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkExtensions.cs index 84fa66ee..a2edf857 100644 --- a/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkExtensions.cs +++ b/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkExtensions.cs @@ -33,11 +33,13 @@ public static class OpenIddictEntityFrameworkExtensions builder.SetDefaultApplicationEntity() .SetDefaultAuthorizationEntity() + .SetDefaultResourceEntity() .SetDefaultScopeEntity() .SetDefaultTokenEntity(); builder.ReplaceApplicationStore() .ReplaceAuthorizationStore() + .ReplaceResourceStore() .ReplaceScopeStore() .ReplaceTokenStore(); diff --git a/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkHelpers.cs b/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkHelpers.cs index b34c713d..3161370c 100644 --- a/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkHelpers.cs +++ b/src/OpenIddict.EntityFramework/OpenIddictEntityFrameworkHelpers.cs @@ -27,6 +27,7 @@ public static class OpenIddictEntityFrameworkHelpers public static DbModelBuilder UseOpenIddict(this DbModelBuilder builder) => builder.UseOpenIddict(); @@ -36,18 +37,20 @@ public static class OpenIddictEntityFrameworkHelpers /// /// /// Note: when using custom entities, the new entities MUST be registered by calling - /// . + /// . /// /// The builder used to configure the Entity Framework context. /// The Entity Framework context builder. public static DbModelBuilder UseOpenIddict< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey>(this DbModelBuilder builder) where TApplication : OpenIddictEntityFrameworkApplication where TAuthorization : OpenIddictEntityFrameworkAuthorization + where TResource : OpenIddictEntityFrameworkResource where TScope : OpenIddictEntityFrameworkScope where TToken : OpenIddictEntityFrameworkToken where TKey : notnull, IEquatable @@ -57,6 +60,7 @@ public static class OpenIddictEntityFrameworkHelpers builder.Configurations .Add(new OpenIddictEntityFrameworkApplicationConfiguration()) .Add(new OpenIddictEntityFrameworkAuthorizationConfiguration()) + .Add(new OpenIddictEntityFrameworkResourceConfiguration()) .Add(new OpenIddictEntityFrameworkScopeConfiguration()) .Add(new OpenIddictEntityFrameworkTokenConfiguration()); diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkApplicationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkApplicationStore.cs index 517d156f..b73f79dd 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkApplicationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkApplicationStore.cs @@ -42,9 +42,9 @@ public class OpenIddictEntityFrameworkApplicationStore : /// /// Provides methods allowing to manage the applications stored in a database. /// -/// The type of the Application entity. -/// The type of the Authorization entity. -/// The type of the Token entity. +/// The type of the application entity. +/// The type of the authorization entity. +/// The type of the token entity. /// The type of the entity primary keys. public class OpenIddictEntityFrameworkApplicationStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs index 02f8b3f9..2c5e295b 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs @@ -40,9 +40,9 @@ public class OpenIddictEntityFrameworkAuthorizationStore : /// /// Provides methods allowing to manage the authorizations stored in a database. /// -/// The type of the Authorization entity. -/// The type of the Application entity. -/// The type of the Token entity. +/// The type of the authorization entity. +/// The type of the application entity. +/// The type of the token entity. /// The type of the entity primary keys. public class OpenIddictEntityFrameworkAuthorizationStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, @@ -153,7 +153,7 @@ public class OpenIddictEntityFrameworkAuthorizationStore< context.Entry(token).State = EntityState.Unchanged; } - throw new ConcurrencyException(SR.GetResourceString(SR.ID0241), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } @@ -430,7 +430,7 @@ public class OpenIddictEntityFrameworkAuthorizationStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0242), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -882,7 +882,7 @@ public class OpenIddictEntityFrameworkAuthorizationStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(authorization).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0241), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkResourceStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkResourceStore.cs new file mode 100644 index 00000000..376841b1 --- /dev/null +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkResourceStore.cs @@ -0,0 +1,635 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.ComponentModel; +using System.Data.Entity.Infrastructure; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenIddict.EntityFramework.Models; +using static OpenIddict.Abstractions.OpenIddictExceptions; + +namespace OpenIddict.EntityFramework; + +/// +/// Provides methods allowing to manage the resources stored in a database. +/// +public class OpenIddictEntityFrameworkResourceStore : + OpenIddictEntityFrameworkResourceStore +{ + public OpenIddictEntityFrameworkResourceStore( + IMemoryCache cache, + IOpenIddictEntityFrameworkContext context, + IOptionsMonitor options) + : base(cache, context, options) + { + } +} + +/// +/// Provides methods allowing to manage the resources stored in a database. +/// +/// The type of the resource entity. +/// The type of the entity primary keys. +public class OpenIddictEntityFrameworkResourceStore< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey> : IOpenIddictResourceStore + where TResource : OpenIddictEntityFrameworkResource + where TKey : notnull, IEquatable +{ + public OpenIddictEntityFrameworkResourceStore( + IMemoryCache cache, + IOpenIddictEntityFrameworkContext context, + IOptionsMonitor options) + { + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); + Context = context ?? throw new ArgumentNullException(nameof(context)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the memory cache associated with the current store. + /// + protected IMemoryCache Cache { get; } + + /// + /// Gets the database context associated with the current store. + /// + protected IOpenIddictEntityFrameworkContext Context { get; } + + /// + /// Gets the options associated with the current store. + /// + protected IOptionsMonitor Options { get; } + + /// + public virtual async ValueTask CountAsync(CancellationToken cancellationToken) + { + var context = await Context.GetDbContextAsync(cancellationToken); + + return await context.Set().LongCountAsync(cancellationToken); + } + + /// + public virtual async ValueTask CountAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + var context = await Context.GetDbContextAsync(cancellationToken); + + return await query(context.Set(), state).LongCountAsync(cancellationToken); + } + + /// + public virtual async ValueTask CreateAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var context = await Context.GetDbContextAsync(cancellationToken); + + context.Set().Add(resource); + + await context.SaveChangesAsync(cancellationToken); + } + + /// + public virtual async ValueTask DeleteAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var context = await Context.GetDbContextAsync(cancellationToken); + + context.Set().Remove(resource); + + try + { + await context.SaveChangesAsync(cancellationToken); + } + + catch (DbUpdateConcurrencyException exception) + { + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + context.Entry(resource).State = EntityState.Unchanged; + + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); + } + } + + /// + public virtual async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + var context = await Context.GetDbContextAsync(cancellationToken); + var key = ConvertIdentifierFromString(identifier); + + return GetTrackedEntity() is TResource resource ? resource : await QueryAsync(); + + TResource? GetTrackedEntity() => + (from entry in context.ChangeTracker.Entries() + where entry.Entity.Id is TKey identifier && identifier.Equals(key) + select entry.Entity).FirstOrDefault(); + + Task QueryAsync() => + (from resource in context.Set() + where resource.Id!.Equals(key) + select resource).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual async ValueTask FindByNameAsync(string name, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var context = await Context.GetDbContextAsync(cancellationToken); + + return GetTrackedEntity() is TResource resource ? resource : await QueryAsync(); + + TResource? GetTrackedEntity() => + (from entry in context.ChangeTracker.Entries() + where string.Equals(entry.Entity.Name, name, StringComparison.Ordinal) + select entry.Entity).FirstOrDefault(); + + Task QueryAsync() => + (from resource in context.Set() + where resource.Name == name + select resource).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual IAsyncEnumerable FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken) + { + if (names.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0203), nameof(names)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var context = await Context.GetDbContextAsync(cancellationToken); + + // Note: Enumerable.Contains() is deliberately used without the extension method syntax to ensure + // ImmutableArray.Contains() (which is not fully supported by Entity Framework 6.x) is not used instead. + await foreach (var resource in + (from resource in context.Set() + where Enumerable.Contains(names, resource.Name) + select resource).AsAsyncEnumerable(cancellationToken)) + { + yield return resource; + } + } + } + + /// + public virtual async ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + var context = await Context.GetDbContextAsync(cancellationToken); + + return await query(context.Set(), state).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual ValueTask GetDescriptionAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.Description); + } + + /// + public virtual ValueTask> GetDescriptionsAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (string.IsNullOrEmpty(resource.Descriptions)) + { + return new(ImmutableDictionary.Create()); + } + + // Note: parsing the stringified descriptions is an expensive operation. + // To mitigate that, the resulting object is stored in the memory cache. + var key = string.Concat("20e1ab51-b505-40b0-9a10-d0596b9f2143", "\x1e", resource.Descriptions); + var descriptions = Cache.GetOrCreate(key, entry => + { + entry.SetPriority(CacheItemPriority.High) + .SetSlidingExpiration(TimeSpan.FromMinutes(1)); + + using var document = JsonDocument.Parse(resource.Descriptions); + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + var value = property.Value.GetString(); + if (string.IsNullOrEmpty(value)) + { + continue; + } + + builder[CultureInfo.GetCultureInfo(property.Name)] = value; + } + + return builder.ToImmutable(); + })!; + + return new(descriptions); + } + + /// + public virtual ValueTask GetDisplayNameAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.DisplayName); + } + + /// + public virtual ValueTask> GetDisplayNamesAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (string.IsNullOrEmpty(resource.DisplayNames)) + { + return new(ImmutableDictionary.Create()); + } + + // Note: parsing the stringified display names is an expensive operation. + // To mitigate that, the resulting object is stored in the memory cache. + var key = string.Concat("65c3ea08-ded7-488f-b001-5098de04172b", "\x1e", resource.DisplayNames); + var names = Cache.GetOrCreate(key, entry => + { + entry.SetPriority(CacheItemPriority.High) + .SetSlidingExpiration(TimeSpan.FromMinutes(1)); + + using var document = JsonDocument.Parse(resource.DisplayNames); + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + var value = property.Value.GetString(); + if (string.IsNullOrEmpty(value)) + { + continue; + } + + builder[CultureInfo.GetCultureInfo(property.Name)] = value; + } + + return builder.ToImmutable(); + })!; + + return new(names); + } + + /// + public virtual ValueTask GetIdAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(ConvertIdentifierToString(resource.Id)); + } + + /// + public virtual ValueTask GetNameAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.Name); + } + + /// + public virtual ValueTask> GetPropertiesAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (string.IsNullOrEmpty(resource.Properties)) + { + return new(ImmutableDictionary.Create()); + } + + // Note: parsing the stringified properties is an expensive operation. + // To mitigate that, the resulting object is stored in the memory cache. + var key = string.Concat("1f414494-e5aa-4cad-9c5f-4f98688e3623", "\x1e", resource.Properties); + var properties = Cache.GetOrCreate(key, entry => + { + entry.SetPriority(CacheItemPriority.High) + .SetSlidingExpiration(TimeSpan.FromMinutes(1)); + + using var document = JsonDocument.Parse(resource.Properties); + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + builder[property.Name] = property.Value.Clone(); + } + + return builder.ToImmutable(); + })!; + + return new(properties); + } + + /// + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + try + { + return new(Activator.CreateInstance()); + } + + catch (MemberAccessException exception) + { + return new(Task.FromException( + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); + } + } + + /// + public virtual async IAsyncEnumerable ListAsync(int? count, int? offset, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var context = await Context.GetDbContextAsync(cancellationToken); + + IQueryable query = context.Set().OrderBy(resource => resource.Id!); + + if (offset.HasValue) + { + query = query.Skip(offset.Value); + } + + if (count.HasValue) + { + query = query.Take(count.Value); + } + + await foreach (var resource in query.AsAsyncEnumerable(cancellationToken)) + { + yield return resource; + } + } + + /// + public virtual IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var context = await Context.GetDbContextAsync(cancellationToken); + + await foreach (var resource in query(context.Set(), state).AsAsyncEnumerable(cancellationToken)) + { + yield return resource; + } + } + } + + /// + public virtual ValueTask SetDescriptionAsync(TResource resource, string? description, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.Description = description; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDescriptionsAsync(TResource resource, + ImmutableDictionary descriptions, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (descriptions is not { Count: > 0 }) + { + resource.Descriptions = null; + + return ValueTask.CompletedTask; + } + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = false + }); + + writer.WriteStartObject(); + + foreach (var description in descriptions) + { + writer.WritePropertyName(description.Key.Name); + writer.WriteStringValue(description.Value); + } + + writer.WriteEndObject(); + writer.Flush(); + + resource.Descriptions = Encoding.UTF8.GetString(stream.ToArray()); + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDisplayNameAsync(TResource resource, string? name, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.DisplayName = name; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDisplayNamesAsync(TResource resource, + ImmutableDictionary names, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (names is not { Count: > 0 }) + { + resource.DisplayNames = null; + + return ValueTask.CompletedTask; + } + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = false + }); + + writer.WriteStartObject(); + + foreach (var name in names) + { + writer.WritePropertyName(name.Key.Name); + writer.WriteStringValue(name.Value); + } + + writer.WriteEndObject(); + writer.Flush(); + + resource.DisplayNames = Encoding.UTF8.GetString(stream.ToArray()); + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetNameAsync(TResource resource, string? name, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.Name = name; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetPropertiesAsync(TResource resource, + ImmutableDictionary properties, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (properties is not { Count: > 0 }) + { + resource.Properties = null; + + return ValueTask.CompletedTask; + } + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = false + }); + + writer.WriteStartObject(); + + foreach (var property in properties) + { + writer.WritePropertyName(property.Key); + property.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + + resource.Properties = Encoding.UTF8.GetString(stream.ToArray()); + + return ValueTask.CompletedTask; + } + + /// + public virtual async ValueTask UpdateAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var context = await Context.GetDbContextAsync(cancellationToken); + + context.Set().Attach(resource); + + // Generate a new concurrency token and attach it + // to the resource before persisting the changes. + resource.ConcurrencyToken = Guid.NewGuid().ToString(); + + context.Entry(resource).State = EntityState.Modified; + + try + { + await context.SaveChangesAsync(cancellationToken); + } + + catch (DbUpdateConcurrencyException exception) + { + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + context.Entry(resource).State = EntityState.Unchanged; + + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); + } + } + + /// + /// Converts the provided identifier to a strongly typed key object. + /// + /// The identifier to convert. + /// An instance of representing the provided identifier. + public virtual TKey? ConvertIdentifierFromString(string? identifier) + { + if (string.IsNullOrEmpty(identifier)) + { + return default; + } + + // Optimization: if the key is a string, directly return it as-is. + if (typeof(TKey) == typeof(string)) + { + return (TKey?) (object?) identifier; + } + + else + { + var converter = +#if NET + TypeDescriptor.GetConverterFromRegisteredType(typeof(TKey)); +#else + TypeDescriptor.GetConverter(typeof(TKey)); +#endif + + return (TKey?) converter.ConvertFromInvariantString(identifier); + } + } + + /// + /// Converts the provided identifier to its string representation. + /// + /// The identifier to convert. + /// A representation of the provided identifier. + public virtual string? ConvertIdentifierToString(TKey? identifier) + { + if (Equals(identifier, default(TKey))) + { + return null; + } + + // Optimization: if the key is a string, directly return it as-is. + if (identifier is string value) + { + return value; + } + + else + { + var converter = +#if NET + TypeDescriptor.GetConverterFromRegisteredType(typeof(TKey)); +#else + TypeDescriptor.GetConverter(typeof(TKey)); +#endif + + return converter.ConvertToInvariantString(identifier); + } + } +} diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkScopeStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkScopeStore.cs index 12af92af..98c07069 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkScopeStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkScopeStore.cs @@ -38,7 +38,7 @@ public class OpenIddictEntityFrameworkScopeStore : /// /// Provides methods allowing to manage the scopes stored in a database. /// -/// The type of the Scope entity. +/// The type of the scope entity. /// The type of the entity primary keys. public class OpenIddictEntityFrameworkScopeStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, @@ -122,7 +122,7 @@ public class OpenIddictEntityFrameworkScopeStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(scope).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0245), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } @@ -194,8 +194,7 @@ public class OpenIddictEntityFrameworkScopeStore< } /// - public virtual IAsyncEnumerable FindByResourceAsync( - string resource, CancellationToken cancellationToken) + public virtual IAsyncEnumerable FindByResourceAsync(string resource, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(resource); @@ -427,7 +426,7 @@ public class OpenIddictEntityFrameworkScopeStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0246), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -672,7 +671,7 @@ public class OpenIddictEntityFrameworkScopeStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(scope).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0245), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs index c25826b1..d7e1a968 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs @@ -40,9 +40,9 @@ public class OpenIddictEntityFrameworkTokenStore : /// /// Provides methods allowing to manage the tokens stored in a database. /// -/// The type of the Token entity. -/// The type of the Application entity. -/// The type of the Authorization entity. +/// The type of the token entity. +/// The type of the application entity. +/// The type of the authorization entity. /// The type of the entity primary keys. public class OpenIddictEntityFrameworkTokenStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, @@ -130,7 +130,7 @@ public class OpenIddictEntityFrameworkTokenStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(token).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0247), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } @@ -478,7 +478,7 @@ public class OpenIddictEntityFrameworkTokenStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0248), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -1016,7 +1016,7 @@ public class OpenIddictEntityFrameworkTokenStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(token).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0247), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } diff --git a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreApplication.cs b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreApplication.cs index 4a983a27..eb7968b6 100644 --- a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreApplication.cs +++ b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreApplication.cs @@ -14,11 +14,7 @@ namespace OpenIddict.EntityFrameworkCore.Models; /// public class OpenIddictEntityFrameworkCoreApplication : OpenIddictEntityFrameworkCoreApplication { - public OpenIddictEntityFrameworkCoreApplication() - { - // Generate a new string identifier. - Id = Guid.NewGuid().ToString(); - } + public OpenIddictEntityFrameworkCoreApplication() => Id = Guid.NewGuid().ToString(); } /// diff --git a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreAuthorization.cs b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreAuthorization.cs index 0ac9d6e0..ee5477a8 100644 --- a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreAuthorization.cs +++ b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreAuthorization.cs @@ -14,11 +14,7 @@ namespace OpenIddict.EntityFrameworkCore.Models; /// public class OpenIddictEntityFrameworkCoreAuthorization : OpenIddictEntityFrameworkCoreAuthorization { - public OpenIddictEntityFrameworkCoreAuthorization() - { - // Generate a new string identifier. - Id = Guid.NewGuid().ToString(); - } + public OpenIddictEntityFrameworkCoreAuthorization() => Id = Guid.NewGuid().ToString(); } /// diff --git a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreResource.cs b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreResource.cs new file mode 100644 index 00000000..2b7603a4 --- /dev/null +++ b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreResource.cs @@ -0,0 +1,72 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace OpenIddict.EntityFrameworkCore.Models; + +/// +/// Represents an OpenIddict resource. +/// +public class OpenIddictEntityFrameworkCoreResource : OpenIddictEntityFrameworkCoreResource +{ + public OpenIddictEntityFrameworkCoreResource() => Id = Guid.NewGuid().ToString(); +} + +/// +/// Represents an OpenIddict resource. +/// +[DebuggerDisplay("Id = {Id.ToString(),nq} ; Name = {Name,nq}")] +public class OpenIddictEntityFrameworkCoreResource where TKey : notnull, IEquatable +{ + /// + /// Gets or sets the concurrency token. + /// + public virtual string? ConcurrencyToken { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the public description associated with the current resource. + /// + public virtual string? Description { get; set; } + + /// + /// Gets or sets the localized public descriptions associated + /// with the current resource, serialized as a JSON object. + /// + [StringSyntax(StringSyntaxAttribute.Json)] + public virtual string? Descriptions { get; set; } + + /// + /// Gets or sets the display name associated with the current resource. + /// + public virtual string? DisplayName { get; set; } + + /// + /// Gets or sets the localized display names + /// associated with the current application, + /// serialized as a JSON object. + /// + [StringSyntax(StringSyntaxAttribute.Json)] + public virtual string? DisplayNames { get; set; } + + /// + /// Gets or sets the unique identifier associated with the current resource. + /// + public virtual TKey? Id { get; set; } + + /// + /// Gets or sets the unique name associated with the current resource. + /// + public virtual string? Name { get; set; } + + /// + /// Gets or sets the additional properties serialized as a JSON object, + /// or if no bag was associated with the current resource. + /// + [StringSyntax(StringSyntaxAttribute.Json)] + public virtual string? Properties { get; set; } +} diff --git a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreScope.cs b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreScope.cs index 06b8e9c3..cbb035c3 100644 --- a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreScope.cs +++ b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreScope.cs @@ -14,11 +14,7 @@ namespace OpenIddict.EntityFrameworkCore.Models; /// public class OpenIddictEntityFrameworkCoreScope : OpenIddictEntityFrameworkCoreScope { - public OpenIddictEntityFrameworkCoreScope() - { - // Generate a new string identifier. - Id = Guid.NewGuid().ToString(); - } + public OpenIddictEntityFrameworkCoreScope() => Id = Guid.NewGuid().ToString(); } /// diff --git a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreToken.cs b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreToken.cs index 60df7c0a..1650359c 100644 --- a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreToken.cs +++ b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreToken.cs @@ -14,11 +14,7 @@ namespace OpenIddict.EntityFrameworkCore.Models; /// public class OpenIddictEntityFrameworkCoreToken : OpenIddictEntityFrameworkCoreToken { - public OpenIddictEntityFrameworkCoreToken() - { - // Generate a new string identifier. - Id = Guid.NewGuid().ToString(); - } + public OpenIddictEntityFrameworkCoreToken() => Id = Guid.NewGuid().ToString(); } /// diff --git a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreApplicationConfiguration.cs b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreApplicationConfiguration.cs index 2c47e701..a2fe346e 100644 --- a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreApplicationConfiguration.cs +++ b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreApplicationConfiguration.cs @@ -12,12 +12,12 @@ using OpenIddict.EntityFrameworkCore.Models; namespace OpenIddict.EntityFrameworkCore; /// -/// Defines a relational mapping for the Application entity. +/// Defines a relational mapping for the application entity. /// -/// The type of the Application entity. -/// The type of the Authorization entity. -/// The type of the Token entity. -/// The type of the Key entity. +/// The type of the application entity. +/// The type of the authorization entity. +/// The type of the token entity. +/// The type of the primary key. [EditorBrowsable(EditorBrowsableState.Never)] public sealed class OpenIddictEntityFrameworkCoreApplicationConfiguration< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, diff --git a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreAuthorizationConfiguration.cs b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreAuthorizationConfiguration.cs index f30a1506..61146224 100644 --- a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreAuthorizationConfiguration.cs +++ b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreAuthorizationConfiguration.cs @@ -12,12 +12,12 @@ using OpenIddict.EntityFrameworkCore.Models; namespace OpenIddict.EntityFrameworkCore; /// -/// Defines a relational mapping for the Authorization entity. +/// Defines a relational mapping for the authorization entity. /// -/// The type of the Authorization entity. -/// The type of the Application entity. -/// The type of the Token entity. -/// The type of the Key entity. +/// The type of the authorization entity. +/// The type of the application entity. +/// The type of the token entity. +/// The type of the primary key. [EditorBrowsable(EditorBrowsableState.Never)] public sealed class OpenIddictEntityFrameworkCoreAuthorizationConfiguration< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, diff --git a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreResourceConfiguration.cs b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreResourceConfiguration.cs new file mode 100644 index 00000000..71783fd2 --- /dev/null +++ b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreResourceConfiguration.cs @@ -0,0 +1,60 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OpenIddict.EntityFrameworkCore.Models; + +namespace OpenIddict.EntityFrameworkCore; + +/// +/// Defines a relational mapping for the resource entity. +/// +/// The type of the resource entity. +/// The type of the primary key. +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class OpenIddictEntityFrameworkCoreResourceConfiguration< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey> : IEntityTypeConfiguration + where TResource : OpenIddictEntityFrameworkCoreResource + where TKey : notnull, IEquatable +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + // Warning: optional foreign keys MUST NOT be added as CLR properties because + // Entity Framework would throw an exception due to the TKey generic parameter + // being non-nullable when using value types like short, int, long or Guid. + + builder.HasKey(static resource => resource.Id); + + // Warning: the non-generic overlord is deliberately used to work around + // a breaking change introduced in Entity Framework Core 3.x (where a + // generic entity type builder is now returned by the HasIndex() method). + builder.HasIndex(nameof(OpenIddictEntityFrameworkCoreResource.Name)) + .IsUnique(); + + builder.Property(static resource => resource.ConcurrencyToken) + .HasMaxLength(50) + .IsConcurrencyToken(); + + builder.Property(static resource => resource.Id) + .ValueGeneratedOnAdd(); + + if (typeof(TKey) == typeof(string)) + { + builder.Property(static resource => resource.Id) + .HasMaxLength(100); + } + + builder.Property(static resource => resource.Name) + .HasMaxLength(200); + + builder.ToTable("OpenIddictResources"); + } +} diff --git a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreScopeConfiguration.cs b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreScopeConfiguration.cs index 118eb381..e0c43f2d 100644 --- a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreScopeConfiguration.cs +++ b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreScopeConfiguration.cs @@ -12,10 +12,10 @@ using OpenIddict.EntityFrameworkCore.Models; namespace OpenIddict.EntityFrameworkCore; /// -/// Defines a relational mapping for the Scope entity. +/// Defines a relational mapping for the scope entity. /// -/// The type of the Scope entity. -/// The type of the Key entity. +/// The type of the scope entity. +/// The type of the primary key. [EditorBrowsable(EditorBrowsableState.Never)] public sealed class OpenIddictEntityFrameworkCoreScopeConfiguration< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, diff --git a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreTokenConfiguration.cs b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreTokenConfiguration.cs index d9d720e9..163f0079 100644 --- a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreTokenConfiguration.cs +++ b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreTokenConfiguration.cs @@ -12,12 +12,12 @@ using OpenIddict.EntityFrameworkCore.Models; namespace OpenIddict.EntityFrameworkCore; /// -/// Defines a relational mapping for the Token entity. +/// Defines a relational mapping for the token entity. /// -/// The type of the Token entity. -/// The type of the Application entity. -/// The type of the Authorization entity. -/// The type of the Key entity. +/// The type of the token entity. +/// The type of the application entity. +/// The type of the authorization entity. +/// The type of the primary key. [EditorBrowsable(EditorBrowsableState.Never)] public sealed class OpenIddictEntityFrameworkCoreTokenConfiguration< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs index f24da85f..65dd6e6e 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs @@ -77,6 +77,7 @@ public sealed class OpenIddictEntityFrameworkCoreBuilder where TKey : notnull, IEquatable => ReplaceDefaultEntities, OpenIddictEntityFrameworkCoreAuthorization, + OpenIddictEntityFrameworkCoreResource, OpenIddictEntityFrameworkCoreScope, OpenIddictEntityFrameworkCoreToken, TKey>(); @@ -88,11 +89,13 @@ public sealed class OpenIddictEntityFrameworkCoreBuilder public OpenIddictEntityFrameworkCoreBuilder ReplaceDefaultEntities< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey>() where TApplication : OpenIddictEntityFrameworkCoreApplication where TAuthorization : OpenIddictEntityFrameworkCoreAuthorization + where TResource : OpenIddictEntityFrameworkCoreResource where TScope : OpenIddictEntityFrameworkCoreScope where TToken : OpenIddictEntityFrameworkCoreToken where TKey : notnull, IEquatable @@ -110,6 +113,8 @@ public sealed class OpenIddictEntityFrameworkCoreBuilder provider.GetRequiredService>())); Services.Replace(ServiceDescriptor.Scoped(static provider => provider.GetRequiredService>())); + Services.Replace(ServiceDescriptor.Scoped(static provider => + provider.GetRequiredService>())); Services.Replace(ServiceDescriptor.Scoped(static provider => provider.GetRequiredService>())); Services.Replace(ServiceDescriptor.Scoped(static provider => @@ -119,6 +124,8 @@ public sealed class OpenIddictEntityFrameworkCoreBuilder OpenIddictEntityFrameworkCoreApplicationStore>()); Services.Replace(ServiceDescriptor.Scoped, OpenIddictEntityFrameworkCoreAuthorizationStore>()); + Services.Replace(ServiceDescriptor.Scoped, + OpenIddictEntityFrameworkCoreResourceStore>()); Services.Replace(ServiceDescriptor.Scoped, OpenIddictEntityFrameworkCoreScopeStore>()); Services.Replace(ServiceDescriptor.Scoped, diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreCustomizer.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreCustomizer.cs index 8ee0f83e..4df783c3 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreCustomizer.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreCustomizer.cs @@ -18,11 +18,13 @@ namespace OpenIddict.EntityFrameworkCore; public sealed class OpenIddictEntityFrameworkCoreCustomizer< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey> : RelationalModelCustomizer where TApplication : OpenIddictEntityFrameworkCoreApplication where TAuthorization : OpenIddictEntityFrameworkCoreAuthorization + where TResource : OpenIddictEntityFrameworkCoreResource where TScope : OpenIddictEntityFrameworkCoreScope where TToken : OpenIddictEntityFrameworkCoreToken where TKey : notnull, IEquatable @@ -39,7 +41,7 @@ public sealed class OpenIddictEntityFrameworkCoreCustomizer< ArgumentNullException.ThrowIfNull(context); // Register the OpenIddict entity sets. - modelBuilder.UseOpenIddict(); + modelBuilder.UseOpenIddict(); base.Customize(modelBuilder, context); } diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreExtensions.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreExtensions.cs index cd1a04bb..6d252d18 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreExtensions.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreExtensions.cs @@ -33,11 +33,13 @@ public static class OpenIddictEntityFrameworkCoreExtensions builder.SetDefaultApplicationEntity() .SetDefaultAuthorizationEntity() + .SetDefaultResourceEntity() .SetDefaultScopeEntity() .SetDefaultTokenEntity(); builder.ReplaceApplicationStore() .ReplaceAuthorizationStore() + .ReplaceResourceStore() .ReplaceScopeStore() .ReplaceTokenStore(); diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs index 1ac67331..49f349bb 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs @@ -26,6 +26,7 @@ public static class OpenIddictEntityFrameworkCoreHelpers public static DbContextOptionsBuilder UseOpenIddict(this DbContextOptionsBuilder builder) => builder.UseOpenIddict(); @@ -57,6 +58,7 @@ public static class OpenIddictEntityFrameworkCoreHelpers where TKey : notnull, IEquatable => builder.UseOpenIddict, OpenIddictEntityFrameworkCoreAuthorization, + OpenIddictEntityFrameworkCoreResource, OpenIddictEntityFrameworkCoreScope, OpenIddictEntityFrameworkCoreToken, TKey>(); @@ -86,19 +88,21 @@ public static class OpenIddictEntityFrameworkCoreHelpers /// /// /// Note: when using custom entities, the new entities MUST be registered by calling - /// . + /// . /// /// The builder used to configure the Entity Framework Core context. /// The Entity Framework Core context builder. public static DbContextOptionsBuilder UseOpenIddict< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey>( this DbContextOptionsBuilder builder) where TApplication : OpenIddictEntityFrameworkCoreApplication where TAuthorization : OpenIddictEntityFrameworkCoreAuthorization + where TResource : OpenIddictEntityFrameworkCoreResource where TScope : OpenIddictEntityFrameworkCoreScope where TToken : OpenIddictEntityFrameworkCoreToken where TKey : notnull, IEquatable @@ -106,7 +110,7 @@ public static class OpenIddictEntityFrameworkCoreHelpers ArgumentNullException.ThrowIfNull(builder); return ReplaceService>(builder); + OpenIddictEntityFrameworkCoreCustomizer>(builder); static DbContextOptionsBuilder ReplaceService< TService, @@ -121,13 +125,14 @@ public static class OpenIddictEntityFrameworkCoreHelpers /// /// /// Note: when using custom entities, the new entities MUST be registered by calling - /// . + /// . /// /// The builder used to configure the Entity Framework Core context. /// The Entity Framework Core context builder. public static DbContextOptionsBuilder UseOpenIddict< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey, @@ -135,12 +140,13 @@ public static class OpenIddictEntityFrameworkCoreHelpers this DbContextOptionsBuilder builder) where TApplication : OpenIddictEntityFrameworkCoreApplication where TAuthorization : OpenIddictEntityFrameworkCoreAuthorization + where TResource : OpenIddictEntityFrameworkCoreResource where TScope : OpenIddictEntityFrameworkCoreScope where TToken : OpenIddictEntityFrameworkCoreToken where TKey : notnull, IEquatable where TContext : DbContext { - builder.UseOpenIddict(); + builder.UseOpenIddict(); return builder; } @@ -153,6 +159,7 @@ public static class OpenIddictEntityFrameworkCoreHelpers public static ModelBuilder UseOpenIddict(this ModelBuilder builder) => builder.UseOpenIddict(); @@ -170,6 +177,7 @@ public static class OpenIddictEntityFrameworkCoreHelpers [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey>(this ModelBuilder builder) where TKey : notnull, IEquatable => builder.UseOpenIddict, OpenIddictEntityFrameworkCoreAuthorization, + OpenIddictEntityFrameworkCoreResource, OpenIddictEntityFrameworkCoreScope, OpenIddictEntityFrameworkCoreToken, TKey>(); @@ -179,18 +187,20 @@ public static class OpenIddictEntityFrameworkCoreHelpers /// /// /// Note: when using custom entities, the new entities MUST be registered by calling - /// . + /// . /// /// The builder used to configure the Entity Framework Core context. /// The Entity Framework Core context builder. public static ModelBuilder UseOpenIddict< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey>(this ModelBuilder builder) where TApplication : OpenIddictEntityFrameworkCoreApplication where TAuthorization : OpenIddictEntityFrameworkCoreAuthorization + where TResource : OpenIddictEntityFrameworkCoreResource where TScope : OpenIddictEntityFrameworkCoreScope where TToken : OpenIddictEntityFrameworkCoreToken where TKey : notnull, IEquatable @@ -200,6 +210,7 @@ public static class OpenIddictEntityFrameworkCoreHelpers return builder .ApplyConfiguration(new OpenIddictEntityFrameworkCoreApplicationConfiguration()) .ApplyConfiguration(new OpenIddictEntityFrameworkCoreAuthorizationConfiguration()) + .ApplyConfiguration(new OpenIddictEntityFrameworkCoreResourceConfiguration()) .ApplyConfiguration(new OpenIddictEntityFrameworkCoreScopeConfiguration()) .ApplyConfiguration(new OpenIddictEntityFrameworkCoreTokenConfiguration()); } diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs index 73ea969c..74000097 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs @@ -61,9 +61,9 @@ public class OpenIddictEntityFrameworkCoreApplicationStore< /// /// Provides methods allowing to manage the applications stored in a database. /// -/// The type of the Application entity. -/// The type of the Authorization entity. -/// The type of the Token entity. +/// The type of the application entity. +/// The type of the authorization entity. +/// The type of the token entity. /// The type of the entity primary keys. public class OpenIddictEntityFrameworkCoreApplicationStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication, diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs index 06613272..6af53222 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs @@ -59,9 +59,9 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore< /// /// Provides methods allowing to manage the authorizations stored in a database. /// -/// The type of the Authorization entity. -/// The type of the Application entity. -/// The type of the Token entity. +/// The type of the authorization entity. +/// The type of the application entity. +/// The type of the token entity. /// The type of the entity primary keys. public class OpenIddictEntityFrameworkCoreAuthorizationStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization, @@ -168,7 +168,7 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(authorization).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0241), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } }); } @@ -219,7 +219,7 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore< context.Entry(token).State = EntityState.Unchanged; } - throw new ConcurrencyException(SR.GetResourceString(SR.ID0241), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } }); } @@ -514,7 +514,7 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0242), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -1062,7 +1062,7 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(authorization).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0241), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreResourceStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreResourceStore.cs new file mode 100644 index 00000000..ae9710e1 --- /dev/null +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreResourceStore.cs @@ -0,0 +1,649 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenIddict.EntityFrameworkCore.Models; +using static OpenIddict.Abstractions.OpenIddictExceptions; + +namespace OpenIddict.EntityFrameworkCore; + +/// +/// Provides methods allowing to manage the resources stored in a database. +/// +public class OpenIddictEntityFrameworkCoreResourceStore : OpenIddictEntityFrameworkCoreResourceStore +{ + public OpenIddictEntityFrameworkCoreResourceStore( + IMemoryCache cache, + IOpenIddictEntityFrameworkCoreContext context, + IOptionsMonitor options) + : base(cache, context, options) + { + } +} + +/// +/// Provides methods allowing to manage the resources stored in a database. +/// +/// The type of the entity primary keys. +public class OpenIddictEntityFrameworkCoreResourceStore< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey> : OpenIddictEntityFrameworkCoreResourceStore, TKey> + where TKey : notnull, IEquatable +{ + public OpenIddictEntityFrameworkCoreResourceStore( + IMemoryCache cache, + IOpenIddictEntityFrameworkCoreContext context, + IOptionsMonitor options) + : base(cache, context, options) + { + } +} + +/// +/// Provides methods allowing to manage the resources stored in a database. +/// +/// The type of the resource entity. +/// The type of the entity primary keys. +public class OpenIddictEntityFrameworkCoreResourceStore< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TKey> : IOpenIddictResourceStore + where TResource : OpenIddictEntityFrameworkCoreResource + where TKey : notnull, IEquatable +{ + public OpenIddictEntityFrameworkCoreResourceStore( + IMemoryCache cache, + IOpenIddictEntityFrameworkCoreContext context, + IOptionsMonitor options) + { + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); + Context = context ?? throw new ArgumentNullException(nameof(context)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the memory cache associated with the current store. + /// + protected IMemoryCache Cache { get; } + + /// + /// Gets the database context associated with the current store. + /// + protected IOpenIddictEntityFrameworkCoreContext Context { get; } + + /// + /// Gets the options associated with the current store. + /// + protected IOptionsMonitor Options { get; } + + /// + public virtual async ValueTask CountAsync(CancellationToken cancellationToken) + { + var context = await Context.GetDbContextAsync(cancellationToken); + + return await context.Set().LongCountAsync(cancellationToken); + } + + /// + public virtual async ValueTask CountAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + var context = await Context.GetDbContextAsync(cancellationToken); + + return await query(context.Set(), state).LongCountAsync(cancellationToken); + } + + /// + public virtual async ValueTask CreateAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var context = await Context.GetDbContextAsync(cancellationToken); + + context.Add(resource); + + await context.SaveChangesAsync(cancellationToken); + } + + /// + public virtual async ValueTask DeleteAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var context = await Context.GetDbContextAsync(cancellationToken); + + context.Remove(resource); + + try + { + await context.SaveChangesAsync(cancellationToken); + } + + catch (DbUpdateConcurrencyException exception) + { + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + context.Entry(resource).State = EntityState.Unchanged; + + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); + } + } + + /// + public virtual async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + var context = await Context.GetDbContextAsync(cancellationToken); + var key = ConvertIdentifierFromString(identifier); + + return GetTrackedEntity() is TResource resource ? resource : await QueryAsync(); + + TResource? GetTrackedEntity() => + (from entry in context.ChangeTracker.Entries() + where entry.Entity.Id is TKey identifier && identifier.Equals(key) + select entry.Entity).FirstOrDefault(); + + Task QueryAsync() => + (from resource in context.Set().AsTracking() + where resource.Id!.Equals(key) + select resource).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual async ValueTask FindByNameAsync(string name, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var context = await Context.GetDbContextAsync(cancellationToken); + + return GetTrackedEntity() is TResource resource ? resource : await QueryAsync(); + + TResource? GetTrackedEntity() => + (from entry in context.ChangeTracker.Entries() + where string.Equals(entry.Entity.Name, name, StringComparison.Ordinal) + select entry.Entity).FirstOrDefault(); + + Task QueryAsync() => + (from resource in context.Set().AsTracking() + where resource.Name == name + select resource).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual IAsyncEnumerable FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken) + { + if (names.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0203), nameof(names)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var context = await Context.GetDbContextAsync(cancellationToken); + + // Note: Enumerable.Contains() is deliberately used without the extension method syntax to ensure + // ImmutableArray.Contains() (which is not fully supported by Entity Framework Core) is not used instead. + await foreach (var resource in (from resource in context.Set().AsTracking() + where Enumerable.Contains(names, resource.Name) + select resource).AsAsyncEnumerable().WithCancellation(cancellationToken)) + { + yield return resource; + } + } + } + + /// + public virtual async ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + var context = await Context.GetDbContextAsync(cancellationToken); + + return await query(context.Set().AsTracking(), state).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual ValueTask GetDescriptionAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.Description); + } + + /// + public virtual ValueTask> GetDescriptionsAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (string.IsNullOrEmpty(resource.Descriptions)) + { + return new(ImmutableDictionary.Create()); + } + + // Note: parsing the stringified descriptions is an expensive operation. + // To mitigate that, the resulting object is stored in the memory cache. + var key = string.Concat("20e1ab51-b505-40b0-9a10-d0596b9f2143", "\x1e", resource.Descriptions); + var descriptions = Cache.GetOrCreate(key, entry => + { + entry.SetPriority(CacheItemPriority.High) + .SetSlidingExpiration(TimeSpan.FromMinutes(1)); + + using var document = JsonDocument.Parse(resource.Descriptions); + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + var value = property.Value.GetString(); + if (string.IsNullOrEmpty(value)) + { + continue; + } + + builder[CultureInfo.GetCultureInfo(property.Name)] = value; + } + + return builder.ToImmutable(); + })!; + + return new(descriptions); + } + + /// + public virtual ValueTask GetDisplayNameAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.DisplayName); + } + + /// + public virtual ValueTask> GetDisplayNamesAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (string.IsNullOrEmpty(resource.DisplayNames)) + { + return new(ImmutableDictionary.Create()); + } + + // Note: parsing the stringified display names is an expensive operation. + // To mitigate that, the resulting object is stored in the memory cache. + var key = string.Concat("65c3ea08-ded7-488f-b001-5098de04172b", "\x1e", resource.DisplayNames); + var names = Cache.GetOrCreate(key, entry => + { + entry.SetPriority(CacheItemPriority.High) + .SetSlidingExpiration(TimeSpan.FromMinutes(1)); + + using var document = JsonDocument.Parse(resource.DisplayNames); + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + var value = property.Value.GetString(); + if (string.IsNullOrEmpty(value)) + { + continue; + } + + builder[CultureInfo.GetCultureInfo(property.Name)] = value; + } + + return builder.ToImmutable(); + })!; + + return new(names); + } + + /// + public virtual ValueTask GetIdAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(ConvertIdentifierToString(resource.Id)); + } + + /// + public virtual ValueTask GetNameAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.Name); + } + + /// + public virtual ValueTask> GetPropertiesAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (string.IsNullOrEmpty(resource.Properties)) + { + return new(ImmutableDictionary.Create()); + } + + // Note: parsing the stringified properties is an expensive operation. + // To mitigate that, the resulting object is stored in the memory cache. + var key = string.Concat("1f414494-e5aa-4cad-9c5f-4f98688e3623", "\x1e", resource.Properties); + var properties = Cache.GetOrCreate(key, entry => + { + entry.SetPriority(CacheItemPriority.High) + .SetSlidingExpiration(TimeSpan.FromMinutes(1)); + + using var document = JsonDocument.Parse(resource.Properties); + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + builder[property.Name] = property.Value.Clone(); + } + + return builder.ToImmutable(); + })!; + + return new(properties); + } + + /// + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + try + { + return new(Activator.CreateInstance()); + } + + catch (MemberAccessException exception) + { + return new(Task.FromException( + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); + } + } + + /// + public virtual async IAsyncEnumerable ListAsync(int? count, int? offset, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var context = await Context.GetDbContextAsync(cancellationToken); + + var query = context.Set().OrderBy(resource => resource.Id!).AsTracking(); + + if (offset.HasValue) + { + query = query.Skip(offset.Value); + } + + if (count.HasValue) + { + query = query.Take(count.Value); + } + + await foreach (var resource in query.AsAsyncEnumerable().WithCancellation(cancellationToken)) + { + yield return resource; + } + } + + /// + public virtual IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var context = await Context.GetDbContextAsync(cancellationToken); + + await foreach (var resource in query(context.Set().AsTracking(), state).AsAsyncEnumerable().WithCancellation(cancellationToken)) + { + yield return resource; + } + } + } + + /// + public virtual ValueTask SetDescriptionAsync(TResource resource, string? description, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.Description = description; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDescriptionsAsync(TResource resource, + ImmutableDictionary descriptions, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (descriptions is not { Count: > 0 }) + { + resource.Descriptions = null; + + return ValueTask.CompletedTask; + } + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = false + }); + + writer.WriteStartObject(); + + foreach (var description in descriptions) + { + writer.WritePropertyName(description.Key.Name); + writer.WriteStringValue(description.Value); + } + + writer.WriteEndObject(); + writer.Flush(); + + resource.Descriptions = Encoding.UTF8.GetString(stream.ToArray()); + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDisplayNameAsync(TResource resource, string? name, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.DisplayName = name; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDisplayNamesAsync(TResource resource, + ImmutableDictionary names, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (names is not { Count: > 0 }) + { + resource.DisplayNames = null; + + return ValueTask.CompletedTask; + } + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = false + }); + + writer.WriteStartObject(); + + foreach (var name in names) + { + writer.WritePropertyName(name.Key.Name); + writer.WriteStringValue(name.Value); + } + + writer.WriteEndObject(); + writer.Flush(); + + resource.DisplayNames = Encoding.UTF8.GetString(stream.ToArray()); + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetNameAsync(TResource resource, string? name, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.Name = name; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetPropertiesAsync(TResource resource, + ImmutableDictionary properties, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (properties is not { Count: > 0 }) + { + resource.Properties = null; + + return ValueTask.CompletedTask; + } + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = false + }); + + writer.WriteStartObject(); + + foreach (var property in properties) + { + writer.WritePropertyName(property.Key); + property.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + + resource.Properties = Encoding.UTF8.GetString(stream.ToArray()); + + return ValueTask.CompletedTask; + } + + /// + public virtual async ValueTask UpdateAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var context = await Context.GetDbContextAsync(cancellationToken); + + context.Attach(resource); + + // Generate a new concurrency token and attach it + // to the resource before persisting the changes. + resource.ConcurrencyToken = Guid.NewGuid().ToString(); + + context.Update(resource); + + try + { + await context.SaveChangesAsync(cancellationToken); + } + + catch (DbUpdateConcurrencyException exception) + { + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + context.Entry(resource).State = EntityState.Unchanged; + + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); + } + } + + /// + /// Converts the provided identifier to a strongly typed key object. + /// + /// The identifier to convert. + /// An instance of representing the provided identifier. + public virtual TKey? ConvertIdentifierFromString(string? identifier) + { + if (string.IsNullOrEmpty(identifier)) + { + return default; + } + + // Optimization: if the key is a string, directly return it as-is. + if (typeof(TKey) == typeof(string)) + { + return (TKey?) (object?) identifier; + } + + else + { + var converter = +#if NET + TypeDescriptor.GetConverterFromRegisteredType(typeof(TKey)); +#else + TypeDescriptor.GetConverter(typeof(TKey)); +#endif + + return (TKey?) converter.ConvertFromInvariantString(identifier); + } + } + + /// + /// Converts the provided identifier to its string representation. + /// + /// The identifier to convert. + /// A representation of the provided identifier. + public virtual string? ConvertIdentifierToString(TKey? identifier) + { + if (Equals(identifier, default(TKey))) + { + return null; + } + + // Optimization: if the key is a string, directly return it as-is. + if (identifier is string value) + { + return value; + } + + else + { + var converter = +#if NET + TypeDescriptor.GetConverterFromRegisteredType(typeof(TKey)); +#else + TypeDescriptor.GetConverter(typeof(TKey)); +#endif + + return converter.ConvertToInvariantString(identifier); + } + } +} diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreScopeStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreScopeStore.cs index d6aed5e2..b5e907d3 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreScopeStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreScopeStore.cs @@ -53,7 +53,7 @@ public class OpenIddictEntityFrameworkCoreScopeStore< /// /// Provides methods allowing to manage the scopes stored in a database. /// -/// The type of the Scope entity. +/// The type of the scope entity. /// The type of the entity primary keys. public class OpenIddictEntityFrameworkCoreScopeStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope, @@ -137,7 +137,7 @@ public class OpenIddictEntityFrameworkCoreScopeStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(scope).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0245), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } @@ -208,8 +208,7 @@ public class OpenIddictEntityFrameworkCoreScopeStore< } /// - public virtual IAsyncEnumerable FindByResourceAsync( - string resource, CancellationToken cancellationToken) + public virtual IAsyncEnumerable FindByResourceAsync(string resource, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(resource); @@ -441,7 +440,7 @@ public class OpenIddictEntityFrameworkCoreScopeStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0246), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -686,7 +685,7 @@ public class OpenIddictEntityFrameworkCoreScopeStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(scope).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0245), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs index 83dae68a..4830ee07 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs @@ -59,9 +59,9 @@ public class OpenIddictEntityFrameworkCoreTokenStore< /// /// Provides methods allowing to manage the tokens stored in a database. /// -/// The type of the Token entity. -/// The type of the Application entity. -/// The type of the Authorization entity. +/// The type of the token entity. +/// The type of the application entity. +/// The type of the authorization entity. /// The type of the entity primary keys. public class OpenIddictEntityFrameworkCoreTokenStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken, @@ -149,7 +149,7 @@ public class OpenIddictEntityFrameworkCoreTokenStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(token).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0247), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } @@ -525,7 +525,7 @@ public class OpenIddictEntityFrameworkCoreTokenStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0248), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -1175,7 +1175,7 @@ public class OpenIddictEntityFrameworkCoreTokenStore< // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. context.Entry(token).State = EntityState.Unchanged; - throw new ConcurrencyException(SR.GetResourceString(SR.ID0247), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); } } diff --git a/src/OpenIddict.MongoDb.Models/OpenIddictMongoDbResource.cs b/src/OpenIddict.MongoDb.Models/OpenIddictMongoDbResource.cs new file mode 100644 index 00000000..73482b2b --- /dev/null +++ b/src/OpenIddict.MongoDb.Models/OpenIddictMongoDbResource.cs @@ -0,0 +1,67 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.Diagnostics; + +namespace OpenIddict.MongoDb.Models; + +/// +/// Represents an OpenIddict resource. +/// +[DebuggerDisplay("Id = {Id.ToString(),nq} ; Name = {Name,nq}")] +public class OpenIddictMongoDbResource +{ + /// + /// Gets or sets the concurrency token. + /// + [BsonElement("concurrency_token"), BsonIgnoreIfNull] + public virtual string? ConcurrencyToken { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the public description associated with the current resource. + /// + [BsonElement("description"), BsonIgnoreIfNull] + public virtual string? Description { get; set; } + + /// + /// Gets or sets the localized public descriptions associated with the current resource. + /// + [BsonElement("descriptions"), BsonIgnoreIfNull] + public virtual IReadOnlyDictionary? Descriptions { get; set; } + = ImmutableDictionary.Create(); + + /// + /// Gets or sets the display name associated with the current resource. + /// + [BsonElement("display_name"), BsonIgnoreIfNull] + public virtual string? DisplayName { get; set; } + + /// + /// Gets or sets the localized display names associated with the current resource. + /// + [BsonElement("display_names"), BsonIgnoreIfNull] + public virtual IReadOnlyDictionary? DisplayNames { get; set; } + = ImmutableDictionary.Create(); + + /// + /// Gets or sets the unique identifier associated with the current resource. + /// + [BsonId, BsonRequired] + public virtual ObjectId Id { get; set; } + + /// + /// Gets or sets the unique name associated with the current resource. + /// + [BsonElement("name"), BsonIgnoreIfNull] + public virtual string? Name { get; set; } + + /// + /// Gets or sets the additional properties associated with the current resource. + /// + [BsonElement("properties"), BsonIgnoreIfNull] + public virtual BsonDocument? Properties { get; set; } +} diff --git a/src/OpenIddict.MongoDb/OpenIddictMongoDbBuilder.cs b/src/OpenIddict.MongoDb/OpenIddictMongoDbBuilder.cs index 9ef8f000..84ec2676 100644 --- a/src/OpenIddict.MongoDb/OpenIddictMongoDbBuilder.cs +++ b/src/OpenIddict.MongoDb/OpenIddictMongoDbBuilder.cs @@ -91,6 +91,23 @@ public sealed class OpenIddictMongoDbBuilder return this; } + /// + /// Configures OpenIddict to use the specified entity as the default resource entity. + /// + /// The instance. + public OpenIddictMongoDbBuilder ReplaceDefaultResourceEntity< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource>() + where TResource : OpenIddictMongoDbResource + { + Services.Replace(ServiceDescriptor.Scoped(static provider => + provider.GetRequiredService>())); + + Services.Replace(ServiceDescriptor.Scoped< + IOpenIddictResourceStore, OpenIddictMongoDbResourceStore>()); + + return this; + } + /// /// Configures OpenIddict to use the specified entity as the default scope entity. /// @@ -149,6 +166,18 @@ public sealed class OpenIddictMongoDbBuilder return Configure(options => options.AuthorizationsCollectionName = name); } + /// + /// Replaces the default resources collection name (by default, openiddict.resources). + /// + /// The collection name + /// The instance. + public OpenIddictMongoDbBuilder SetResourcesCollectionName(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return Configure(options => options.ResourcesCollectionName = name); + } + /// /// Replaces the default scopes collection name (by default, openiddict.scopes). /// diff --git a/src/OpenIddict.MongoDb/OpenIddictMongoDbExtensions.cs b/src/OpenIddict.MongoDb/OpenIddictMongoDbExtensions.cs index 0fa3c6d8..ff2187cd 100644 --- a/src/OpenIddict.MongoDb/OpenIddictMongoDbExtensions.cs +++ b/src/OpenIddict.MongoDb/OpenIddictMongoDbExtensions.cs @@ -32,6 +32,7 @@ public static class OpenIddictMongoDbExtensions builder.SetDefaultApplicationEntity() .SetDefaultAuthorizationEntity() + .SetDefaultResourceEntity() .SetDefaultScopeEntity() .SetDefaultTokenEntity(); @@ -39,6 +40,7 @@ public static class OpenIddictMongoDbExtensions // be safely registered as singleton services and shared/reused across requests. builder.ReplaceApplicationStore(ServiceLifetime.Singleton) .ReplaceAuthorizationStore(ServiceLifetime.Singleton) + .ReplaceResourceStore(ServiceLifetime.Singleton) .ReplaceScopeStore(ServiceLifetime.Singleton) .ReplaceTokenStore(ServiceLifetime.Singleton); diff --git a/src/OpenIddict.MongoDb/OpenIddictMongoDbOptions.cs b/src/OpenIddict.MongoDb/OpenIddictMongoDbOptions.cs index 9efd53ec..799f10c2 100644 --- a/src/OpenIddict.MongoDb/OpenIddictMongoDbOptions.cs +++ b/src/OpenIddict.MongoDb/OpenIddictMongoDbOptions.cs @@ -27,6 +27,11 @@ public sealed class OpenIddictMongoDbOptions /// public IMongoDatabase? Database { get; set; } + /// + /// Gets or sets the name of the resources collection (by default, openiddict.resources). + /// + public string ResourcesCollectionName { get; set; } = "openiddict.resources"; + /// /// Gets or sets the name of the scopes collection (by default, openiddict.scopes). /// diff --git a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbApplicationStore.cs b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbApplicationStore.cs index 8f35055b..e1ede282 100644 --- a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbApplicationStore.cs +++ b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbApplicationStore.cs @@ -34,7 +34,7 @@ public class OpenIddictMongoDbApplicationStore : OpenIddictMongoDbApplicationSto /// /// Provides methods allowing to manage the applications stored in a database. /// -/// The type of the Application entity. +/// The type of the application entity. public class OpenIddictMongoDbApplicationStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TApplication> : IOpenIddictApplicationStore where TApplication : OpenIddictMongoDbApplication @@ -251,8 +251,8 @@ public class OpenIddictMongoDbApplicationStore< } return new(application.DisplayNames.ToImmutableDictionary( - pair => CultureInfo.GetCultureInfo(pair.Key), - pair => pair.Value)); + static pair => CultureInfo.GetCultureInfo(pair.Key), + static pair => pair.Value)); } /// @@ -506,8 +506,8 @@ public class OpenIddictMongoDbApplicationStore< } application.DisplayNames = names.ToImmutableDictionary( - pair => pair.Key.Name, - pair => pair.Value); + static pair => pair.Key.Name, + static pair => pair.Value); return ValueTask.CompletedTask; } diff --git a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs index fc74098c..ca28685f 100644 --- a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs +++ b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs @@ -32,7 +32,7 @@ public class OpenIddictMongoDbAuthorizationStore : OpenIddictMongoDbAuthorizatio /// /// Provides methods allowing to manage the authorizations stored in a database. /// -/// The type of the Authorization entity. +/// The type of the authorization entity. public class OpenIddictMongoDbAuthorizationStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TAuthorization> : IOpenIddictAuthorizationStore where TAuthorization : OpenIddictMongoDbAuthorization @@ -100,7 +100,7 @@ public class OpenIddictMongoDbAuthorizationStore< entity.Id == authorization.Id && entity.ConcurrencyToken == authorization.ConcurrencyToken, cancellationToken)).DeletedCount is 0) { - throw new ConcurrencyException(SR.GetResourceString(SR.ID0241)); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239)); } // Delete the tokens associated with the authorization. @@ -322,7 +322,7 @@ public class OpenIddictMongoDbAuthorizationStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0242), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -601,7 +601,7 @@ public class OpenIddictMongoDbAuthorizationStore< entity.Id == authorization.Id && entity.ConcurrencyToken == timestamp, authorization, null as ReplaceOptions, cancellationToken)).MatchedCount is 0) { - throw new ConcurrencyException(SR.GetResourceString(SR.ID0241)); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239)); } } } diff --git a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbResourceStore.cs b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbResourceStore.cs new file mode 100644 index 00000000..dd1ab8c7 --- /dev/null +++ b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbResourceStore.cs @@ -0,0 +1,437 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Options; +using OpenIddict.MongoDb.Models; +using static OpenIddict.Abstractions.OpenIddictExceptions; + +namespace OpenIddict.MongoDb; + +/// +/// Provides methods allowing to manage the resources stored in a database. +/// +public class OpenIddictMongoDbResourceStore : OpenIddictMongoDbResourceStore +{ + public OpenIddictMongoDbResourceStore( + IOpenIddictMongoDbContext context, + IOptionsMonitor options) + : base(context, options) + { + } +} + +/// +/// Provides methods allowing to manage the resources stored in a database. +/// +/// The type of the resource entity. +public class OpenIddictMongoDbResourceStore< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResource> : IOpenIddictResourceStore + where TResource : OpenIddictMongoDbResource +{ + public OpenIddictMongoDbResourceStore( + IOpenIddictMongoDbContext context, + IOptionsMonitor options) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the database context associated with the current store. + /// + protected IOpenIddictMongoDbContext Context { get; } + + /// + /// Gets the options associated with the current store. + /// + protected IOptionsMonitor Options { get; } + + /// + public virtual async ValueTask CountAsync(CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + return await collection.CountDocumentsAsync(FilterDefinition.Empty, null, cancellationToken); + } + + /// + public virtual async ValueTask CountAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + return await query(collection.AsQueryable(), state).LongCountAsync(cancellationToken); + } + + /// + public virtual async ValueTask CreateAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + await collection.InsertOneAsync(resource, null, cancellationToken); + } + + /// + public virtual async ValueTask DeleteAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + if ((await collection.DeleteOneAsync(entity => + entity.Id == resource.Id && + entity.ConcurrencyToken == resource.ConcurrencyToken, cancellationToken)).DeletedCount is 0) + { + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239)); + } + } + + /// + public virtual async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + return await collection.Find(resource => resource.Id == ObjectId.Parse(identifier)).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual async ValueTask FindByNameAsync(string name, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + return await collection.Find(resource => resource.Name == name).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual IAsyncEnumerable FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken) + { + if (names.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0203), nameof(names)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + // Note: Enumerable.Contains() is deliberately used without the extension method syntax to ensure + // ImmutableArray.Contains() (which is not fully supported by MongoDB) is not used instead. + await foreach (var resource in collection.Find(resource => Enumerable.Contains(names, resource.Name)).ToAsyncEnumerable(cancellationToken)) + { + yield return resource; + } + } + } + + /// + public virtual async ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + return await query(collection.AsQueryable(), state).FirstOrDefaultAsync(cancellationToken); + } + + /// + public virtual ValueTask GetDescriptionAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.Description); + } + + /// + public virtual ValueTask> GetDescriptionsAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (resource.Descriptions is not { Count: > 0 }) + { + return new(ImmutableDictionary.Create()); + } + + return new(resource.Descriptions.ToImmutableDictionary( + static pair => CultureInfo.GetCultureInfo(pair.Key), + static pair => pair.Value)); + } + + /// + public virtual ValueTask GetDisplayNameAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.DisplayName); + } + + /// + public virtual ValueTask> GetDisplayNamesAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (resource.DisplayNames is not { Count: > 0 }) + { + return new(ImmutableDictionary.Create()); + } + + return new(resource.DisplayNames.ToImmutableDictionary( + static pair => CultureInfo.GetCultureInfo(pair.Key), + static pair => pair.Value)); + } + + /// + public virtual ValueTask GetIdAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.Id.ToString()); + } + + /// + public virtual ValueTask GetNameAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + return new(resource.Name); + } + + /// + public virtual ValueTask> GetPropertiesAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (resource.Properties is null) + { + return new(ImmutableDictionary.Create()); + } + + using var document = JsonDocument.Parse(resource.Properties.ToJson()); + var builder = ImmutableDictionary.CreateBuilder(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + builder[property.Name] = property.Value.Clone(); + } + + return new(builder.ToImmutable()); + } + + /// + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + try + { + return new(Activator.CreateInstance()); + } + + catch (MemberAccessException exception) + { + return new(Task.FromException( + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); + } + } + + /// + public virtual async IAsyncEnumerable ListAsync( + int? count, int? offset, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + var query = (IQueryable) collection.AsQueryable().OrderBy(resource => resource.Id); + + if (offset.HasValue) + { + query = query.Skip(offset.Value); + } + + if (count.HasValue) + { + query = query.Take(count.Value); + } + + await foreach (var resource in ((IAsyncCursorSource) query).ToAsyncEnumerable(cancellationToken)) + { + yield return resource; + } + } + + /// + public virtual IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + await foreach (var element in query(collection.AsQueryable(), state).ToAsyncEnumerable(cancellationToken)) + { + yield return element; + } + } + } + + /// + public virtual ValueTask SetDescriptionAsync(TResource resource, string? description, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.Description = description; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDescriptionsAsync(TResource resource, + ImmutableDictionary descriptions, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (descriptions is not { Count: > 0 }) + { + resource.Descriptions = null; + + return ValueTask.CompletedTask; + } + + resource.Descriptions = descriptions.ToImmutableDictionary( + static pair => pair.Key.Name, + static pair => pair.Value); + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDisplayNamesAsync(TResource resource, + ImmutableDictionary names, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (names is not { Count: > 0 }) + { + resource.DisplayNames = null; + + return ValueTask.CompletedTask; + } + + resource.DisplayNames = names.ToImmutableDictionary( + static pair => pair.Key.Name, + static pair => pair.Value); + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetDisplayNameAsync(TResource resource, string? name, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.DisplayName = name; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetNameAsync(TResource resource, string? name, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + resource.Name = name; + + return ValueTask.CompletedTask; + } + + /// + public virtual ValueTask SetPropertiesAsync(TResource resource, + ImmutableDictionary properties, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + if (properties is not { Count: > 0 }) + { + resource.Properties = null; + + return ValueTask.CompletedTask; + } + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = false + }); + + writer.WriteStartObject(); + + foreach (var property in properties) + { + writer.WritePropertyName(property.Key); + property.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + + resource.Properties = BsonDocument.Parse(Encoding.UTF8.GetString(stream.ToArray())); + + return ValueTask.CompletedTask; + } + + /// + public virtual async ValueTask UpdateAsync(TResource resource, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + + // Generate a new concurrency token and attach it + // to the resource before persisting the changes. + var timestamp = resource.ConcurrencyToken; + resource.ConcurrencyToken = Guid.NewGuid().ToString(); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ResourcesCollectionName); + + if ((await collection.ReplaceOneAsync(entity => + entity.Id == resource.Id && + entity.ConcurrencyToken == timestamp, resource, null as ReplaceOptions, cancellationToken)).MatchedCount is 0) + { + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239)); + } + } +} diff --git a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbScopeStore.cs b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbScopeStore.cs index 693ab921..e6ae3a9f 100644 --- a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbScopeStore.cs +++ b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbScopeStore.cs @@ -33,7 +33,7 @@ public class OpenIddictMongoDbScopeStore : OpenIddictMongoDbScopeStore /// Provides methods allowing to manage the scopes stored in a database. /// -/// The type of the Scope entity. +/// The type of the scope entity. public class OpenIddictMongoDbScopeStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TScope> : IOpenIddictScopeStore where TScope : OpenIddictMongoDbScope @@ -101,7 +101,7 @@ public class OpenIddictMongoDbScopeStore< entity.Id == scope.Id && entity.ConcurrencyToken == scope.ConcurrencyToken, cancellationToken)).DeletedCount is 0) { - throw new ConcurrencyException(SR.GetResourceString(SR.ID0245)); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239)); } } @@ -202,8 +202,8 @@ public class OpenIddictMongoDbScopeStore< } return new(scope.Descriptions.ToImmutableDictionary( - pair => CultureInfo.GetCultureInfo(pair.Key), - pair => pair.Value)); + static pair => CultureInfo.GetCultureInfo(pair.Key), + static pair => pair.Value)); } /// @@ -225,8 +225,8 @@ public class OpenIddictMongoDbScopeStore< } return new(scope.DisplayNames.ToImmutableDictionary( - pair => CultureInfo.GetCultureInfo(pair.Key), - pair => pair.Value)); + static pair => CultureInfo.GetCultureInfo(pair.Key), + static pair => pair.Value)); } /// @@ -290,7 +290,7 @@ public class OpenIddictMongoDbScopeStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0246), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -364,8 +364,8 @@ public class OpenIddictMongoDbScopeStore< } scope.Descriptions = descriptions.ToImmutableDictionary( - pair => pair.Key.Name, - pair => pair.Value); + static pair => pair.Key.Name, + static pair => pair.Value); return ValueTask.CompletedTask; } @@ -384,8 +384,8 @@ public class OpenIddictMongoDbScopeStore< } scope.DisplayNames = names.ToImmutableDictionary( - pair => pair.Key.Name, - pair => pair.Value); + static pair => pair.Key.Name, + static pair => pair.Value); return ValueTask.CompletedTask; } @@ -480,7 +480,7 @@ public class OpenIddictMongoDbScopeStore< entity.Id == scope.Id && entity.ConcurrencyToken == timestamp, scope, null as ReplaceOptions, cancellationToken)).MatchedCount is 0) { - throw new ConcurrencyException(SR.GetResourceString(SR.ID0245)); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239)); } } } diff --git a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs index 6e5b6599..ec935d4c 100644 --- a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs +++ b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs @@ -32,7 +32,7 @@ public class OpenIddictMongoDbTokenStore : OpenIddictMongoDbTokenStore /// Provides methods allowing to manage the tokens stored in a database. /// -/// The type of the Token entity. +/// The type of the token entity. public class OpenIddictMongoDbTokenStore< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToken> : IOpenIddictTokenStore where TToken : OpenIddictMongoDbToken @@ -100,7 +100,7 @@ public class OpenIddictMongoDbTokenStore< entity.Id == token.Id && entity.ConcurrencyToken == token.ConcurrencyToken, cancellationToken)).DeletedCount is 0) { - throw new ConcurrencyException(SR.GetResourceString(SR.ID0247)); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239)); } } @@ -379,7 +379,7 @@ public class OpenIddictMongoDbTokenStore< catch (MemberAccessException exception) { return new(Task.FromException( - new InvalidOperationException(SR.GetResourceString(SR.ID0248), exception))); + new InvalidOperationException(SR.GetResourceString(SR.ID0240), exception))); } } @@ -712,7 +712,7 @@ public class OpenIddictMongoDbTokenStore< entity.Id == token.Id && entity.ConcurrencyToken == timestamp, token, null as ReplaceOptions, cancellationToken)).MatchedCount is 0) { - throw new ConcurrencyException(SR.GetResourceString(SR.ID0247)); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239)); } } } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index 7ca85859..96c7230a 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -1570,26 +1570,62 @@ public static partial class OpenIddictServerHandlers /// public sealed class ValidateResources : IOpenIddictServerHandler { + private readonly IOpenIddictResourceManager? _resourceManager; + + public ValidateResources(IOpenIddictResourceManager? resourceManager = null) + => _resourceManager = resourceManager; + /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() - .UseSingletonHandler() + .UseScopedHandler(static provider => + { + // Note: the resource manager is only resolved if the degraded mode was not enabled to ensure + // invalid core configuration exceptions are not thrown even if the managers were registered. + var options = provider.GetRequiredService>().CurrentValue; + + return options.EnableDegradedMode ? + new ValidateResources() : + new ValidateResources(provider.GetService() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); + }) .SetOrder(ValidateScopes.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); /// - public ValueTask HandleAsync(ValidateAuthorizationRequestContext context) + public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context) { ArgumentNullException.ThrowIfNull(context); - // If at least one resource was not recognized, return an error. + // If all the specified resources are registered in the options, avoid making a database lookup. var resources = context.Request.GetResources().ToHashSet(StringComparer.Ordinal); resources.ExceptWith(context.Options.Resources.Select(static resource => resource.AbsoluteUri)); + // Note: the remaining resources are only checked if the degraded mode was not enabled, + // as this requires using the resource manager, which is never used with the degraded mode, + // even if the service was registered and resolved from the dependency injection container. + if (resources.Count is not 0 && !context.Options.EnableDegradedMode) + { + if (_resourceManager is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + } + + await foreach (var resource in _resourceManager.FindByNamesAsync([.. resources])) + { + var name = await _resourceManager.GetNameAsync(resource); + if (!string.IsNullOrEmpty(name)) + { + resources.Remove(name); + } + } + } + + // If at least one resource was not recognized, return an error. if (resources.Count is not 0) { context.Logger.LogInformation(6275, SR.GetResourceString(SR.ID6274), resources); @@ -1599,10 +1635,8 @@ public static partial class OpenIddictServerHandlers description: SR.FormatID2190(Parameters.Resource), uri: SR.FormatID8000(SR.ID2190)); - return ValueTask.CompletedTask; + return; } - - return ValueTask.CompletedTask; } } @@ -3623,26 +3657,62 @@ public static partial class OpenIddictServerHandlers /// public sealed class ValidatePushedResources : IOpenIddictServerHandler { + private readonly IOpenIddictResourceManager? _resourceManager; + + public ValidatePushedResources(IOpenIddictResourceManager? resourceManager = null) + => _resourceManager = resourceManager; + /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() - .UseSingletonHandler() + .UseScopedHandler(static provider => + { + // Note: the resource manager is only resolved if the degraded mode was not enabled to ensure + // invalid core configuration exceptions are not thrown even if the managers were registered. + var options = provider.GetRequiredService>().CurrentValue; + + return options.EnableDegradedMode ? + new ValidatePushedResources() : + new ValidatePushedResources(provider.GetService() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); + }) .SetOrder(ValidatePushedScopes.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); /// - public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) { ArgumentNullException.ThrowIfNull(context); - // If at least one resource was not recognized, return an error. + // If all the specified resources are registered in the options, avoid making a database lookup. var resources = context.Request.GetResources().ToHashSet(StringComparer.Ordinal); resources.ExceptWith(context.Options.Resources.Select(static resource => resource.AbsoluteUri)); + // Note: the remaining resources are only checked if the degraded mode was not enabled, + // as this requires using the resource manager, which is never used with the degraded mode, + // even if the service was registered and resolved from the dependency injection container. + if (resources.Count is not 0 && !context.Options.EnableDegradedMode) + { + if (_resourceManager is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + } + + await foreach (var resource in _resourceManager.FindByNamesAsync([.. resources])) + { + var name = await _resourceManager.GetNameAsync(resource); + if (!string.IsNullOrEmpty(name)) + { + resources.Remove(name); + } + } + } + + // If at least one resource was not recognized, return an error. if (resources.Count is not 0) { context.Logger.LogInformation(6275, SR.GetResourceString(SR.ID6275), resources); @@ -3652,10 +3722,8 @@ public static partial class OpenIddictServerHandlers description: SR.FormatID2190(Parameters.Resource), uri: SR.FormatID8000(SR.ID2190)); - return ValueTask.CompletedTask; + return; } - - return ValueTask.CompletedTask; } } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs index bc3c466c..752fe638 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs @@ -1118,26 +1118,62 @@ public static partial class OpenIddictServerHandlers /// public sealed class ValidateResources : IOpenIddictServerHandler { + private readonly IOpenIddictResourceManager? _resourceManager; + + public ValidateResources(IOpenIddictResourceManager? resourceManager = null) + => _resourceManager = resourceManager; + /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() - .UseSingletonHandler() + .UseScopedHandler(static provider => + { + // Note: the resource manager is only resolved if the degraded mode was not enabled to ensure + // invalid core configuration exceptions are not thrown even if the managers were registered. + var options = provider.GetRequiredService>().CurrentValue; + + return options.EnableDegradedMode ? + new ValidateResources() : + new ValidateResources(provider.GetService() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); + }) .SetOrder(ValidateAudiences.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); /// - public ValueTask HandleAsync(ValidateTokenRequestContext context) + public async ValueTask HandleAsync(ValidateTokenRequestContext context) { ArgumentNullException.ThrowIfNull(context); - // If at least one resource was not recognized, return an error. + // If all the specified resources are registered in the options, avoid making a database lookup. var resources = context.Request.GetResources().ToHashSet(StringComparer.Ordinal); resources.ExceptWith(context.Options.Resources.Select(static resource => resource.AbsoluteUri)); + // Note: the remaining resources are only checked if the degraded mode was not enabled, + // as this requires using the resource manager, which is never used with the degraded mode, + // even if the service was registered and resolved from the dependency injection container. + if (resources.Count is not 0 && !context.Options.EnableDegradedMode) + { + if (_resourceManager is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + } + + await foreach (var resource in _resourceManager.FindByNamesAsync([.. resources])) + { + var name = await _resourceManager.GetNameAsync(resource); + if (!string.IsNullOrEmpty(name)) + { + resources.Remove(name); + } + } + } + + // If at least one resource was not recognized, return an error. if (resources.Count is not 0) { context.Logger.LogInformation(6273, SR.GetResourceString(SR.ID6273), resources); @@ -1147,10 +1183,8 @@ public static partial class OpenIddictServerHandlers description: SR.FormatID2190(Parameters.Resource), uri: SR.FormatID8000(SR.ID2190)); - return ValueTask.CompletedTask; + return; } - - return ValueTask.CompletedTask; } } diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs index d61c08c1..7120c1bc 100644 --- a/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs @@ -433,5 +433,5 @@ public class OpenIddictApplicationCacheTests () => cache.RemoveAsync(application, CancellationToken.None).AsTask()); } - public sealed class OpenIddictApplication { } + public sealed class OpenIddictApplication; } diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictAuthorizationCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictAuthorizationCacheTests.cs index 6fc7b5c8..38fb088c 100644 --- a/test/OpenIddict.Core.Tests/Caches/OpenIddictAuthorizationCacheTests.cs +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictAuthorizationCacheTests.cs @@ -326,5 +326,5 @@ public class OpenIddictAuthorizationCacheTests () => cache.RemoveAsync(authorization, CancellationToken.None).AsTask()); } - public sealed class OpenIddictAuthorization { } + public sealed class OpenIddictAuthorization; } diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictResourceCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictResourceCacheTests.cs new file mode 100644 index 00000000..6da9e7e4 --- /dev/null +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictResourceCacheTests.cs @@ -0,0 +1,361 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictResourceCacheTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictResourceCache(options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = (IOpenIddictResourceStore) null!; + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictResourceCache(options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task AddAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictResourceCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.AddAsync(resource: null!, CancellationToken.None).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictResourceCache(options, store); + + // Act and assert + cache.Dispose(); + cache.Dispose(); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictResourceCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: null!, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictResourceCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ReturnsCachedResourceOnCacheHit() + { + // Arrange + var resource = new OpenIddictResource(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("resource-id"); + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("resource-name"); + + var cache = new OpenIddictResourceCache(options, store.Object); + + await cache.AddAsync(resource, CancellationToken.None); + + // Act + var result = await cache.FindByIdAsync("resource-id", CancellationToken.None); + + // Assert + Assert.Same(resource, result); + store.Verify(store => store.FindByIdAsync("resource-id", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var resource = new OpenIddictResource(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("resource-id", It.IsAny())) + .ReturnsAsync(resource); + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("resource-id"); + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("resource-name"); + + var cache = new OpenIddictResourceCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("resource-id", CancellationToken.None); + + // Assert + Assert.Same(resource, result); + store.Verify(store => store.FindByIdAsync("resource-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ReturnsNullWhenResourceNotFound() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("resource-id", It.IsAny())) + .ReturnsAsync((OpenIddictResource?) null); + + var cache = new OpenIddictResourceCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("resource-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task FindByNameAsync_ThrowsAnExceptionForNullName() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictResourceCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByNameAsync(name: null!, CancellationToken.None).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindByNameAsync_ThrowsAnExceptionForEmptyName() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictResourceCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByNameAsync(name: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindByNameAsync_ReturnsCachedResourceOnCacheHit() + { + // Arrange + var resource = new OpenIddictResource(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("resource-id"); + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("resource-name"); + + var cache = new OpenIddictResourceCache(options, store.Object); + + await cache.AddAsync(resource, CancellationToken.None); + + // Act + var result = await cache.FindByNameAsync("resource-name", CancellationToken.None); + + // Assert + Assert.Same(resource, result); + store.Verify(store => store.FindByNameAsync("resource-name", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByNameAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var resource = new OpenIddictResource(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByNameAsync("resource-name", It.IsAny())) + .ReturnsAsync(resource); + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("resource-id"); + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("resource-name"); + + var cache = new OpenIddictResourceCache(options, store.Object); + + // Act + var result = await cache.FindByNameAsync("resource-name", CancellationToken.None); + + // Assert + Assert.Same(resource, result); + store.Verify(store => store.FindByNameAsync("resource-name", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByNamesAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var resources = new[] + { + new OpenIddictResource(), + new OpenIddictResource() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(resources[0], It.IsAny())) + .ReturnsAsync("resource-id-1"); + store.Setup(store => store.GetNameAsync(resources[0], It.IsAny())) + .ReturnsAsync("resource-name-1"); + + store.Setup(store => store.GetIdAsync(resources[1], It.IsAny())) + .ReturnsAsync("resource-id-2"); + store.Setup(store => store.GetNameAsync(resources[1], It.IsAny())) + .ReturnsAsync("resource-name-2"); + + store.Setup(store => store.FindByNamesAsync(It.IsAny>(), It.IsAny())) + .Returns(resources.ToAsyncEnumerable()); + + var cache = new OpenIddictResourceCache(options, store.Object); + + // Act + var results = await cache.FindByNamesAsync(["resource-name-1", "resource-name-2"], CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(resources[0], results); + Assert.Contains(resources[1], results); + } + + [Fact] + public async Task RemoveAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictResourceCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.RemoveAsync(resource: null!, CancellationToken.None).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task RemoveAsync_InvalidatesCachedEntries() + { + // Arrange + var resource = new OpenIddictResource(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("resource-id"); + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("resource-name"); + + var cache = new OpenIddictResourceCache(options, store.Object); + + await cache.AddAsync(resource, CancellationToken.None); + + // Act + await cache.RemoveAsync(resource, CancellationToken.None); + + var result = await cache.FindByIdAsync("resource-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task RemoveAsync_ThrowsForResourceWithoutId() + { + // Arrange + var resource = new OpenIddictResource(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictResourceCache(options, store.Object); + + // Act and assert + await Assert.ThrowsAsync( + () => cache.RemoveAsync(resource, CancellationToken.None).AsTask()); + } + + public sealed class OpenIddictResource; +} diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictScopeCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictScopeCacheTests.cs index e5fbe4bb..feb2ce53 100644 --- a/test/OpenIddict.Core.Tests/Caches/OpenIddictScopeCacheTests.cs +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictScopeCacheTests.cs @@ -413,5 +413,5 @@ public class OpenIddictScopeCacheTests () => cache.RemoveAsync(scope, CancellationToken.None).AsTask()); } - public sealed class OpenIddictScope { } + public sealed class OpenIddictScope; } diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictTokenCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictTokenCacheTests.cs index 21b9eb14..5cfd4b05 100644 --- a/test/OpenIddict.Core.Tests/Caches/OpenIddictTokenCacheTests.cs +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictTokenCacheTests.cs @@ -499,5 +499,5 @@ public class OpenIddictTokenCacheTests () => cache.RemoveAsync(token, CancellationToken.None).AsTask()); } - public sealed class OpenIddictToken { } + public sealed class OpenIddictToken; } diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictApplicationManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictApplicationManagerTests.cs index 8e014b64..d31f328e 100644 --- a/test/OpenIddict.Core.Tests/Managers/OpenIddictApplicationManagerTests.cs +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictApplicationManagerTests.cs @@ -3537,7 +3537,7 @@ public class OpenIddictApplicationManagerTests Assert.Equal("certificate", exception.ParamName); } - public class CustomApplication { } + public class CustomApplication; private class CustomApplicationManagerWithProtectedAccess : OpenIddictApplicationManager { diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictAuthorizationManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictAuthorizationManagerTests.cs index 4240c99e..4bf63f1d 100644 --- a/test/OpenIddict.Core.Tests/Managers/OpenIddictAuthorizationManagerTests.cs +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictAuthorizationManagerTests.cs @@ -1286,5 +1286,5 @@ public class OpenIddictAuthorizationManagerTests Assert.DoesNotContain(results, static result => result != ValidationResult.Success); } - public class CustomAuthorization { } + public class CustomAuthorization; } diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictResourceManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictResourceManagerTests.cs new file mode 100644 index 00000000..8c9d63ef --- /dev/null +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictResourceManagerTests.cs @@ -0,0 +1,888 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictResourceManagerTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullCache() + { + // Arrange + var cache = (IOpenIddictResourceCache) null!; + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictResourceManager(cache, logger, options, store)); + + Assert.Equal("cache", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullLogger() + { + // Arrange + var cache = Mock.Of>(); + var logger = (ILogger>) null!; + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictResourceManager(cache, logger, options, store)); + + Assert.Equal("logger", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictResourceManager(cache, logger, options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = (IOpenIddictResourceStore) null!; + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictResourceManager(cache, logger, options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task CountAsync_CallsStoreMethod() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.CountAsync(It.IsAny())) + .ReturnsAsync(42); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var count = await manager.CountAsync(); + + // Assert + Assert.Equal(42, count); + store.Verify(store => store.CountAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task CountAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CountAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task CreateAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CreateAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.DeleteAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_RemovesResourceFromCache_WhenCachingIsEnabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + var resource = new CustomResource(); + + var manager = new OpenIddictResourceManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(resource); + + // Assert + cache.Verify(cache => cache.RemoveAsync(resource, It.IsAny()), Times.Once()); + store.Verify(store => store.DeleteAsync(resource, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task DeleteAsync_DoesNotRemoveFromCache_WhenCachingIsDisabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + var resource = new CustomResource(); + + var manager = new OpenIddictResourceManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(resource); + + // Assert + cache.Verify(cache => cache.RemoveAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.DeleteAsync(resource, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_UsesCache_WhenCachingIsEnabled() + { + // Arrange + var resource = new CustomResource(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + + cache.Setup(cache => cache.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(resource); + + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictResourceManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(resource, result); + cache.Verify(cache => cache.FindByIdAsync("id", It.IsAny()), Times.Once()); + store.Verify(store => store.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_UsesStore_WhenCachingIsDisabled() + { + // Arrange + var resource = new CustomResource(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(resource); + + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictResourceManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(resource, result); + cache.Verify(cache => cache.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.FindByIdAsync("id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByNameAsync_ThrowsAnExceptionForNullName() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByNameAsync(name: null!).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindByNameAsync_ThrowsAnExceptionForEmptyName() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByNameAsync(name: string.Empty).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindByNamesAsync_ReturnsEmptyWhenNoResourcesMatch() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + cache.Setup(cache => cache.FindByNamesAsync(It.IsAny>(), It.IsAny())) + .Returns((ImmutableArray names, CancellationToken cancellationToken) => + { + return Enumerable.Empty().ToAsyncEnumerable(); + }); + + var manager = new OpenIddictResourceManager(cache.Object, logger, options, store); + + // Act + var results = new List(); + await foreach (var resource in manager.FindByNamesAsync(["resource1", "resource2"])) + { + results.Add(resource); + } + + // Assert + Assert.Empty(results); + } + + [Fact] + public async Task GetAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQueryAndState_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!, state: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetDescriptionAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDescriptionAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetDescriptionsAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDescriptionsAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetDisplayNameAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDisplayNameAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetDisplayNamesAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDisplayNamesAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetIdAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ReturnsIdentifierFromStore() + { + // Arrange + var resource = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("unique-resource-id"); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var id = await manager.GetIdAsync(resource); + + // Assert + Assert.Equal("unique-resource-id", id); + store.Verify(store => store.GetIdAsync(resource, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetLocalizedDescriptionAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetLocalizedDescriptionAsync(resource: null!, CultureInfo.InvariantCulture).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetLocalizedDescriptionAsync_ReturnsDescriptionForMatchingCulture() + { + // Arrange + var resource = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetDescriptionsAsync(resource, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create() + .Add(CultureInfo.GetCultureInfo("en-US"), "English description") + .Add(CultureInfo.GetCultureInfo("fr-FR"), "Description française")); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var description = await manager.GetLocalizedDescriptionAsync(resource, CultureInfo.GetCultureInfo("fr-FR")); + + // Assert + Assert.Equal("Description française", description); + } + + [Fact] + public async Task GetLocalizedDescriptionAsync_FallsBackToNonLocalizedDescriptionWhenNoCultureMatches() + { + // Arrange + var resource = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetDescriptionsAsync(resource, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create() + .Add(CultureInfo.GetCultureInfo("en-US"), "English description")); + + store.Setup(store => store.GetDescriptionAsync(resource, It.IsAny())) + .ReturnsAsync("Default description"); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var description = await manager.GetLocalizedDescriptionAsync(resource, CultureInfo.GetCultureInfo("ja-JP")); + + // Assert + Assert.Equal("Default description", description); + } + + [Fact] + public async Task GetLocalizedDisplayNameAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetLocalizedDisplayNameAsync(resource: null!, CultureInfo.InvariantCulture).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetLocalizedDisplayNameAsync_ReturnsDisplayNameForMatchingCulture() + { + // Arrange + var resource = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetDisplayNamesAsync(resource, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create() + .Add(CultureInfo.GetCultureInfo("en-US"), "English name") + .Add(CultureInfo.GetCultureInfo("fr-FR"), "Nom fran�ais")); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var name = await manager.GetLocalizedDisplayNameAsync(resource, CultureInfo.GetCultureInfo("fr-FR")); + + // Assert + Assert.Equal("Nom fran�ais", name); + } + + [Fact] + public async Task GetNameAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetNameAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetPropertiesAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPropertiesAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task ListAsync_ReturnsAllResources() + { + // Arrange + var resources = new[] { new CustomResource(), new CustomResource() }; + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(resources.ToAsyncEnumerable()); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var results = new List(); + await foreach (var scp in manager.ListAsync()) + { + results.Add(scp); + } + + // Assert + Assert.Equal(2, results.Count); + store.Verify(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + var descriptor = new OpenIddictResourceDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(resource: null!, descriptor).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullDescriptor() + { + // Arrange + var resource = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(resource, descriptor: null!).AsTask()); + + Assert.Equal("descriptor", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(resource: null!).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_WithDescriptor_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + var descriptor = new OpenIddictResourceDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(resource: null!, descriptor).AsTask()); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public void ValidateAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictResourceManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.ValidateAsync(resource: null!)); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenNameIsEmpty() + { + // Arrange + var resource = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync(string.Empty); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(resource).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2206)); + } + + [Theory] + [InlineData("resource")] + [InlineData("/resource")] + [InlineData("urn:resource#fragment")] + public async Task ValidateAsync_ReturnsErrorWhenNameIsNotValidUri(string name) + { + // Arrange + var resource = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync(name); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(resource).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2207)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenNameIsAlreadyUsed() + { + // Arrange + var resource = new CustomResource(); + var other = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("urn:resource"); + + store.Setup(store => store.FindByNameAsync("urn:resource", It.IsAny())) + .ReturnsAsync(other); + + store.Setup(store => store.GetIdAsync(resource, It.IsAny())) + .ReturnsAsync("urn:resource-id"); + + store.Setup(store => store.GetIdAsync(other, It.IsAny())) + .ReturnsAsync("urn:other-resource-id"); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(resource).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2208)); + } + + [Fact] + public async Task ValidateAsync_ReturnsNoErrorsForValidResource() + { + // Arrange + var resource = new CustomResource(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("urn:resource"); + + store.Setup(store => store.FindByNameAsync("urn:resource", It.IsAny())) + .ReturnsAsync((CustomResource?) null); + + var manager = new OpenIddictResourceManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(resource).ToListAsync(); + + // Assert + Assert.DoesNotContain(results, static result => result != ValidationResult.Success); + } + + public class CustomResource; +} diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictScopeManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictScopeManagerTests.cs index a67d529a..39e584cd 100644 --- a/test/OpenIddict.Core.Tests/Managers/OpenIddictScopeManagerTests.cs +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictScopeManagerTests.cs @@ -574,7 +574,7 @@ public class OpenIddictScopeManagerTests store.Setup(store => store.GetDescriptionsAsync(scope, It.IsAny())) .ReturnsAsync(ImmutableDictionary.Create() .Add(CultureInfo.GetCultureInfo("en-US"), "English description") - .Add(CultureInfo.GetCultureInfo("fr-FR"), "Description fran�aise")); + .Add(CultureInfo.GetCultureInfo("fr-FR"), "Description française")); var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); @@ -582,7 +582,7 @@ public class OpenIddictScopeManagerTests var description = await manager.GetLocalizedDescriptionAsync(scope, CultureInfo.GetCultureInfo("fr-FR")); // Assert - Assert.Equal("Description fran�aise", description); + Assert.Equal("Description française", description); } [Fact] @@ -973,5 +973,5 @@ public class OpenIddictScopeManagerTests Assert.DoesNotContain(results, static result => result != ValidationResult.Success); } - public class CustomScope { } + public class CustomScope; } diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictTokenManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictTokenManagerTests.cs index 779634bb..d9fd1aec 100644 --- a/test/OpenIddict.Core.Tests/Managers/OpenIddictTokenManagerTests.cs +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictTokenManagerTests.cs @@ -1339,5 +1339,5 @@ public class OpenIddictTokenManagerTests Assert.DoesNotContain(results, static result => result != ValidationResult.Success); } - public class CustomToken { } + public class CustomToken; } diff --git a/test/OpenIddict.Core.Tests/OpenIddictCoreBuilderTests.cs b/test/OpenIddict.Core.Tests/OpenIddictCoreBuilderTests.cs index c67d2d71..dc89ed40 100644 --- a/test/OpenIddict.Core.Tests/OpenIddictCoreBuilderTests.cs +++ b/test/OpenIddict.Core.Tests/OpenIddictCoreBuilderTests.cs @@ -115,6 +115,51 @@ public class OpenIddictCoreBuilderTests Assert.Equal(typeof(OpenGenericAuthorizationManager<>), descriptor.ImplementationType); } + [Fact] + public void ReplaceResourceManager_ThrowsAnExceptionForClosedSourceManager() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.ReplaceResourceManager(typeof(ClosedGenericResourceManager))); + + Assert.Equal("type", exception.ParamName); + Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); + } + + [Fact] + public void ReplaceResourceManager_ThrowsAnExceptionForInvalidManager() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.ReplaceResourceManager(typeof(object))); + + Assert.Equal("type", exception.ParamName); + Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); + } + + [Fact] + public void ReplaceResourceManager_OverridesDefaultOpenGenericManager() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.ReplaceResourceManager(typeof(OpenGenericResourceManager<>)); + + // Assert + var descriptor = Assert.Single(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(OpenIddictResourceManager<>)); + Assert.Equal(typeof(OpenGenericResourceManager<>), descriptor.ImplementationType); + } + [Fact] public void ReplaceScopeManager_ThrowsAnExceptionForClosedSourceManager() { @@ -472,6 +517,23 @@ public class OpenIddictCoreBuilderTests service.ImplementationFactory is not null); } + [Fact] + public void SetDefaultResourceEntity_ReplacesUntypedManager() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetDefaultResourceEntity(); + + // Assert + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceManager) && + service.ImplementationFactory is not null); + } + [Fact] public void SetDefaultScopeEntity_ReplacesUntypedManager() { @@ -516,10 +578,11 @@ private static OpenIddictCoreBuilder CreateBuilder(IServiceCollection services) return services; } - private class CustomApplication { } - private class CustomAuthorization { } - private class CustomScope { } - private class CustomToken { } + private class CustomApplication; + private class CustomAuthorization; + private class CustomResource; + private class CustomScope; + private class CustomToken; private class ClosedGenericApplicationManager : OpenIddictApplicationManager { @@ -571,6 +634,31 @@ private static OpenIddictCoreBuilder CreateBuilder(IServiceCollection services) } } + private class ClosedGenericResourceManager : OpenIddictResourceManager + { + public ClosedGenericResourceManager( + IOpenIddictResourceCache cache, + ILogger> logger, + IOptionsMonitor options, + IOpenIddictResourceStore store) + : base(cache, logger, options, store) + { + } + } + + private class OpenGenericResourceManager : OpenIddictResourceManager + where TResource : class + { + public OpenGenericResourceManager( + IOpenIddictResourceCache cache, + ILogger> logger, + IOptionsMonitor options, + IOpenIddictResourceStore store) + : base(cache, logger, options, store) + { + } + } + private class ClosedGenericScopeManager : OpenIddictScopeManager { public ClosedGenericScopeManager( diff --git a/test/OpenIddict.Core.Tests/OpenIddictCoreExtensionsTests.cs b/test/OpenIddict.Core.Tests/OpenIddictCoreExtensionsTests.cs index 80dcc397..ffac8d39 100644 --- a/test/OpenIddict.Core.Tests/OpenIddictCoreExtensionsTests.cs +++ b/test/OpenIddict.Core.Tests/OpenIddictCoreExtensionsTests.cs @@ -69,6 +69,7 @@ public class OpenIddictCoreExtensionsTests [Theory] [InlineData(typeof(OpenIddictApplicationManager<>))] [InlineData(typeof(OpenIddictAuthorizationManager<>))] + [InlineData(typeof(OpenIddictResourceManager<>))] [InlineData(typeof(OpenIddictScopeManager<>))] [InlineData(typeof(OpenIddictTokenManager<>))] public void AddCore_RegistersDefaultManagers(Type type) @@ -87,6 +88,7 @@ public class OpenIddictCoreExtensionsTests [Theory] [InlineData(typeof(IOpenIddictApplicationManager))] [InlineData(typeof(IOpenIddictAuthorizationManager))] + [InlineData(typeof(IOpenIddictResourceManager))] [InlineData(typeof(IOpenIddictScopeManager))] [InlineData(typeof(IOpenIddictTokenManager))] public void AddCore_RegistersUntypedProxies(Type type) @@ -138,6 +140,24 @@ public class OpenIddictCoreExtensionsTests Assert.Equal(SR.GetResourceString(SR.ID0472), exception.Message); } + [Fact] + public void AddCore_ResolvingUntypedResourceManagerThrowsAnException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictBuilder(services); + + // Act + builder.AddCore(); + + // Assert + var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(provider.GetRequiredService); + + Assert.Equal(SR.GetResourceString(SR.ID0472), exception.Message); + } + [Fact] public void AddCore_ResolvingUntypedScopeManagerThrowsAnException() { diff --git a/test/OpenIddict.EntityFramework.Tests/OpenIddictEntityFrameworkBuilderTests.cs b/test/OpenIddict.EntityFramework.Tests/OpenIddictEntityFrameworkBuilderTests.cs index 1e72e1c8..649572a5 100644 --- a/test/OpenIddict.EntityFramework.Tests/OpenIddictEntityFrameworkBuilderTests.cs +++ b/test/OpenIddict.EntityFramework.Tests/OpenIddictEntityFrameworkBuilderTests.cs @@ -33,7 +33,7 @@ public class OpenIddictEntityFrameworkBuilderTests var builder = CreateBuilder(services); // Act - builder.ReplaceDefaultEntities(); + builder.ReplaceDefaultEntities(); // Assert Assert.Contains(services, service => @@ -44,6 +44,10 @@ public class OpenIddictEntityFrameworkBuilderTests service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictAuthorizationStore) && service.ImplementationType == typeof(OpenIddictEntityFrameworkAuthorizationStore)); + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceStore) && + service.ImplementationType == typeof(OpenIddictEntityFrameworkResourceStore)); Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictScopeStore) && @@ -82,10 +86,11 @@ public class OpenIddictEntityFrameworkBuilderTests return services; } - public class CustomApplication : OpenIddictEntityFrameworkApplication { } - public class CustomAuthorization : OpenIddictEntityFrameworkAuthorization { } - public class CustomScope : OpenIddictEntityFrameworkScope { } - public class CustomToken : OpenIddictEntityFrameworkToken { } + public class CustomApplication : OpenIddictEntityFrameworkApplication; + public class CustomAuthorization : OpenIddictEntityFrameworkAuthorization; + public class CustomResource : OpenIddictEntityFrameworkResource; + public class CustomScope : OpenIddictEntityFrameworkScope; + public class CustomToken : OpenIddictEntityFrameworkToken; public class CustomDbContext : DbContext { diff --git a/test/OpenIddict.EntityFramework.Tests/OpenIddictEntityFrameworkExtensionsTests.cs b/test/OpenIddict.EntityFramework.Tests/OpenIddictEntityFrameworkExtensionsTests.cs index be802b95..46ce8854 100644 --- a/test/OpenIddict.EntityFramework.Tests/OpenIddictEntityFrameworkExtensionsTests.cs +++ b/test/OpenIddict.EntityFramework.Tests/OpenIddictEntityFrameworkExtensionsTests.cs @@ -56,6 +56,10 @@ public class OpenIddictEntityFrameworkExtensionsTests service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictAuthorizationManager) && service.ImplementationFactory is not null); + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceManager) && + service.ImplementationFactory is not null); Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictScopeManager) && @@ -85,6 +89,10 @@ public class OpenIddictEntityFrameworkExtensionsTests service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictAuthorizationStore) && service.ImplementationType == typeof(OpenIddictEntityFrameworkAuthorizationStore)); + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceStore) && + service.ImplementationType == typeof(OpenIddictEntityFrameworkResourceStore)); Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictScopeStore) && diff --git a/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreBuilderTests.cs b/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreBuilderTests.cs index 344128d0..b4fb5c7d 100644 --- a/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreBuilderTests.cs +++ b/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreBuilderTests.cs @@ -33,7 +33,7 @@ public class OpenIddictEntityFrameworkCoreBuilderTests var builder = CreateBuilder(services); // Act - builder.ReplaceDefaultEntities(); + builder.ReplaceDefaultEntities(); // Assert Assert.Contains(services, service => @@ -44,6 +44,10 @@ public class OpenIddictEntityFrameworkCoreBuilderTests service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictAuthorizationStore) && service.ImplementationType == typeof(OpenIddictEntityFrameworkCoreAuthorizationStore)); + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceStore) && + service.ImplementationType == typeof(OpenIddictEntityFrameworkCoreResourceStore)); Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictScopeStore) && @@ -82,10 +86,11 @@ public class OpenIddictEntityFrameworkCoreBuilderTests return services; } - public class CustomApplication : OpenIddictEntityFrameworkCoreApplication { } - public class CustomAuthorization : OpenIddictEntityFrameworkCoreAuthorization { } - public class CustomScope : OpenIddictEntityFrameworkCoreScope { } - public class CustomToken : OpenIddictEntityFrameworkCoreToken { } + public class CustomApplication : OpenIddictEntityFrameworkCoreApplication; + public class CustomAuthorization : OpenIddictEntityFrameworkCoreAuthorization; + public class CustomResource : OpenIddictEntityFrameworkCoreResource; + public class CustomScope : OpenIddictEntityFrameworkCoreScope; + public class CustomToken : OpenIddictEntityFrameworkCoreToken; public class CustomDbContext : DbContext { diff --git a/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreExtensionsTests.cs b/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreExtensionsTests.cs index 570bb28b..ad1fbd2b 100644 --- a/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreExtensionsTests.cs +++ b/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreExtensionsTests.cs @@ -56,6 +56,10 @@ public class OpenIddictEntityFrameworkCoreExtensionsTests service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictAuthorizationManager) && service.ImplementationFactory is not null); + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceManager) && + service.ImplementationFactory is not null); Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictScopeManager) && @@ -85,6 +89,10 @@ public class OpenIddictEntityFrameworkCoreExtensionsTests service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictAuthorizationStore) && service.ImplementationType == typeof(OpenIddictEntityFrameworkCoreAuthorizationStore)); + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceStore) && + service.ImplementationType == typeof(OpenIddictEntityFrameworkCoreResourceStore)); Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictScopeStore) && diff --git a/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreHelpersTests.cs b/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreHelpersTests.cs index beb213d1..dabd1924 100644 --- a/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreHelpersTests.cs +++ b/test/OpenIddict.EntityFrameworkCore.Tests/OpenIddictEntityFrameworkCoreHelpersTests.cs @@ -23,6 +23,8 @@ public class OpenIddictEntityFrameworkCoreHelpersTests .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>())) .Returns(builder.Object); + builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>())) + .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>())) .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>())) @@ -36,6 +38,8 @@ public class OpenIddictEntityFrameworkCoreHelpersTests It.IsAny>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( It.IsAny>()), Times.Once()); + builder.Verify(mock => mock.ApplyConfiguration( + It.IsAny>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( It.IsAny>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( @@ -51,6 +55,8 @@ public class OpenIddictEntityFrameworkCoreHelpersTests .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>>())) .Returns(builder.Object); + builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>>())) + .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>>())) .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>>())) @@ -64,6 +70,8 @@ public class OpenIddictEntityFrameworkCoreHelpersTests It.IsAny, OpenIddictEntityFrameworkCoreAuthorization, OpenIddictEntityFrameworkCoreToken, long>>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( It.IsAny, OpenIddictEntityFrameworkCoreApplication, OpenIddictEntityFrameworkCoreToken, long>>()), Times.Once()); + builder.Verify(mock => mock.ApplyConfiguration( + It.IsAny, long>>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( It.IsAny, long>>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( @@ -79,27 +87,32 @@ public class OpenIddictEntityFrameworkCoreHelpersTests .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>())) .Returns(builder.Object); + builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>())) + .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>())) .Returns(builder.Object); builder.Setup(mock => mock.ApplyConfiguration(It.IsAny>())) .Returns(builder.Object); // Act - builder.Object.UseOpenIddict(); + builder.Object.UseOpenIddict(); // Assert builder.Verify(mock => mock.ApplyConfiguration( It.IsAny>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( It.IsAny>()), Times.Once()); + builder.Verify(mock => mock.ApplyConfiguration( + It.IsAny>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( It.IsAny>()), Times.Once()); builder.Verify(mock => mock.ApplyConfiguration( It.IsAny>()), Times.Once()); } - public class CustomApplication : OpenIddictEntityFrameworkCoreApplication { } - public class CustomAuthorization : OpenIddictEntityFrameworkCoreAuthorization { } - public class CustomScope : OpenIddictEntityFrameworkCoreScope { } - public class CustomToken : OpenIddictEntityFrameworkCoreToken { } + public class CustomApplication : OpenIddictEntityFrameworkCoreApplication; + public class CustomAuthorization : OpenIddictEntityFrameworkCoreAuthorization; + public class CustomResource : OpenIddictEntityFrameworkCoreResource; + public class CustomScope : OpenIddictEntityFrameworkCoreScope; + public class CustomToken : OpenIddictEntityFrameworkCoreToken; } diff --git a/test/OpenIddict.MongoDb.Tests/OpenIddictMongoDbBuilderTests.cs b/test/OpenIddict.MongoDb.Tests/OpenIddictMongoDbBuilderTests.cs index 66a9b8f6..051e9be6 100644 --- a/test/OpenIddict.MongoDb.Tests/OpenIddictMongoDbBuilderTests.cs +++ b/test/OpenIddict.MongoDb.Tests/OpenIddictMongoDbBuilderTests.cs @@ -61,6 +61,23 @@ public class OpenIddictMongoDbBuilderTests service.ImplementationType == typeof(OpenIddictMongoDbAuthorizationStore)); } + [Fact] + public void ReplaceDefaultResourceEntity_StoreIsCorrectlyReplaced() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.ReplaceDefaultResourceEntity(); + + // Assert + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceStore) && + service.ImplementationType == typeof(OpenIddictMongoDbResourceStore)); + } + [Fact] public void ReplaceDefaultScopeEntity_StoreIsCorrectlyReplaced() { @@ -159,6 +176,38 @@ public class OpenIddictMongoDbBuilderTests Assert.Equal("custom_collection", options.AuthorizationsCollectionName); } + [Theory] + [InlineData(null)] + [InlineData("")] + public void SetResourcesCollectionName_ThrowsAnExceptionForNullOrEmptyCollectionName(string? name) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.ThrowsAny(() => builder.SetResourcesCollectionName(name!)); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public void SetResourcesCollectionName_CollectionNameIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetResourcesCollectionName("custom_collection"); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal("custom_collection", options.ResourcesCollectionName); + } + [Theory] [InlineData(null)] [InlineData("")] @@ -268,8 +317,9 @@ public class OpenIddictMongoDbBuilderTests return services; } - public class CustomApplication : OpenIddictMongoDbApplication { } - public class CustomAuthorization : OpenIddictMongoDbAuthorization { } - public class CustomScope : OpenIddictMongoDbScope { } - public class CustomToken : OpenIddictMongoDbToken { } + public class CustomApplication : OpenIddictMongoDbApplication; + public class CustomAuthorization : OpenIddictMongoDbAuthorization; + public class CustomResource : OpenIddictMongoDbResource; + public class CustomScope : OpenIddictMongoDbScope; + public class CustomToken : OpenIddictMongoDbToken; } diff --git a/test/OpenIddict.MongoDb.Tests/OpenIddictMongoDbExtensionsTests.cs b/test/OpenIddict.MongoDb.Tests/OpenIddictMongoDbExtensionsTests.cs index 24ee6b21..05b83e5c 100644 --- a/test/OpenIddict.MongoDb.Tests/OpenIddictMongoDbExtensionsTests.cs +++ b/test/OpenIddict.MongoDb.Tests/OpenIddictMongoDbExtensionsTests.cs @@ -56,6 +56,10 @@ public class OpenIddictMongoDbExtensionsTests service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictAuthorizationManager) && service.ImplementationFactory is not null); + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictResourceManager) && + service.ImplementationFactory is not null); Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Scoped && service.ServiceType == typeof(IOpenIddictScopeManager) && @@ -85,6 +89,10 @@ public class OpenIddictMongoDbExtensionsTests service.Lifetime == ServiceLifetime.Singleton && service.ServiceType == typeof(IOpenIddictAuthorizationStore) && service.ImplementationType == typeof(OpenIddictMongoDbAuthorizationStore)); + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Singleton && + service.ServiceType == typeof(IOpenIddictResourceStore) && + service.ImplementationType == typeof(OpenIddictMongoDbResourceStore)); Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Singleton && service.ServiceType == typeof(IOpenIddictScopeStore) && diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs index f2f780c9..3b2f0eda 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs @@ -1087,6 +1087,14 @@ public abstract partial class OpenIddictServerIntegrationTests mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) .ReturnsAsync(true); })); + + options.Services.AddSingleton(CreateResourceManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(resources => resources.Length == 1 && resources[0] == "urn:unregistered_resource"), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + })); }); await using var client = await server.CreateClientAsync(); @@ -1144,6 +1152,78 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.NotNull(response.AccessToken); } + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsValidatedWhenRegisteredResourceIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + var resource = new OpenIddictResource(); + + options.RegisterResources("urn:resource_registered_in_options"); + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Services.AddSingleton(CreateApplicationManager(mock => + { + var application = new OpenIddictApplication(); + + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + })); + + options.Services.AddSingleton(CreateResourceManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(resources => resources.Length == 1 && resources[0] == "urn:resource_registered_in_database"), + It.IsAny())) + .Returns(new[] { resource }.ToAsyncEnumerable()); + + mock.Setup(manager => manager.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("urn:resource_registered_in_database"); + })); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return ValueTask.CompletedTask; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + Resources = ["urn:resource_registered_in_database", "urn:resource_registered_in_options"], + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.AccessToken); + } + [Fact] public async Task ValidateAuthorizationRequest_UnknownResponseModeParameterIsRejected() { @@ -3921,6 +4001,14 @@ public abstract partial class OpenIddictServerIntegrationTests mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) .ReturnsAsync(true); })); + + options.Services.AddSingleton(CreateResourceManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(resources => resources.Length == 1 && resources[0] == "urn:unregistered_resource"), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + })); }); await using var client = await server.CreateClientAsync(); @@ -3930,7 +4018,7 @@ public abstract partial class OpenIddictServerIntegrationTests { ClientId = "Fabrikam", RedirectUri = "http://www.fabrikam.com/path", - Resources = ["urn:registered_resource"], + Resources = ["urn:unregistered_resource"], ResponseType = ResponseTypes.Code }); @@ -3969,6 +4057,69 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.NotNull(response.RequestUri); } + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenRegisteredResourceIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + var resource = new OpenIddictResource(); + + options.RegisterResources("urn:resource_registered_in_options"); + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Services.AddSingleton(CreateApplicationManager(mock => + { + var application = new OpenIddictApplication(); + + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + })); + + options.Services.AddSingleton(CreateResourceManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(resources => resources.Length == 1 && resources[0] == "urn:resource_registered_in_database"), + It.IsAny())) + .Returns(new[] { resource }.ToAsyncEnumerable()); + + mock.Setup(manager => manager.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("urn:resource_registered_in_database"); + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + Resources = ["urn:resource_registered_in_database", "urn:resource_registered_in_options"], + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + } + [Fact] public async Task ValidatePushedAuthorizationRequest_UnknownResponseModeParameterIsRejected() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs index 6307bda1..b75cdb49 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs @@ -2251,7 +2251,17 @@ public abstract partial class OpenIddictServerIntegrationTests public async Task ValidateTokenRequest_RequestIsRejectedWhenUnregisteredResourceIsSpecified() { // Arrange - await using var server = await CreateServerAsync(); + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(CreateResourceManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(resources => resources.Length == 1 && resources[0] == "urn:unregistered_resource"), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + })); + }); + await using var client = await server.CreateClientAsync(); // Act @@ -2314,6 +2324,62 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.NotNull(response.AccessToken); } + [Fact] + public async Task ValidateTokenRequest_RequestIsValidatedWhenRegisteredResourceIsSpecified() + { + // Arrange + var resource = new OpenIddictResource(); + + var manager = CreateResourceManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(resources => resources.Length == 1 && resources[0] == "urn:resource_registered_in_database"), + It.IsAny())) + .Returns(new[] { resource }.ToAsyncEnumerable()); + + mock.Setup(manager => manager.GetNameAsync(resource, It.IsAny())) + .ReturnsAsync("urn:resource_registered_in_database"); + }); + + await using var server = await CreateServerAsync(options => + { + options.RegisterResources("urn:resource_registered_in_options"); + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Services.AddSingleton(manager); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return ValueTask.CompletedTask; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w", + Resources = ["urn:resource_registered_in_database", "urn:resource_registered_in_options"] + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.AccessToken); + } + [Fact] public async Task ValidateTokenRequest_RequestIsRejectedWhenClientAssertionIsSpecifiedWithoutType() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index d82410c1..46fa1dea 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -5201,11 +5201,13 @@ public abstract partial class OpenIddictServerIntegrationTests { options.SetDefaultApplicationEntity() .SetDefaultAuthorizationEntity() + .SetDefaultResourceEntity() .SetDefaultScopeEntity() .SetDefaultTokenEntity(); options.Services.AddSingleton(CreateApplicationManager()) .AddSingleton(CreateAuthorizationManager()) + .AddSingleton(CreateResourceManager()) .AddSingleton(CreateScopeManager()) .AddSingleton(CreateTokenManager()); }) @@ -5313,6 +5315,20 @@ public abstract partial class OpenIddictServerIntegrationTests return manager.Object; } + protected OpenIddictResourceManager CreateResourceManager( + Action>>? configuration = null) + { + var manager = new Mock>( + Mock.Of>(), + OutputHelper.ToLogger>(), + Mock.Of>(), + Mock.Of>()); + + configuration?.Invoke(manager); + + return manager.Object; + } + protected OpenIddictScopeManager CreateScopeManager( Action>>? configuration = null) { @@ -5341,8 +5357,9 @@ public abstract partial class OpenIddictServerIntegrationTests return manager.Object; } - public class OpenIddictApplication { } - public class OpenIddictAuthorization { } - public class OpenIddictScope { } - public class OpenIddictToken { } + public class OpenIddictApplication; + public class OpenIddictAuthorization; + public class OpenIddictResource; + public class OpenIddictScope; + public class OpenIddictToken; } diff --git a/test/OpenIddict.Validation.IntegrationTests/OpenIddictValidationIntegrationTests.cs b/test/OpenIddict.Validation.IntegrationTests/OpenIddictValidationIntegrationTests.cs index ffc2001b..7637cd60 100644 --- a/test/OpenIddict.Validation.IntegrationTests/OpenIddictValidationIntegrationTests.cs +++ b/test/OpenIddict.Validation.IntegrationTests/OpenIddictValidationIntegrationTests.cs @@ -449,6 +449,6 @@ public abstract partial class OpenIddictValidationIntegrationTests return manager.Object; } - public class OpenIddictAuthorization { } - public class OpenIddictToken { } + public class OpenIddictAuthorization; + public class OpenIddictToken; }