diff --git a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs index 8bf44cc33..f1f632e82 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs +++ b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs @@ -5,6 +5,7 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Utilities.Authentication; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Mediator; using Grand.Web.Common.Controllers; using Grand.Web.Common.Security.ExternalRegistration; @@ -23,6 +24,8 @@ public class OidcLoginController : BasePluginController public OidcLoginController( IExternalRegistrationContext externalRegistrationContext, IOidcCustomerResolver oidcCustomerResolver, + OidcProfileSynchronizer profileSynchronizer, + OidcLoginConfig loginConfig, IGrandAuthenticationService authenticationService, IOidcSessionService oidcSessionService, IMediator mediator, @@ -34,6 +37,8 @@ public OidcLoginController( { _externalRegistrationContext = externalRegistrationContext; _oidcCustomerResolver = oidcCustomerResolver; + _profileSynchronizer = profileSynchronizer; + _loginConfig = loginConfig; _authenticationService = authenticationService; _oidcSessionService = oidcSessionService; _mediator = mediator; @@ -52,6 +57,8 @@ public OidcLoginController( private readonly IExternalRegistrationContext _externalRegistrationContext; private readonly IOidcCustomerResolver _oidcCustomerResolver; + private readonly OidcProfileSynchronizer _profileSynchronizer; + private readonly OidcLoginConfig _loginConfig; private readonly IGrandAuthenticationService _authenticationService; private readonly IOidcSessionService _oidcSessionService; private readonly IMediator _mediator; @@ -118,6 +125,16 @@ public async Task Callback(string returnUrl) { case OidcLoginOutcome.SignIn: { + //the profile never blocks the sign-in; the pending identity is still needed here + try + { + await _profileSynchronizer.Sync(result.Customer, pending, _contextAccessor.StoreContext.CurrentStore.Id); + } + catch (Exception e) + { + _logger.LogWarning(e, "OpenID Connect profile sync failed for customer {CustomerId}", result.Customer.Id); + } + //read before the pending identity is cleared: the same cookie carries them var (refreshToken, sid) = await OidcLoginSessionData.Read(HttpContext); await _externalRegistrationContext.Clear(); @@ -143,6 +160,9 @@ public async Task Callback(string returnUrl) public async Task Link(string returnUrl) { + if (await RefuseLinkInProviderOnly() is { } refused) + return refused; + var pending = await _externalRegistrationContext.GetPending(); if (pending == null) return RedirectToRoute("OidcLoginFailed"); @@ -173,6 +193,9 @@ public async Task Link(string returnUrl) public async Task LinkComplete(string returnUrl) { + if (await RefuseLinkInProviderOnly() is { } refused) + return refused; + returnUrl = LocalOrNull(returnUrl); var customer = _contextAccessor.WorkContext.CurrentCustomer; if (!await _groupService.IsRegistered(customer)) @@ -224,6 +247,15 @@ public IActionResult Failed(int? reason, bool mismatch = false, bool taken = fal #region Utilities + //ProviderOnly has no password path: a stale link URL must not open one + private async Task RefuseLinkInProviderOnly() + { + if (!_loginConfig.ProviderOnly) + return null; + await _externalRegistrationContext.Clear(); + return RedirectToRoute("OidcLoginFailed"); + } + private bool IsAvailable() => _externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName); diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcProfileSynchronizer.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcProfileSynchronizer.cs new file mode 100644 index 000000000..623aa9d29 --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcProfileSynchronizer.cs @@ -0,0 +1,83 @@ +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.Extensions.Logging; + +namespace Authentication.OpenIdConnect.Infrastructure; + +/// +/// ProviderOnly: the identity provider owns the name and the e-mail, so a provider sign-in copies them onto the +/// account. Mixed mode never touches the account. +/// +public class OidcProfileSynchronizer( + ICustomerService customerService, + CustomerSettings customerSettings, + CustomerConfig customerConfig, + OidcLoginConfig loginConfig, + ILogger logger) +{ + public async Task Sync(Customer customer, ExternalRegistrationIdentity identity, string storeId) + { + if (!loginConfig.ProviderOnly || customer == null || identity == null) + return; + + await SyncUserField(customer, SystemCustomerFieldNames.FirstName, identity.FirstName); + await SyncUserField(customer, SystemCustomerFieldNames.LastName, identity.LastName); + await SyncEmail(customer, identity, storeId); + } + + private async Task SyncUserField(Customer customer, string key, string value) + { + if (string.IsNullOrEmpty(value)) + return; + var stored = customer.GetUserFieldFromEntity(key); + if (!string.Equals(stored, value, StringComparison.Ordinal)) + await customerService.UpdateUserField(customer, key, value); + } + + private async Task SyncEmail(Customer customer, ExternalRegistrationIdentity identity, string storeId) + { + if (!identity.EmailVerified || string.IsNullOrWhiteSpace(identity.Email)) + return; + + var newEmail = identity.Email.Trim().ToLowerInvariant(); + if (string.Equals(newEmail, customer.Email, StringComparison.OrdinalIgnoreCase)) + return; + + //a break-glass account is listed by its e-mail in OidcLogin:BreakGlassAccounts: renaming it would lose the + //emergency access + if (loginConfig.IsBreakGlass(customer.Email)) + { + logger.LogWarning("OpenID Connect profile sync: break-glass customer {CustomerId} keeps {OldEmail} " + + "(the identity provider has {NewEmail})", customer.Id, customer.Email, newEmail); + return; + } + + //nor may an account take an address listed there: the password it still has would open the Admin login page + //and keep it signed in past the provider + if (loginConfig.IsBreakGlass(newEmail)) + { + logger.LogWarning("OpenID Connect profile sync: customer {CustomerId} keeps {OldEmail}: {NewEmail} is a " + + "break-glass address", customer.Id, customer.Email, newEmail); + return; + } + + var owner = await customerService.GetCustomerByEmail(newEmail, customerConfig.RegisterCustomersPerStore ? storeId : ""); + if (owner != null && owner.Id != customer.Id) + { + logger.LogWarning("OpenID Connect profile sync: customer {CustomerId} keeps {OldEmail}: {NewEmail} belongs to customer {OtherId}", + customer.Id, customer.Email, newEmail, owner.Id); + return; + } + + await customerService.UpdateCustomerField(customer, x => x.Email, newEmail); + customer.Email = newEmail; + if (!customerSettings.UsernamesEnabled) + { + await customerService.UpdateCustomerField(customer, x => x.Username, newEmail); + customer.Username = newEmail; + } + } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcSignOutProvider.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcSignOutProvider.cs new file mode 100644 index 000000000..c890d9abc --- /dev/null +++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcSignOutProvider.cs @@ -0,0 +1,46 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Authentication.OpenIdConnect.Infrastructure; + +/// +/// Ends the session at the identity provider (OpenID Connect RP-Initiated Logout 1.0) through the end-session endpoint of its metadata. +/// No id_token_hint is sent: the ID token is not stored. +/// +public class OidcSignOutProvider( + IOptionsMonitor optionsMonitor, + OidcLoginConfig config, + IExternalAuthenticationService externalAuthenticationService, + ILogger logger) : IExternalSignOutProvider +{ + public async Task GetSignOutUrl(string postLogoutRedirectUri) + { + if (!externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)) + return null; + + var options = optionsMonitor.Get(OidcLoginDefaults.AuthenticationScheme); + if (options.ConfigurationManager == null) + return null; + + try + { + var configuration = await options.ConfigurationManager.GetConfigurationAsync(CancellationToken.None); + if (string.IsNullOrEmpty(configuration.EndSessionEndpoint)) + return null; + + var separator = configuration.EndSessionEndpoint.Contains('?') ? '&' : '?'; + return $"{configuration.EndSessionEndpoint}{separator}client_id={Uri.EscapeDataString(config.ClientId)}" + + $"&post_logout_redirect_uri={Uri.EscapeDataString(postLogoutRedirectUri)}"; + } + catch (Exception ex) + { + //sign-out still finishes locally + logger.LogWarning("The identity provider metadata could not be read to end the session there ({Exception})", + ex.GetType().Name); + return null; + } + } +} diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs index f0772704c..54b364e91 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs +++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/StartupApplication.cs @@ -16,7 +16,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddHttpClient(OidcLoginDefaults.HttpClientName, client => client.Timeout = TimeSpan.FromSeconds(10)) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); + services.AddScoped(); } public int Priority => 10; diff --git a/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs b/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs index 688a559ae..088b5c8c2 100644 --- a/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs +++ b/src/Plugins/Authentication.OpenIdConnect/OidcLoginPlugin.cs @@ -35,6 +35,8 @@ private static readonly (string Key, string Value)[] Resources = [ "You are already signed in with another account. Sign out first, then sign in with your identity provider."), ("Failed.SessionUnavailable", "Signing in with the identity provider is not set up completely. Use another sign-in method, or ask the administrator."), + ("Failed.EmailNotVerified", + "Your identity provider has not verified your e-mail address. Verify it there, then sign in again."), ("DisplayOrder", "Display order"), ("ClientId", "Client ID"), ("ClientSecret", "Client secret"), diff --git a/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml b/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml index bbfc7f06f..f1d4558de 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml +++ b/src/Plugins/Authentication.OpenIdConnect/Views/OidcLogin/Failed.cshtml @@ -14,6 +14,7 @@ OidcLoginRefusal.AccountInactive => "Plugins.ExternalAuth.OpenIdConnect.Failed.AccountInactive", OidcLoginRefusal.RegistrationDisabled => "Plugins.ExternalAuth.OpenIdConnect.Failed.RegistrationDisabled", OidcLoginRefusal.LinkedToOtherIdentity => "Plugins.ExternalAuth.OpenIdConnect.Failed.LinkedToOtherIdentity", + OidcLoginRefusal.EmailNotVerified => "Plugins.ExternalAuth.OpenIdConnect.Failed.EmailNotVerified", _ => "Plugins.ExternalAuth.OpenIdConnect.Failed.Generic" }; } diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs index bc246860f..5620f56c5 100644 --- a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs @@ -2,6 +2,8 @@ using Authentication.OpenIdConnect.Controllers; using Authentication.OpenIdConnect.Infrastructure; using Authentication.OpenIdConnect.Models; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Infrastructure.Configuration; using Grand.Business.Core.Events.Customers; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; @@ -44,6 +46,8 @@ public class OidcLoginControllerTests private OidcLoginController _controller; private Mock _httpAuth; private Mock> _logger; + private Mock _customers; + private OidcLoginConfig _loginConfig; private DateTimeOffset? _ticketIssued; private readonly List _routes = []; @@ -99,11 +103,15 @@ public void Init() .ReturnsAsync(() => AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("ext")), new AuthenticationProperties(_externalItems), GrandCookieAuthenticationDefaults.ExternalAuthenticationScheme))); _logger = new Mock>(); + _customers = new Mock(); + _loginConfig = new OidcLoginConfig(); + var sync = new OidcProfileSynchronizer(_customers.Object, new CustomerSettings(), new CustomerConfig(), _loginConfig, + new Mock>().Object); var http = new DefaultHttpContext { Session = new TestSession(), RequestServices = new ServiceCollection().AddSingleton(_httpAuth.Object).BuildServiceProvider() }; - _controller = new OidcLoginController(_external.Object, _resolver.Object, _auth.Object, _sessions.Object, _mediator.Object, + _controller = new OidcLoginController(_external.Object, _resolver.Object, sync, _loginConfig, _auth.Object, _sessions.Object, _mediator.Object, _externalAuth.Object, _schemes.Object, _groups.Object, ctx.Object, _logger.Object) { ControllerContext = new ControllerContext { HttpContext = http } }; @@ -579,4 +587,70 @@ public async Task LinkComplete_OtherProviderPending_ClearsAndFails() _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); _external.Verify(x => x.Clear(), Times.Once); } + + [TestMethod] + public async Task Callback_ProviderOnly_SignIn_SyncsProfile() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + var customer = new Customer { Id = "c9", Email = "john@example.com" }; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.SignIn(customer)); + await _controller.Callback("/"); + _customers.Verify(x => x.UpdateUserField(customer, SystemCustomerFieldNames.FirstName, "John", ""), Times.Once); + _customers.Verify(x => x.UpdateUserField(customer, SystemCustomerFieldNames.LastName, "Doe", ""), Times.Once); + _auth.Verify(x => x.SignIn(customer, false, "session-1"), Times.Once); + } + + [TestMethod] + public async Task Callback_SyncThrows_StillSignsIn() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + var customer = new Customer { Id = "c9", Email = "john@example.com" }; + _customers.Setup(x => x.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException()); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.SignIn(customer)); + await _controller.Callback("/"); + _auth.Verify(x => x.SignIn(customer, false, "session-1"), Times.Once); + } + + [TestMethod] + public async Task Callback_Mixed_SignIn_NoSync() + { + var customer = new Customer { Id = "c9", Email = "john@example.com" }; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + _resolver.Setup(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OidcLoginResult.SignIn(customer)); + await _controller.Callback("/"); + _customers.Verify(x => x.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Link_ProviderOnly_Failed() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Link("/")).RouteName); + _external.Verify(x => x.Clear(), Times.Once); + } + + [TestMethod] + public async Task LinkComplete_ProviderOnly_Failed() + { + _loginConfig.SignInMode = OidcSignInMode.ProviderOnly; + _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); + _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); + Marker("sub-1"); + Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); + _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public void Failed_EmailNotVerified_ShowsText() + { + var model = (FailedModel)((ViewResult)_controller.Failed((int)OidcLoginRefusal.EmailNotVerified)).Model!; + Assert.AreEqual(OidcLoginRefusal.EmailNotVerified, model.Reason); + } } diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcProfileSynchronizerTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcProfileSynchronizerTests.cs new file mode 100644 index 000000000..e504437c2 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcProfileSynchronizerTests.cs @@ -0,0 +1,165 @@ +using Authentication.OpenIdConnect.Infrastructure; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure.Configuration; +using Grand.Web.Common.Security.ExternalRegistration; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Modules.Tests.Plugins.OpenIdConnect; + +[TestClass] +public class OidcProfileSynchronizerTests +{ + private Mock _customers; + private Mock> _logger; + private CustomerSettings _settings; + private CustomerConfig _config; + private OidcLoginConfig _login; + private Customer _customer; + + [TestInitialize] + public void Init() + { + _customers = new Mock(); + _logger = new Mock>(); + _settings = new CustomerSettings { UsernamesEnabled = false }; + _config = new CustomerConfig(); + _login = new OidcLoginConfig { SignInMode = OidcSignInMode.ProviderOnly }; + _customer = new Customer { Id = "c1", Email = "old@shop.com", Username = "old@shop.com" }; + _customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.FirstName, Value = "John", StoreId = "" }); + _customer.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.LastName, Value = "Doe", StoreId = "" }); + } + + private Task Sync(string email = "old@shop.com", bool verified = true, string first = "John", string last = "Doe") => + new OidcProfileSynchronizer(_customers.Object, _settings, _config, _login, _logger.Object) + .Sync(_customer, new ExternalRegistrationIdentity("OpenIdConnect", "sub-1", email, verified, first, last), "s1"); + + private void VerifyEmail(string value, Times times) => + _customers.Verify(x => x.UpdateCustomerField(_customer, It.IsAny>>(), value), times); + + [TestMethod] + public async Task Mixed_NothingWritten() + { + _login.SignInMode = OidcSignInMode.Mixed; + await Sync("new@shop.com", first: "Jane", last: "Roe"); + _customers.Verify(x => x.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + VerifyEmail("new@shop.com", Times.Never()); + } + + [TestMethod] + public async Task Names_Written_WhenChanged() + { + await Sync(first: "Jane", last: "Roe"); + _customers.Verify(x => x.UpdateUserField(_customer, SystemCustomerFieldNames.FirstName, "Jane", ""), Times.Once); + _customers.Verify(x => x.UpdateUserField(_customer, SystemCustomerFieldNames.LastName, "Roe", ""), Times.Once); + } + + [TestMethod] + public async Task Names_Empty_Kept() + { + await Sync(first: "", last: null); + _customers.Verify(x => x.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Names_Same_NotWritten() + { + await Sync(); + _customers.Verify(x => x.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Email_BreakGlassAccount_Kept_NamesStillSynced() + { + _login.BreakGlassAccounts = ["Old@Shop.com"]; + + await Sync("new@shop.com", first: "Jane"); + + VerifyEmail("new@shop.com", Times.Never()); + Assert.AreEqual("old@shop.com", _customer.Email); + _customers.Verify(x => x.UpdateUserField(_customer, SystemCustomerFieldNames.FirstName, "Jane", ""), Times.Once); + _logger.Verify(x => x.Log(LogLevel.Warning, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Email_NewAddressIsBreakGlass_Kept() + { + _login.BreakGlassAccounts = ["Emergency@Shop.com"]; + + await Sync("emergency@shop.com"); + + VerifyEmail("emergency@shop.com", Times.Never()); + Assert.AreEqual("old@shop.com", _customer.Email); + _logger.Verify(x => x.Log(LogLevel.Warning, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Email_Verified_Changed_WrittenWithUsername() + { + await Sync("New@Shop.com"); + VerifyEmail("new@shop.com", Times.Exactly(2)); + Assert.AreEqual("new@shop.com", _customer.Email); + Assert.AreEqual("new@shop.com", _customer.Username); + } + + [TestMethod] + public async Task Email_UsernamesEnabled_UsernameKept() + { + _settings.UsernamesEnabled = true; + _customer.Username = "johnny"; + await Sync("new@shop.com"); + VerifyEmail("new@shop.com", Times.Once()); + Assert.AreEqual("johnny", _customer.Username); + } + + [TestMethod] + public async Task Email_NotVerified_Kept() + { + await Sync("new@shop.com", false); + VerifyEmail("new@shop.com", Times.Never()); + Assert.AreEqual("old@shop.com", _customer.Email); + } + + [TestMethod] + public async Task Email_TakenByOther_Kept() + { + _customers.Setup(x => x.GetCustomerByEmail("new@shop.com", It.IsAny())).ReturnsAsync(new Customer { Id = "c2" }); + await Sync("new@shop.com"); + VerifyEmail("new@shop.com", Times.Never()); + Assert.AreEqual("old@shop.com", _customer.Email); + _logger.Verify(x => x.Log(LogLevel.Warning, It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains("keeps old@shop.com") && v.ToString()!.Contains("customer c2")), + It.IsAny(), It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Email_CaseOnly_NotWritten() + { + _customer.Email = "user@shop.com"; + await Sync("User@Shop.com"); + VerifyEmail("user@shop.com", Times.Never()); + _customers.Verify(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Email_PerStoreIdentity_LooksUpInStore() + { + _config.RegisterCustomersPerStore = true; + await Sync("new@shop.com"); + _customers.Verify(x => x.GetCustomerByEmail("new@shop.com", "s1"), Times.Once); + } + + [TestMethod] + public async Task Email_GlobalIdentity_LooksUpGlobally() + { + _config.RegisterCustomersPerStore = false; + await Sync("new@shop.com"); + _customers.Verify(x => x.GetCustomerByEmail("new@shop.com", ""), Times.Once); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcSignOutProviderTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcSignOutProviderTests.cs new file mode 100644 index 000000000..6bcafb76c --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcSignOutProviderTests.cs @@ -0,0 +1,95 @@ +using Authentication.OpenIdConnect; +using Authentication.OpenIdConnect.Infrastructure; +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Modules.Tests.Plugins.OpenIdConnect; + +[TestClass] +public class OidcSignOutProviderTests +{ + private const string Back = "https://shop.test/signed-out"; + + private Mock _authenticationService; + private Mock> _manager; + private Mock> _logger; + + [TestInitialize] + public void Init() + { + _authenticationService = new Mock(); + _authenticationService.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)) + .Returns(true); + _manager = new Mock>(); + _logger = new Mock>(); + Metadata("https://idp.test/logout"); + } + + private void Metadata(string endSession) => + _manager.Setup(x => x.GetConfigurationAsync(It.IsAny())) + .ReturnsAsync(new OpenIdConnectConfiguration { EndSessionEndpoint = endSession }); + + private OidcSignOutProvider Provider(bool withManager = true) + { + var monitor = new Mock>(); + monitor.Setup(x => x.Get(OidcLoginDefaults.AuthenticationScheme)).Returns(new OpenIdConnectOptions { + ConfigurationManager = withManager ? _manager.Object : null + }); + return new OidcSignOutProvider(monitor.Object, new OidcLoginConfig { ClientId = "shop app" }, + _authenticationService.Object, _logger.Object); + } + + [TestMethod] + public async Task EndpointPresent_BuildsUrl() + { + var url = await Provider().GetSignOutUrl(Back); + Assert.AreEqual("https://idp.test/logout?client_id=shop%20app&post_logout_redirect_uri=" + + Uri.EscapeDataString(Back), url); + } + + [TestMethod] + public async Task EndpointWithQuery_AppendsWithAmpersand() + { + Metadata("https://idp.test/logout?realm=a"); + var url = await Provider().GetSignOutUrl(Back); + Assert.IsTrue(url.StartsWith("https://idp.test/logout?realm=a&client_id=shop%20app&"), url); + } + + [TestMethod] + public async Task NoEndpoint_Null() + { + Metadata(null); + Assert.IsNull(await Provider().GetSignOutUrl(Back)); + } + + [TestMethod] + public async Task NoConfigurationManager_Null() => + Assert.IsNull(await Provider(false).GetSignOutUrl(Back)); + + [TestMethod] + public async Task NotAvailable_Null() + { + _authenticationService.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)) + .Returns(false); + Assert.IsNull(await Provider().GetSignOutUrl(Back)); + _manager.Verify(x => x.GetConfigurationAsync(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task MetadataThrows_NullAndWarning() + { + _manager.Setup(x => x.GetConfigurationAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("down")); + Assert.IsNull(await Provider().GetSignOutUrl(Back)); + _logger.Verify(x => x.Log(LogLevel.Warning, It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny>()), Times.Once); + } +}