diff --git a/iac/Azure/SQLServer/AzureSQLServer-Seed-Eventing-Generic.sql b/iac/Azure/SQLServer/AzureSQLServer-Seed-Eventing-Generic.sql index 841d2e2e..f5117863 100644 --- a/iac/Azure/SQLServer/AzureSQLServer-Seed-Eventing-Generic.sql +++ b/iac/Azure/SQLServer/AzureSQLServer-Seed-Eventing-Generic.sql @@ -528,6 +528,7 @@ CREATE TABLE [dbo].[SSOUser] [FirstName] [nvarchar](max) NULL, [LastName] [nvarchar](max) NULL, [ProviderName] [nvarchar](max) NULL, + [ProviderUId] [nvarchar](max) NULL, [Timezone] [nvarchar](max) NULL, [Tokens] [nvarchar](max) NULL, [UserId] [nvarchar](100) NULL, diff --git a/src/Application.Resources.Shared/Identity.cs b/src/Application.Resources.Shared/Identity.cs index 8c2c0577..51f6f61d 100644 --- a/src/Application.Resources.Shared/Identity.cs +++ b/src/Application.Resources.Shared/Identity.cs @@ -74,4 +74,11 @@ public enum TokenType OtherToken = 0, // e.g. idToken AccessToken = 1, // access_token RefreshToken = 2 // refresh_token +} + +public class SSOUser : IIdentifiableResource +{ + public required string Id { get; set; } + + public required string ProviderUId { get; set; } } \ No newline at end of file diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs index 5a39b18f..e44cb193 100644 --- a/src/Application.Services.Shared/IEndUsersService.cs +++ b/src/Application.Services.Shared/IEndUsersService.cs @@ -6,9 +6,6 @@ namespace Application.Services.Shared; public interface IEndUsersService { - Task, Error>> FindPersonByEmailPrivateAsync(ICallerContext caller, string emailAddress, - CancellationToken cancellationToken); - Task> GetMembershipsPrivateAsync(ICallerContext caller, string id, CancellationToken cancellationToken); diff --git a/src/Common/CountryCodes.cs b/src/Common/CountryCodes.cs index dbc7b291..a75ba029 100644 --- a/src/Common/CountryCodes.cs +++ b/src/Common/CountryCodes.cs @@ -8,57 +8,63 @@ public static class CountryCodes public static readonly CountryCodeIso3166 Australia = CountryCodeIso3166.Create("Australia", "AU", "AUS", "036"); public static readonly CountryCodeIso3166 UnitedStates = CountryCodeIso3166.Create("United States of America", "US", "USA", "840"); - public static readonly CountryCodeIso3166 Default = UnitedStates; + public static readonly CountryCodeIso3166 Default = UnitedStates; //EXTEND: set your default country code public static readonly CountryCodeIso3166 NewZealand = CountryCodeIso3166.Create("New Zealand", "NZ", "NZL", "554"); #if TESTINGONLY - public static readonly CountryCodeIso3166 Test = CountryCodeIso3166.Create("Test", "XX", "XXX", "001"); + internal static readonly CountryCodeIso3166 Test = CountryCodeIso3166.Create("Test", "XX", "XXX", "001"); #endif /// - /// Whether the specified timezone by its exists + /// Returns the specified timezone by its if it exists, + /// which tries to match the 3 letter first, + /// then tries to match the 2 letter , + /// then tries to match the number last. /// - public static bool Exists(string? countryCodeAlpha3) + public static bool Exists(string? countryCode) { - if (countryCodeAlpha3.NotExists()) + if (countryCode.NotExists()) { return false; } - return Find(countryCodeAlpha3).Exists(); + return Find(countryCode).Exists(); } /// - /// Returns the specified timezone by its if it exists + /// Returns the specified timezone by its if it exists, + /// which tries to match the 3 letter first, + /// then tries to match the 2 letter , + /// then tries to match the number last. /// - public static CountryCodeIso3166? Find(string? countryCodeAlpha3) + public static CountryCodeIso3166? Find(string? countryCode) { - if (countryCodeAlpha3.NotExists()) + if (countryCode.NotExists()) { return null; } #if TESTINGONLY - if (countryCodeAlpha3 == Test.Alpha3 - || countryCodeAlpha3 == Test.Alpha2 - || countryCodeAlpha3 == Test.Numeric) + if (countryCode == Test.Alpha3 + || countryCode == Test.Alpha2 + || countryCode == Test.Numeric) { return Test; } #endif - var alpha3 = CountryCodesResolver.GetByAlpha3Code(countryCodeAlpha3); + var alpha3 = CountryCodesResolver.GetByAlpha3Code(countryCode); if (alpha3.Exists()) { return CountryCodeIso3166.Create(alpha3.Name, alpha3.Alpha2, alpha3.Alpha3, alpha3.NumericCode); } - var alpha2 = CountryCodesResolver.GetByAlpha2Code(countryCodeAlpha3); + var alpha2 = CountryCodesResolver.GetByAlpha2Code(countryCode); if (alpha2.Exists()) { return CountryCodeIso3166.Create(alpha2.Name, alpha2.Alpha2, alpha2.Alpha3, alpha2.NumericCode); } - var numeric = CountryCodesResolver.GetList().FirstOrDefault(cc => cc.NumericCode == countryCodeAlpha3); + var numeric = CountryCodesResolver.GetList().FirstOrDefault(cc => cc.NumericCode == countryCode); if (numeric.Exists()) { return CountryCodeIso3166.Create(numeric.Name, numeric.Alpha2, numeric.Alpha3, numeric.NumericCode); diff --git a/src/Common/Timezones.cs b/src/Common/Timezones.cs index 7a36dcff..72d88ca2 100644 --- a/src/Common/Timezones.cs +++ b/src/Common/Timezones.cs @@ -5,16 +5,23 @@ namespace Common; public static class Timezones { + //See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + public const string GmtIANA = "GMT"; public const string NewZealandIANA = "Pacific/Auckland"; public const string NewZealandWindows = "New Zealand Standard Time"; public const string SydneyIANA = "Australia/Sydney"; public const string UniversalCoordinatedIANA = "Etc/UTC"; public const string UniversalCoordinatedWindows = "UTC"; + public const string UsPacificIANA = "US/Pacific"; public static readonly TimezoneIANA Sydney = TimezoneIANA.Create(SydneyIANA, TimeSpan.FromHours(10), "AEST", TimeSpan.FromHours(11), "AEDT"); public static readonly TimezoneIANA NewZealand = TimezoneIANA.Create(NewZealandIANA, TimeSpan.FromHours(12), "NZST", TimeSpan.FromHours(13), "NZDT"); - public static readonly TimezoneIANA Default = NewZealand; + public static readonly TimezoneIANA UsPacific = TimezoneIANA.Create(UsPacificIANA, TimeSpan.FromHours(-8), + "PST", TimeSpan.FromHours(-7), "PDT"); + public static readonly TimezoneIANA Default = UsPacific; //EXTEND: set your default country code + public static readonly TimezoneIANA Gmt = TimezoneIANA.Create(GmtIANA, TimeSpan.FromHours(0), + "GMT", TimeSpan.FromHours(0), "GMT"); #if TESTINGONLY public static readonly TimezoneIANA Test = TimezoneIANA.Create("testTimezone", TimeSpan.FromHours(1), "TSST", diff --git a/src/Domain.Events.Shared/Identities/SSOUsers/DetailsAdded.cs b/src/Domain.Events.Shared/Identities/SSOUsers/DetailsAdded.cs index bccf9299..e89e40c4 100644 --- a/src/Domain.Events.Shared/Identities/SSOUsers/DetailsAdded.cs +++ b/src/Domain.Events.Shared/Identities/SSOUsers/DetailsAdded.cs @@ -24,4 +24,6 @@ public DetailsAdded() public string? LastName { get; set; } public required string Timezone { get; set; } + + public required string ProviderUId { get; set; } } \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index ab8194a9..eec04150 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -592,45 +592,6 @@ public async Task WhenUnassignPlatformRolesAsync_ThenUnassigns() } #endif - [Fact] - public async Task WhenFindPersonByEmailAsyncAndNotExists_ThenReturnsNone() - { - _userProfilesService.Setup(ups => - ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny())) - .ReturnsAsync(Optional.None); - _invitationRepository.Setup(rep => - rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional.None()); - - var result = - await _application.FindPersonByEmailAddressAsync(_caller.Object, "auser@company.com", - CancellationToken.None); - - result.Should().BeSuccess(); - result.Value.Should().BeNone(); - } - - [Fact] - public async Task WhenFindPersonByEmailAsyncAndExists_ThenReturns() - { - var endUser = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - _userProfilesService.Setup(ups => - ups.FindPersonByEmailAddressPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny())) - .ReturnsAsync(Optional.None); - _invitationRepository.Setup(rep => - rep.FindInvitedGuestByEmailAddressAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(endUser.ToOptional()); - - var result = - await _application.FindPersonByEmailAddressAsync(_caller.Object, "auser@company.com", - CancellationToken.None); - - result.Should().BeSuccess(); - result.Value.Value.Id.Should().Be("anid"); - } - [Fact] public async Task WhenGetMembershipsAndNotRegisteredOrMemberAsync_ThenReturnsUser() { diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index 93d7a85e..f8209507 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -140,27 +140,6 @@ public async Task> ChangeDefaultMembershipAsync(ICallerCo return user.ToUser(); } - public async Task, Error>> FindPersonByEmailAddressAsync(ICallerContext caller, - string emailAddress, CancellationToken cancellationToken) - { - var email = EmailAddress.Create(emailAddress); - if (email.IsFailure) - { - return email.Error; - } - - var retrieved = - await FindRegisteredPersonOrInvitedGuestByEmailAddressAsync(caller, email.Value, cancellationToken); - if (retrieved.IsFailure) - { - return retrieved.Error; - } - - return retrieved.Value.HasValue - ? retrieved.Value.Value.User.ToUser().ToOptional() - : Optional.None; - } - public async Task> GetMembershipsAsync(ICallerContext caller, string id, CancellationToken cancellationToken) { diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index 9ee6bee0..210c3444 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -12,9 +12,6 @@ Task> AssignPlatformRolesAsync(ICallerContext caller, str Task> ChangeDefaultMembershipAsync(ICallerContext caller, string organizationId, CancellationToken cancellationToken); - Task, Error>> FindPersonByEmailAddressAsync(ICallerContext caller, string emailAddress, - CancellationToken cancellationToken); - Task> GetMembershipsAsync(ICallerContext caller, string id, CancellationToken cancellationToken); diff --git a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs index 63683f05..4d8acf77 100644 --- a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs +++ b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs @@ -19,12 +19,6 @@ public EndUsersInProcessServiceClient(IEndUsersApplication endUsersApplication) _endUsersApplication = endUsersApplication; } - public async Task, Error>> FindPersonByEmailPrivateAsync(ICallerContext caller, - string emailAddress, CancellationToken cancellationToken) - { - return await _endUsersApplication.FindPersonByEmailAddressAsync(caller, emailAddress, cancellationToken); - } - public async Task> GetMembershipsPrivateAsync(ICallerContext caller, string id, CancellationToken cancellationToken) { diff --git a/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs b/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs index 43c472ff..3d756fbc 100644 --- a/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs +++ b/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs @@ -4,6 +4,7 @@ using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Events.Shared.Identities.SSOUsers; using Domain.Interfaces.Entities; using Domain.Services.Shared; using Domain.Shared; @@ -15,6 +16,7 @@ using Moq; using UnitTesting.Common; using Xunit; +using PersonName = Domain.Shared.PersonName; namespace IdentityApplication.UnitTests; @@ -41,18 +43,19 @@ public GivenNoAuthProviders() } [Fact] - public async Task WhenFindProviderByNameAsyncAndNotRegistered_ThenReturnsNone() + public async Task WhenAuthenticateAndNoProvider_ThenReturnsError() { - var result = await _service.FindProviderByNameAsync("aname", CancellationToken.None); + var result = await _service.AuthenticateUserAsync(_caller.Object, "aprovidername", "anauthcode", + "ausername", CancellationToken.None); - result.Should().BeSuccess(); - result.Value.Should().BeNone(); + result.Should().BeError(ErrorCode.EntityNotFound, + Resources.SSOProvidersService_UnknownProvider.Format("aprovidername")); } [Fact] public async Task WhenSaveInfoOnBehalfOfUserAsyncAndProviderNotRegistered_ThenReturnsError() { - var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + var userInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "afirstname", null, Timezones.Default, CountryCodes.Default); var result = @@ -62,47 +65,170 @@ await _service.SaveInfoOnBehalfOfUserAsync(_caller.Object, "aprovidername", "aus result.Should().BeError(ErrorCode.EntityNotFound, Resources.SSOProvidersService_UnknownProvider.Format("aprovidername")); } + + [Fact] + public async Task WhenFindUserByProviderAsync_ThenReturnsError() + { + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "anemailaddress", "afirstname", + "alastname", + Timezones.Default, CountryCodes.Default); + + var result = await _service.FindUserByProviderAsync(_caller.Object, "aprovidername", authUserInfo, + CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound, + Resources.SSOProvidersService_UnknownProvider.Format("aprovidername")); + } } [Trait("Category", "Unit")] public class GivenAuthProviders { + private readonly Mock _caller; + private readonly Mock _idFactory; + private readonly Mock _provider; + private readonly Mock _recorder; + private readonly Mock _repository; private readonly SSOProvidersService _service; public GivenAuthProviders() { - var recorder = new Mock(); - var idFactory = new Mock(); - idFactory.Setup(idf => idf.Create(It.IsAny())) + _caller = new Mock(); + _recorder = new Mock(); + _idFactory = new Mock(); + _idFactory.Setup(idf => idf.Create(It.IsAny())) .Returns("anid".ToId()); - var repository = new Mock(); + _repository = new Mock(); var encryptionService = new Mock(); + _provider = new Mock(); + _provider.Setup(p => p.ProviderName) + .Returns("aprovidername"); - _service = new SSOProvidersService(recorder.Object, idFactory.Object, encryptionService.Object, + _service = new SSOProvidersService(_recorder.Object, _idFactory.Object, encryptionService.Object, new List { - new TestSSOAuthenticationProvider() + _provider.Object }, - repository.Object); + _repository.Object); + } + + [Fact] + public async Task WhenAuthenticateUserAndProviderNotAuthenticates_ThenReturnsError() + { + _provider.Setup(p => p.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.NotAuthenticated()); + + var result = await _service.AuthenticateUserAsync(_caller.Object, "aprovidername", "anauthcode", + "ausername", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public async Task WhenAuthenticateUserAndProviderReturnInfoWithoutUid_ThenReturnsError() + { + var authUserInfo = new SSOAuthUserInfo(new List(), "", "auser@company.com", "afirstname", + "alastname", + Timezones.Default, CountryCodes.Default); + _provider.Setup(p => p.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(authUserInfo); + + var result = await _service.AuthenticateUserAsync(_caller.Object, "aprovidername", "anauthcode", + "ausername", CancellationToken.None); + + result.Should().BeError(ErrorCode.Validation, Resources.SSOProvidersService_Authentication_MissingUid); } [Fact] - public async Task WhenFindProviderByNameAsyncAndNotRegistered_ThenReturnsNone() + public async Task WhenAuthenticateUserAndProviderReturnInfoWithoutEmail_ThenReturnsError() { - var result = await _service.FindProviderByNameAsync("aname", CancellationToken.None); + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "", "afirstname", "alastname", + Timezones.Default, CountryCodes.Default); + _provider.Setup(p => p.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(authUserInfo); + + var result = await _service.AuthenticateUserAsync(_caller.Object, "aprovidername", "anauthcode", + "ausername", CancellationToken.None); + + result.Should().BeError(ErrorCode.Validation, + Resources.SSOProvidersService_Authentication_InvalidEmailAddress); + } + + [Fact] + public async Task WhenAuthenticateUserAndProviderReturnInfoWithoutFirstNameThenReturnsError() + { + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "", "alastname", + Timezones.Default, CountryCodes.Default); + _provider.Setup(p => p.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(authUserInfo); + + var result = await _service.AuthenticateUserAsync(_caller.Object, "aprovidername", "anauthcode", + "ausername", CancellationToken.None); + + result.Should().BeError(ErrorCode.Validation, + Resources.SSOProvidersService_Authentication_InvalidNames); + } + + [Fact] + public async Task WhenAuthenticateUserAndProviderAuthenticates_ThenReturnsUserInfo() + { + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "afirstname", + "alastname", + Timezones.Default, CountryCodes.Default); + _provider.Setup(p => p.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(authUserInfo); + + var result = await _service.AuthenticateUserAsync(_caller.Object, "aprovidername", "anauthcode", + "ausername", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Should().Be(authUserInfo); + } + + [Fact] + public async Task WhenFindUserByProviderAsyncAndNoUser_ThenReturnsNone() + { + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "anemailaddress", "afirstname", + "alastname", + Timezones.Default, CountryCodes.Default); + _repository.Setup(repo => repo.FindByProviderUIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = await _service.FindUserByProviderAsync(_caller.Object, "aprovidername", authUserInfo, + CancellationToken.None); result.Should().BeSuccess(); result.Value.Should().BeNone(); } [Fact] - public async Task WhenFindProviderByNameAsyncRegistered_ThenReturnsProvider() + public async Task WhenFindUserByProviderAsync_ThenReturnsUser() { - var result = - await _service.FindProviderByNameAsync(TestSSOAuthenticationProvider.Name, CancellationToken.None); + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "anemailaddress", "afirstname", + "alastname", + Timezones.Default, CountryCodes.Default); + var ssoUser = SSOUserRoot.Create(_recorder.Object, _idFactory.Object, "aprovidername", "auserid".ToId()) + .Value; + ssoUser.AddDetails(SSOAuthTokens.Create(new List()).Value, "aprovideruid", + EmailAddress.Create("auser@company.com").Value, + PersonName.Create("afirstname", "alastname").Value, Timezone.Default, + Address.Create(CountryCodes.Default).Value); + _repository.Setup(repo => repo.FindByProviderUIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ssoUser.ToOptional()); + + var result = await _service.FindUserByProviderAsync(_caller.Object, "aprovidername", authUserInfo, + CancellationToken.None); result.Should().BeSuccess(); - result.Value.Value.Should().BeOfType(); + result.Value.Value.Id.Should().Be("auserid"); + result.Value.Value.ProviderUId.Should().Be("aprovideruid"); } } @@ -143,7 +269,7 @@ public GivenAnAuthProvider() [Fact] public async Task WhenSaveInfoOnBehalfOfUserAsyncAndProviderNotRegistered_ThenReturnsError() { - var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + var userInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "afirstname", null, Timezones.Default, CountryCodes.Default); var result = @@ -157,7 +283,7 @@ await _service.SaveInfoOnBehalfOfUserAsync(_caller.Object, "aprovidername", "aus [Fact] public async Task WhenSaveInfoOnBehalfOfUserAsyncAndUserNotExists_ThenCreatesAndSavesDetails() { - var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + var userInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "afirstname", null, Timezones.Default, CountryCodes.Default); _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny(), @@ -488,7 +614,7 @@ public class TestSSOAuthenticationProvider : ISSOAuthenticationProvider { public const string Name = "atestprovider"; - public Task> AuthenticateAsync(ICallerContext caller, string authCode, + public Task> AuthenticateAsync(ICallerContext caller, string authCode, string? emailAddress, CancellationToken cancellationToken) { diff --git a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs index e12c6e6b..a999d0fc 100644 --- a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs @@ -28,9 +28,6 @@ public SingleSignOnApplicationSpec() _endUsersService = new Mock(); _ssoProvider = new Mock(); _ssoProvidersService = new Mock(); - _ssoProvidersService - .Setup(sps => sps.FindProviderByNameAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(_ssoProvider.Object.ToOptional()); _ssoProvidersService.Setup(sps => sps.FindProviderByUserIdAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -43,56 +40,43 @@ public SingleSignOnApplicationSpec() } [Fact] - public async Task WhenAuthenticateAndNoProvider_ThenReturnsError() + public async Task WhenAuthenticateAndProviderReturnsAuthenticationError_ThenReturnsError() { - _ssoProvidersService.Setup(sp => sp.FindProviderByNameAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional.None); - - var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", - "anauthcode", null, CancellationToken.None); - - result.Should().BeError(ErrorCode.NotAuthenticated); - _endUsersService.Verify( - eus => eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); - } - - [Fact] - public async Task WhenAuthenticateAndProviderErrors_ThenReturnsError() - { - _ssoProvider.Setup(sp => - sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _ssoProvidersService.Setup(sps => + sps.AuthenticateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) - .ReturnsAsync(Error.Unexpected("amessage")); + .ReturnsAsync(Error.NotAuthenticated("amessage")); var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", - "anauthcode", null, - CancellationToken.None); + "anauthcode", null, CancellationToken.None); result.Should().BeError(ErrorCode.NotAuthenticated); - _endUsersService.Verify( - eus => eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); + _ssoProvidersService.Verify(sps => + sps.AuthenticateUserAsync(_caller.Object, "aprovidername", "anauthcode", null, + It.IsAny())); } [Fact] public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesToken() { - var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "afirstname", null, Timezones.Default, CountryCodes.Default); - _ssoProvider.Setup(sp => - sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _ssoProvidersService.Setup(sps => + sps.AuthenticateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) - .ReturnsAsync(userInfo); - var endUser = new EndUser + .ReturnsAsync(authUserInfo); + var ssoUser = new SSOUser { - Id = "anexistinguserid" + Id = "anexistinguserid", + ProviderUId = "aprovideruid" }; - _endUsersService.Setup(eus => - eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), + _ssoProvidersService.Setup(sps => + sps.FindUserByProviderAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(endUser.ToOptional()); + .ReturnsAsync(ssoUser.ToOptional()); _endUsersService.Setup(eus => eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -112,15 +96,15 @@ public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesT CancellationToken.None); result.Should().BeError(ErrorCode.NotAuthenticated); - _endUsersService.Verify(eus => - eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); + _ssoProvidersService.Verify(sps => + sps.FindUserByProviderAsync(_caller.Object, "aprovidername", authUserInfo, It.IsAny())); _endUsersService.Verify( eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _ssoProvidersService.Verify( sps => sps.SaveInfoOnBehalfOfUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); _endUsersService.Verify(eus => eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); @@ -132,21 +116,23 @@ public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesT [Fact] public async Task WhenAuthenticateAndPersonIsSuspended_ThenIssuesToken() { - var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "afirstname", null, Timezones.Default, CountryCodes.Default); - _ssoProvider.Setup(sp => - sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _ssoProvidersService.Setup(sps => + sps.AuthenticateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) - .ReturnsAsync(userInfo); - var endUser = new EndUser + .ReturnsAsync(authUserInfo); + var ssoUser = new SSOUser { - Id = "anexistinguserid" + Id = "anexistinguserid", + ProviderUId = "aprovideruid" }; - _endUsersService.Setup(eus => - eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), + _ssoProvidersService.Setup(sps => + sps.FindUserByProviderAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(endUser.ToOptional()); + .ReturnsAsync(ssoUser.ToOptional()); _endUsersService.Setup(eus => eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -166,15 +152,15 @@ public async Task WhenAuthenticateAndPersonIsSuspended_ThenIssuesToken() CancellationToken.None); result.Should().BeError(ErrorCode.EntityLocked, Resources.SingleSignOnApplication_AccountSuspended); - _endUsersService.Verify(eus => - eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); + _ssoProvidersService.Verify(sps => + sps.FindUserByProviderAsync(_caller.Object, "aprovidername", authUserInfo, It.IsAny())); _endUsersService.Verify( eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _ssoProvidersService.Verify( sps => sps.SaveInfoOnBehalfOfUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); _endUsersService.Verify(eus => eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); @@ -186,16 +172,18 @@ public async Task WhenAuthenticateAndPersonIsSuspended_ThenIssuesToken() [Fact] public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssuesToken() { - var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, Timezones.Sydney, + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "afirstname", null, + Timezones.Sydney, CountryCodes.Australia); - _ssoProvider.Setup(sp => - sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _ssoProvidersService.Setup(sps => + sps.AuthenticateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) - .ReturnsAsync(userInfo); - _endUsersService.Setup(eus => - eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), + .ReturnsAsync(authUserInfo); + _ssoProvidersService.Setup(sps => + sps.FindUserByProviderAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Optional.None()); + .ReturnsAsync(Optional.None()); _endUsersService.Setup(eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -235,14 +223,14 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue result.Value.AccessToken.ExpiresOn.Should().Be(expiresOn); result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn); - _endUsersService.Verify(eus => - eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); + _ssoProvidersService.Verify(sps => + sps.FindUserByProviderAsync(_caller.Object, "aprovidername", authUserInfo, It.IsAny())); _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", "auser@company.com", "afirstname", null, Timezones.Sydney.ToString(), CountryCodes.Australia.ToString(), true, It.IsAny())); _ssoProvidersService.Verify(sps => sps.SaveInfoOnBehalfOfUserAsync(_caller.Object, "aprovidername", "aregistereduserid".ToId(), - It.Is(ui => ui == userInfo), It.IsAny())); + It.Is(ui => ui == authUserInfo), It.IsAny())); _endUsersService.Verify(eus => eus.GetMembershipsPrivateAsync(_caller.Object, "aregistereduserid", It.IsAny())); _authTokensService.Verify(ats => ats.IssueTokensAsync(_caller.Object, It.Is(eu => @@ -253,21 +241,23 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue [Fact] public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() { - var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + var authUserInfo = new SSOAuthUserInfo(new List(), "auid", "auser@company.com", "afirstname", null, Timezones.Default, CountryCodes.Default); - _ssoProvider.Setup(sp => - sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _ssoProvidersService.Setup(sps => + sps.AuthenticateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) - .ReturnsAsync(userInfo); - var endUser = new EndUser + .ReturnsAsync(authUserInfo); + var ssoUser = new SSOUser { - Id = "anexistinguserid" + Id = "anexistinguserid", + ProviderUId = "aprovideruid" }; - _endUsersService.Setup(eus => - eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), + _ssoProvidersService.Setup(sps => + sps.FindUserByProviderAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(endUser.ToOptional()); + .ReturnsAsync(ssoUser.ToOptional()); _endUsersService.Setup(eus => eus.GetMembershipsPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -301,15 +291,15 @@ public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() result.Value.AccessToken.ExpiresOn.Should().Be(expiresOn); result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn); - _endUsersService.Verify(eus => - eus.FindPersonByEmailPrivateAsync(_caller.Object, "auser@company.com", It.IsAny())); + _ssoProvidersService.Verify(sps => + sps.FindUserByProviderAsync(_caller.Object, "aprovidername", authUserInfo, It.IsAny())); _endUsersService.Verify( eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _ssoProvidersService.Verify(sps => sps.SaveInfoOnBehalfOfUserAsync(_caller.Object, "aprovidername", "anexistinguserid".ToId(), - It.Is(ui => ui == userInfo), It.IsAny())); + It.Is(ui => ui == authUserInfo), It.IsAny())); _endUsersService.Verify(eus => eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); _authTokensService.Verify(ats => ats.IssueTokensAsync(_caller.Object, It.Is(eu => diff --git a/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs b/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs index acbd354b..18c58eb7 100644 --- a/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs +++ b/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs @@ -16,7 +16,7 @@ public interface ISSOAuthenticationProvider /// Returns the authenticated user with the specified for the specified /// /// - Task> AuthenticateAsync(ICallerContext caller, string authCode, string? emailAddress, + Task> AuthenticateAsync(ICallerContext caller, string authCode, string? emailAddress, CancellationToken cancellationToken); /// @@ -27,14 +27,15 @@ Task> RefreshTokenAsync(ICallerConte } /// -/// Provides the information about a user from a 3rd party system +/// Provides the information about user info from a 3rd party system /// -public class SSOUserInfo +public class SSOAuthUserInfo { - public SSOUserInfo(IReadOnlyList tokens, string emailAddress, string firstName, string? lastName, - TimezoneIANA timezone, CountryCodeIso3166 countryCode) + public SSOAuthUserInfo(IReadOnlyList tokens, string uId, string emailAddress, string firstName, + string? lastName, TimezoneIANA timezone, CountryCodeIso3166 countryCode) { Tokens = tokens; + UId = uId; EmailAddress = emailAddress; FirstName = firstName; LastName = lastName; @@ -57,4 +58,6 @@ public SSOUserInfo(IReadOnlyList tokens, string emailAddress, string public TimezoneIANA Timezone { get; } public IReadOnlyList Tokens { get; } + + public string UId { get; } } \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs b/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs index 9bcceeef..0827023b 100644 --- a/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs +++ b/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs @@ -9,12 +9,15 @@ namespace IdentityApplication.ApplicationServices; /// public interface ISSOProvidersService { - Task, Error>> FindProviderByNameAsync(string providerName, - CancellationToken cancellationToken); + Task> AuthenticateUserAsync(ICallerContext caller, string providerName, + string authCode, string? username, CancellationToken cancellationToken); Task, Error>> FindProviderByUserIdAsync(ICallerContext caller, string userId, string providerName, CancellationToken cancellationToken); + Task, Error>> FindUserByProviderAsync(ICallerContext caller, string providerName, + SSOAuthUserInfo authUserInfo, CancellationToken cancellationToken); + Task, Error>> GetTokensAsync(ICallerContext caller, CancellationToken cancellationToken); @@ -22,7 +25,7 @@ Task, Error>> GetTokensOnBeha string userId, CancellationToken cancellationToken); Task> SaveInfoOnBehalfOfUserAsync(ICallerContext caller, string providerName, string userId, - SSOUserInfo userInfo, CancellationToken cancellationToken); + SSOAuthUserInfo authUserInfo, CancellationToken cancellationToken); Task> SaveTokensOnBehalfOfUserAsync(ICallerContext caller, string providerName, string userId, ProviderAuthenticationTokens tokens, CancellationToken cancellationToken); diff --git a/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs b/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs index a164846c..85997a37 100644 --- a/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs +++ b/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs @@ -36,12 +36,47 @@ public SSOProvidersService(IRecorder recorder, IIdentifierFactory identifierFact _authenticationProviders = authenticationProviders; } - public Task, Error>> FindProviderByNameAsync(string providerName, + public async Task> AuthenticateUserAsync(ICallerContext caller, string providerName, + string authCode, string? username, CancellationToken cancellationToken) { - var provider = - _authenticationProviders.FirstOrDefault(provider => provider.ProviderName.EqualsIgnoreCase(providerName)); - return Task.FromResult, Error>>(provider.ToOptional()); + var retrievedProvider = FindProviderByNameInternal(providerName); + if (retrievedProvider.IsFailure) + { + return retrievedProvider.Error; + } + + if (!retrievedProvider.Value.HasValue) + { + return Error.EntityNotFound(Resources.SSOProvidersService_UnknownProvider.Format(providerName)); + } + + var provider = retrievedProvider.Value.Value; + var authenticated = await provider.AuthenticateAsync(caller, authCode, username, cancellationToken); + if (authenticated.IsFailure) + { + return Error.NotAuthenticated(); + } + + var userInfo = authenticated.Value; + if (userInfo.UId.HasNoValue()) + { + return Error.Validation(Resources.SSOProvidersService_Authentication_MissingUid); + } + + var email = EmailAddress.Create(userInfo.EmailAddress); + if (email.IsFailure) + { + return Error.Validation(Resources.SSOProvidersService_Authentication_InvalidEmailAddress); + } + + var name = PersonName.Create(userInfo.FirstName, userInfo.LastName); + if (name.IsFailure) + { + return Error.Validation(Resources.SSOProvidersService_Authentication_InvalidNames); + } + + return userInfo; } public async Task, Error>> FindProviderByUserIdAsync( @@ -74,9 +109,42 @@ public async Task, Error>> FindProvi return viewed.Error; } + _recorder.TraceInformation(caller.ToCall(), "SSO Provider {Provider} retrieved", provider.ProviderName); + return provider.ToOptional(); } + public async Task, Error>> FindUserByProviderAsync(ICallerContext caller, + string providerName, SSOAuthUserInfo authUserInfo, CancellationToken cancellationToken) + { + var retrievedProvider = FindProviderByNameInternal(providerName); + if (retrievedProvider.IsFailure) + { + return retrievedProvider.Error; + } + + if (!retrievedProvider.Value.HasValue) + { + return Error.EntityNotFound(Resources.SSOProvidersService_UnknownProvider.Format(providerName)); + } + + var retrievedUser = await _repository.FindByProviderUIdAsync(providerName, authUserInfo.UId, cancellationToken); + if (retrievedUser.IsFailure) + { + return retrievedUser.Error; + } + + if (!retrievedUser.Value.HasValue) + { + return Optional.None; + } + + var user = retrievedUser.Value.Value; + _recorder.TraceInformation(caller.ToCall(), "SSO User {UserId} retrieved", user.UserId); + + return user.ToUser().ToOptional(); + } + public async Task, Error>> GetTokensAsync(ICallerContext caller, CancellationToken cancellationToken) { @@ -91,9 +159,9 @@ public async Task, Error>> Ge public async Task> SaveInfoOnBehalfOfUserAsync(ICallerContext caller, string providerName, string userId, - SSOUserInfo userInfo, CancellationToken cancellationToken) + SSOAuthUserInfo authUserInfo, CancellationToken cancellationToken) { - var retrievedProvider = await FindProviderByNameAsync(providerName, cancellationToken); + var retrievedProvider = FindProviderByNameInternal(providerName); if (retrievedProvider.IsFailure) { return retrievedProvider.Error; @@ -128,31 +196,31 @@ public async Task> SaveInfoOnBehalfOfUserAsync(ICallerContext call user = created.Value; } - var name = PersonName.Create(userInfo.FirstName, userInfo.LastName); + var name = PersonName.Create(authUserInfo.FirstName, authUserInfo.LastName); if (name.IsFailure) { return name.Error; } - var emailAddress = EmailAddress.Create(userInfo.EmailAddress); + var emailAddress = EmailAddress.Create(authUserInfo.EmailAddress); if (emailAddress.IsFailure) { return emailAddress.Error; } - var timezone = Timezone.Create(userInfo.Timezone); + var timezone = Timezone.Create(authUserInfo.Timezone); if (timezone.IsFailure) { return timezone.Error; } - var address = Address.Create(userInfo.CountryCode); + var address = Address.Create(authUserInfo.CountryCode); if (address.IsFailure) { return address.Error; } - var toks = userInfo.Tokens.ToAuthTokens(_encryptionService); + var toks = authUserInfo.Tokens.ToAuthTokens(_encryptionService); if (toks.IsFailure) { return toks.Error; @@ -164,7 +232,9 @@ public async Task> SaveInfoOnBehalfOfUserAsync(ICallerContext call return tokens.Error; } - var updated = user.AddDetails(tokens.Value, emailAddress.Value, name.Value, timezone.Value, address.Value); + var uId = authUserInfo.UId; + + var updated = user.AddDetails(tokens.Value, uId, emailAddress.Value, name.Value, timezone.Value, address.Value); if (updated.IsFailure) { return updated.Error; @@ -187,7 +257,7 @@ public async Task> SaveTokensOnBehalfOfUserAsync(ICallerContext ca string userId, ProviderAuthenticationTokens tokens, CancellationToken cancellationToken) { - var retrievedProvider = await FindProviderByNameAsync(providerName, cancellationToken); + var retrievedProvider = FindProviderByNameInternal(providerName); if (retrievedProvider.IsFailure) { return retrievedProvider.Error; @@ -243,6 +313,13 @@ public async Task> SaveTokensOnBehalfOfUserAsync(ICallerContext ca return Result.Ok; } + private Result, Error> FindProviderByNameInternal(string providerName) + { + var provider = + _authenticationProviders.FirstOrDefault(provider => provider.ProviderName.EqualsIgnoreCase(providerName)); + return provider.ToOptional(); + } + private async Task, Error>> GetTokensInternalAsync( Identifier userId, CancellationToken cancellationToken) { @@ -391,4 +468,13 @@ public static ProviderAuthenticationTokens ToProviderAuthenticationTokens(this S return providerTokens; } + + public static SSOUser ToUser(this SSOUserRoot user) + { + return new SSOUser + { + Id = user.UserId, + ProviderUId = user.ProviderUId + }; + } } \ No newline at end of file diff --git a/src/IdentityApplication/Persistence/ISSOUsersRepository.cs b/src/IdentityApplication/Persistence/ISSOUsersRepository.cs index 5ca0d24e..a2524727 100644 --- a/src/IdentityApplication/Persistence/ISSOUsersRepository.cs +++ b/src/IdentityApplication/Persistence/ISSOUsersRepository.cs @@ -7,6 +7,9 @@ namespace IdentityApplication.Persistence; public interface ISSOUsersRepository : IApplicationRepository { + Task, Error>> FindByProviderUIdAsync(string providerName, string providerUId, + CancellationToken cancellationToken); + Task, Error>> FindByUserIdAsync(string providerName, Identifier userId, CancellationToken cancellationToken); diff --git a/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs b/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs index 56273390..f38ebf95 100644 --- a/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs +++ b/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs @@ -22,5 +22,7 @@ public class SSOUser : ReadModelEntity public Optional Tokens { get; set; } + public Optional ProviderUId { get; set; } + public Optional UserId { get; set; } } \ No newline at end of file diff --git a/src/IdentityApplication/Resources.Designer.cs b/src/IdentityApplication/Resources.Designer.cs index 5e027d64..2ff38af0 100644 --- a/src/IdentityApplication/Resources.Designer.cs +++ b/src/IdentityApplication/Resources.Designer.cs @@ -158,6 +158,33 @@ internal static string SingleSignOnApplication_AccountSuspended { } } + /// + /// Looks up a localized string similar to SSO Authentication provider has not provided a valid EmailAddress for this user. + /// + internal static string SSOProvidersService_Authentication_InvalidEmailAddress { + get { + return ResourceManager.GetString("SSOProvidersService_Authentication_InvalidEmailAddress", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to SSO Authentication provider has not provided valid First and Last names for this user. + /// + internal static string SSOProvidersService_Authentication_InvalidNames { + get { + return ResourceManager.GetString("SSOProvidersService_Authentication_InvalidNames", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to SSO Authentication provider has not provided a valid UID for this user. + /// + internal static string SSOProvidersService_Authentication_MissingUid { + get { + return ResourceManager.GetString("SSOProvidersService_Authentication_MissingUid", resourceCulture); + } + } + /// /// Looks up a localized string similar to The '{0}' is not registered. /// diff --git a/src/IdentityApplication/Resources.resx b/src/IdentityApplication/Resources.resx index df4b8a35..cb537893 100644 --- a/src/IdentityApplication/Resources.resx +++ b/src/IdentityApplication/Resources.resx @@ -60,4 +60,13 @@ Authentication requires another factor + + SSO Authentication provider has not provided a valid UID for this user + + + SSO Authentication provider has not provided a valid EmailAddress for this user + + + SSO Authentication provider has not provided valid First and Last names for this user + \ No newline at end of file diff --git a/src/IdentityApplication/SingleSignOnApplication.cs b/src/IdentityApplication/SingleSignOnApplication.cs index 6e73c11d..f6bcbd20 100644 --- a/src/IdentityApplication/SingleSignOnApplication.cs +++ b/src/IdentityApplication/SingleSignOnApplication.cs @@ -30,27 +30,25 @@ public async Task> AuthenticateAsync(ICallerCo string? invitationToken, string providerName, string authCode, string? username, CancellationToken cancellationToken) { - var retrievedProvider = await _ssoProvidersService.FindProviderByNameAsync(providerName, cancellationToken); - if (retrievedProvider.IsFailure) - { - return retrievedProvider.Error; - } - - if (!retrievedProvider.Value.HasValue) - { - return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); - } - - var provider = retrievedProvider.Value.Value; - var authenticated = await provider.AuthenticateAsync(caller, authCode, username, cancellationToken); + var authenticated = + await _ssoProvidersService.AuthenticateUserAsync(caller, providerName, authCode, username, + cancellationToken); if (authenticated.IsFailure) { - return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); + if (authenticated.Error.Is(ErrorCode.NotAuthenticated) + || authenticated.Error.Is(ErrorCode.EntityNotFound) + || authenticated.Error.Is(ErrorCode.Validation)) + { + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); + } + + return authenticated.Error; } - var userInfo = authenticated.Value; + //Have we seen you before? based on ProviderName+(OID+TID) (e.g. Microsoft's unique ID for a user, unique in a MS tenant?) + var authUserInfo = authenticated.Value; var existingUser = - await _endUsersService.FindPersonByEmailPrivateAsync(caller, userInfo.EmailAddress, cancellationToken); + await _ssoProvidersService.FindUserByProviderAsync(caller, providerName, authUserInfo, cancellationToken); if (existingUser.IsFailure) { return existingUser.Error; @@ -60,8 +58,9 @@ public async Task> AuthenticateAsync(ICallerCo if (!existingUser.Value.HasValue) { var autoRegistered = await _endUsersService.RegisterPersonPrivateAsync(caller, invitationToken, - userInfo.EmailAddress, - userInfo.FirstName, userInfo.LastName, userInfo.Timezone.ToString(), userInfo.CountryCode.ToString(), + authUserInfo.EmailAddress, + authUserInfo.FirstName, authUserInfo.LastName, authUserInfo.Timezone.ToString(), + authUserInfo.CountryCode.ToString(), true, cancellationToken); if (autoRegistered.IsFailure) @@ -106,9 +105,7 @@ public async Task> AuthenticateAsync(ICallerCo } var saved = await _ssoProvidersService.SaveInfoOnBehalfOfUserAsync(caller, providerName, - registeredUserId.ToId(), - userInfo, - cancellationToken); + registeredUserId.ToId(), authUserInfo, cancellationToken); if (saved.IsFailure) { return saved.Error; @@ -118,7 +115,7 @@ public async Task> AuthenticateAsync(ICallerCo Audits.SingleSignOnApplication_Authenticate_Succeeded, "User {Id} succeeded to authenticate with SSO {Provider}", user.Id, providerName); _recorder.TrackUsageFor(caller.ToCall(), user.Id, UsageConstants.Events.UsageScenarios.Generic.UserLogin, - user.ToLoginUserUsage(providerName, userInfo)); + user.ToLoginUserUsage(providerName, authUserInfo)); var issued = await _authTokensService.IssueTokensAsync(caller, user, cancellationToken); if (issued.IsFailure) @@ -246,20 +243,20 @@ private static Dictionary GetAuthenticationErrorData(string prov internal static class SingleSignOnApplicationExtensions { public static Dictionary ToLoginUserUsage(this EndUserWithMemberships user, string providerName, - SSOUserInfo userInfo) + SSOAuthUserInfo authUserInfo) { var context = new Dictionary { { UsageConstants.Properties.AuthProvider, providerName }, { UsageConstants.Properties.UserIdOverride, user.Id }, - { UsageConstants.Properties.Name, userInfo.FullName }, - { UsageConstants.Properties.EmailAddress, userInfo.EmailAddress } + { UsageConstants.Properties.Name, authUserInfo.FullName }, + { UsageConstants.Properties.EmailAddress, authUserInfo.EmailAddress } }; var defaultMembership = user.Memberships.FirstOrDefault(ms => ms.IsDefault); if (defaultMembership.Exists()) { - context.Add(UsageConstants.Properties.DefaultOrganizationId, defaultMembership.Id); + context.Add(UsageConstants.Properties.DefaultOrganizationId, defaultMembership.OrganizationId); } return context; diff --git a/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs b/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs index 4e49cf6e..bddbb878 100644 --- a/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs +++ b/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs @@ -50,10 +50,11 @@ public void WhenAddedDetails_ThenAdds() .Value; var tokens = SSOAuthTokens.Create([token]).Value; - var result = _user.AddDetails(tokens, EmailAddress.Create("auser@company.com").Value, + var result = _user.AddDetails(tokens, "aprovideruid", EmailAddress.Create("auser@company.com").Value, PersonName.Create("afirstname", null).Value, Timezone.Default, Address.Default); result.Should().BeSuccess(); + _user.ProviderUId.Should().Be("aprovideruid"); _user.UserId.Should().Be("auserid".ToId()); _user.EmailAddress.Value.Address.Should().Be("auser@company.com"); _user.Name.Value.FirstName.Text.Should().Be("afirstname"); diff --git a/src/IdentityDomain/Events.cs b/src/IdentityDomain/Events.cs index c278912a..ee38a219 100644 --- a/src/IdentityDomain/Events.cs +++ b/src/IdentityDomain/Events.cs @@ -311,11 +311,12 @@ public static Domain.Events.Shared.Identities.SSOUsers.Created Created(Identifie }; } - public static DetailsAdded DetailsAdded(Identifier id, EmailAddress emailAddress, + public static DetailsAdded DetailsAdded(Identifier id, string uId, EmailAddress emailAddress, PersonName name, Timezone timezone, Address address) { return new DetailsAdded(id) { + ProviderUId = uId, EmailAddress = emailAddress, FirstName = name.FirstName, LastName = name.LastName.ValueOrDefault?.Text, diff --git a/src/IdentityDomain/SSOUserRoot.cs b/src/IdentityDomain/SSOUserRoot.cs index c1ee0183..7027da9d 100644 --- a/src/IdentityDomain/SSOUserRoot.cs +++ b/src/IdentityDomain/SSOUserRoot.cs @@ -38,6 +38,8 @@ private SSOUserRoot(IRecorder recorder, IIdentifierFactory idFactory, ISingleVal public Optional ProviderName { get; private set; } + public Optional ProviderUId { get; private set; } + public Optional Timezone { get; private set; } public Optional Tokens { get; private set; } @@ -88,6 +90,7 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case DetailsAdded added: { + ProviderUId = added.ProviderUId; var emailAddress = Domain.Shared.EmailAddress.Create(added.EmailAddress); if (emailAddress.IsFailure) { @@ -125,11 +128,11 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco } } - public Result AddDetails(SSOAuthTokens tokens, EmailAddress emailAddress, + public Result AddDetails(SSOAuthTokens tokens, string uId, EmailAddress emailAddress, PersonName name, Timezone timezone, Address address) { var detailsUpdated = RaiseChangeEvent( - IdentityDomain.Events.SSOUsers.DetailsAdded(Id, emailAddress, name, timezone, + IdentityDomain.Events.SSOUsers.DetailsAdded(Id, uId, emailAddress, name, timezone, address)); if (detailsUpdated.IsFailure) { diff --git a/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs b/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs index 3ec72842..8a595d2c 100644 --- a/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs +++ b/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs @@ -53,10 +53,10 @@ public static Result GetProviderTokensFromT }; } - public static SSOUserInfo GetUserInfoFromTokens(List tokens) + public static SSOAuthUserInfo GetUserInfoFromTokens(List tokens) { var accessToken = tokens.Single(tok => tok.Type == TokenType.AccessToken).Value; - + var uid = Guid.NewGuid().ToString("N"); var claims = new JwtSecurityTokenHandler().ReadJwtToken(accessToken).Claims.ToArray(); var emailAddress = claims.Single(c => c.Type == ClaimTypes.Email).Value; var firstName = claims.Single(c => c.Type == ClaimTypes.GivenName).Value; @@ -65,7 +65,7 @@ public static SSOUserInfo GetUserInfoFromTokens(List tokens) Timezones.FindOrDefault(claims.Single(c => c.Type == AuthenticationConstants.Claims.ForTimezone).Value); var country = CountryCodes.FindOrDefault(claims.Single(c => c.Type == ClaimTypes.Country).Value); - return new SSOUserInfo(tokens, emailAddress, firstName, lastName, timezone, country); + return new SSOAuthUserInfo(tokens, uid, emailAddress, firstName, lastName, timezone, country); } private static AuthToken CreateAccessToken(OAuth2CodeTokenExchangeOptions options) diff --git a/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs b/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs index 6e3e8fcd..b6d02c86 100644 --- a/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs +++ b/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs @@ -27,7 +27,7 @@ private FakeSSOAuthenticationProvider(IOAuth2Service auth2Service) _auth2Service = auth2Service; } - public async Task> AuthenticateAsync(ICallerContext caller, string authCode, + public async Task> AuthenticateAsync(ICallerContext caller, string authCode, string? emailAddress, CancellationToken cancellationToken) { authCode.ThrowIfNotValuedParameter(nameof(authCode), diff --git a/src/IdentityInfrastructure/ApplicationServices/MicrosoftSSOAuthenticationProvider.cs b/src/IdentityInfrastructure/ApplicationServices/MicrosoftSSOAuthenticationProvider.cs index 92c8c8e5..760e5e82 100644 --- a/src/IdentityInfrastructure/ApplicationServices/MicrosoftSSOAuthenticationProvider.cs +++ b/src/IdentityInfrastructure/ApplicationServices/MicrosoftSSOAuthenticationProvider.cs @@ -1,5 +1,4 @@ using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; using System.Text.Json; using Application.Interfaces; using Application.Resources.Shared; @@ -8,7 +7,6 @@ using Common.Configuration; using Common.Extensions; using IdentityApplication.ApplicationServices; -using Infrastructure.Interfaces; using Infrastructure.Shared.ApplicationServices.External; namespace IdentityInfrastructure.ApplicationServices; @@ -33,7 +31,7 @@ private MicrosoftSSOAuthenticationProvider(IOAuth2Service auth2Service) _auth2Service = auth2Service; } - public async Task> AuthenticateAsync(ICallerContext caller, string authCode, + public async Task> AuthenticateAsync(ICallerContext caller, string authCode, string? emailAddress, CancellationToken cancellationToken) { authCode.ThrowIfNotValuedParameter(nameof(authCode), @@ -107,7 +105,7 @@ await _auth2Service.RefreshTokenAsync(caller, internal static class MicrosoftSSOAuthenticationProviderExtensions { - public static Result ToSSoUserInfo(this List tokens) + public static Result ToSSoUserInfo(this List tokens) { var idToken = tokens.FirstOrDefault(t => t.Type == TokenType.OtherToken); if (idToken.NotExists()) @@ -116,13 +114,31 @@ public static Result ToSSoUserInfo(this List toke } var claims = new JwtSecurityTokenHandler().ReadJwtToken(idToken.Value).Claims.ToArray(); - var emailAddress = claims.Single(c => c.Type == ClaimTypes.Email).Value; - var firstName = claims.Single(c => c.Type == ClaimTypes.GivenName).Value; - var lastName = claims.Single(c => c.Type == "family_name").Value; - var timezone = - Timezones.FindOrDefault(claims.Single(c => c.Type == AuthenticationConstants.Claims.ForTimezone).Value); - var country = CountryCodes.FindOrDefault(claims.Single(c => c.Type == ClaimTypes.Country).Value); - - return new SSOUserInfo(tokens, emailAddress, firstName, lastName, timezone, country); + var oId = claims.Single(c => c.Type == MicrosoftIdentityClaims.ObjectId).Value; + var emailAddress = claims.Single(c => c.Type == MicrosoftIdentityClaims.PreferredUserName).Value; + var firstName = claims.Single(c => c.Type == MicrosoftIdentityClaims.GivenName).Value; + var lastName = claims.Single(c => c.Type == MicrosoftIdentityClaims.FamilyName).Value; + var timezone = Timezones.Gmt; // Note, we cannot reliably derive the users timezone from these claims! + var countryCode = claims.FirstOrDefault(c => c.Type == MicrosoftIdentityClaims.Country)?.Value + ?? claims.FirstOrDefault(c => c.Type == MicrosoftIdentityClaims.TenantCountry)?.Value + ?? CountryCodes.Default.ToString(); + var country = CountryCodes.FindOrDefault(countryCode); + + return new SSOAuthUserInfo(tokens, oId, emailAddress, firstName, lastName, timezone, country); + } + + /// + /// See the idToken claims: + /// + /// and optional + /// + private static class MicrosoftIdentityClaims + { + public const string Country = "ctry"; + public const string FamilyName = "family_name"; + public const string GivenName = "given_name"; + public const string ObjectId = "oid"; + public const string PreferredUserName = "preferred_username"; + public const string TenantCountry = "tenant_ctry"; } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs index 5bd651b0..38c43ea1 100644 --- a/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs +++ b/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs @@ -36,6 +36,7 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven case DetailsAdded e: return await _users.HandleUpdateAsync(e.RootId, dto => { + dto.ProviderUId = e.ProviderUId; dto.EmailAddress = e.EmailAddress; dto.FirstName = e.FirstName; dto.LastName = e.LastName; diff --git a/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs b/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs index 5fdc7936..15a05555 100644 --- a/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs +++ b/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs @@ -33,6 +33,15 @@ public async Task> DestroyAllAsync(CancellationToken cancellationT } #endif + public async Task, Error>> FindByProviderUIdAsync(string providerName, + string providerUId, CancellationToken cancellationToken) + { + var query = Query.From() + .Where(usr => usr.ProviderUId, ConditionOperator.EqualTo, providerUId) + .AndWhere(usr => usr.ProviderName, ConditionOperator.EqualTo, providerName); + return await FindFirstByQueryAsync(query, cancellationToken); + } + public async Task, Error>> FindByUserIdAsync(string providerName, Identifier userId, CancellationToken cancellationToken) { diff --git a/src/SaaStack.sln b/src/SaaStack.sln index 59a9f14d..3c2ecd36 100644 --- a/src/SaaStack.sln +++ b/src/SaaStack.sln @@ -407,6 +407,19 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools.GitHubActions", "Tool EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Persistence.Azure.UnitTests", "Infrastructure.Persistence.Azure.UnitTests\Infrastructure.Persistence.Azure.UnitTests.csproj", "{7549CF63-1BA4-44EC-B4AF-32C2145A71E2}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IAC", "IAC", "{5C263252-2914-4FC0-8AEF-7E936C77CAFD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure", "Azure", "{F39B3109-A863-46EA-AC73-317207764ADE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SQLServer", "SQLServer", "{1E28068B-6B6A-4808-A09A-25CD0EABB748}" + ProjectSection(SolutionItems) = preProject + ..\iac\Azure\SQLServer\AzureSQLServer-Seed-Eventing-Core.sql = ..\iac\Azure\SQLServer\AzureSQLServer-Seed-Eventing-Core.sql + ..\iac\Azure\SQLServer\AzureSQLServer-Seed-Eventing-Generic.sql = ..\iac\Azure\SQLServer\AzureSQLServer-Seed-Eventing-Generic.sql + ..\iac\Azure\SQLServer\AzureSQLServer-Seed-EventStore.sql = ..\iac\Azure\SQLServer\AzureSQLServer-Seed-EventStore.sql + ..\iac\Azure\SQLServer\AzureSQLServer-Seed-Snapshotting-Core.sql = ..\iac\Azure\SQLServer\AzureSQLServer-Seed-Snapshotting-Core.sql + ..\iac\Azure\SQLServer\AzureSQLServer-Seed-Snapshotting-Generic.sql = ..\iac\Azure\SQLServer\AzureSQLServer-Seed-Snapshotting-Generic.sql + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1515,5 +1528,8 @@ Global {E7D4CD46-E7A7-4466-A718-A322CA6FE677} = {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} {BF293010-EB3D-4508-8C46-F583589120CE} = {BAE0D6F2-6920-4B02-9F30-D71B04B7170D} {7549CF63-1BA4-44EC-B4AF-32C2145A71E2} = {9B6B0235-BD3F-4604-8E93-B0112A241C63} + {5C263252-2914-4FC0-8AEF-7E936C77CAFD} = {56E869FB-D718-421C-85DF-07291F1F47C6} + {F39B3109-A863-46EA-AC73-317207764ADE} = {5C263252-2914-4FC0-8AEF-7E936C77CAFD} + {1E28068B-6B6A-4808-A09A-25CD0EABB748} = {F39B3109-A863-46EA-AC73-317207764ADE} EndGlobalSection EndGlobal diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 4ba2d245..a55cfe40 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -1729,6 +1729,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1793,6 +1794,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True