From 51bd3c5d725ffbebffacacfb00ce2c11ef3b4ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Mon, 10 Aug 2020 19:36:34 +0200 Subject: [PATCH] Introduce OpenIddict.Server.Quartz --- Directory.Packages.props | 2 + NuGet.config | 1 + OpenIddict.sln | 14 + samples/Mvc.Server/Mvc.Server.csproj | 2 + samples/Mvc.Server/Startup.cs | 16 + .../Resources/OpenIddictResources.resx | 20 + .../Resources/xlf/OpenIddictResources.fr.xlf | 10 + .../OpenIddict.Server.Quartz.csproj | 22 ++ .../OpenIddictServerQuartzBuilder.cs | 92 +++++ .../OpenIddictServerQuartzExtensions.cs | 101 +++++ .../OpenIddictServerQuartzJob.cs | 208 +++++++++++ .../OpenIddictServerQuartzOptions.cs | 30 ++ .../OpenIddict.Server.Quartz.Tests.csproj | 18 + .../OpenIddictServerQuartzBuilderTests.cs | 127 +++++++ .../OpenIddictServerQuartzExtensionsTests.cs | 105 ++++++ .../OpenIddictServerQuartzJobTests.cs | 349 ++++++++++++++++++ .../OpenIddictServerBuilderTests.cs | 11 +- 17 files changed, 1123 insertions(+), 5 deletions(-) create mode 100644 src/OpenIddict.Server.Quartz/OpenIddict.Server.Quartz.csproj create mode 100644 src/OpenIddict.Server.Quartz/OpenIddictServerQuartzBuilder.cs create mode 100644 src/OpenIddict.Server.Quartz/OpenIddictServerQuartzExtensions.cs create mode 100644 src/OpenIddict.Server.Quartz/OpenIddictServerQuartzJob.cs create mode 100644 src/OpenIddict.Server.Quartz/OpenIddictServerQuartzOptions.cs create mode 100644 test/OpenIddict.Server.Quartz.Tests/OpenIddict.Server.Quartz.Tests.csproj create mode 100644 test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzBuilderTests.cs create mode 100644 test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzExtensionsTests.cs create mode 100644 test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzJobTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b50fe7f0..98c84489 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,8 @@ + + diff --git a/NuGet.config b/NuGet.config index e94c8f75..6f4cb7ad 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,6 +1,7 @@ + diff --git a/OpenIddict.sln b/OpenIddict.sln index b5a3c2a8..7e3f8cf0 100644 --- a/OpenIddict.sln +++ b/OpenIddict.sln @@ -107,6 +107,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\build.yml = .github\workflows\build.yml EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenIddict.Server.Quartz", "src\OpenIddict.Server.Quartz\OpenIddict.Server.Quartz.csproj", "{FD150A90-1997-45C7-9EBE-7C6E62E464E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenIddict.Server.Quartz.Tests", "test\OpenIddict.Server.Quartz.Tests\OpenIddict.Server.Quartz.Tests.csproj", "{01420929-33B8-4B60-A618-8C8B5F139630}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -245,6 +249,14 @@ Global {D94B10D3-3DD3-4829-B305-17C48833AB33}.Debug|Any CPU.Build.0 = Debug|Any CPU {D94B10D3-3DD3-4829-B305-17C48833AB33}.Release|Any CPU.ActiveCfg = Release|Any CPU {D94B10D3-3DD3-4829-B305-17C48833AB33}.Release|Any CPU.Build.0 = Release|Any CPU + {FD150A90-1997-45C7-9EBE-7C6E62E464E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD150A90-1997-45C7-9EBE-7C6E62E464E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD150A90-1997-45C7-9EBE-7C6E62E464E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD150A90-1997-45C7-9EBE-7C6E62E464E8}.Release|Any CPU.Build.0 = Release|Any CPU + {01420929-33B8-4B60-A618-8C8B5F139630}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01420929-33B8-4B60-A618-8C8B5F139630}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01420929-33B8-4B60-A618-8C8B5F139630}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01420929-33B8-4B60-A618-8C8B5F139630}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -283,6 +295,8 @@ Global {FBFDB9E2-4A44-4B90-B896-E094BFC05C03} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} {E62124D4-3660-4590-B4D1-787168BBBEDD} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} {D94B10D3-3DD3-4829-B305-17C48833AB33} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} + {FD150A90-1997-45C7-9EBE-7C6E62E464E8} = {D544447C-D701-46BB-9A5B-C76C612A596B} + {01420929-33B8-4B60-A618-8C8B5F139630} = {5FC71D6A-A994-4F62-977F-88A7D25379D7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A710059F-0466-4D48-9B3A-0EF4F840B616} diff --git a/samples/Mvc.Server/Mvc.Server.csproj b/samples/Mvc.Server/Mvc.Server.csproj index dbfe1de7..5e76d4a6 100644 --- a/samples/Mvc.Server/Mvc.Server.csproj +++ b/samples/Mvc.Server/Mvc.Server.csproj @@ -10,11 +10,13 @@ + + diff --git a/samples/Mvc.Server/Startup.cs b/samples/Mvc.Server/Startup.cs index 7051d4f3..7822d5f2 100644 --- a/samples/Mvc.Server/Startup.cs +++ b/samples/Mvc.Server/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Mvc.Server.Models; using Mvc.Server.Services; +using Quartz; using static OpenIddict.Abstractions.OpenIddictConstants; namespace Mvc.Server @@ -46,6 +47,18 @@ namespace Mvc.Server options.ClaimsIdentity.RoleClaimType = Claims.Role; }); + // OpenIddict offers native integration with Quartz.NET to perform scheduled tasks + // (like pruning orphaned authorizations/tokens from the database) at regular intervals. + services.AddQuartz(options => + { + options.UseMicrosoftDependencyInjectionJobFactory(); + options.UseSimpleTypeLoader(); + options.UseInMemoryStore(); + }); + + // Register the Quartz.NET service and configure it to block shutdown until jobs are complete. + services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); + services.AddOpenIddict() // Register the OpenIddict core components. @@ -97,6 +110,9 @@ namespace Mvc.Server .EnableVerificationEndpointPassthrough() .DisableTransportSecurityRequirement(); // During development, you can disable the HTTPS requirement. + // Enable Quartz.NET integration. + options.UseQuartz(); + // Note: if you don't want to specify a client_id when sending // a token or revocation request, uncomment the following line: // diff --git a/src/OpenIddict.Abstractions/Resources/OpenIddictResources.resx b/src/OpenIddict.Abstractions/Resources/OpenIddictResources.resx index e707e673..92883256 100644 --- a/src/OpenIddict.Abstractions/Resources/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/Resources/OpenIddictResources.resx @@ -480,6 +480,11 @@ Consider using 'options.AddSigningCredentials(SigningCredentials)' instead.Endpoint addresses cannot start with '{0}'. {Locked} + + Dependency injection support must be enabled in Quartz.NET when using the OpenIddict server integration. +To enable DI support, call 'services.AddQuartz(options => options.UseMicrosoftDependencyInjectionJobFactory())'. + {Locked} + Reference tokens cannot be used when disabling token storage. {Locked} @@ -1363,6 +1368,15 @@ To register the Entity Framework Core stores, reference the 'OpenIddict.EntityFr Consider creating non-generic classes derived from the default entities for the application, authorization, scope and token entities. {Locked} + + The core services must be registered when enabling the OpenIddict server Quartz.NET integration. +To register the OpenIddict core services, reference the 'OpenIddict.Core' package and call 'services.AddOpenIddict().AddCore()' from 'ConfigureServices'. + {Locked} + + + The maximum refire count cannot be negative. + {Locked} + The security token is missing. @@ -2469,4 +2483,10 @@ This may indicate that the hashed entry is corrupted or malformed. An exception occurred while trying to revoke the token '{Identifier}'. {Locked} + + Removes orphaned tokens and authorizations from the database. + + + Starts the scheduled task at regular intervals. + \ No newline at end of file diff --git a/src/OpenIddict.Abstractions/Resources/xlf/OpenIddictResources.fr.xlf b/src/OpenIddict.Abstractions/Resources/xlf/OpenIddictResources.fr.xlf index fe832d0b..3bbe33b0 100644 --- a/src/OpenIddict.Abstractions/Resources/xlf/OpenIddictResources.fr.xlf +++ b/src/OpenIddict.Abstractions/Resources/xlf/OpenIddictResources.fr.xlf @@ -587,6 +587,16 @@ Les URLs de rappel doit être des URLs valides et absolues. + + Removes orphaned tokens and authorizations from the database. + Supprime les jetons et autorisations orphelines de la base de données. + + + + Starts the scheduled task at regular intervals. + Démarre la tâche planifiée à intervalles réguliers. + + \ No newline at end of file diff --git a/src/OpenIddict.Server.Quartz/OpenIddict.Server.Quartz.csproj b/src/OpenIddict.Server.Quartz/OpenIddict.Server.Quartz.csproj new file mode 100644 index 00000000..b81c07fd --- /dev/null +++ b/src/OpenIddict.Server.Quartz/OpenIddict.Server.Quartz.csproj @@ -0,0 +1,22 @@ + + + + net461;netcoreapp2.1;netcoreapp3.1;netstandard2.0;netstandard2.1 + enable + false + + + + Quartz.NET integration package for the OpenIddict server services. + $(PackageTags);quartz;server + + + + + + + + + + + diff --git a/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzBuilder.cs b/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzBuilder.cs new file mode 100644 index 00000000..7bca725d --- /dev/null +++ b/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzBuilder.cs @@ -0,0 +1,92 @@ +/* + * 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; +using System.ComponentModel; +using OpenIddict.Server.Quartz; +using SR = OpenIddict.Abstractions.OpenIddictResources; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes the necessary methods required to configure + /// the OpenIddict server Quartz.NET integration. + /// + public class OpenIddictServerQuartzBuilder + { + /// + /// Initializes a new instance of . + /// + /// The services collection. + public OpenIddictServerQuartzBuilder(IServiceCollection services) + => Services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Amends the default OpenIddict server Quartz.NET configuration. + /// + /// The delegate used to configure the OpenIddict options. + /// This extension can be safely called multiple times. + /// The . + public OpenIddictServerQuartzBuilder Configure(Action configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Services.Configure(configuration); + + return this; + } + + /// + /// Disables authorizations pruning. + /// + /// The . + public OpenIddictServerQuartzBuilder DisableAuthorizationsPruning() + => Configure(options => options.DisableAuthorizationsPruning = true); + + /// + /// Disables tokens pruning. + /// + /// The . + public OpenIddictServerQuartzBuilder DisableTokensPruning() + => Configure(options => options.DisableTokensPruning = true); + + /// + /// Sets the number of times a failed Quartz.NET job can be retried. + /// + /// The number of times a failed Quartz.NET job can be retried. + /// The . + public OpenIddictServerQuartzBuilder SetMaximumRefireCount(int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count), SR.GetResourceString(SR.ID1278)); + } + + return Configure(options => options.MaximumRefireCount = count); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals(object? obj) => base.Equals(obj); + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => base.GetHashCode(); + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override string? ToString() => base.ToString(); + } +} diff --git a/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzExtensions.cs b/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzExtensions.cs new file mode 100644 index 00000000..15a3c562 --- /dev/null +++ b/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzExtensions.cs @@ -0,0 +1,101 @@ +/* + * 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; +using System.Linq; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenIddict.Server.Quartz; +using Quartz; +using SR = OpenIddict.Abstractions.OpenIddictResources; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Exposes extensions allowing to register the OpenIddict server Quartz.NET integration. + /// + public static class OpenIddictServerQuartzExtensions + { + /// + /// Registers the OpenIddict server Quartz.NET integration in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictServerQuartzBuilder UseQuartz(this OpenIddictServerBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + // Warning: the AddQuartz() method is deliberately not used as it's not idempotent. + // Calling it at this point may override user-defined services (e.g Quartz DI support). + + builder.Services.TryAddTransient(); + + // To ensure this method can be safely called multiple times, the job details + // of the OpenIddict server job are only added if no existing IJobDetail instance + // pointing to OpenIddictServerQuartzJob was already registered in the DI container. + if (!builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IJobDetail) && + descriptor.ImplementationInstance is IJobDetail job && + job.Key.Equals(OpenIddictServerQuartzJob.Identity))) + { + builder.Services.AddSingleton( + JobBuilder.Create() + .StoreDurably() + .WithIdentity(OpenIddictServerQuartzJob.Identity) + .WithDescription(SR.GetResourceString(SR.ID9000)) + .Build()); + } + + // To ensure this method can be safely called multiple times, the trigger details + // of the OpenIddict server job are only added if no existing ITrigger instance + // pointing to OpenIddictServerQuartzJob was already registered in the DI container. + if (!builder.Services.Any(descriptor => descriptor.ServiceType == typeof(ITrigger) && + descriptor.ImplementationInstance is ITrigger trigger && + trigger.JobKey.Equals(OpenIddictServerQuartzJob.Identity))) + { + // Note: this trigger uses a quite long interval (1 hour), which means it may be + // potentially never reached if the application is shut down or recycled. As such, + // this trigger is set up to fire immediately to ensure it's executed at least once. + builder.Services.AddSingleton( + TriggerBuilder.Create() + .ForJob(OpenIddictServerQuartzJob.Identity) + .WithSimpleSchedule(options => options.WithIntervalInHours(1).RepeatForever()) + .WithDescription(SR.GetResourceString(SR.ID9001)) + .StartNow() + .Build()); + } + + return new OpenIddictServerQuartzBuilder(builder.Services); + } + + /// + /// Registers the OpenIddict server Quartz.NET integration in the DI container. + /// + /// The services builder used by OpenIddict to register new services. + /// The configuration delegate used to configure the server services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictServerBuilder UseQuartz( + this OpenIddictServerBuilder builder, Action configuration) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + configuration(builder.UseQuartz()); + + return builder; + } + } +} diff --git a/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzJob.cs b/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzJob.cs new file mode 100644 index 00000000..189e95f9 --- /dev/null +++ b/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzJob.cs @@ -0,0 +1,208 @@ +/* + * 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; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using Quartz; +using SR = OpenIddict.Abstractions.OpenIddictResources; + +namespace OpenIddict.Server.Quartz +{ + /// + /// Represents a Quartz.NET job performing scheduled tasks for the OpenIddict server feature. + /// + [DisallowConcurrentExecution] + public class OpenIddictServerQuartzJob : IJob + { + private readonly IOptionsMonitor _options; + private readonly IServiceProvider _provider; + + /// + /// Creates a new instance of the class. + /// + public OpenIddictServerQuartzJob() => throw new InvalidOperationException(SR.GetResourceString(SR.ID1081)); + + /// + /// Creates a new instance of the class. + /// + /// The OpenIddict server Quartz.NET options. + /// The service provider. + public OpenIddictServerQuartzJob(IOptionsMonitor options, IServiceProvider provider) + { + _options = options; + _provider = provider; + } + + /// + /// Gets the default identity assigned to this job. + /// + public static JobKey Identity { get; } = new JobKey( + name: typeof(OpenIddictServerQuartzJob).Name, + group: typeof(OpenIddictServerQuartzJob).Assembly.GetName().Name!); + + /// + public async Task Execute(IJobExecutionContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + List? exceptions = null; + + // Note: this job is registered as a transient service. As such, it cannot directly depend on scoped services + // like the core managers. To work around this limitation, a scope is manually created for each invocation. + var scope = _provider.CreateScope(); + + try + { + // Note: this background task is responsible of automatically removing orphaned tokens/authorizations + // (i.e tokens that are no longer valid and ad-hoc authorizations that have no valid tokens associated). + // Since ad-hoc authorizations and their associated tokens are removed as part of the same operation + // when they no longer have any token attached, it's more efficient to remove the authorizations first. + + // Note: the authorization/token managers MUST be resolved from the scoped provider + // as they depend on scoped stores that should be disposed as soon as possible. + + if (!_options.CurrentValue.DisableAuthorizationsPruning) + { + var manager = scope.ServiceProvider.GetService(); + if (manager == null) + { + // Inform Quartz.NET that the triggers associated with this job should be removed, + // as the future invocations will always fail until the application is correctly + // re-configured to register the OpenIddict core services in the DI container. + throw new JobExecutionException(new InvalidOperationException(SR.GetResourceString(SR.ID1277))) + { + RefireImmediately = false, + UnscheduleAllTriggers = true, + UnscheduleFiringTrigger = true + }; + } + + try + { + await manager.PruneAsync(context.CancellationToken); + } + + // OutOfMemoryExceptions are treated as fatal errors and are always re-thrown as-is. + catch (OutOfMemoryException) + { + throw; + } + + // OperationCanceledExceptions are typically thrown the host is about to shut down. + // To allow the host to shut down as fast as possible, this exception type is special-cased + // to prevent further processing in this job and inform Quartz.NET it shouldn't be refired. + catch (OperationCanceledException exception) when (exception.CancellationToken == context.CancellationToken) + { + throw new JobExecutionException(exception) + { + RefireImmediately = false + }; + } + + // AggregateExceptions are generally thrown by the manager itself when one or multiple exception(s) + // occurred while trying to prune the entities. In this case, add the inner exceptions to the collection. + catch (AggregateException exception) + { + exceptions ??= new List(capacity: 1); + exceptions.AddRange(exception.InnerExceptions); + } + + // Other exceptions are assumed to be transient and are added to the exceptions collection + // to be re-thrown later (typically, at the very end of this job, as an AggregateException). + catch (Exception exception) + { + exceptions ??= new List(capacity: 1); + exceptions.Add(exception); + } + } + + if (!_options.CurrentValue.DisableTokensPruning) + { + var manager = scope.ServiceProvider.GetService(); + if (manager == null) + { + // Inform Quartz.NET that the triggers associated with this job should be removed, + // as the future invocations will always fail until the application is correctly + // re-configured to register the OpenIddict core services in the DI container. + throw new JobExecutionException(new InvalidOperationException(SR.GetResourceString(SR.ID1277))) + { + RefireImmediately = false, + UnscheduleAllTriggers = true, + UnscheduleFiringTrigger = true + }; + } + + try + { + await manager.PruneAsync(context.CancellationToken); + } + + // OutOfMemoryExceptions are treated as fatal errors and are always re-thrown as-is. + catch (OutOfMemoryException) + { + throw; + } + + // OperationCanceledExceptions are typically thrown the host is about to shut down. + // To allow the host to shut down as fast as possible, this exception type is special-cased + // to prevent further processing in this job and inform Quartz.NET it shouldn't be refired. + catch (OperationCanceledException exception) when (exception.CancellationToken == context.CancellationToken) + { + throw new JobExecutionException(exception) + { + RefireImmediately = false + }; + } + + // AggregateExceptions are generally thrown by the manager itself when one or multiple exception(s) + // occurred while trying to prune the entities. In this case, add the inner exceptions to the collection. + catch (AggregateException exception) + { + exceptions ??= new List(capacity: 1); + exceptions.AddRange(exception.InnerExceptions); + } + + // Other exceptions are assumed to be transient and are added to the exceptions collection + // to be re-thrown later (typically, at the very end of this job, as an AggregateException). + catch (Exception exception) + { + exceptions ??= new List(capacity: 1); + exceptions.Add(exception); + } + } + + if (exceptions != null) + { + throw new JobExecutionException(new AggregateException(exceptions)) + { + // Only refire the job if the maximum refire count set in the options wasn't reached. + RefireImmediately = context.RefireCount < _options.CurrentValue.MaximumRefireCount + }; + } + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + } +} diff --git a/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzOptions.cs b/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzOptions.cs new file mode 100644 index 00000000..948f66c3 --- /dev/null +++ b/src/OpenIddict.Server.Quartz/OpenIddictServerQuartzOptions.cs @@ -0,0 +1,30 @@ +/* + * 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. + */ + +namespace OpenIddict.Server.Quartz +{ + /// + /// Provides various settings needed to configure the OpenIddict Quartz.NET server integration. + /// + public class OpenIddictServerQuartzOptions + { + /// + /// Gets or sets a boolean indicating whether authorizations pruning should be disabled. + /// + public bool DisableAuthorizationsPruning { get; set; } + + /// + /// Gets or sets a boolean indicating whether tokens pruning should be disabled. + /// + public bool DisableTokensPruning { get; set; } + + /// + /// Gets or sets the number of times a failed Quartz.NET job can be retried. + /// By default, failed jobs are automatically retried twice after the initial failure. + /// + public int MaximumRefireCount { get; set; } = 2; + } +} diff --git a/test/OpenIddict.Server.Quartz.Tests/OpenIddict.Server.Quartz.Tests.csproj b/test/OpenIddict.Server.Quartz.Tests/OpenIddict.Server.Quartz.Tests.csproj new file mode 100644 index 00000000..596ae845 --- /dev/null +++ b/test/OpenIddict.Server.Quartz.Tests/OpenIddict.Server.Quartz.Tests.csproj @@ -0,0 +1,18 @@ + + + + net461;netcoreapp2.1;netcoreapp3.1 + false + enable + + + + + + + + + + + + diff --git a/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzBuilderTests.cs b/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzBuilderTests.cs new file mode 100644 index 00000000..3c7cbda4 --- /dev/null +++ b/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzBuilderTests.cs @@ -0,0 +1,127 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; +using SR = OpenIddict.Abstractions.OpenIddictResources; + +namespace OpenIddict.Server.Quartz.Tests +{ + public class OpenIddictServerQuartzBuilderTests + { + [Fact] + public void Constructor_ThrowsAnExceptionForNullServices() + { + // Arrange + var services = (IServiceCollection) null!; + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictServerQuartzBuilder(services)); + + Assert.Equal("services", exception.ParamName); + } + + [Fact] + public void Configure_DelegateIsCorrectlyRegistered() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + var configuration = new Action(options => { }); + + // Act + builder.Configure(configuration); + + // Assert + Assert.Contains(services, service => service.ServiceType == typeof(IConfigureOptions) && + service.ImplementationInstance is ConfigureNamedOptions options && + options.Action == configuration && string.IsNullOrEmpty(options.Name)); + } + + [Fact] + public void Configure_ThrowsAnExceptionWhenConfigurationIsNull() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.Configure(configuration: null!)); + Assert.Equal("configuration", exception.ParamName); + } + + [Fact] + public void DisableAuthorizationsPruning_AuthorizationsPruningIsDisabled() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.DisableAuthorizationsPruning(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.DisableAuthorizationsPruning); + } + + [Fact] + public void DisableTokensPruning_TokensPruningIsDisabled() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.DisableTokensPruning(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.DisableTokensPruning); + } + + [Fact] + public void SetMaximumRefireCount_ThrowsAnExceptionForNegativeCount() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetMaximumRefireCount(-1)); + + Assert.Equal("count", exception.ParamName); + Assert.StartsWith(SR.GetResourceString(SR.ID1278), exception.Message); + } + + [Fact] + public void SetMaximumRefireCount_MaximumRefireCountIsSet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetMaximumRefireCount(42); + + var options = GetOptions(services); + + // Assert + Assert.Equal(42, options.MaximumRefireCount); + } + + private static IServiceCollection CreateServices() + => new ServiceCollection().AddOptions(); + + private static OpenIddictServerQuartzBuilder CreateBuilder(IServiceCollection services) + => new OpenIddictServerQuartzBuilder(services); + + private static OpenIddictServerQuartzOptions GetOptions(IServiceCollection services) + { + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + return options.Value; + } + } +} diff --git a/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzExtensionsTests.cs b/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzExtensionsTests.cs new file mode 100644 index 00000000..12c679aa --- /dev/null +++ b/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzExtensionsTests.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using Xunit; + +namespace OpenIddict.Server.Quartz.Tests +{ + public class OpenIddictServerQuartzExtensionsTests + { + [Fact] + public void UseQuartz_ThrowsAnExceptionForNullBuilder() + { + // Arrange + var builder = (OpenIddictServerBuilder) null!; + + // Act and assert + var exception = Assert.Throws(() => builder.UseQuartz()); + + Assert.Equal("builder", exception.ParamName); + } + + [Fact] + public void UseQuartz_ThrowsAnExceptionForNullConfiguration() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictServerBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.UseQuartz(configuration: null!)); + + Assert.Equal("configuration", exception.ParamName); + } + + [Fact] + public void UseQuartz_RegistersJobService() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictServerBuilder(services); + + // Act + builder.UseQuartz(); + + // Assert + Assert.Contains(services, service => service.ServiceType == typeof(OpenIddictServerQuartzJob) && + service.ImplementationType == typeof(OpenIddictServerQuartzJob) && + service.Lifetime == ServiceLifetime.Transient); + } + + [Fact] + public void UseQuartz_RegistersJobDetails() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictServerBuilder(services); + + // Act + builder.UseQuartz(); + + // Assert + Assert.Contains(services, service => service.ServiceType == typeof(IJobDetail) && + service.ImplementationInstance is IJobDetail job && + job.Key.Equals(OpenIddictServerQuartzJob.Identity)); + } + + [Fact] + public void UseQuartz_RegistersTriggerDetails() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictServerBuilder(services); + + // Act + builder.UseQuartz(); + + // Assert + Assert.Contains(services, service => service.ServiceType == typeof(ITrigger) && + service.ImplementationInstance is ITrigger trigger && + trigger.JobKey.Equals(OpenIddictServerQuartzJob.Identity)); + } + + [Fact] + public void UseQuartz_CanBeSafelyInvokedMultipleTimes() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictServerBuilder(services); + + // Act + builder.UseQuartz(); + builder.UseQuartz(); + builder.UseQuartz(); + + // Assert + Assert.Single(services, service => service.ServiceType == typeof(IJobDetail) && + service.ImplementationInstance is IJobDetail job && + job.Key.Equals(OpenIddictServerQuartzJob.Identity)); + + Assert.Single(services, service => service.ServiceType == typeof(ITrigger) && + service.ImplementationInstance is ITrigger trigger && + trigger.JobKey.Equals(OpenIddictServerQuartzJob.Identity)); + } + } +} diff --git a/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzJobTests.cs b/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzJobTests.cs new file mode 100644 index 00000000..a2d2b9e3 --- /dev/null +++ b/test/OpenIddict.Server.Quartz.Tests/OpenIddictServerQuartzJobTests.cs @@ -0,0 +1,349 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using OpenIddict.Abstractions; +using Quartz; +using Xunit; +using SR = OpenIddict.Abstractions.OpenIddictResources; + +namespace OpenIddict.Server.Quartz.Tests +{ + public class OpenIddictServerQuartzJobTests + { + [Fact] + public void Constructor_ThrowsAnException() + { + // Arrange, act and assert + var exception = Assert.Throws(() => new OpenIddictServerQuartzJob()); + + Assert.Equal(SR.GetResourceString(SR.ID1081), exception.Message); + } + + [Fact] + public async Task Execute_UsesServiceScope() + { + // Arrange + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == Mock.Of() && + provider.GetService(typeof(IOpenIddictTokenManager)) == Mock.Of()); + + var scope = Mock.Of(scope => scope.ServiceProvider == provider); + var factory = Mock.Of(factory => factory.CreateScope() == scope); + var monitor = Mock.Of>( + monitor => monitor.CurrentValue == new OpenIddictServerQuartzOptions()); + + var job = new OpenIddictServerQuartzJob(monitor, + Mock.Of(provider => provider.GetService(typeof(IServiceScopeFactory)) == factory)); + + // Act + await job.Execute(Mock.Of()); + + Mock.Get(factory).Verify(factory => factory.CreateScope(), Times.Once()); + Mock.Get(scope).Verify(scope => scope.Dispose(), Times.Once()); + } + + [Fact] + public async Task Execute_IgnoresPruningWhenAuthorizationsPruningIsDisabled() + { + // Arrange + + var manager = new Mock(); + + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == manager.Object && + provider.GetService(typeof(IOpenIddictTokenManager)) == Mock.Of()); + + var job = CreateJob(provider, new OpenIddictServerQuartzOptions + { + DisableAuthorizationsPruning = true + }); + + // Act + await job.Execute(Mock.Of()); + + // Assert + manager.Verify(manager => manager.PruneAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task Execute_IgnoresPruningWhenTokensPruningIsDisabled() + { + // Arrange + + var manager = new Mock(); + + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == Mock.Of() && + provider.GetService(typeof(IOpenIddictTokenManager)) == manager.Object); + + var job = CreateJob(provider, new OpenIddictServerQuartzOptions + { + DisableTokensPruning = true + }); + + // Act + await job.Execute(Mock.Of()); + + // Assert + manager.Verify(manager => manager.PruneAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task Execute_UnschedulesTriggersWhenAuthorizationManagerIsMissing() + { + // Arrange + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == null); + + var job = CreateJob(provider); + + // Act and assert + var exception = await Assert.ThrowsAsync(() => job.Execute(Mock.Of())); + + Assert.False(exception.RefireImmediately); + Assert.True(exception.UnscheduleAllTriggers); + Assert.True(exception.UnscheduleFiringTrigger); + + Assert.IsType(exception.InnerException); + Assert.Equal(SR.GetResourceString(SR.ID1277), exception.InnerException!.Message); + } + + [Fact] + public async Task Execute_UnschedulesTriggersWhenTokenManagerIsMissing() + { + // Arrange + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == Mock.Of() && + provider.GetService(typeof(IOpenIddictTokenManager)) == null); + + var job = CreateJob(provider); + + // Act and assert + var exception = await Assert.ThrowsAsync(() => job.Execute(Mock.Of())); + + Assert.False(exception.RefireImmediately); + Assert.True(exception.UnscheduleAllTriggers); + Assert.True(exception.UnscheduleFiringTrigger); + + Assert.IsType(exception.InnerException); + Assert.Equal(SR.GetResourceString(SR.ID1277), exception.InnerException!.Message); + } + + [Fact] + public async Task Execute_RethrowsOutOfMemoryExceptionsThrownDuringAuthorizationsPruning() + { + // Arrange + var manager = new Mock(); + manager.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(new OutOfMemoryException()); + + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == manager.Object && + provider.GetService(typeof(IOpenIddictTokenManager)) == Mock.Of()); + + var job = CreateJob(provider); + + // Act and assert + await Assert.ThrowsAsync(() => job.Execute(Mock.Of())); + } + + [Fact] + public async Task Execute_RethrowsOutOfMemoryExceptionsThrownDuringTokensPruning() + { + // Arrange + var manager = new Mock(); + manager.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(new OutOfMemoryException()); + + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == Mock.Of() && + provider.GetService(typeof(IOpenIddictTokenManager)) == manager.Object); + + var job = CreateJob(provider); + + // Act and assert + await Assert.ThrowsAsync(() => job.Execute(Mock.Of())); + } + + [Fact] + public async Task Execute_DisablesRefiringWhenJobIsCanceledDuringAuthorizationsPruning() + { + // Arrange + var token = new CancellationToken(canceled: true); + + var manager = new Mock(); + manager.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(new OperationCanceledException(token)); + + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == manager.Object && + provider.GetService(typeof(IOpenIddictTokenManager)) == Mock.Of()); + + var context = Mock.Of(context => context.CancellationToken == token); + + var job = CreateJob(provider); + + // Act and assert + var exception = await Assert.ThrowsAsync(() => job.Execute(context)); + + Assert.False(exception.RefireImmediately); + + manager.Verify(manager => manager.PruneAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task Execute_DisablesRefiringWhenJobIsCanceledDuringTokensPruning() + { + // Arrange + var token = new CancellationToken(canceled: true); + + var manager = new Mock(); + manager.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(new OperationCanceledException(token)); + + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == Mock.Of() && + provider.GetService(typeof(IOpenIddictTokenManager)) == manager.Object); + + var context = Mock.Of(context => context.CancellationToken == token); + + var job = CreateJob(provider); + + // Act and assert + var exception = await Assert.ThrowsAsync(() => job.Execute(context)); + + Assert.False(exception.RefireImmediately); + + manager.Verify(manager => manager.PruneAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task Execute_AllowsRefiringWhenExceptionsAreThrown() + { + // Arrange + var provider = new Mock(); + provider.Setup(provider => provider.GetService(typeof(IOpenIddictAuthorizationManager))) + .Returns(CreateAuthorizationManager(new ApplicationException())); + + provider.Setup(provider => provider.GetService(typeof(IOpenIddictTokenManager))) + .Returns(CreateTokenManager(new ApplicationException())); + + var context = Mock.Of(context => context.RefireCount == 0); + + var job = CreateJob(provider.Object); + + // Act and assert + var exception = await Assert.ThrowsAsync(() => job.Execute(context)); + + Assert.True(exception.RefireImmediately); + Assert.IsType(exception.InnerException); + Assert.Equal(2, ((AggregateException) exception.InnerException!).InnerExceptions.Count); + Assert.IsType(((AggregateException) exception.InnerException!).InnerExceptions[0]); + Assert.IsType(((AggregateException) exception.InnerException!).InnerExceptions[1]); + + static IOpenIddictAuthorizationManager CreateAuthorizationManager(Exception exception) + { + var mock = new Mock(); + mock.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(exception); + + return mock.Object; + } + + static IOpenIddictTokenManager CreateTokenManager(Exception exception) + { + var mock = new Mock(); + mock.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(exception); + + return mock.Object; + } + } + + [Fact] + public async Task Execute_AllowsRefiringWhenAggregateExceptionsAreThrown() + { + // Arrange + var provider = new Mock(); + provider.Setup(provider => provider.GetService(typeof(IOpenIddictAuthorizationManager))) + .Returns(CreateAuthorizationManager(new AggregateException( + new InvalidOperationException(), new ApplicationException()))); + + provider.Setup(provider => provider.GetService(typeof(IOpenIddictTokenManager))) + .Returns(CreateTokenManager(new AggregateException( + new InvalidOperationException(), new ApplicationException()))); + + var context = Mock.Of(context => context.RefireCount == 0); + + var job = CreateJob(provider.Object); + + // Act and assert + var exception = await Assert.ThrowsAsync(() => job.Execute(context)); + + Assert.True(exception.RefireImmediately); + Assert.IsType(exception.InnerException); + Assert.Equal(4, ((AggregateException) exception.InnerException!).InnerExceptions.Count); + Assert.IsType(((AggregateException) exception.InnerException!).InnerExceptions[0]); + Assert.IsType(((AggregateException) exception.InnerException!).InnerExceptions[1]); + Assert.IsType(((AggregateException) exception.InnerException!).InnerExceptions[2]); + Assert.IsType(((AggregateException) exception.InnerException!).InnerExceptions[3]); + + static IOpenIddictAuthorizationManager CreateAuthorizationManager(Exception exception) + { + var mock = new Mock(); + mock.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(exception); + + return mock.Object; + } + + static IOpenIddictTokenManager CreateTokenManager(Exception exception) + { + var mock = new Mock(); + mock.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(exception); + + return mock.Object; + } + } + + [Fact] + public async Task Execute_DisallowsRefiringWhenMaximumRefireCountIsReached() + { + // Arrange + var manager = new Mock(); + manager.Setup(manager => manager.PruneAsync(It.IsAny())) + .Throws(new ApplicationException()); + + var provider = Mock.Of(provider => + provider.GetService(typeof(IOpenIddictAuthorizationManager)) == manager.Object && + provider.GetService(typeof(IOpenIddictTokenManager)) == Mock.Of()); + + var context = Mock.Of(context => context.RefireCount == 5); + + var job = CreateJob(provider, new OpenIddictServerQuartzOptions + { + MaximumRefireCount = 5 + }); + + // Act and assert + var exception = await Assert.ThrowsAsync(() => job.Execute(context)); + + Assert.False(exception.RefireImmediately); + } + + private static OpenIddictServerQuartzJob CreateJob(IServiceProvider provider, OpenIddictServerQuartzOptions? options = null) + { + var scope = Mock.Of(scope => scope.ServiceProvider == provider); + var factory = Mock.Of(factory => factory.CreateScope() == scope); + var monitor = Mock.Of>( + monitor => monitor.CurrentValue == (options ?? new OpenIddictServerQuartzOptions())); + + return new OpenIddictServerQuartzJob(monitor, + Mock.Of(provider => provider.GetService(typeof(IServiceScopeFactory)) == factory)); + } + } +} diff --git a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs index 7c53ab23..1e943d69 100644 --- a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs +++ b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs @@ -212,19 +212,20 @@ namespace OpenIddict.Server.Tests } [Fact] - public void Configure_OptionsAreCorrectlyAmended() + public void Configure_DelegateIsCorrectlyRegistered() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); + var configuration = new Action(options => { }); // Act - builder.Configure(configuration => configuration.AccessTokenLifetime = TimeSpan.FromDays(1)); - - var options = GetOptions(services); + builder.Configure(configuration); // Assert - Assert.Equal(TimeSpan.FromDays(1), options.AccessTokenLifetime); + Assert.Contains(services, service => service.ServiceType == typeof(IConfigureOptions) && + service.ImplementationInstance is ConfigureNamedOptions options && + options.Action == configuration && string.IsNullOrEmpty(options.Name)); } [Fact]