From c615cfaa1b5605dbd8124fdaa90f93c2cadba415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Wed, 14 Aug 2024 15:47:55 +0200 Subject: [PATCH] [Prototype] Update the OpenID module to use the OpenIddict client --- Directory.Packages.props | 2 + .../OpenIdClientConfiguration.cs | 111 +++++++---- .../Controllers/CallbackController.cs | 185 ++++++++++++++++++ .../OrchardCore.OpenId.csproj | 3 +- .../OrchardCore.OpenId/Startup.cs | 135 +++++++++---- .../Views/{Access => Shared}/Error.cshtml | 0 6 files changed, 359 insertions(+), 77 deletions(-) create mode 100644 src/OrchardCore.Modules/OrchardCore.OpenId/Controllers/CallbackController.cs rename src/OrchardCore.Modules/OrchardCore.OpenId/Views/{Access => Shared}/Error.cshtml (100%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 829b71fd7f1c..0221d7c1b50e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -54,6 +54,8 @@ + + diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs index 64fb49971c72..c2ff9d1a73a3 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Configuration/OpenIdClientConfiguration.cs @@ -1,10 +1,14 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics; +using System.Security.Cryptography; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Client; +using OpenIddict.Client.AspNetCore; using OrchardCore.Environment.Shell; using OrchardCore.OpenId.Services; using OrchardCore.OpenId.Settings; @@ -13,21 +17,25 @@ namespace OrchardCore.OpenId.Configuration; public sealed class OpenIdClientConfiguration : IConfigureOptions, - IConfigureNamedOptions + IConfigureOptions, + IConfigureNamedOptions { private readonly IOpenIdClientService _clientService; private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IServiceProvider _serviceProvider; private readonly ShellSettings _shellSettings; private readonly ILogger _logger; public OpenIdClientConfiguration( IOpenIdClientService clientService, IDataProtectionProvider dataProtectionProvider, + IServiceProvider serviceProvider, ShellSettings shellSettings, ILogger logger) { _clientService = clientService; _dataProtectionProvider = dataProtectionProvider; + _serviceProvider = serviceProvider; _shellSettings = shellSettings; _logger = logger; } @@ -40,42 +48,53 @@ public void Configure(AuthenticationOptions options) return; } - // Register the OpenID Connect client handler in the authentication handlers collection. - options.AddScheme(OpenIdConnectDefaults.AuthenticationScheme, settings.DisplayName); - } + options.AddScheme( + OpenIddictClientAspNetCoreDefaults.AuthenticationScheme, displayName: null); - public void Configure(string name, OpenIdConnectOptions options) - { - // Ignore OpenID Connect client handler instances that don't correspond to the instance managed by the OpenID module. - if (!string.Equals(name, OpenIdConnectDefaults.AuthenticationScheme, StringComparison.Ordinal)) + foreach (var scheme in _serviceProvider.GetRequiredService>() + .CurrentValue.ForwardedAuthenticationSchemes) { - return; + options.AddScheme(scheme.Name, scheme.DisplayName); } + } + public void Configure(OpenIddictClientOptions options) + { var settings = GetClientSettingsAsync().GetAwaiter().GetResult(); if (settings == null) { return; } - options.Authority = settings.Authority.AbsoluteUri; - options.ClientId = settings.ClientId; - options.SignedOutRedirectUri = settings.SignedOutRedirectUri ?? options.SignedOutRedirectUri; - options.SignedOutCallbackPath = settings.SignedOutCallbackPath ?? options.SignedOutCallbackPath; - options.RequireHttpsMetadata = string.Equals(settings.Authority.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); - options.GetClaimsFromUserInfoEndpoint = true; - options.ResponseMode = settings.ResponseMode; - options.ResponseType = settings.ResponseType; - options.SaveTokens = settings.StoreExternalTokens; + // Note: the provider name, redirect URI and post-logout redirect URI use the same default + // values as the Microsoft ASP.NET Core OpenID Connect handler, for compatibility reasons. + var registration = new OpenIddictClientRegistration + { + Issuer = settings.Authority, + ClientId = settings.ClientId, + RedirectUri = new Uri(settings.CallbackPath ?? "signin-oidc", UriKind.RelativeOrAbsolute), + PostLogoutRedirectUri = new Uri(settings.SignedOutCallbackPath ?? "signout-callback-oidc", UriKind.RelativeOrAbsolute), + ProviderName = "OpenIdConnect", + ProviderDisplayName = settings.DisplayName, + Properties = + { + [nameof(OpenIdClientSettings)] = settings + } + }; + + if (!string.IsNullOrEmpty(settings.ResponseMode)) + { + registration.ResponseModes.Add(settings.ResponseMode); + } - options.CallbackPath = settings.CallbackPath ?? options.CallbackPath; + if (!string.IsNullOrEmpty(settings.ResponseType)) + { + registration.ResponseTypes.Add(settings.ResponseType); + } if (settings.Scopes != null) { - foreach (var scope in settings.Scopes) - { - options.Scope.Add(scope); - } + registration.Scopes.UnionWith(settings.Scopes); } if (!string.IsNullOrEmpty(settings.ClientSecret)) @@ -84,7 +103,7 @@ public void Configure(string name, OpenIdConnectOptions options) try { - options.ClientSecret = protector.Unprotect(settings.ClientSecret); + registration.ClientSecret = protector.Unprotect(settings.ClientSecret); } catch { @@ -92,22 +111,38 @@ public void Configure(string name, OpenIdConnectOptions options) } } - if (settings.Parameters != null && settings.Parameters.Length > 0) - { - var parameters = settings.Parameters; - options.Events.OnRedirectToIdentityProvider = (context) => - { - foreach (var parameter in parameters) - { - context.ProtocolMessage.SetParameter(parameter.Name, parameter.Value); - } + options.Registrations.Add(registration); - return Task.CompletedTask; - }; - } + // Note: claims are mapped by CallbackController, so the built-in mapping feature is unnecessary. + options.DisableWebServicesFederationClaimMapping = true; + + // TODO: use proper encryption/signing credentials, similar to what's used for the server feature. + options.EncryptionCredentials.Add(new EncryptingCredentials(new SymmetricSecurityKey( + RandomNumberGenerator.GetBytes(256 / 8)), SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512)); + + options.SigningCredentials.Add(new SigningCredentials(new SymmetricSecurityKey( + RandomNumberGenerator.GetBytes(256 / 8)), SecurityAlgorithms.HmacSha256)); + } + + public void Configure(string name, OpenIddictClientAspNetCoreOptions options) + { + // Note: the OpenID module handles the redirection requests in its dedicated + // ASP.NET Core MVC controller, which requires enabling the pass-through mode. + options.EnableRedirectionEndpointPassthrough = true; + options.EnablePostLogoutRedirectionEndpointPassthrough = true; + + // Note: error pass-through is enabled to allow the actions of the MVC callback controller + // to handle the errors returned by the interactive endpoints without relying on the generic + // status code pages middleware to rewrite the response later in the request processing. + options.EnableErrorPassthrough = true; + + // Note: in Orchard, transport security is usually configured via the dedicated HTTPS module. + // To make configuration easier and avoid having to configure it in two different features, + // the transport security requirement enforced by OpenIddict by default is always turned off. + options.DisableTransportSecurityRequirement = true; } - public void Configure(OpenIdConnectOptions options) => Debug.Fail("This infrastructure method shouldn't be called."); + public void Configure(OpenIddictClientAspNetCoreOptions options) => Debug.Fail("This infrastructure method shouldn't be called."); private async Task GetClientSettingsAsync() { diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Controllers/CallbackController.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Controllers/CallbackController.cs new file mode 100644 index 000000000000..eb005ef84d5c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Controllers/CallbackController.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Client; +using OpenIddict.Client.AspNetCore; +using OrchardCore.Modules; +using OrchardCore.OpenId.Settings; +using OrchardCore.OpenId.ViewModels; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants; + +namespace OrchardCore.OpenId.Controllers; + +[AllowAnonymous, Feature(OpenIdConstants.Features.Client)] +public class CallbackController : Controller +{ + private readonly OpenIddictClientService _service; + + public CallbackController(OpenIddictClientService service) + => _service = service; + + [IgnoreAntiforgeryToken] + public async Task LogInCallback() + { + var response = HttpContext.GetOpenIddictClientResponse(); + if (response != null) + { + return View("Error", new ErrorViewModel + { + Error = response.Error, + ErrorDescription = response.ErrorDescription + }); + } + + var request = HttpContext.GetOpenIddictClientRequest(); + if (request == null) + { + return NotFound(); + } + + // Retrieve the authorization data validated by OpenIddict as part of the callback handling. + var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + + // Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint, + // result.Principal.Identity will represent an unauthenticated identity and won't contain any claim. + // + // Such identities cannot be used as-is to build an authentication cookie in ASP.NET Core, as the + // antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity. + if (result.Principal.Identity is not ClaimsIdentity { IsAuthenticated: true }) + { + throw new InvalidOperationException("The external authorization data cannot be used for authentication."); + } + + // Build an identity based on the external claims and that will be used to create the authentication cookie. + // + // Note: for compatibility reasons, the claims are mapped to their WS-Federation equivalent + // using the default mapping provided by JwtSecurityTokenHandler.DefaultInboundClaimTypeMap. + var claims = result.Principal.Claims.Select(claim => + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.TryGetValue(claim.Type, out var type) ? + new Claim(type, claim.Value, claim.ValueType, claim.Issuer, claim.OriginalIssuer, claim.Subject) : claim); + + var identity = new ClaimsIdentity(claims, + authenticationType: CookieAuthenticationDefaults.AuthenticationScheme, + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role); + + // Build the authentication properties based on the properties that were added when the challenge was triggered. + var properties = new AuthenticationProperties(result.Properties.Items) + { + RedirectUri = result.Properties.RedirectUri ?? "/" + }; + + // If enabled, preserve the received tokens in the authentication cookie. + // + // Note: for compatibility reasons, the tokens are stored using the same + // names as the Microsoft ASP.NET Core OIDC client: when both a frontchannel + // and a backchannel token exist, the backchannel one is always preferred. + var registration = await _service.GetClientRegistrationByIdAsync(result.Principal.FindFirstValue(Claims.Private.RegistrationId)); + if (registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var settings) && + settings is OpenIdClientSettings { StoreExternalTokens: true }) + { + List tokens = []; + + if (!string.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelAccessToken)) || + !string.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelAccessToken))) + { + tokens.Add(new AuthenticationToken + { + Name = Parameters.AccessToken, + Value = result.Properties.GetTokenValue(Tokens.BackchannelAccessToken) ?? + result.Properties.GetTokenValue(Tokens.FrontchannelAccessToken) + }); + } + + if (!string.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate)) || + !string.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelAccessTokenExpirationDate))) + { + tokens.Add(new AuthenticationToken + { + Name = "expires_at", + Value = result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate) ?? + result.Properties.GetTokenValue(Tokens.FrontchannelAccessTokenExpirationDate) + }); + } + + if (!string.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.BackchannelIdentityToken)) || + !string.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.FrontchannelIdentityToken))) + { + tokens.Add(new AuthenticationToken + { + Name = Parameters.IdToken, + Value = result.Properties.GetTokenValue(Tokens.BackchannelIdentityToken) ?? + result.Properties.GetTokenValue(Tokens.FrontchannelIdentityToken) + }); + } + + if (!string.IsNullOrEmpty(result.Properties.GetTokenValue(Tokens.RefreshToken))) + { + tokens.Add(new AuthenticationToken + { + Name = Parameters.RefreshToken, + Value = result.Properties.GetTokenValue(Tokens.RefreshToken) + }); + } + + properties.StoreTokens(tokens); + } + + else + { + properties.StoreTokens(Enumerable.Empty()); + } + + // Ask the cookie authentication handler to return a new cookie and redirect + // the user agent to the return URL stored in the authentication properties. + return SignIn(new ClaimsPrincipal(identity), properties); + } + + [IgnoreAntiforgeryToken] + public async Task LogOutCallback() + { + var response = HttpContext.GetOpenIddictClientResponse(); + if (response != null) + { + return View("Error", new ErrorViewModel + { + Error = response.Error, + ErrorDescription = response.ErrorDescription + }); + } + + var request = HttpContext.GetOpenIddictClientRequest(); + if (request == null) + { + return NotFound(); + } + + // Retrieve the data stored by OpenIddict in the state token created when the logout was triggered + // and redirect the user agent to the URI attached to the authentication properties, if applicable. + var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + if (!string.IsNullOrEmpty(result.Properties.RedirectUri)) + { + return Redirect(result.Properties.RedirectUri); + } + + // Otherwise, return the user agent to the static URI attached to the client registration if it it managed by Orchard. + var registration = await _service.GetClientRegistrationByIdAsync(result.Principal.FindFirstValue(Claims.Private.RegistrationId)); + if (registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var settings) && + settings is OpenIdClientSettings { SignedOutRedirectUri: { Length: > 0 } uri }) + { + return Redirect(uri); + } + + // As a last resort, return the user agent to the home page. + return Redirect("/"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj b/src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj index e6974b9d4dd3..91da25b6ebe6 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/OrchardCore.OpenId.csproj @@ -30,8 +30,9 @@ - + + diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs b/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs index 0a3e41050d51..e524d6271c4b 100644 --- a/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.OpenId/Startup.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using OpenIddict.Client; +using OpenIddict.Client.AspNetCore; using OpenIddict.Server; using OpenIddict.Server.AspNetCore; using OpenIddict.Server.DataProtection; @@ -61,25 +62,95 @@ public sealed class ClientStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { + services.AddOpenIddict() + .AddClient(options => + { + options.UseAspNetCore(); + options.UseSystemNetHttp(); + + // TODO: determine what flows we want to enable and whether this + // should be configurable by the user (like the server feature). + options.AllowAuthorizationCodeFlow() + .AllowHybridFlow() + .AllowImplicitFlow(); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(static context => + { + // If the client registration is managed by Orchard, attach the custom parameters set by the user. + if (context.Registration.Properties.TryGetValue(nameof(OpenIdClientSettings), out var value) && + value is OpenIdClientSettings settings && settings.Parameters is { Length: > 0 } parameters) + { + foreach (var parameter in parameters) + { + context.Parameters[parameter.Name] = parameter.Value; + } + } + + return default; + }); + + builder.SetOrder(OpenIddictClientHandlers.AttachCustomChallengeParameters.Descriptor.Order - 1); + }); + }); + services.TryAddSingleton(); // Note: the following services are registered using TryAddEnumerable to prevent duplicate registrations. - services.TryAddEnumerable(new[] + services.TryAddEnumerable(ServiceDescriptor.Scoped, OpenIdClientSettingsDisplayDriver>()); + services.AddRecipeExecutionStep(); + + // Note: the OpenIddict ASP.NET host adds an authentication options initializer that takes care of + // registering the client ASP.NET Core handler. Yet, it MUST NOT be registered at this stage + // as it is lazily registered by OpenIdClientConfiguration only after checking the OpenID client + // settings are valid and can be safely used in this tenant without causing runtime exceptions. + // To prevent that, the initializer is manually removed from the services collection of the tenant. + services.RemoveAll, OpenIddictClientAspNetCoreConfiguration>(); + + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdClientConfiguration>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdClientConfiguration>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdClientConfiguration>()); + } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + var settings = GetClientSettingsAsync().GetAwaiter().GetResult(); + if (settings == null) { - ServiceDescriptor.Scoped, OpenIdClientSettingsDisplayDriver>(), - }); + return; + } - services.AddRecipeExecutionStep(); - // Register the options initializers required by the OpenID Connect client handler. - services.TryAddEnumerable(new[] + // Note: the redirection and post-logout redirection endpoints use the same default values + // as the Microsoft ASP.NET Core OpenID Connect handler, for compatibility reasons. + routes.MapAreaControllerRoute( + name: "Callback.LogInCallback", + areaName: typeof(Startup).Namespace, + pattern: settings.CallbackPath ?? "signin-oidc", + defaults: new { controller = "Callback", action = "LogInCallback" } + ); + + routes.MapAreaControllerRoute( + name: "Callback.LogOutCallback", + areaName: typeof(Startup).Namespace, + pattern: settings.SignedOutCallbackPath ?? "signout-callback-oidc", + defaults: new { controller = "Callback", action = "LogOutCallback" } + ); + + async Task GetClientSettingsAsync() { - // Orchard-specific initializers: - ServiceDescriptor.Singleton, OpenIdClientConfiguration>(), - ServiceDescriptor.Singleton, OpenIdClientConfiguration>(), + // Note: the OpenID client service is registered as a singleton service and thus can be + // safely used with the non-scoped/root service provider available at this stage. + var service = serviceProvider.GetRequiredService(); + + var configuration = await service.GetSettingsAsync(); + if ((await service.ValidateSettingsAsync(configuration)).Any(result => result != ValidationResult.Success)) + { + return null; + } - // Built-in initializers: - ServiceDescriptor.Singleton, OpenIdConnectPostConfigureOptions>() - }); + return configuration; + } } } @@ -98,14 +169,11 @@ public override void ConfigureServices(IServiceCollection services) services.TryAddSingleton(); services.AddDataMigration(); - // Note: the following services are registered using TryAddEnumerable to prevent duplicate registrations. - services.TryAddEnumerable(new[] - { - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped, OpenIdServerSettingsDisplayDriver>(), - ServiceDescriptor.Singleton() - }); + // Note: the following services are registered using TryAddEnumerable to prevent duplicate registrations. + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped, OpenIdServerSettingsDisplayDriver>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.AddRecipeExecutionStep() .AddRecipeExecutionStep() @@ -118,13 +186,10 @@ public override void ConfigureServices(IServiceCollection services) // To prevent that, the initializer is manually removed from the services collection of the tenant. services.RemoveAll, OpenIddictServerAspNetCoreConfiguration>(); - services.TryAddEnumerable(new[] - { - ServiceDescriptor.Singleton, OpenIdServerConfiguration>(), - ServiceDescriptor.Singleton, OpenIdServerConfiguration>(), - ServiceDescriptor.Singleton, OpenIdServerConfiguration>(), - ServiceDescriptor.Singleton, OpenIdServerConfiguration>() - }); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdServerConfiguration>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdServerConfiguration>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdServerConfiguration>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdServerConfiguration>()); } public override async ValueTask ConfigureAsync(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) @@ -217,10 +282,7 @@ public override void ConfigureServices(IServiceCollection services) services.TryAddSingleton(); // Note: the following services are registered using TryAddEnumerable to prevent duplicate registrations. - services.TryAddEnumerable(new[] - { - ServiceDescriptor.Scoped, OpenIdValidationSettingsDisplayDriver>(), - }); + services.TryAddEnumerable(ServiceDescriptor.Scoped, OpenIdValidationSettingsDisplayDriver>()); services.AddRecipeExecutionStep(); @@ -231,13 +293,10 @@ public override void ConfigureServices(IServiceCollection services) // To prevent that, the initializer is manually removed from the services collection of the tenant. services.RemoveAll, OpenIddictValidationAspNetCoreConfiguration>(); - services.TryAddEnumerable(new[] - { - ServiceDescriptor.Singleton, OpenIdValidationConfiguration>(), - ServiceDescriptor.Singleton, OpenIdValidationConfiguration>(), - ServiceDescriptor.Singleton, OpenIdValidationConfiguration>(), - ServiceDescriptor.Singleton, OpenIdValidationConfiguration>() - }); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdValidationConfiguration>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdValidationConfiguration>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdValidationConfiguration>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdValidationConfiguration>()); } } diff --git a/src/OrchardCore.Modules/OrchardCore.OpenId/Views/Access/Error.cshtml b/src/OrchardCore.Modules/OrchardCore.OpenId/Views/Shared/Error.cshtml similarity index 100% rename from src/OrchardCore.Modules/OrchardCore.OpenId/Views/Access/Error.cshtml rename to src/OrchardCore.Modules/OrchardCore.OpenId/Views/Shared/Error.cshtml