diff --git a/docs/design-principles/0110-back-end-for-front-end.md b/docs/design-principles/0110-back-end-for-front-end.md index b1bcf708..4d57c9a1 100644 --- a/docs/design-principles/0110-back-end-for-front-end.md +++ b/docs/design-principles/0110-back-end-for-front-end.md @@ -81,8 +81,6 @@ If the attempt to authenticate is successful, the authentication response from t These "auth tokens" are then added to [HTTPOnly, secure] cookies that are then returned to the browser to be used between the browser and BEFFE for subsequent requests/responses. -![Authentication](../images/Authentication-Credentials.png) - At some point in time, either of those auth tokens will expire, at which point either the `access_token` can be refreshed (using the `refresh_token`), or the `refresh_tokesn` expires, and the end user will need to re-authenticate again. #### Login @@ -99,20 +97,26 @@ For example, } ``` +![Credentials Authentication](../images/Authentication-Credentials.png) + or with a body containing an SSO authentication code, For example, ```json { - "AuthCode": "anauthocde", - "Provider": "sso" + "AuthCode": "anauthcode", + "Provider": "google" } ``` +> Note: The Backend API call to authenticate this "OAuth2 identity" will receive some tokens (from the 3rd party provider) that should include an email address \[claim\] that identifies the actual person to the system. However, some OAuth2 providers today do not include that email address claim in the returned tokens, and in those cases the parson cannot be identified. Instead, their email address can be included in the above request (as `Username`) which can be available in the first steps of the "OAuth Authorization Flow". + +![SSO Authentication](../images/Authentication-SSO.png) + > Note: you will also need to include CSRF protection in these requests, like all others coming from a JS app. -A successful response from this request will yield the following body, +A successful response from either of these requests will yield the following body, For example, @@ -122,7 +126,7 @@ For example, } ``` -But it will also include these cookies (for the current domain): +But, the response will also include these cookies (for the current domain): `auth-tok=anaccesstoken` diff --git a/docs/images/Authentication-SSO.png b/docs/images/Authentication-SSO.png index c153e375..82be792d 100644 Binary files a/docs/images/Authentication-SSO.png and b/docs/images/Authentication-SSO.png differ diff --git a/docs/images/BEFFE-ReverseProxy.png b/docs/images/BEFFE-ReverseProxy.png index 844a0acc..e76b4c8d 100644 Binary files a/docs/images/BEFFE-ReverseProxy.png and b/docs/images/BEFFE-ReverseProxy.png differ diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 9e7a652d..4728b56b 100644 Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json index aface782..b9cdf03f 100644 --- a/src/ApiHost1/appsettings.json +++ b/src/ApiHost1/appsettings.json @@ -23,6 +23,11 @@ "SenderProductName": "SaaStack", "SenderEmailAddress": "noreply@saastack.com", "SenderDisplayName": "Support" + }, + "SSOProvidersService": { + "SSOUserTokens": { + "AesSecret": "V7z5SZnhHRa7z68adsvazQjeIbSiWWcR+4KuAUikhe0=::u4ErEVotb170bM8qKWyT8A==" + } } }, "Hosts": { diff --git a/src/Application.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs index 225054d9..dbfd696d 100644 --- a/src/Application.Interfaces/Audits.Designer.cs +++ b/src/Application.Interfaces/Audits.Designer.cs @@ -130,5 +130,32 @@ public static string PasswordCredentialsApplication_Authenticate_Succeeded { return ResourceManager.GetString("PasswordCredentialsApplication_Authenticate_Succeeded", resourceCulture); } } + + /// + /// Looks up a localized string similar to SingleSignOn.AutoRegistered. + /// + public static string SingleSignOnApplication_Authenticate_AccountOnboarded { + get { + return ResourceManager.GetString("SingleSignOnApplication_Authenticate_AccountOnboarded", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authentication.Failed.AccountSuspended. + /// + public static string SingleSignOnApplication_Authenticate_AccountSuspended { + get { + return ResourceManager.GetString("SingleSignOnApplication_Authenticate_AccountSuspended", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authentication.Passed. + /// + public static string SingleSignOnApplication_Authenticate_Succeeded { + get { + return ResourceManager.GetString("SingleSignOnApplication_Authenticate_Succeeded", resourceCulture); + } + } } } diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx index 9ea57fe6..e7167c26 100644 --- a/src/Application.Interfaces/Audits.resx +++ b/src/Application.Interfaces/Audits.resx @@ -48,4 +48,13 @@ EndUser.PlatformRolesAssigned + + SingleSignOn.AutoRegistered + + + Authentication.Failed.AccountSuspended + + + Authentication.Passed + \ No newline at end of file diff --git a/src/Application.Resources.Shared/Identity.cs b/src/Application.Resources.Shared/Identity.cs index 0d64b32d..627f3ea5 100644 --- a/src/Application.Resources.Shared/Identity.cs +++ b/src/Application.Resources.Shared/Identity.cs @@ -4,15 +4,18 @@ namespace Application.Resources.Shared; public class AuthenticateTokens { - public required string AccessToken { get; set; } + public required AuthenticateToken AccessToken { get; set; } - public required DateTime AccessTokenExpiresOn { get; set; } + public required AuthenticateToken RefreshToken { get; set; } - public required string RefreshToken { get; set; } + public required string UserId { get; set; } +} - public required DateTime RefreshTokenExpiresOn { get; set; } +public class AuthenticateToken +{ + public required DateTime ExpiresOn { get; set; } - public required string UserId { get; set; } + public required string Value { get; set; } } public class APIKey : IIdentifiableResource @@ -26,4 +29,27 @@ public class APIKey : IIdentifiableResource public required string UserId { get; set; } public required string Id { get; set; } +} + +public class AuthToken +{ + public AuthToken(TokenType type, string value, DateTime? expiresOn) + { + Type = type; + Value = value; + ExpiresOn = expiresOn; + } + + public DateTime? ExpiresOn { get; } + + public TokenType Type { get; } + + public string Value { get; } +} + +public enum TokenType +{ + AccessToken = 1, + RefreshToken = 2, + IdToken = 3 } \ No newline at end of file diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs index 3c3a4853..86ec2275 100644 --- a/src/Application.Services.Shared/IEndUsersService.cs +++ b/src/Application.Services.Shared/IEndUsersService.cs @@ -6,15 +6,18 @@ namespace Application.Services.Shared; public interface IEndUsersService { - Task> GetPersonAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + Task, Error>> FindPersonByEmailAsync(ICallerContext caller, string emailAddress, + CancellationToken cancellationToken); - Task> GetMembershipsAsync(ICallerContext context, string id, + Task> GetMembershipsAsync(ICallerContext caller, string id, CancellationToken cancellationToken); - Task> RegisterMachineAsync(ICallerContext context, string name, string? timezone, + Task> GetPersonAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + + Task> RegisterMachineAsync(ICallerContext caller, string name, string? timezone, string? countryCode, CancellationToken cancellationToken); Task> RegisterPersonAsync(ICallerContext caller, string emailAddress, - string firstName, string lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, + string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Common/Extensions/EnumExtensions.cs b/src/Common/Extensions/EnumExtensions.cs index f0cb892c..45fab797 100644 --- a/src/Common/Extensions/EnumExtensions.cs +++ b/src/Common/Extensions/EnumExtensions.cs @@ -24,7 +24,7 @@ public static TTargetEnum ToEnum(this TSourceEnum sour /// Converts the to an value of the , /// and in the case where no value can be found, uses the /// - public static TTargetEnum ToEnumOrDefault(this string value, TTargetEnum defaultValue) + public static TTargetEnum ToEnumOrDefault(this string? value, TTargetEnum defaultValue) { if (value.HasNoValue()) { diff --git a/src/Domain.Interfaces/Domain.Interfaces.csproj b/src/Domain.Interfaces/Domain.Interfaces.csproj index 5be2f664..7379efbe 100644 --- a/src/Domain.Interfaces/Domain.Interfaces.csproj +++ b/src/Domain.Interfaces/Domain.Interfaces.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Domain.Interfaces/Validations/CommonValidations.cs b/src/Domain.Interfaces/Validations/CommonValidations.cs index 23f1dc15..039dfd1d 100644 --- a/src/Domain.Interfaces/Validations/CommonValidations.cs +++ b/src/Domain.Interfaces/Validations/CommonValidations.cs @@ -1,5 +1,6 @@ using Common; using Common.Extensions; +using PhoneNumbers; namespace Domain.Interfaces.Validations; @@ -14,6 +15,34 @@ public static class CommonValidations public static readonly Validation FeatureLevel = new(@"^[\w\d]{4,30}$", 4, 30); public static readonly Validation Identifier = new(@"^[\w]{1,20}_[\d\w]{10,22}$", 12, 43); public static readonly Validation IdentifierPrefix = new(@"^[^\W_]*$", 1, 20); + + /// + /// Validations for International + /// + public static readonly Validation PhoneNumber = new(value => + { + if (!value.HasValue()) + { + return false; + } + + if (!value.StartsWith("+")) + { + return false; + } + + var util = PhoneNumberUtil.GetInstance(); + try + { + var number = util.Parse(value, null); + return util.IsValidNumber(number); + } + catch (NumberParseException) + { + return false; + } + }); + public static readonly Validation RoleLevel = new(@"^[\w\d]{4,30}$", 4, 30); public static readonly Validation Timezone = new(Timezones.Exists); public static readonly Validation Url = new(s => Uri.IsWellFormedUriString(s, UriKind.Absolute)); diff --git a/src/Domain.Shared/Address.cs b/src/Domain.Shared/Address.cs new file mode 100644 index 00000000..0d914579 --- /dev/null +++ b/src/Domain.Shared/Address.cs @@ -0,0 +1,68 @@ +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace Domain.Shared; + +public sealed class Address : ValueObjectBase
+{ + public static readonly Address Default = Create(CountryCodes.Default).Value; + + public static Result Create(string line1, string line2, string line3, string city, string state, + CountryCodeIso3166 countryCode, string zip) + { + return new Address(line1, line2, line3, city, state, countryCode, zip); + } + + public static Result Create(CountryCodeIso3166 countryCode) + { + return new Address(countryCode); + } + + private Address(CountryCodeIso3166 countryCode) : this(string.Empty, string.Empty, string.Empty, + string.Empty, string.Empty, countryCode, string.Empty) + { + } + + private Address(string? line1, string? line2, string? line3, string? city, string? state, + CountryCodeIso3166 countryCode, string? zip) + { + Line1 = line1; + Line2 = line2; + Line3 = line3; + City = city; + State = state; + CountryCode = countryCode; + Zip = zip; + } + + public string? City { get; } + + public CountryCodeIso3166 CountryCode { get; } + + public string? Line1 { get; } + + public string? Line2 { get; } + + public string? Line3 { get; } + + public string? State { get; } + + public string? Zip { get; } + + public static ValueObjectFactory
Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new Address(parts[0], parts[1], parts[2], parts[3], parts[4], + CountryCodes.FindOrDefault(parts[5]), + parts[6]); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object?[] { Line1, Line2, Line3, City, State, CountryCode.Alpha3, Zip }; + } +} \ No newline at end of file diff --git a/src/Domain.Shared/PhoneNumber.cs b/src/Domain.Shared/PhoneNumber.cs new file mode 100644 index 00000000..39fa9a3d --- /dev/null +++ b/src/Domain.Shared/PhoneNumber.cs @@ -0,0 +1,38 @@ +using Common; +using Common.Extensions; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Validations; + +namespace Domain.Shared; + +public sealed class PhoneNumber : SingleValueObjectBase +{ + public static Result Create(string phoneNumber) + { + if (phoneNumber.IsNotValuedParameter(nameof(phoneNumber), out var error1)) + { + return error1; + } + + if (phoneNumber.IsInvalidParameter(CommonValidations.PhoneNumber, nameof(phoneNumber), + Resources.PhoneNumber_InvalidPhoneNumber, out var error2)) + { + return error2; + } + + return new PhoneNumber(phoneNumber); + } + + private PhoneNumber(string phoneNumber) : base(phoneNumber) + { + } + + public string Number => Value; + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => new PhoneNumber(property); + } +} \ No newline at end of file diff --git a/src/Domain.Shared/Resources.Designer.cs b/src/Domain.Shared/Resources.Designer.cs index 4244e0e5..1ffaea96 100644 --- a/src/Domain.Shared/Resources.Designer.cs +++ b/src/Domain.Shared/Resources.Designer.cs @@ -86,6 +86,15 @@ internal static string Features_InvalidFeature { } } + /// + /// Looks up a localized string similar to The PhoneNumber value is not a valid international phone number. + /// + internal static string PhoneNumber_InvalidPhoneNumber { + get { + return ResourceManager.GetString("PhoneNumber_InvalidPhoneNumber", resourceCulture); + } + } + /// /// Looks up a localized string similar to The Role value is invalid. /// @@ -94,5 +103,14 @@ internal static string Roles_InvalidRole { return ResourceManager.GetString("Roles_InvalidRole", resourceCulture); } } + + /// + /// Looks up a localized string similar to The Timezone value is invalid. + /// + internal static string Timezone_InvalidTimezone { + get { + return ResourceManager.GetString("Timezone_InvalidTimezone", resourceCulture); + } + } } } diff --git a/src/Domain.Shared/Resources.resx b/src/Domain.Shared/Resources.resx index 3ba331dc..45d7e04c 100644 --- a/src/Domain.Shared/Resources.resx +++ b/src/Domain.Shared/Resources.resx @@ -36,4 +36,10 @@ The Feature value is invalid + + The Timezone value is invalid + + + The PhoneNumber value is not a valid international phone number + \ No newline at end of file diff --git a/src/Domain.Shared/Timezone.cs b/src/Domain.Shared/Timezone.cs new file mode 100644 index 00000000..25f98479 --- /dev/null +++ b/src/Domain.Shared/Timezone.cs @@ -0,0 +1,45 @@ +using Common; +using Common.Extensions; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Validations; + +namespace Domain.Shared; + +public sealed class Timezone : SingleValueObjectBase +{ + public static readonly Timezone Default = Create(Timezones.Default).Value; + + public static Result Create(string timezone) + { + if (timezone.IsNotValuedParameter(nameof(timezone), out var error1)) + { + return error1; + } + + if (timezone.IsInvalidParameter(CommonValidations.Timezone, nameof(timezone), + Resources.Timezone_InvalidTimezone, out var error2)) + { + return error2; + } + + return new Timezone(Timezones.FindOrDefault(timezone)); + } + + public static Result Create(TimezoneIANA timezone) + { + return new Timezone(timezone); + } + + private Timezone(TimezoneIANA timezone) : base(timezone) + { + } + + public TimezoneIANA Code => Value; + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => new Timezone(Timezones.FindOrDefault(property)); + } +} \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index e509de31..cc429d87 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -48,7 +48,7 @@ public async Task WhenGetPersonAndUnregistered_ThenReturnsUser() { var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromResult>(user)); + .ReturnsAsync(user); var result = await _application.GetPersonAsync(_caller.Object, "anid", CancellationToken.None); @@ -156,11 +156,11 @@ public async Task WhenAssignPlatformRolesAsync_ThenAssigns() assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, Optional.None); _repository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) - .Returns(Task.FromResult>(assignee)); + .ReturnsAsync(assignee); var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), Optional.None); _repository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) - .Returns(Task.FromResult>(assigner)); + .ReturnsAsync(assigner); var result = await _application.AssignPlatformRolesAsync(_caller.Object, "anassigneeid", new List { PlatformRoles.TestingOnly.Name }, @@ -183,13 +183,13 @@ public async Task WhenAssignTenantRolesAsync_ThenAssigns() assignee.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); _repository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) - .Returns(Task.FromResult>(assignee)); + .ReturnsAsync(assignee); var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), Optional.None); assigner.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); _repository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) - .Returns(Task.FromResult>(assigner)); + .ReturnsAsync(assigner); var result = await _application.AssignTenantRolesAsync(_caller.Object, "anorganizationid", "anassigneeid", new List { TenantRoles.TestingOnly.Name }, @@ -201,4 +201,31 @@ public async Task WhenAssignTenantRolesAsync_ThenAssigns() .ContainInOrder(TenantRoles.Member.Name, TenantRoles.TestingOnly.Name); } #endif + + [Fact] + public async Task WhenFindPersonByEmailAsyncAndNotExists_ThenReturnsNone() + { + _repository.Setup(rep => rep.FindByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None()); + + var result = + await _application.FindPersonByEmailAsync(_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; + _repository.Setup(rep => rep.FindByEmailAddressAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(endUser.ToOptional()); + + var result = + await _application.FindPersonByEmailAsync(_caller.Object, "auser@company.com", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Value.Id.Should().Be("anid"); + } } \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index 6e25eed0..259f243c 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -110,7 +110,7 @@ public async Task> RegisterMachineAsync(ICaller } public async Task> RegisterPersonAsync(ICallerContext context, string emailAddress, - string firstName, string lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, + string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) { if (!termsAndConditionsAccepted) @@ -169,6 +169,33 @@ public async Task> RegisterPersonAsync(ICallerC CountryCodes.FindOrDefault(countryCode)); } + public async Task, Error>> FindPersonByEmailAsync(ICallerContext context, + string emailAddress, CancellationToken cancellationToken) + { + //TODO: find the profile of this person by email address from the profilesService + //And then, if not, lookup a endUser that has the email address as an invited guest + + var match = EmailAddress.Create(emailAddress); + if (!match.IsSuccessful) + { + return match.Error; + } + + var retrieved = await _repository.FindByEmailAddressAsync(match.Value, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (retrieved.Value.HasValue) + { + var endUser = retrieved.Value.Value; + return endUser.ToUser().ToOptional(); + } + + return Optional.None; + } + public async Task> AssignPlatformRolesAsync(ICallerContext context, string id, List roles, CancellationToken cancellationToken) { diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index fc1bf4b6..dbbe4fb2 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -22,6 +22,9 @@ Task> RegisterMachineAsync(ICallerContext conte string? countryCode, CancellationToken cancellationToken); Task> RegisterPersonAsync(ICallerContext context, string emailAddress, - string firstName, string lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, + string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, + CancellationToken cancellationToken); + + Task, Error>> FindPersonByEmailAsync(ICallerContext context, string emailAddress, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/EndUsersApplication/Persistence/IEndUserRepository.cs b/src/EndUsersApplication/Persistence/IEndUserRepository.cs index ce90ff52..58b8fe6f 100644 --- a/src/EndUsersApplication/Persistence/IEndUserRepository.cs +++ b/src/EndUsersApplication/Persistence/IEndUserRepository.cs @@ -1,6 +1,7 @@ using Application.Persistence.Interfaces; using Common; using Domain.Common.ValueObjects; +using Domain.Shared; using EndUsersDomain; namespace EndUsersApplication.Persistence; @@ -10,4 +11,7 @@ public interface IEndUserRepository : IApplicationRepository Task> LoadAsync(Identifier id, CancellationToken cancellationToken); Task> SaveAsync(EndUserRoot endUser, CancellationToken cancellationToken); + + Task, Error>> FindByEmailAddressAsync(EmailAddress emailAddress, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs index 90f70118..cb35928f 100644 --- a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs +++ b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs @@ -25,24 +25,30 @@ public async Task> GetPersonAsync(ICallerContext caller, return await _endUsersApplication.GetPersonAsync(caller, id, cancellationToken); } - public async Task> GetMembershipsAsync(ICallerContext context, string id, + public async Task, Error>> FindPersonByEmailAsync(ICallerContext caller, + string emailAddress, CancellationToken cancellationToken) + { + return await _endUsersApplication.FindPersonByEmailAsync(caller, emailAddress, cancellationToken); + } + + public async Task> GetMembershipsAsync(ICallerContext caller, string id, CancellationToken cancellationToken) { - return await _endUsersApplication.GetMembershipsAsync(context, id, cancellationToken); + return await _endUsersApplication.GetMembershipsAsync(caller, id, cancellationToken); } public async Task> RegisterPersonAsync(ICallerContext caller, string emailAddress, - string firstName, string lastName, - string? timezone, string? countryCode, bool termsAndConditionsAccepted, CancellationToken cancellationToken) + string firstName, string? lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted, + CancellationToken cancellationToken) { return await _endUsersApplication.RegisterPersonAsync(caller, emailAddress, firstName, lastName, timezone, countryCode, termsAndConditionsAccepted, cancellationToken); } - public async Task> RegisterMachineAsync(ICallerContext context, string name, + public async Task> RegisterMachineAsync(ICallerContext caller, string name, string? timezone, string? countryCode, CancellationToken cancellationToken) { - return await _endUsersApplication.RegisterMachineAsync(context, name, timezone, countryCode, cancellationToken); + return await _endUsersApplication.RegisterMachineAsync(caller, name, timezone, countryCode, cancellationToken); } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs index 80f4a65c..55efedbc 100644 --- a/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs +++ b/src/EndUsersInfrastructure/Persistence/EndUserRepository.cs @@ -3,11 +3,13 @@ using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Interfaces; +using Domain.Shared; using EndUsersApplication.Persistence; using EndUsersApplication.Persistence.ReadModels; using EndUsersDomain; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; +using QueryAny; namespace EndUsersInfrastructure.Persistence; @@ -47,4 +49,36 @@ public async Task> SaveAsync(EndUserRoot endUser, Can return endUser; } + + public async Task, Error>> FindByEmailAddressAsync(EmailAddress emailAddress, + CancellationToken cancellationToken) + { + var query = Query.From() + .Where(at => at.Username, ConditionOperator.EqualTo, emailAddress.Address); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + private async Task, Error>> FindFirstByQueryAsync(QueryClause query, + CancellationToken cancellationToken) + { + var queried = await _userQueries.QueryAsync(query, false, cancellationToken); + if (!queried.IsSuccessful) + { + return queried.Error; + } + + var matching = queried.Value.Results.FirstOrDefault(); + if (matching.NotExists()) + { + return Optional.None; + } + + var users = await _users.LoadAsync(matching.Id.Value.ToId(), cancellationToken); + if (!users.IsSuccessful) + { + return users.Error; + } + + return users.Value.ToOptional(); + } } \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs b/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs index 28e23eb4..49b2518b 100644 --- a/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs @@ -28,7 +28,7 @@ public class APIKeysApplicationSpec private readonly Mock _endUsersService; private readonly Mock _idFactory; private readonly Mock _recorder; - private readonly Mock _repository; + private readonly Mock _repository; private readonly Mock _tokensService; public APIKeysApplicationSpec() @@ -61,7 +61,7 @@ public APIKeysApplicationSpec() _apiKeyHasherService.Setup(khs => khs.ValidateAPIKeyHash(It.IsAny())) .Returns(true); _endUsersService = new Mock(); - _repository = new Mock(); + _repository = new Mock(); _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) .Returns((APIKeyRoot root, CancellationToken _) => Task.FromResult>(root)); diff --git a/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs b/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs index b502cc1a..3f8ccf9c 100644 --- a/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/AuthTokensApplicationSpec.cs @@ -141,9 +141,10 @@ public async Task WhenRefreshTokenAsyncAndTokensExist_ThenReturnsRefreshedTokens var result = await _application.RefreshTokenAsync(_caller.Object, "arefreshtoken1", CancellationToken.None); - result.Value.AccessToken.Should().Be("anaccesstoken2"); - result.Value.RefreshToken.Should().Be("arefreshtoken2"); - result.Value.AccessTokenExpiresOn.Should().Be(expiresOn2); + result.Value.AccessToken.Value.Should().Be("anaccesstoken2"); + result.Value.AccessToken.ExpiresOn.Should().Be(expiresOn2); + result.Value.RefreshToken.Value.Should().Be("arefreshtoken2"); + result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn2); _jwtTokensService.Verify(jts => jts.IssueTokensAsync(user)); _repository.Verify(rep => rep.SaveAsync(It.Is(at => at.Id == "anid" diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs index 5f490c49..b0ad59c0 100644 --- a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs @@ -266,10 +266,10 @@ public async Task WhenAuthenticateAsyncWithCorrectPassword_ThenReturnsError() await _application.AuthenticateAsync(_caller.Object, "ausername", "apassword", CancellationToken.None); result.Should().BeSuccess(); - result.Value.AccessToken.Should().Be("anaccesstoken"); - result.Value.RefreshToken.Should().Be("arefreshtoken"); - result.Value.AccessTokenExpiresOn.Should().Be(expiresOn); - result.Value.RefreshTokenExpiresOn.Should().Be(expiresOn); + result.Value.AccessToken.Value.Should().Be("anaccesstoken"); + result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); + result.Value.AccessToken.ExpiresOn.Should().Be(expiresOn); + result.Value.RefreshToken.ExpiresOn.Should().Be(expiresOn); _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny())); _recorder.Verify(rec => rec.AuditAgainst(It.IsAny(), "auserid", Audits.PasswordCredentialsApplication_Authenticate_Succeeded, It.IsAny(), diff --git a/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs b/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs new file mode 100644 index 00000000..6596a63e --- /dev/null +++ b/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs @@ -0,0 +1,166 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using FluentAssertions; +using IdentityApplication.ApplicationServices; +using IdentityApplication.Persistence; +using IdentityDomain; +using JetBrains.Annotations; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityApplication.UnitTests; + +[UsedImplicitly] +public class SSOProvidersServiceSpec +{ + [Trait("Category", "Unit")] + public class GivenNoAuthProviders + { + private readonly SSOProvidersService _service; + + public GivenNoAuthProviders() + { + var recorder = new Mock(); + var idFactory = new Mock(); + var repository = new Mock(); + var encryptionService = new Mock(); + encryptionService.Setup(es => es.Encrypt(It.IsAny())) + .Returns((string value) => value); + encryptionService.Setup(es => es.Decrypt(It.IsAny())) + .Returns((string value) => value); + + _service = new SSOProvidersService(recorder.Object, idFactory.Object, encryptionService.Object, + new List(), + repository.Object); + } + + [Fact] + public async Task WhenFindByNameAsyncAndNotRegistered_ThenReturnsNone() + { + var result = await _service.FindByNameAsync("aname", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Should().BeNone(); + } + + [Fact] + public async Task WhenSaveUserInfoAsyncAndProviderNotRegistered_ThenReturnsError() + { + var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + Timezones.Default, CountryCodes.Default); + + var result = + await _service.SaveUserInfoAsync("aprovidername", "auserid".ToId(), userInfo, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound, + Resources.SSOProvidersService_UnknownProvider.Format("aprovidername")); + } + } + + [Trait("Category", "Unit")] + public class GivenAuthProviders + { + 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())) + .Returns("anid".ToId()); + _repository = new Mock(); + var encryptionService = new Mock(); + encryptionService.Setup(es => es.Encrypt(It.IsAny())) + .Returns((string value) => value); + encryptionService.Setup(es => es.Decrypt(It.IsAny())) + .Returns((string value) => value); + + _service = new SSOProvidersService(recorder.Object, idFactory.Object, encryptionService.Object, + new List + { + new TestSSOAuthenticationProvider() + }, + _repository.Object); + } + + [Fact] + public async Task WhenFindByNameAsyncAndNotRegistered_ThenReturnsNone() + { + var result = await _service.FindByNameAsync("aname", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Should().BeNone(); + } + + [Fact] + public async Task WhenFindByNameAsyncRegistered_ThenReturnsProvider() + { + var result = await _service.FindByNameAsync(TestSSOAuthenticationProvider.Name, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Value.Should().BeOfType(); + } + + [Fact] + public async Task WhenSaveUserInfoAsyncAndProviderNotRegistered_ThenReturnsError() + { + var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + Timezones.Default, CountryCodes.Default); + + var result = + await _service.SaveUserInfoAsync("aprovidername", "auserid".ToId(), userInfo, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound, + Resources.SSOProvidersService_UnknownProvider.Format("aprovidername")); + } + + [Fact] + public async Task WhenSaveUserInfoAsyncAndUserNotExists_ThenCreatesAndSavesDetails() + { + var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + Timezones.Default, CountryCodes.Default); + _repository.Setup(rep => + rep.FindUserInfoByUserIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _service.SaveUserInfoAsync(TestSSOAuthenticationProvider.Name, "auserid".ToId(), userInfo, + CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(user => + user.Id == "anid" + && user.UserId == "auserid" + && user.EmailAddress.Value.Address == "auser@company.com" + && user.Name.Value.FirstName == "afirstname" + && user.Name.Value.LastName == Optional.None + && user.Timezone.Value == Timezones.Default + && user.Address.Value.CountryCode == CountryCodes.Default + ), It.IsAny())); + } + } +} + +public class TestSSOAuthenticationProvider : ISSOAuthenticationProvider +{ + public const string Name = "atestprovider"; + + public Task> AuthenticateAsync(ICallerContext context, string authCode, + string? emailAddress, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public string ProviderName => Name; +} \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs new file mode 100644 index 00000000..1e25fea4 --- /dev/null +++ b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs @@ -0,0 +1,279 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Domain.Common.ValueObjects; +using FluentAssertions; +using IdentityApplication.ApplicationServices; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityApplication.UnitTests; + +[Trait("Category", "Unit")] +public class SingleSignOnApplicationSpec +{ + private readonly SingleSignOnApplication _application; + private readonly Mock _authTokensService; + private readonly Mock _caller; + private readonly Mock _endUsersService; + private readonly Mock _ssoProvider; + private readonly Mock _ssoProvidersService; + + public SingleSignOnApplicationSpec() + { + var recorder = new Mock(); + _caller = new Mock(); + _endUsersService = new Mock(); + _ssoProvider = new Mock(); + _ssoProvidersService = new Mock(); + _ssoProvidersService.Setup(sps => sps.FindByNameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(_ssoProvider.Object.ToOptional()); + _authTokensService = new Mock(); + + _application = new SingleSignOnApplication(recorder.Object, _endUsersService.Object, + _ssoProvidersService.Object, + _authTokensService.Object); + } + + [Fact] + public async Task WhenAuthenticateAndNoProvider_ThenReturnsError() + { + _ssoProvidersService.Setup(sp => sp.FindByNameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + _endUsersService.Verify( + eus => eus.FindPersonByEmailAsync(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(), + It.IsAny())) + .ReturnsAsync(Error.Unexpected("amessage")); + + var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + CancellationToken.None); + + result.Should().BeError(ErrorCode.Unexpected, "amessage"); + _endUsersService.Verify( + eus => eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesToken() + { + var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + Timezones.Default, + CountryCodes.Default); + _ssoProvider.Setup(sp => + sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(userInfo); + var endUser = new EndUser + { + Id = "anexistinguserid" + }; + _endUsersService.Setup(eus => + eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(endUser.ToOptional()); + _endUsersService.Setup(eus => + eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "amembershipsuserid", + Status = EndUserStatus.Unregistered, + Access = EndUserAccess.Enabled + }); + var expiresOn = DateTime.UtcNow; + _authTokensService.Setup(ats => ats.IssueTokensAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn, "arefreshtoken", expiresOn)); + + var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + _endUsersService.Verify(eus => + eus.FindPersonByEmailAsync(_caller.Object, "auser@company.com", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _ssoProvidersService.Verify( + sps => sps.SaveUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _endUsersService.Verify(eus => + eus.GetMembershipsAsync(_caller.Object, "anexistinguserid", It.IsAny())); + _authTokensService.Verify( + ats => ats.IssueTokensAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenAuthenticateAndPersonExistsButSuspended_ThenIssuesToken() + { + var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + Timezones.Default, + CountryCodes.Default); + _ssoProvider.Setup(sp => + sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(userInfo); + var endUser = new EndUser + { + Id = "anexistinguserid" + }; + _endUsersService.Setup(eus => + eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(endUser.ToOptional()); + _endUsersService.Setup(eus => + eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "amembershipsuserid", + Status = EndUserStatus.Registered, + Access = EndUserAccess.Suspended + }); + var expiresOn = DateTime.UtcNow; + _authTokensService.Setup(ats => ats.IssueTokensAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn, "arefreshtoken", expiresOn)); + + var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, Resources.SingleSignOnApplication_AccountSuspended); + _endUsersService.Verify(eus => + eus.FindPersonByEmailAsync(_caller.Object, "auser@company.com", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _ssoProvidersService.Verify( + sps => sps.SaveUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _endUsersService.Verify(eus => + eus.GetMembershipsAsync(_caller.Object, "anexistinguserid", It.IsAny())); + _authTokensService.Verify( + ats => ats.IssueTokensAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssuesToken() + { + var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + Timezones.Default, + CountryCodes.Default); + _ssoProvider.Setup(sp => + sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(userInfo); + _endUsersService.Setup(eus => + eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None()); + _endUsersService.Setup(eus => eus.RegisterPersonAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new RegisteredEndUser + { + Id = "aregistereduserid" + }); + _endUsersService.Setup(eus => + eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "amembershipsuserid", + Status = EndUserStatus.Registered, + Access = EndUserAccess.Enabled + }); + var expiresOn = DateTime.UtcNow; + _authTokensService.Setup(ats => ats.IssueTokensAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn, "arefreshtoken", expiresOn)); + + var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.AccessToken.Value.Should().Be("anaccesstoken"); + 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.FindPersonByEmailAsync(_caller.Object, "auser@company.com", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonAsync(_caller.Object, "auser@company.com", "afirstname", null, + Timezones.Default.ToString(), CountryCodes.Default.ToString(), true, It.IsAny())); + _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync("aprovidername", "aregistereduserid".ToId(), + It.Is(ui => ui == userInfo), It.IsAny())); + _endUsersService.Verify(eus => + eus.GetMembershipsAsync(_caller.Object, "aregistereduserid", It.IsAny())); + _authTokensService.Verify(ats => ats.IssueTokensAsync(_caller.Object, It.Is(eu => + eu.Id == "amembershipsuserid" + ), It.IsAny())); + } + + [Fact] + public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() + { + var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, + Timezones.Default, + CountryCodes.Default); + _ssoProvider.Setup(sp => + sp.AuthenticateAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(userInfo); + var endUser = new EndUser + { + Id = "anexistinguserid" + }; + _endUsersService.Setup(eus => + eus.FindPersonByEmailAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(endUser.ToOptional()); + _endUsersService.Setup(eus => + eus.GetMembershipsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new EndUserWithMemberships + { + Id = "amembershipsuserid", + Status = EndUserStatus.Registered, + Access = EndUserAccess.Enabled + }); + var expiresOn = DateTime.UtcNow; + _authTokensService.Setup(ats => ats.IssueTokensAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessTokens("anaccesstoken", expiresOn, "arefreshtoken", expiresOn)); + + var result = await _application.AuthenticateAsync(_caller.Object, "aprovidername", "anauthcode", null, + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.AccessToken.Value.Should().Be("anaccesstoken"); + 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.FindPersonByEmailAsync(_caller.Object, "auser@company.com", It.IsAny())); + _endUsersService.Verify(eus => eus.RegisterPersonAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync("aprovidername", "anexistinguserid".ToId(), + It.Is(ui => ui == userInfo), It.IsAny())); + _endUsersService.Verify(eus => + eus.GetMembershipsAsync(_caller.Object, "anexistinguserid", It.IsAny())); + _authTokensService.Verify(ats => ats.IssueTokensAsync(_caller.Object, It.Is(eu => + eu.Id == "amembershipsuserid" + ), It.IsAny())); + } +} \ No newline at end of file diff --git a/src/IdentityApplication/APIKeysApplication.cs b/src/IdentityApplication/APIKeysApplication.cs index b48b755f..b94500ea 100644 --- a/src/IdentityApplication/APIKeysApplication.cs +++ b/src/IdentityApplication/APIKeysApplication.cs @@ -19,11 +19,11 @@ public class APIKeysApplication : IAPIKeysApplication private readonly IEndUsersService _endUsersService; private readonly IIdentifierFactory _identifierFactory; private readonly IRecorder _recorder; - private readonly IAPIKeyRepository _repository; + private readonly IAPIKeysRepository _repository; private readonly ITokensService _tokensService; public APIKeysApplication(IRecorder recorder, IIdentifierFactory identifierFactory, ITokensService tokensService, - IAPIKeyHasherService apiKeyHasherService, IEndUsersService endUsersService, IAPIKeyRepository repository) + IAPIKeyHasherService apiKeyHasherService, IEndUsersService endUsersService, IAPIKeysRepository repository) { _recorder = recorder; _identifierFactory = identifierFactory; diff --git a/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs b/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs new file mode 100644 index 00000000..17df541c --- /dev/null +++ b/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs @@ -0,0 +1,45 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication.ApplicationServices; + +/// +/// Defines a Single Sign On provider of authentication services +/// +public interface ISSOAuthenticationProvider +{ + string ProviderName { get; } + + Task> AuthenticateAsync(ICallerContext context, string authCode, string? emailAddress, + CancellationToken cancellationToken); +} + +/// +/// Provides the information about a user from a 3rd party system +/// +public class SSOUserInfo +{ + public SSOUserInfo(IReadOnlyList tokens, string emailAddress, string firstName, string? lastName, + TimezoneIANA timezone, CountryCodeIso3166 countryCode) + { + Tokens = tokens; + EmailAddress = emailAddress; + FirstName = firstName; + LastName = lastName; + Timezone = timezone; + CountryCode = countryCode; + } + + public CountryCodeIso3166 CountryCode { get; } + + public string EmailAddress { get; } + + public string FirstName { get; } + + public string? LastName { get; } + + public TimezoneIANA Timezone { get; } + + public IReadOnlyList Tokens { get; } +} \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs b/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs new file mode 100644 index 00000000..c396cb2f --- /dev/null +++ b/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs @@ -0,0 +1,16 @@ +using Common; +using Domain.Common.ValueObjects; + +namespace IdentityApplication.ApplicationServices; + +/// +/// Defines a service for accessing registered s +/// +public interface ISSOProvidersService +{ + Task, Error>> FindByNameAsync(string name, + CancellationToken cancellationToken); + + Task> SaveUserInfoAsync(string providerName, Identifier userId, SSOUserInfo userInfo, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs b/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs new file mode 100644 index 00000000..036fa1c7 --- /dev/null +++ b/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs @@ -0,0 +1,130 @@ +using Common; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using IdentityApplication.Persistence; +using IdentityDomain; +using PersonName = Domain.Shared.PersonName; + +namespace IdentityApplication.ApplicationServices; + +/// +/// Provides a service to manage registered s +/// +public class SSOProvidersService : ISSOProvidersService +{ + private readonly IEnumerable _authenticationProviders; + private readonly IIdentifierFactory _identifierFactory; + private readonly IEncryptionService _encryptionService; + private readonly IRecorder _recorder; + private readonly ISSOUsersRepository _repository; + + public SSOProvidersService(IRecorder recorder, IIdentifierFactory identifierFactory, + IEncryptionService encryptionService, + IEnumerable authenticationProviders, + ISSOUsersRepository repository) + { + _recorder = recorder; + _identifierFactory = identifierFactory; + _encryptionService = encryptionService; + _repository = repository; + _authenticationProviders = authenticationProviders; + } + + public Task, Error>> FindByNameAsync(string name, + CancellationToken cancellationToken) + { + var provider = + _authenticationProviders.FirstOrDefault(provider => provider.ProviderName.EqualsIgnoreCase(name)); + return Task.FromResult, Error>>(provider.ToOptional()); + } + + public async Task> SaveUserInfoAsync(string providerName, Identifier userId, SSOUserInfo userInfo, + CancellationToken cancellationToken) + { + var retrievedProvider = await FindByNameAsync(providerName, cancellationToken); + if (!retrievedProvider.IsSuccessful) + { + return retrievedProvider.Error; + } + + if (!retrievedProvider.Value.HasValue) + { + return Error.EntityNotFound(Resources.SSOProvidersService_UnknownProvider.Format(providerName)); + } + + var provider = retrievedProvider.Value.Value; + var retrievedUser = + await _repository.FindUserInfoByUserIdAsync(provider.ProviderName, userId, cancellationToken); + if (!retrievedUser.IsSuccessful) + { + return retrievedUser.Error; + } + + SSOUserRoot user; + if (retrievedUser.Value.HasValue) + { + user = retrievedUser.Value.Value; + } + else + { + var created = SSOUserRoot.Create(_recorder, _identifierFactory, _encryptionService, providerName, userId); + if (!created.IsSuccessful) + { + return created.Error; + } + + user = created.Value; + } + + var name = PersonName.Create(userInfo.FirstName, userInfo.LastName); + if (!name.IsSuccessful) + { + return name.Error; + } + + var emailAddress = EmailAddress.Create(userInfo.EmailAddress); + if (!emailAddress.IsSuccessful) + { + return emailAddress.Error; + } + + var timezone = Timezone.Create(userInfo.Timezone); + if (!timezone.IsSuccessful) + { + return timezone.Error; + } + + var address = Address.Create(userInfo.CountryCode); + if (!address.IsSuccessful) + { + return address.Error; + } + + var tokens = SSOAuthTokens.Create(userInfo.Tokens + .Select(tok => + SSOAuthToken.Create(tok.Type.ToEnumOrDefault(SSOAuthTokenType.AccessToken), tok.Value, tok.ExpiresOn) + .Value) + .ToList()); + if (!tokens.IsSuccessful) + { + return tokens.Error; + } + + var updated = user.UpdateDetails(tokens.Value, emailAddress.Value, name.Value, timezone.Value, address.Value); + if (!updated.IsSuccessful) + { + return updated.Error; + } + + var saved = await _repository.SaveAsync(user, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + return Result.Ok; + } +} \ No newline at end of file diff --git a/src/IdentityApplication/AuthTokensApplication.cs b/src/IdentityApplication/AuthTokensApplication.cs index c8ef265b..fe589085 100644 --- a/src/IdentityApplication/AuthTokensApplication.cs +++ b/src/IdentityApplication/AuthTokensApplication.cs @@ -79,7 +79,7 @@ public async Task> IssueTokensAsync(ICallerContext c return tokens; } - public async Task> RefreshTokenAsync(ICallerContext context, string refreshToken, + public async Task> RefreshTokenAsync(ICallerContext context, string refreshToken, CancellationToken cancellationToken) { var retrieved = await _repository.FindByRefreshTokenAsync(refreshToken, cancellationToken); @@ -123,7 +123,20 @@ public async Task> RefreshTokenAsync(ICallerContext _recorder.TraceInformation(context.ToCall(), "AuthTokens were refreshed for {Id}", updated.Value.Id); - return tokens; + return new AuthenticateTokens + { + UserId = user.Id, + AccessToken = new AuthenticateToken + { + Value = tokens.AccessToken, + ExpiresOn = tokens.AccessTokenExpiresOn + }, + RefreshToken = new AuthenticateToken + { + Value = tokens.RefreshToken, + ExpiresOn = tokens.RefreshTokenExpiresOn + } + }; } public async Task> RevokeRefreshTokenAsync(ICallerContext context, string refreshToken, diff --git a/src/IdentityApplication/IAuthTokensApplication.cs b/src/IdentityApplication/IAuthTokensApplication.cs index 8aa2e430..c73bb218 100644 --- a/src/IdentityApplication/IAuthTokensApplication.cs +++ b/src/IdentityApplication/IAuthTokensApplication.cs @@ -10,7 +10,7 @@ public interface IAuthTokensApplication Task> IssueTokensAsync(ICallerContext context, EndUserWithMemberships user, CancellationToken cancellationToken); - Task> RefreshTokenAsync(ICallerContext context, string refreshToken, + Task> RefreshTokenAsync(ICallerContext context, string refreshToken, CancellationToken cancellationToken); Task> RevokeRefreshTokenAsync(ICallerContext context, string refreshToken, diff --git a/src/IdentityApplication/ISingleSignOnApplication.cs b/src/IdentityApplication/ISingleSignOnApplication.cs new file mode 100644 index 00000000..aa81a6d8 --- /dev/null +++ b/src/IdentityApplication/ISingleSignOnApplication.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace IdentityApplication; + +public interface ISingleSignOnApplication +{ + Task> AuthenticateAsync(ICallerContext context, string providerName, + string authCode, string? username, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs index 7ec46a40..4fe472fb 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -78,25 +78,25 @@ public async Task> AuthenticateAsync(ICallerCo { await DelayForRandomPeriodAsync(MinAuthenticateDelayInSecs, MaxAuthenticateDelayInSecs, cancellationToken); - var fetched = await _repository.FindCredentialsByUsernameAsync(username, cancellationToken); - if (!fetched.IsSuccessful) + var retrieved = await _repository.FindCredentialsByUsernameAsync(username, cancellationToken); + if (!retrieved.IsSuccessful) { return Error.NotAuthenticated(); } - if (!fetched.Value.HasValue) + if (!retrieved.Value.HasValue) { return Error.NotAuthenticated(); } - var credentials = fetched.Value.Value; - var retrieved = await _endUsersService.GetMembershipsAsync(context, credentials.UserId, cancellationToken); - if (!retrieved.IsSuccessful) + var credentials = retrieved.Value.Value; + var registered = await _endUsersService.GetMembershipsAsync(context, credentials.UserId, cancellationToken); + if (!registered.IsSuccessful) { return Error.NotAuthenticated(); } - var user = retrieved.Value; + var user = registered.Value; if (user.Status != EndUserStatus.Registered) { return Error.NotAuthenticated(); @@ -154,10 +154,16 @@ public async Task> AuthenticateAsync(ICallerCo var tokens = issued.Value; return new Result(new AuthenticateTokens { - AccessToken = tokens.AccessToken, - RefreshToken = tokens.RefreshToken, - AccessTokenExpiresOn = tokens.AccessTokenExpiresOn, - RefreshTokenExpiresOn = tokens.RefreshTokenExpiresOn, + AccessToken = new AuthenticateToken + { + Value = tokens.AccessToken, + ExpiresOn = tokens.AccessTokenExpiresOn + }, + RefreshToken = new AuthenticateToken + { + Value = tokens.RefreshToken, + ExpiresOn = tokens.RefreshTokenExpiresOn + }, UserId = user.Id }); diff --git a/src/IdentityApplication/Persistence/IAPIKeyRepository.cs b/src/IdentityApplication/Persistence/IAPIKeysRepository.cs similarity index 85% rename from src/IdentityApplication/Persistence/IAPIKeyRepository.cs rename to src/IdentityApplication/Persistence/IAPIKeysRepository.cs index cf7a5d1f..31634123 100644 --- a/src/IdentityApplication/Persistence/IAPIKeyRepository.cs +++ b/src/IdentityApplication/Persistence/IAPIKeysRepository.cs @@ -4,7 +4,7 @@ namespace IdentityApplication.Persistence; -public interface IAPIKeyRepository : IApplicationRepository +public interface IAPIKeysRepository : IApplicationRepository { Task, Error>> FindByAPIKeyTokenAsync(string keyToken, CancellationToken cancellationToken); diff --git a/src/IdentityApplication/Persistence/ISSOUsersRepository.cs b/src/IdentityApplication/Persistence/ISSOUsersRepository.cs new file mode 100644 index 00000000..a3152ae3 --- /dev/null +++ b/src/IdentityApplication/Persistence/ISSOUsersRepository.cs @@ -0,0 +1,14 @@ +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using IdentityDomain; + +namespace IdentityApplication.Persistence; + +public interface ISSOUsersRepository : IApplicationRepository +{ + Task, Error>> FindUserInfoByUserIdAsync(string providerName, Identifier userId, + CancellationToken cancellationToken); + + Task> SaveAsync(SSOUserRoot user, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs b/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs new file mode 100644 index 00000000..fd46ad49 --- /dev/null +++ b/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs @@ -0,0 +1,25 @@ +using Application.Persistence.Common; +using Common; +using QueryAny; + +namespace IdentityApplication.Persistence.ReadModels; + +[EntityName("SSOUser")] +public class SSOUser : ReadModelEntity +{ + public Optional CountryCode { get; set; } + + public Optional EmailAddress { get; set; } + + public Optional FirstName { get; set; } + + public Optional LastName { get; set; } + + public Optional ProviderName { get; set; } + + public Optional Timezone { get; set; } + + public Optional Tokens { 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 1a673758..c1255367 100644 --- a/src/IdentityApplication/Resources.Designer.cs +++ b/src/IdentityApplication/Resources.Designer.cs @@ -103,5 +103,23 @@ internal static string PasswordCredentialsApplication_RegistrationNotVerified { return ResourceManager.GetString("PasswordCredentialsApplication_RegistrationNotVerified", resourceCulture); } } + + /// + /// Looks up a localized string similar to The user account is suspended. + /// + internal static string SingleSignOnApplication_AccountSuspended { + get { + return ResourceManager.GetString("SingleSignOnApplication_AccountSuspended", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The '{0}' is not registered. + /// + internal static string SSOProvidersService_UnknownProvider { + get { + return ResourceManager.GetString("SSOProvidersService_UnknownProvider", resourceCulture); + } + } } } diff --git a/src/IdentityApplication/Resources.resx b/src/IdentityApplication/Resources.resx index f3cb7ca9..aef53748 100644 --- a/src/IdentityApplication/Resources.resx +++ b/src/IdentityApplication/Resources.resx @@ -39,4 +39,10 @@ The user account ID is not a valid identifier + + The user account is suspended + + + The '{0}' is not registered + \ No newline at end of file diff --git a/src/IdentityApplication/SingleSignOnApplication.cs b/src/IdentityApplication/SingleSignOnApplication.cs new file mode 100644 index 00000000..63ae366b --- /dev/null +++ b/src/IdentityApplication/SingleSignOnApplication.cs @@ -0,0 +1,132 @@ +using Application.Common; +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Domain.Common.ValueObjects; +using IdentityApplication.ApplicationServices; + +namespace IdentityApplication; + +public class SingleSignOnApplication : ISingleSignOnApplication +{ + private readonly IAuthTokensService _authTokensService; + private readonly IEndUsersService _endUsersService; + private readonly IRecorder _recorder; + private readonly ISSOProvidersService _ssoProvidersService; + + public SingleSignOnApplication(IRecorder recorder, IEndUsersService endUsersService, + ISSOProvidersService ssoProvidersService, IAuthTokensService authTokensService) + { + _recorder = recorder; + _endUsersService = endUsersService; + _ssoProvidersService = ssoProvidersService; + _authTokensService = authTokensService; + } + + public async Task> AuthenticateAsync(ICallerContext context, string providerName, + string authCode, string? username, CancellationToken cancellationToken) + { + var retrieved = await _ssoProvidersService.FindByNameAsync(providerName, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.NotAuthenticated(); + } + + var provider = retrieved.Value.Value; + var authenticated = await provider.AuthenticateAsync(context, authCode, username, cancellationToken); + if (!authenticated.IsSuccessful) + { + return authenticated.Error; + } + + var userInfo = authenticated.Value; + var userExists = + await _endUsersService.FindPersonByEmailAsync(context, userInfo.EmailAddress, cancellationToken); + if (!userExists.IsSuccessful) + { + return userExists.Error; + } + + string registeredUserId; + if (!userExists.Value.HasValue) + { + var autoRegistered = await _endUsersService.RegisterPersonAsync(context, userInfo.EmailAddress, + userInfo.FirstName, userInfo.LastName, userInfo.Timezone.ToString(), userInfo.CountryCode.ToString(), + true, + cancellationToken); + if (!autoRegistered.IsSuccessful) + { + return autoRegistered.Error; + } + + registeredUserId = autoRegistered.Value.Id; + + _recorder.AuditAgainst(context.ToCall(), autoRegistered.Value.Id, + Audits.SingleSignOnApplication_Authenticate_AccountOnboarded, + "User {Id} was registered automatically from SSO {Provider}", autoRegistered.Value.Id, providerName); + } + else + { + registeredUserId = userExists.Value.Value.Id; + } + + var registered = await _endUsersService.GetMembershipsAsync(context, registeredUserId, cancellationToken); + if (!registered.IsSuccessful) + { + return Error.NotAuthenticated(); + } + + var user = registered.Value; + if (user.Status != EndUserStatus.Registered) + { + return Error.NotAuthenticated(); + } + + if (user.Access == EndUserAccess.Suspended) + { + _recorder.AuditAgainst(context.ToCall(), user.Id, + Audits.SingleSignOnApplication_Authenticate_AccountSuspended, + "User {Id} tried to authenticate with SSO {Provider} with a suspended account", user.Id, providerName); + return Error.EntityExists(Resources.SingleSignOnApplication_AccountSuspended); + } + + var saved = await _ssoProvidersService.SaveUserInfoAsync(providerName, registeredUserId.ToId(), userInfo, + cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + _recorder.AuditAgainst(context.ToCall(), user.Id, + Audits.SingleSignOnApplication_Authenticate_Succeeded, + "User {Id} succeeded to authenticate with SSO {Provider}", user.Id, providerName); + + var issued = await _authTokensService.IssueTokensAsync(context, user, cancellationToken); + if (!issued.IsSuccessful) + { + return issued.Error; + } + + var tokens = issued.Value; + return new Result(new AuthenticateTokens + { + AccessToken = new AuthenticateToken + { + Value = tokens.AccessToken, + ExpiresOn = tokens.AccessTokenExpiresOn + }, + RefreshToken = new AuthenticateToken + { + Value = tokens.RefreshToken, + ExpiresOn = tokens.RefreshTokenExpiresOn + }, + UserId = user.Id + }); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/SSOAuthTokenSpec.cs b/src/IdentityDomain.UnitTests/SSOAuthTokenSpec.cs new file mode 100644 index 00000000..b9264b5c --- /dev/null +++ b/src/IdentityDomain.UnitTests/SSOAuthTokenSpec.cs @@ -0,0 +1,30 @@ +using Common; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class SSOAuthTokenSpec +{ + [Fact] + public void WhenCreateAndValueIsEmpty_ThenReturnsError() + { + var result = SSOAuthToken.Create(SSOAuthTokenType.AccessToken, string.Empty, null); + + result.Should().BeError(ErrorCode.Validation); + } + + [Fact] + public void WhenCreate_ThenReturns() + { + var expiresOn = DateTime.UtcNow; + var result = SSOAuthToken.Create(SSOAuthTokenType.AccessToken, "atoken", expiresOn); + + result.Should().BeSuccess(); + result.Value.Type.Should().Be(SSOAuthTokenType.AccessToken); + result.Value.Value.Should().Be("atoken"); + result.Value.ExpiresOn.Should().Be(expiresOn); + } +} \ No newline at end of file diff --git a/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs b/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs new file mode 100644 index 00000000..6c3ea990 --- /dev/null +++ b/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs @@ -0,0 +1,62 @@ +using Common; +using Common.Extensions; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces.Entities; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; +using FluentAssertions; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityDomain.UnitTests; + +[Trait("Category", "Unit")] +public class SSOUserRootSpec +{ + private readonly SSOUserRoot _user; + + public SSOUserRootSpec() + { + var recorder = new Mock(); + var idFactory = new Mock(); + idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + var encryptionService = new Mock(); + encryptionService.Setup(es => es.Encrypt(It.IsAny())) + .Returns((string value) => value); + encryptionService.Setup(es => es.Decrypt(It.IsAny())) + .Returns((string value) => value); + _user = SSOUserRoot.Create(recorder.Object, idFactory.Object, encryptionService.Object, "aprovidername", + "auserid".ToId()).Value; + } + + [Fact] + public void WhenConstructed_ThenAssigned() + { + _user.ProviderName.Should().Be("aprovidername"); + _user.UserId.Should().Be("auserid".ToId()); + } + + [Fact] + public void WhenUpdateDetails_ThenUpdates() + { + var expiresOn = DateTime.UtcNow; + var tokens = SSOAuthTokens.Create(new List + { + SSOAuthToken.Create(SSOAuthTokenType.AccessToken, "anaccesstoken", expiresOn).Value + }).Value; + + _user.UpdateDetails(tokens, EmailAddress.Create("auser@company.com").Value, + PersonName.Create("afirstname", null).Value, Timezone.Default, Address.Default); + + _user.UserId.Should().Be("auserid".ToId()); + _user.EmailAddress.Value.Address.Should().Be("auser@company.com"); + _user.Name.Value.FirstName.Text.Should().Be("afirstname"); + _user.Name.Value.LastName.Should().BeNone(); + _user.Timezone.Value.Code.Should().Be(Timezones.Default); + _user.Address.Value.CountryCode.Should().Be(CountryCodes.Default); + _user.Events.Last().Should().BeOfType(); + } +} \ No newline at end of file diff --git a/src/IdentityDomain/Events.cs b/src/IdentityDomain/Events.cs index 30652a7a..d34af954 100644 --- a/src/IdentityDomain/Events.cs +++ b/src/IdentityDomain/Events.cs @@ -276,10 +276,10 @@ public static TokensRefreshed Create(Identifier id, Identifier userId, string ac public required DateTime AccessTokenExpiresOn { get; set; } - public required DateTime RefreshTokenExpiresOn { get; set; } - public required string RefreshToken { get; set; } + public required DateTime RefreshTokenExpiresOn { get; set; } + public required string UserId { get; set; } public required string RootId { get; set; } @@ -376,4 +376,64 @@ public static KeyVerified Create(Identifier id, bool isVerified) public DateTime OccurredUtc { get; set; } } } + + public static class SSOUsers + { + public class Created : IDomainEvent + { + public static Created Create(Identifier id, string providerName, Identifier userId) + { + return new Created + { + RootId = id, + ProviderName = providerName, + UserId = userId, + OccurredUtc = DateTime.UtcNow + }; + } + + public required string ProviderName { get; set; } + + public required string UserId { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + + public class TokensUpdated : IDomainEvent + { + public static TokensUpdated Create(Identifier id, string tokens, EmailAddress emailAddress, + PersonName name, Timezone timezone, Address address) + { + return new TokensUpdated + { + RootId = id, + Tokens = tokens, + EmailAddress = emailAddress, + FirstName = name.FirstName, + LastName = name.LastName.ValueOrDefault?.Text, + Timezone = timezone.Code.ToString(), + CountryCode = address.CountryCode.ToString(), + OccurredUtc = DateTime.UtcNow + }; + } + + public required string CountryCode { get; set; } + + public required string EmailAddress { get; set; } + + public required string FirstName { get; set; } + + public string? LastName { get; set; } + + public required string Timezone { get; set; } + + public required string Tokens { get; set; } + + public required string RootId { get; set; } + + public DateTime OccurredUtc { get; set; } + } + } } \ No newline at end of file diff --git a/src/IdentityDomain/SSOAuthToken.cs b/src/IdentityDomain/SSOAuthToken.cs new file mode 100644 index 00000000..675713b8 --- /dev/null +++ b/src/IdentityDomain/SSOAuthToken.cs @@ -0,0 +1,47 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace IdentityDomain; + +public sealed class SSOAuthToken : ValueObjectBase +{ + public static Result Create(SSOAuthTokenType type, string value, DateTime? expiresOn) + { + if (value.IsNotValuedParameter(nameof(value), out var error1)) + { + return error1; + } + + return new SSOAuthToken(type, value, expiresOn); + } + + private SSOAuthToken(SSOAuthTokenType type, string value, DateTime? expiresOn) + { + Type = type; + Value = value; + ExpiresOn = expiresOn; + } + + public DateTime? ExpiresOn { get; } + + public SSOAuthTokenType Type { get; } + + public string Value { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new SSOAuthToken(parts[0].ToEnumOrDefault(SSOAuthTokenType.AccessToken), parts[1]!, + parts[2]?.FromIso8601()); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object?[] { Type, Value, ExpiresOn }; + } +} \ No newline at end of file diff --git a/src/IdentityDomain/SSOAuthTokenType.cs b/src/IdentityDomain/SSOAuthTokenType.cs new file mode 100644 index 00000000..3008f3be --- /dev/null +++ b/src/IdentityDomain/SSOAuthTokenType.cs @@ -0,0 +1,8 @@ +namespace IdentityDomain; + +public enum SSOAuthTokenType +{ + AccessToken = 0, + RefreshToken = 1, + IdToken = 2 +} \ No newline at end of file diff --git a/src/IdentityDomain/SSOAuthTokens.cs b/src/IdentityDomain/SSOAuthTokens.cs new file mode 100644 index 00000000..8b5bd86b --- /dev/null +++ b/src/IdentityDomain/SSOAuthTokens.cs @@ -0,0 +1,26 @@ +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace IdentityDomain; + +public sealed class SSOAuthTokens : SingleValueObjectBase> +{ + public static Result Create(List value) + { + return new SSOAuthTokens(value); + } + + private SSOAuthTokens(List value) : base(value) + { + } + + public static ValueObjectFactory Rehydrate() + { + return (property, container) => + { + var items = RehydrateToList(property, true, true); + return new SSOAuthTokens(items.Select(item => SSOAuthToken.Rehydrate()(item!, container)).ToList()); + }; + } +} \ No newline at end of file diff --git a/src/IdentityDomain/SSOUserRoot.cs b/src/IdentityDomain/SSOUserRoot.cs new file mode 100644 index 00000000..1af67565 --- /dev/null +++ b/src/IdentityDomain/SSOUserRoot.cs @@ -0,0 +1,131 @@ +using Common; +using Common.Extensions; +using Domain.Common.Entities; +using Domain.Common.Identity; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Entities; +using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared.DomainServices; +using Domain.Shared; + +namespace IdentityDomain; + +public sealed class SSOUserRoot : AggregateRootBase +{ + private readonly IEncryptionService _encryptionService; + + public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, + IEncryptionService encryptionService, + string providerName, Identifier userId) + { + var root = new SSOUserRoot(recorder, idFactory, encryptionService); + root.RaiseCreateEvent(IdentityDomain.Events.SSOUsers.Created.Create(root.Id, providerName, userId)); + return root; + } + + private SSOUserRoot(IRecorder recorder, IIdentifierFactory idFactory, IEncryptionService encryptionService) : base( + recorder, idFactory) + { + _encryptionService = encryptionService; + } + + private SSOUserRoot(IRecorder recorder, IIdentifierFactory idFactory, IEncryptionService encryptionService, + ISingleValueObject identifier) : base( + recorder, idFactory, identifier) + { + _encryptionService = encryptionService; + } + + public Optional
Address { get; private set; } + + public Optional EmailAddress { get; private set; } + + public Optional Name { get; private set; } + + public Optional ProviderName { get; private set; } + + public Optional Timezone { get; private set; } + + public Optional Tokens { get; private set; } + + public Identifier UserId { get; private set; } = Identifier.Empty(); + + public static AggregateRootFactory Rehydrate() + { + return (identifier, container, _) => new SSOUserRoot(container.Resolve(), + container.Resolve(), container.Resolve(), identifier); + } + + public override Result EnsureInvariants() + { + var ensureInvariants = base.EnsureInvariants(); + if (!ensureInvariants.IsSuccessful) + { + return ensureInvariants.Error; + } + + return Result.Ok; + } + + protected override Result OnStateChanged(IDomainEvent @event, bool isReconstituting) + { + switch (@event) + { + case Events.SSOUsers.Created created: + { + ProviderName = created.ProviderName; + UserId = created.UserId.ToId(); + return Result.Ok; + } + + case Events.SSOUsers.TokensUpdated changed: + { + var emailAddress = Domain.Shared.EmailAddress.Create(changed.EmailAddress); + if (!emailAddress.IsSuccessful) + { + return emailAddress.Error; + } + + var name = PersonName.Create(changed.FirstName, changed.LastName); + if (!name.IsSuccessful) + { + return name.Error; + } + + var timezone = Domain.Shared.Timezone.Create(changed.Timezone); + if (!timezone.IsSuccessful) + { + return timezone.Error; + } + + var address = Domain.Shared.Address.Create(CountryCodes.FindOrDefault(changed.CountryCode)); + if (!address.IsSuccessful) + { + return address.Error; + } + + var tokens = _encryptionService.Decrypt(changed.Tokens); + Name = name.Value; + EmailAddress = emailAddress.Value; + Timezone = timezone.Value; + Address = address.Value; + Tokens = tokens; + Recorder.TraceDebug(null, "User {Id} has updated their tokens", Id); + return Result.Ok; + } + + default: + return HandleUnKnownStateChangedEvent(@event); + } + } + + public Result UpdateDetails(SSOAuthTokens tokens, EmailAddress emailAddress, + PersonName name, Timezone timezone, Address address) + { + var secureTokens = _encryptionService.Encrypt(tokens.ToJson(false)!); + return RaiseChangeEvent( + IdentityDomain.Events.SSOUsers.TokensUpdated.Create(Id, secureTokens, emailAddress, name, timezone, + address)); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.IntegrationTests/AuthTokensApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/AuthTokensApiSpec.cs index fc82fe54..27d50002 100644 --- a/src/IdentityInfrastructure.IntegrationTests/AuthTokensApiSpec.cs +++ b/src/IdentityInfrastructure.IntegrationTests/AuthTokensApiSpec.cs @@ -43,28 +43,28 @@ await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest Password = "1Password!" }); - oldTokens.Content.Value.AccessToken.Should().NotBeNull(); - oldTokens.Content.Value.RefreshToken.Should().NotBeNull(); - oldTokens.Content.Value.AccessTokenExpiresOnUtc.Should() + oldTokens.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + oldTokens.Content.Value.Tokens.AccessToken.ExpiresOn.Should() .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); - oldTokens.Content.Value.RefreshTokenExpiresOnUtc.Should() + oldTokens.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + oldTokens.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); await Task.Delay(TimeSpan .FromSeconds(1)); //HACK: to ensure that the new token is not the same (in time) as the old token - var oldAccessToken = oldTokens.Content.Value.AccessToken; - var oldRefreshToken = oldTokens.Content.Value.RefreshToken; + var oldAccessToken = oldTokens.Content.Value.Tokens.AccessToken.Value; + var oldRefreshToken = oldTokens.Content.Value.Tokens.RefreshToken.Value; var newTokens = await Api.PostAsync(new RefreshTokenRequest { - RefreshToken = oldRefreshToken! + RefreshToken = oldRefreshToken }); - newTokens.Content.Value.AccessToken.Should().NotBeNull().And.NotBe(oldAccessToken); - newTokens.Content.Value.RefreshToken.Should().NotBeNull().And.NotBe(oldRefreshToken); - newTokens.Content.Value.AccessTokenExpiresOnUtc.Should() + newTokens.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull().And.NotBe(oldAccessToken); + newTokens.Content.Value.Tokens.AccessToken.ExpiresOn.Should() .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); - newTokens.Content.Value.RefreshTokenExpiresOnUtc.Should() + newTokens.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull().And.NotBe(oldRefreshToken); + newTokens.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); } @@ -74,11 +74,13 @@ public async Task WhenRevokeRefreshToken_ThenRevokes() var user = await LoginUserAsync(); var oldRefreshToken = user.RefreshToken; - await Api.DeleteAsync(new RevokeRefreshTokenRequest + var revoked = await Api.DeleteAsync(new RevokeRefreshTokenRequest { RefreshToken = oldRefreshToken }); + revoked.StatusCode.Should().Be(HttpStatusCode.NoContent); + var refreshed = await Api.PostAsync(new RefreshTokenRequest { RefreshToken = oldRefreshToken diff --git a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs index f9f8f445..9051d4d5 100644 --- a/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs +++ b/src/IdentityInfrastructure.IntegrationTests/PasswordCredentialsApiSpec.cs @@ -90,11 +90,11 @@ await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest Password = "1Password!" }); - result.Content.Value.AccessToken.Should().NotBeNull(); - result.Content.Value.RefreshToken.Should().NotBeNull(); - result.Content.Value.AccessTokenExpiresOnUtc.Should() + result.Content.Value.Tokens!.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); - result.Content.Value.RefreshTokenExpiresOnUtc.Should() + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); } @@ -122,7 +122,7 @@ await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest Password = "1Password!" }); - var accessToken = authenticate.Content.Value.AccessToken!; + var accessToken = authenticate.Content.Value.Tokens!.AccessToken.Value; #if TESTINGONLY var result = await Api.GetAsync(new GetCallerWithTokenOrAPIKeyTestingOnlyRequest(), diff --git a/src/IdentityInfrastructure.IntegrationTests/SingleSignOnApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/SingleSignOnApiSpec.cs new file mode 100644 index 00000000..536189e6 --- /dev/null +++ b/src/IdentityInfrastructure.IntegrationTests/SingleSignOnApiSpec.cs @@ -0,0 +1,149 @@ +using System.Net; +using ApiHost1; +using FluentAssertions; +using IdentityInfrastructure.ApplicationServices; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Identities; +using Infrastructure.Web.Api.Operations.Shared.TestingOnly; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using UnitTesting.Common.Validation; +using Xunit; + +namespace IdentityInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.Web")] +[Collection("API")] +public class SingleSignOnApiSpec : WebApiSpec +{ + public SingleSignOnApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + } + + [Fact] + public async Task WhenAuthenticateAndUnknownProvider_ThenReturnsError() + { + var result = await Api.PostAsync(new AuthenticateSingleSignOnRequest + { + Provider = "anunknownprovider", + AuthCode = "1234567890" + }); + + result.Content.Error.Status.Should().Be((int)HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task WhenAuthenticateAndNoUsername_ThenReturnsError() + { +#if TESTINGONLY + var result = await Api.PostAsync(new AuthenticateSingleSignOnRequest + { + Username = null, + Provider = FakeSSOAuthenticationProvider.SSOName, + AuthCode = "1234567890" + }); + + result.Content.Error.Status.Should().Be((int)HttpStatusCode.BadRequest); + result.Content.Error.Detail.Should().Be(Resources.TestSSOAuthenticationProvider_MissingUsername); +#endif + } + + [Fact] + public async Task WhenAuthenticateAndWrongAuthCode_ThenReturnsError() + { +#if TESTINGONLY + var result = await Api.PostAsync(new AuthenticateSingleSignOnRequest + { + Username = "auser@company.com", + Provider = FakeSSOAuthenticationProvider.SSOName, + AuthCode = "awrongcode" + }); + + result.Content.Error.Status.Should().Be((int)HttpStatusCode.Unauthorized); +#endif + } + + [Fact] + public async Task WhenAuthenticateAndUserNotExists_ThenRegistersAndReturnsUser() + { +#if TESTINGONLY + var result = await Api.PostAsync(new AuthenticateSingleSignOnRequest + { + Username = "auser@company.com", + Provider = FakeSSOAuthenticationProvider.SSOName, + AuthCode = "1234567890" + }); + + result.Content.Value.Tokens!.UserId.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); +#endif + } + + [Fact] + public async Task WhenAuthenticateAndUserExists_ThenReturnsTokens() + { +#if TESTINGONLY + var person = await Api.PostAsync(new RegisterPersonPasswordRequest + { + EmailAddress = "auser@company.com", + FirstName = "afirstname", + LastName = "alastname", + Password = "1Password!", + TermsAndConditionsAccepted = true + }); + + var token = NotificationsService.LastRegistrationConfirmationToken; + await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest + { + Token = token! + }); + + var result = await Api.PostAsync(new AuthenticateSingleSignOnRequest + { + Username = "auser@company.com", + Provider = FakeSSOAuthenticationProvider.SSOName, + AuthCode = "1234567890" + }); + + result.Content.Value.Tokens!.UserId.Should().Be(person.Content.Value.Credential!.User.Id); + result.Content.Value.Tokens.AccessToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.AccessToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry)); + result.Content.Value.Tokens.RefreshToken.Value.Should().NotBeNull(); + result.Content.Value.Tokens.RefreshToken.ExpiresOn.Should() + .BeNear(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultRefreshTokenExpiry)); +#endif + } + + [Fact] + public async Task WhenCallingSecureApiAfterAuthenticate_ThenReturnsResponse() + { +#if TESTINGONLY + var authenticate = await Api.PostAsync(new AuthenticateSingleSignOnRequest + { + Username = "auser@company.com", + Provider = FakeSSOAuthenticationProvider.SSOName, + AuthCode = "1234567890" + }); + + var accessToken = authenticate.Content.Value.Tokens!.AccessToken.Value; + + var result = await Api.GetAsync(new GetCallerWithTokenOrAPIKeyTestingOnlyRequest(), + req => req.SetJWTBearerToken(accessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Content.Value.CallerId.Should().Be(authenticate.Content.Value.Tokens.UserId); +#endif + } + + private static void OverrideDependencies(IServiceCollection services) + { + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidatorSpec.cs b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidatorSpec.cs index 03bd56e5..90bb9062 100644 --- a/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidatorSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidatorSpec.cs @@ -19,7 +19,8 @@ public AuthenticateSingleSignOnRequestValidatorSpec() _dto = new AuthenticateSingleSignOnRequest { Provider = "aprovider", - AuthCode = "anauthcode" + AuthCode = "anauthcode", + Username = null }; } @@ -50,4 +51,31 @@ public void WhenAuthCodeIsEmpty_ThenThrows() .Should().Throw() .WithMessageLike(Resources.AuthenticateSingleSignOnRequestValidator_InvalidAuthCode); } + + [Fact] + public void WhenUsernameIsNull_ThenSucceeds() + { + _dto.Username = null; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenUsernameIsValid_ThenSucceeds() + { + _dto.Username = "auser@company.com"; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenUsernameIsInvalid_ThenThrows() + { + _dto.Username = "notanemail"; + + _validator + .Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateSingleSignOnRequestValidator_InvalidUsername); + } } \ No newline at end of file diff --git a/src/IdentityInfrastructure.UnitTests/ApplicationServices/FakeSSOAuthenticationProviderSpec.cs b/src/IdentityInfrastructure.UnitTests/ApplicationServices/FakeSSOAuthenticationProviderSpec.cs new file mode 100644 index 00000000..51d46dd1 --- /dev/null +++ b/src/IdentityInfrastructure.UnitTests/ApplicationServices/FakeSSOAuthenticationProviderSpec.cs @@ -0,0 +1,64 @@ +#if TESTINGONLY +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using FluentAssertions; +using IdentityInfrastructure.ApplicationServices; +using Infrastructure.Interfaces; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace IdentityInfrastructure.UnitTests.ApplicationServices; + +[Trait("Category", "Unit")] +public class FakeSSOAuthenticationProviderSpec +{ + private readonly Mock _caller; + private readonly FakeSSOAuthenticationProvider _provider; + + public FakeSSOAuthenticationProviderSpec() + { + _caller = new Mock(); + _provider = new FakeSSOAuthenticationProvider(); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndNoUsername_ThenReturnsError() + { + var result = + await _provider.AuthenticateAsync(_caller.Object, "1234567890", null, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.TestSSOAuthenticationProvider_MissingUsername); + } + + [Fact] + public async Task WhenAuthenticateAsyncAndWrongAuthCode_ThenReturnsError() + { + var result = + await _provider.AuthenticateAsync(_caller.Object, "awrongcode", null, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.TestSSOAuthenticationProvider_MissingUsername); + } + + [Fact] + public async Task WhenAuthenticateAsync_ThenReturnsTokens() + { + var result = + await _provider.AuthenticateAsync(_caller.Object, "1234567890", "anemailaddress", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Tokens.Count.Should().Be(1); + result.Value.Tokens[0].Type.Should().Be(TokenType.AccessToken); + result.Value.Tokens[0].Value.Should().NotBeNull(); + result.Value.Tokens[0].ExpiresOn.Should() + .BeCloseTo(DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry), + TimeSpan.FromMinutes(1)); + result.Value.EmailAddress.Should().Be("anemailaddress"); + result.Value.FirstName.Should().Be("anemailaddress"); + result.Value.LastName.Should().Be("asurname"); + result.Value.Timezone.Should().Be(Timezones.Default); + result.Value.CountryCode.Should().Be(CountryCodes.Default); + } +} +#endif \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs b/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs index f08b137d..fe9c5d8d 100644 --- a/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs +++ b/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs @@ -1,5 +1,5 @@ +using Application.Resources.Shared; using IdentityApplication; -using IdentityApplication.ApplicationServices; using Infrastructure.Interfaces; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Interfaces; @@ -19,30 +19,27 @@ public AuthTokensApi(ICallerContextFactory contextFactory, _authTokensApplication = authTokensApplication; } - public async Task Revoke(RevokeRefreshTokenRequest request, + public async Task> Refresh(RefreshTokenRequest request, CancellationToken cancellationToken) { var tokens = - await _authTokensApplication.RevokeRefreshTokenAsync(_contextFactory.Create(), request.RefreshToken, + await _authTokensApplication.RefreshTokenAsync(_contextFactory.Create(), request.RefreshToken, cancellationToken); - return () => tokens.HandleApplicationResult(); + return () => tokens.HandleApplicationResult(tok => + new PostResult(new RefreshTokenResponse + { + Tokens = tok + })); } - public async Task> Refresh(RefreshTokenRequest request, + public async Task Revoke(RevokeRefreshTokenRequest request, CancellationToken cancellationToken) { var tokens = - await _authTokensApplication.RefreshTokenAsync(_contextFactory.Create(), request.RefreshToken, + await _authTokensApplication.RevokeRefreshTokenAsync(_contextFactory.Create(), request.RefreshToken, cancellationToken); - return () => tokens.HandleApplicationResult(x => - new PostResult(new RefreshTokenResponse - { - AccessToken = x.AccessToken, - RefreshToken = x.RefreshToken, - AccessTokenExpiresOnUtc = x.AccessTokenExpiresOn, - RefreshTokenExpiresOnUtc = x.RefreshTokenExpiresOn - })); + return () => tokens.HandleApplicationResult(); } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidator.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidator.cs index 86953ee3..c8419906 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidator.cs +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/AuthenticateSingleSignOnRequestValidator.cs @@ -1,4 +1,6 @@ +using Common.Extensions; using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; using Infrastructure.Web.Api.Operations.Shared.Identities; namespace IdentityInfrastructure.Api.PasswordCredentials; @@ -13,5 +15,10 @@ public AuthenticateSingleSignOnRequestValidator() RuleFor(req => req.AuthCode) .NotEmpty() .WithMessage(Resources.AuthenticateSingleSignOnRequestValidator_InvalidAuthCode); + RuleFor(req => req.Username) + .NotEmpty() + .IsEmailAddress() + .When(req => req.Username.HasValue()) + .WithMessage(Resources.AuthenticateSingleSignOnRequestValidator_InvalidUsername); } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs index 0ecdc4ee..1c18b98e 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs @@ -32,11 +32,7 @@ await _passwordCredentialsApplication.AuthenticateAsync(_contextFactory.Create() return () => authenticated.HandleApplicationResult(tok => new PostResult(new AuthenticateResponse { - AccessToken = tok.AccessToken, - RefreshToken = tok.RefreshToken, - AccessTokenExpiresOnUtc = tok.AccessTokenExpiresOn, - RefreshTokenExpiresOnUtc = tok.RefreshTokenExpiresOn, - UserId = tok.UserId + Tokens = tok })); } diff --git a/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs b/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs new file mode 100644 index 00000000..a6b6498b --- /dev/null +++ b/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs @@ -0,0 +1,35 @@ +using Application.Resources.Shared; +using IdentityApplication; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Identities; + +namespace IdentityInfrastructure.Api.SSO; + +public class SingleSignOnApi : IWebApiService +{ + private readonly ICallerContextFactory _contextFactory; + private readonly ISingleSignOnApplication _singleSignOnApplication; + + public SingleSignOnApi(ICallerContextFactory contextFactory, + ISingleSignOnApplication singleSignOnApplication) + { + _contextFactory = contextFactory; + _singleSignOnApplication = singleSignOnApplication; + } + + public async Task> Authenticate( + AuthenticateSingleSignOnRequest request, CancellationToken cancellationToken) + { + var authenticated = + await _singleSignOnApplication.AuthenticateAsync(_contextFactory.Create(), request.Provider, + request.AuthCode, request.Username, cancellationToken); + + return () => authenticated.HandleApplicationResult(tok => + new PostResult(new AuthenticateResponse + { + Tokens = tok + })); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs b/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs new file mode 100644 index 00000000..97280204 --- /dev/null +++ b/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs @@ -0,0 +1,66 @@ +#if TESTINGONLY +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using IdentityApplication.ApplicationServices; +using Infrastructure.Interfaces; +using Infrastructure.Shared; + +namespace IdentityInfrastructure.ApplicationServices; + +/// +/// Provides a fake example OAuth2 service that returns a set of OAuth tokens +/// +public class FakeOAuth2Service : IOAuth2Service +{ + public Task, Error>> ExchangeCodeForTokensAsync(ICallerContext context, + OAuth2CodeTokenExchangeOptions options, + CancellationToken cancellationToken) + { + if (options.Code != "1234567890") + { + return Task.FromResult, Error>>(Error.RuleViolation()); + } + + return Task.FromResult, Error>>(new List + { + CreateAccessToken(options) + }); + } + + public static AuthToken CreateAccessToken(OAuth2CodeTokenExchangeOptions options) + { + var expiresOn = DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry); + var accessToken = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: new Claim[] + { + new(ClaimTypes.Email, options.CodeVerifier!), + new(ClaimTypes.GivenName, options.CodeVerifier!), + new(ClaimTypes.Surname, "asurname"), + new(AuthenticationConstants.Claims.ForTimezone, Timezones.Default.ToString()), + new(ClaimTypes.Country, CountryCodes.Default.ToString()) + }, expires: expiresOn, + issuer: options.ServiceName + )); + + return new AuthToken(TokenType.AccessToken, accessToken, expiresOn); + } + + public static SSOUserInfo GetInfoFromToken(List tokens) + { + var accessToken = tokens.Single(tok => tok.Type == TokenType.AccessToken).Value; + + 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; + var lastName = claims.Single(c => c.Type == ClaimTypes.Surname).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); + } +} +#endif \ No newline at end of file diff --git a/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs b/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs new file mode 100644 index 00000000..f9d12654 --- /dev/null +++ b/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs @@ -0,0 +1,53 @@ +#if TESTINGONLY +using Application.Interfaces; +using Common; +using Common.Extensions; +using IdentityApplication.ApplicationServices; +using Infrastructure.Shared; + +namespace IdentityInfrastructure.ApplicationServices; + +/// +/// Provides a fake example that can be copied for real providers, +/// such as Google, Microsoft, Facebook and others +/// +public class FakeSSOAuthenticationProvider : ISSOAuthenticationProvider +{ + public const string SSOName = "testsso"; + private const string ServiceName = "FakeOAuth2Service"; + private readonly IOAuth2Service _auth2Service; + + public FakeSSOAuthenticationProvider() : this(new FakeOAuth2Service()) + { + } + + private FakeSSOAuthenticationProvider(IOAuth2Service auth2Service) + { + _auth2Service = auth2Service; + } + + public async Task> AuthenticateAsync(ICallerContext context, string authCode, + string? emailAddress, CancellationToken cancellationToken) + { + if (emailAddress.HasNoValue()) + { + return Error.RuleViolation(Resources.TestSSOAuthenticationProvider_MissingUsername); + } + + var retrievedTokens = + await _auth2Service.ExchangeCodeForTokensAsync(context, + new OAuth2CodeTokenExchangeOptions(ServiceName, authCode, emailAddress), + cancellationToken); + if (!retrievedTokens.IsSuccessful) + { + return Error.NotAuthenticated(); + } + + var tokens = retrievedTokens.Value; + + return FakeOAuth2Service.GetInfoFromToken(tokens); + } + + public string ProviderName => SSOName; +} +#endif \ No newline at end of file diff --git a/src/IdentityInfrastructure/IdentityInfrastructure.csproj b/src/IdentityInfrastructure/IdentityInfrastructure.csproj index 005be3e1..b3de063a 100644 --- a/src/IdentityInfrastructure/IdentityInfrastructure.csproj +++ b/src/IdentityInfrastructure/IdentityInfrastructure.csproj @@ -7,6 +7,7 @@ + @@ -20,6 +21,9 @@ <_Parameter1>$(AssemblyName).UnitTests + + <_Parameter1>IdentityInfrastructure.IntegrationTests + diff --git a/src/IdentityInfrastructure/IdentityModule.cs b/src/IdentityInfrastructure/IdentityModule.cs index d4fe63eb..451027c5 100644 --- a/src/IdentityInfrastructure/IdentityModule.cs +++ b/src/IdentityInfrastructure/IdentityModule.cs @@ -2,7 +2,9 @@ using Application.Persistence.Interfaces; using Application.Services.Shared; using Common; +using Common.Configuration; using Domain.Interfaces; +using Domain.Services.Shared.DomainServices; using IdentityApplication; using IdentityApplication.ApplicationServices; using IdentityApplication.Persistence; @@ -13,6 +15,7 @@ using IdentityInfrastructure.DomainServices; using IdentityInfrastructure.Persistence; using IdentityInfrastructure.Persistence.ReadModels; +using Infrastructure.Common.DomainServices; using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Persistence.Interfaces; using Infrastructure.Web.Hosting.Common; @@ -51,11 +54,15 @@ public Action RegisterServices services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(); + services.RegisterUnshared(c => new AesEncryptionService(c + .ResolveForUnshared().Platform + .GetString("ApplicationServices:SSOProvidersService:SSOUserTokens:AesSecret"))); services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(); + services.RegisterUnshared(); services.RegisterUnshared(c => new PasswordCredentialsRepository( c.ResolveForUnshared(), c.ResolveForUnshared(), @@ -74,7 +81,7 @@ public Action RegisterServices c => new AuthTokensProjection(c.ResolveForUnshared(), c.ResolveForUnshared(), c.ResolveForPlatform())); - services.RegisterUnshared(c => new APIKeyRepository( + services.RegisterUnshared(c => new APIKeysRepository( c.ResolveForUnshared(), c.ResolveForUnshared(), c.ResolveForUnshared>(), @@ -83,9 +90,23 @@ public Action RegisterServices c => new APIKeyProjection(c.ResolveForUnshared(), c.ResolveForUnshared(), c.ResolveForPlatform())); + services.RegisterUnshared(c => new SSOUsersRepository( + c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForUnshared>(), + c.ResolveForPlatform())); + services.RegisterUnTenantedEventing( + c => new SSOUserProjection(c.ResolveForUnshared(), + c.ResolveForUnshared(), + c.ResolveForPlatform())); services.RegisterUnshared(); services.RegisterUnshared(); + services.RegisterUnshared(); +#if TESTINGONLY + // EXTEND: replace these registrations with your own OAuth2 implementations + services.RegisterUnshared(); +#endif }; } } diff --git a/src/IdentityInfrastructure/Persistence/APIKeyRepository.cs b/src/IdentityInfrastructure/Persistence/APIKeysRepository.cs similarity index 94% rename from src/IdentityInfrastructure/Persistence/APIKeyRepository.cs rename to src/IdentityInfrastructure/Persistence/APIKeysRepository.cs index 492f3bac..3eb69357 100644 --- a/src/IdentityInfrastructure/Persistence/APIKeyRepository.cs +++ b/src/IdentityInfrastructure/Persistence/APIKeysRepository.cs @@ -12,12 +12,12 @@ namespace IdentityInfrastructure.Persistence; -public class APIKeyRepository : IAPIKeyRepository +public class APIKeysRepository : IAPIKeysRepository { private readonly SnapshottingQueryStore _apiKeyQueries; private readonly IEventSourcingDddCommandStore _apiKeys; - public APIKeyRepository(IRecorder recorder, IDomainFactory domainFactory, + public APIKeysRepository(IRecorder recorder, IDomainFactory domainFactory, IEventSourcingDddCommandStore apiKeyStore, IDataStore store) { _apiKeyQueries = new SnapshottingQueryStore(recorder, domainFactory, store); diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs new file mode 100644 index 00000000..7d0fd844 --- /dev/null +++ b/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs @@ -0,0 +1,53 @@ +using Application.Persistence.Common.Extensions; +using Application.Persistence.Interfaces; +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Entities; +using IdentityApplication.Persistence.ReadModels; +using IdentityDomain; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; + +namespace IdentityInfrastructure.Persistence.ReadModels; + +public class SSOUserProjection : IReadModelProjection +{ + private readonly IReadModelProjectionStore _users; + + public SSOUserProjection(IRecorder recorder, IDomainFactory domainFactory, IDataStore store) + { + _users = new ReadModelProjectionStore(recorder, domainFactory, store); + } + + public Type RootAggregateType => typeof(SSOUserRoot); + + public async Task> ProjectEventAsync(IDomainEvent changeEvent, + CancellationToken cancellationToken) + { + switch (changeEvent) + { + case Events.SSOUsers.Created e: + return await _users.HandleCreateAsync(e.RootId.ToId(), dto => + { + dto.UserId = e.UserId; + dto.ProviderName = e.ProviderName; + }, + cancellationToken); + + case Events.SSOUsers.TokensUpdated e: + return await _users.HandleUpdateAsync(e.RootId.ToId(), dto => + { + dto.Tokens = e.Tokens; + dto.EmailAddress = e.EmailAddress; + dto.FirstName = e.FirstName; + dto.LastName = e.LastName; + dto.Timezone = e.Timezone; + dto.CountryCode = e.CountryCode; + }, cancellationToken); + + default: + return false; + } + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs b/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs new file mode 100644 index 00000000..b1b0402c --- /dev/null +++ b/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs @@ -0,0 +1,74 @@ +using Application.Persistence.Interfaces; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using IdentityApplication.Persistence; +using IdentityApplication.Persistence.ReadModels; +using IdentityDomain; +using Infrastructure.Persistence.Common; +using Infrastructure.Persistence.Interfaces; +using QueryAny; + +namespace IdentityInfrastructure.Persistence; + +public class SSOUsersRepository : ISSOUsersRepository +{ + private readonly ISnapshottingQueryStore _userQueries; + private readonly IEventSourcingDddCommandStore _users; + + public SSOUsersRepository(IRecorder recorder, IDomainFactory domainFactory, + IEventSourcingDddCommandStore usersStore, IDataStore store) + { + _userQueries = new SnapshottingQueryStore(recorder, domainFactory, store); + _users = usersStore; + } + + public async Task> DestroyAllAsync(CancellationToken cancellationToken) + { + return await Tasks.WhenAllAsync( + _userQueries.DestroyAllAsync(cancellationToken), + _users.DestroyAllAsync(cancellationToken)); + } + + public async Task, Error>> FindUserInfoByUserIdAsync(string providerName, + Identifier userId, CancellationToken cancellationToken) + { + var query = Query.From() + .Where(usr => usr.UserId, ConditionOperator.EqualTo, userId) + .AndWhere(usr => usr.ProviderName, ConditionOperator.EqualTo, providerName); + return await FindFirstByQueryAsync(query, cancellationToken); + } + + public async Task> SaveAsync(SSOUserRoot user, CancellationToken cancellationToken) + { + await _users.SaveAsync(user, cancellationToken); + + return user; + } + + private async Task, Error>> FindFirstByQueryAsync( + QueryClause query, + CancellationToken cancellationToken) + { + var queried = await _userQueries.QueryAsync(query, false, cancellationToken); + if (!queried.IsSuccessful) + { + return queried.Error; + } + + var matching = queried.Value.Results.FirstOrDefault(); + if (matching.NotExists()) + { + return Optional.None; + } + + var users = await _users.LoadAsync(matching.Id.Value.ToId(), cancellationToken); + if (!users.IsSuccessful) + { + return users.Error; + } + + return users.Value.ToOptional(); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/Resources.Designer.cs b/src/IdentityInfrastructure/Resources.Designer.cs index 45610723..3ce49531 100644 --- a/src/IdentityInfrastructure/Resources.Designer.cs +++ b/src/IdentityInfrastructure/Resources.Designer.cs @@ -95,6 +95,15 @@ internal static string AuthenticateSingleSignOnRequestValidator_InvalidProvider } } + /// + /// Looks up a localized string similar to The 'Username' is invalid. + /// + internal static string AuthenticateSingleSignOnRequestValidator_InvalidUsername { + get { + return ResourceManager.GetString("AuthenticateSingleSignOnRequestValidator_InvalidUsername", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'Token' is either missing or invalid. /// @@ -211,5 +220,14 @@ internal static string RevokeRefreshTokenRequestValidator_InvalidToken { return ResourceManager.GetString("RevokeRefreshTokenRequestValidator_InvalidToken", resourceCulture); } } + + /// + /// Looks up a localized string similar to The 'Username' must be provided for this authentication attempt. + /// + internal static string TestSSOAuthenticationProvider_MissingUsername { + get { + return ResourceManager.GetString("TestSSOAuthenticationProvider_MissingUsername", resourceCulture); + } + } } } diff --git a/src/IdentityInfrastructure/Resources.resx b/src/IdentityInfrastructure/Resources.resx index 9041af7a..6d972222 100644 --- a/src/IdentityInfrastructure/Resources.resx +++ b/src/IdentityInfrastructure/Resources.resx @@ -75,4 +75,10 @@ The 'AuthCode' is invalid or missing + + The 'Username' is invalid + + + The 'Username' must be provided for this authentication attempt + \ No newline at end of file diff --git a/src/Infrastructure.Interfaces/AuthenticationConstants.cs b/src/Infrastructure.Interfaces/AuthenticationConstants.cs index 880e6137..0befe229 100644 --- a/src/Infrastructure.Interfaces/AuthenticationConstants.cs +++ b/src/Infrastructure.Interfaces/AuthenticationConstants.cs @@ -11,6 +11,7 @@ public static class Claims public const string ForRole = ClaimTypes.Role; public const string PlatformPrefix = "Platform"; public const string TenantPrefix = "Tenant"; + public const string ForTimezone = "zoneinfo"; } public static class Authorization @@ -30,7 +31,6 @@ public static class Cookies public static class Providers { public const string Credentials = "credentials"; - public const string SingleSignOn = "sso"; } public static class Tokens diff --git a/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs new file mode 100644 index 00000000..17e1b0af --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs @@ -0,0 +1,70 @@ +using Application.Common; +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared._3rdParties; +using Infrastructure.Web.Interfaces.Clients; + +namespace Infrastructure.Shared.ApplicationServices; + +/// +/// Provides a general purpose OAuth2 service client for exchanging authorization codes for tokens. +/// Assumes The OAuth 2.0 Authorization Framework +/// +public class OAuth2HttpServiceClient : IOAuth2Service +{ + private readonly string _clientId; + private readonly string? _clientSecret; + private readonly IRecorder _recorder; + private readonly string _redirectUri; + private readonly IServiceClient _serviceClient; + + public OAuth2HttpServiceClient(IRecorder recorder, IServiceClient serviceClient, string clientId, + string? clientSecret, string redirectUri) + { + _recorder = recorder; + _serviceClient = serviceClient; + _clientId = clientId; + _clientSecret = clientSecret; + _redirectUri = redirectUri; + } + + public async Task, Error>> ExchangeCodeForTokensAsync(ICallerContext context, + OAuth2CodeTokenExchangeOptions options, CancellationToken cancellationToken) + { + try + { + var response = await _serviceClient.PostAsync(context, new ExchangeOAuth2CodeForTokensRequest + { + GrantType = "authorization_code", + Code = options.Code, + ClientId = _clientId, + ClientSecret = _clientSecret, + Scope = options.Scope, + RedirectUri = _redirectUri + }, null, cancellationToken); + + var tokens = new List(); + if (!response.IsSuccessful) + { + return Error.NotAuthenticated(response.Error.Detail ?? response.Error.Title); + } + + var expiresOn = DateTime.UtcNow.Add(TimeSpan.FromSeconds(response.Value.ExpiresIn)); + tokens.Add(new AuthToken(TokenType.AccessToken, response.Value.AccessToken!, expiresOn)); + if (response.Value.RefreshToken.HasValue()) + { + tokens.Add(new AuthToken(TokenType.RefreshToken, response.Value.AccessToken!, null)); + } + + return tokens; + } + catch (Exception ex) + { + _recorder.TraceError(context.ToCall(), ex, "Failed to exchange OAuth2 code with OAuth2 server {Server}", + options.ServiceName); + return Error.Unexpected(ex.Message); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/IOAuth2Service.cs b/src/Infrastructure.Shared/IOAuth2Service.cs new file mode 100644 index 00000000..24885bc0 --- /dev/null +++ b/src/Infrastructure.Shared/IOAuth2Service.cs @@ -0,0 +1,41 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Common.Extensions; + +namespace Infrastructure.Shared; + +/// +/// Defines a service for exchanging OAuth2 codes for tokens +/// +public interface IOAuth2Service +{ + Task, Error>> ExchangeCodeForTokensAsync(ICallerContext context, + OAuth2CodeTokenExchangeOptions options, + CancellationToken cancellationToken); +} + +/// +/// Defines options for token exchange +/// +public class OAuth2CodeTokenExchangeOptions +{ + public OAuth2CodeTokenExchangeOptions(string serviceName, string code, string? codeVerifier = null, + string? scope = null) + { + serviceName.ThrowIfNotValuedParameter(nameof(serviceName)); + code.ThrowIfNotValuedParameter(nameof(code)); + ServiceName = serviceName; + Code = code; + CodeVerifier = codeVerifier; + Scope = scope; + } + + public string Code { get; } + + public string? CodeVerifier { get; } + + public string? Scope { get; } + + public string ServiceName { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/Infrastructure.Shared.csproj b/src/Infrastructure.Shared/Infrastructure.Shared.csproj index b0446aab..3475be91 100644 --- a/src/Infrastructure.Shared/Infrastructure.Shared.csproj +++ b/src/Infrastructure.Shared/Infrastructure.Shared.csproj @@ -30,6 +30,8 @@ + + diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensRequest.cs new file mode 100644 index 00000000..f490d50b --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensRequest.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties; + +[Route("/auth/token", ServiceOperation.Post)] +public class ExchangeOAuth2CodeForTokensRequest : UnTenantedRequest +{ + [JsonPropertyName("client_id")] public required string ClientId { get; set; } + + [JsonPropertyName("client_secret")] public string? ClientSecret { get; set; } + + [JsonPropertyName("code")] public required string Code { get; set; } + + [JsonPropertyName("grant_type")] public required string GrantType { get; set; } + + [JsonPropertyName("redirect_uri")] public required string RedirectUri { get; set; } + + [JsonPropertyName("scope")] public string? Scope { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensResponse.cs new file mode 100644 index 00000000..f654e26e --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/ExchangeOAuth2CodeForTokensResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties; + +public class ExchangeOAuth2CodeForTokensResponse : IWebResponse +{ + [JsonPropertyName("access_token")] public string? AccessToken { get; set; } + + [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } + + [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } + + [JsonPropertyName("token_type")] public string? TokenType { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateResponse.cs index d734ebef..24152ebf 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateResponse.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateResponse.cs @@ -1,16 +1,9 @@ +using Application.Resources.Shared; using Infrastructure.Web.Api.Interfaces; namespace Infrastructure.Web.Api.Operations.Shared.Identities; public class AuthenticateResponse : IWebResponse { - public string? AccessToken { get; set; } - - public DateTime? AccessTokenExpiresOnUtc { get; set; } - - public string? RefreshToken { get; set; } - - public DateTime? RefreshTokenExpiresOnUtc { get; set; } - - public string? UserId { get; set; } + public AuthenticateTokens? Tokens { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateSingleSignOnRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateSingleSignOnRequest.cs index 11415830..0e250c93 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateSingleSignOnRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/AuthenticateSingleSignOnRequest.cs @@ -8,4 +8,6 @@ public class AuthenticateSingleSignOnRequest : UnTenantedRequest { public required string RefreshToken { get; set; } diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenResponse.cs index 0936bc7a..c295b1bc 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenResponse.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RefreshTokenResponse.cs @@ -1,14 +1,9 @@ +using Application.Resources.Shared; using Infrastructure.Web.Api.Interfaces; namespace Infrastructure.Web.Api.Operations.Shared.Identities; public class RefreshTokenResponse : IWebResponse { - public string? AccessToken { get; set; } - - public DateTime? AccessTokenExpiresOnUtc { get; set; } - - public string? RefreshToken { get; set; } - - public DateTime? RefreshTokenExpiresOnUtc { get; set; } + public AuthenticateTokens? Tokens { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RevokeRefreshTokenRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RevokeRefreshTokenRequest.cs index cf5c39d1..7e37990f 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/Identities/RevokeRefreshTokenRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/Identities/RevokeRefreshTokenRequest.cs @@ -2,7 +2,7 @@ namespace Infrastructure.Web.Api.Operations.Shared.Identities; -[Route("tokens/{RefreshToken}", ServiceOperation.Delete)] +[Route("/tokens/{RefreshToken}", ServiceOperation.Delete)] public class RevokeRefreshTokenRequest : UnTenantedDeleteRequest { public required string RefreshToken { get; set; } diff --git a/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticateRequestValidatorSpec.cs b/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticateRequestValidatorSpec.cs index e33d598e..b1959431 100644 --- a/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticateRequestValidatorSpec.cs +++ b/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticateRequestValidatorSpec.cs @@ -2,6 +2,7 @@ using FluentValidation; using Infrastructure.Interfaces; using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; +using JetBrains.Annotations; using UnitTesting.Common.Validation; using WebsiteHost; using WebsiteHost.Api.AuthN; @@ -9,100 +10,169 @@ namespace Infrastructure.Web.Website.UnitTests.Api.AuthN; -[Trait("Category", "Unit")] +[UsedImplicitly] public class AuthenticateRequestValidatorSpec { - private readonly AuthenticateRequest _dto; - private readonly AuthenticateRequestValidator _validator; - - public AuthenticateRequestValidatorSpec() + [Trait("Category", "Unit")] + public class GivenCredentialsProvider { - _validator = new AuthenticateRequestValidator(); - _dto = new AuthenticateRequest + private readonly AuthenticateRequest _dto; + private readonly AuthenticateRequestValidator _validator; + + public GivenCredentialsProvider() { - Provider = AuthenticationConstants.Providers.Credentials, - Username = "auser@company.com", - Password = "1Password!" - }; - } + _validator = new AuthenticateRequestValidator(); + _dto = new AuthenticateRequest + { + Provider = AuthenticationConstants.Providers.Credentials, + Username = "auser@company.com", + Password = "1Password!" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } - [Fact] - public void WhenAllProperties_ThenSucceeds() - { - _validator.ValidateAndThrow(_dto); - } + [Fact] + public void WhenProviderIsEmpty_ThenThrows() + { + _dto.Provider = string.Empty; - [Fact] - public void WhenProviderIsEmpty_TheThrows() - { - _dto.Provider = string.Empty; + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidProvider); + } - _validator.Invoking(x => x.ValidateAndThrow(_dto)) - .Should().Throw() - .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidProvider); - } + [Fact] + public void WhenUsernameIsEmpty_ThenThrows() + { + _dto.Provider = AuthenticationConstants.Providers.Credentials; + _dto.Username = string.Empty; - [Fact] - public void WhenProviderIsCredentialsAndUsernameIsEmpty_TheThrows() - { - _dto.Provider = AuthenticationConstants.Providers.Credentials; - _dto.Username = string.Empty; + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidUsername); + } - _validator.Invoking(x => x.ValidateAndThrow(_dto)) - .Should().Throw() - .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidUsername); - } + [Fact] + public void WhenUsernameIsNotValid_ThenThrows() + { + _dto.Provider = AuthenticationConstants.Providers.Credentials; + _dto.Username = "notanemail"; - [Fact] - public void WhenProviderIsCredentialsAndUsernameIsNotValid_TheThrows() - { - _dto.Provider = AuthenticationConstants.Providers.Credentials; - _dto.Username = "notanemail"; + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidUsername); + } - _validator.Invoking(x => x.ValidateAndThrow(_dto)) - .Should().Throw() - .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidUsername); - } + [Fact] + public void WhenPasswordIsEmpty_ThenThrows() + { + _dto.Provider = AuthenticationConstants.Providers.Credentials; + _dto.Password = string.Empty; - [Fact] - public void WhenProviderIsCredentialsAndPasswordIsEmpty_TheThrows() - { - _dto.Provider = AuthenticationConstants.Providers.Credentials; - _dto.Password = string.Empty; + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidPassword); + } - _validator.Invoking(x => x.ValidateAndThrow(_dto)) - .Should().Throw() - .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidPassword); + [Fact] + public void WhenPasswordIsNotValid_ThenThrows() + { + _dto.Provider = AuthenticationConstants.Providers.Credentials; + _dto.Password = "notapassword"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidPassword); + } } - [Fact] - public void WhenProviderIsCredentialsAndPasswordIsNotValid_TheThrows() + [Trait("Category", "Unit")] + public class GivenAnotherProvider { - _dto.Provider = AuthenticationConstants.Providers.Credentials; - _dto.Password = "notapassword"; + private readonly AuthenticateRequest _dto; + private readonly AuthenticateRequestValidator _validator; - _validator.Invoking(x => x.ValidateAndThrow(_dto)) - .Should().Throw() - .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidPassword); - } + public GivenAnotherProvider() + { + _validator = new AuthenticateRequestValidator(); + _dto = new AuthenticateRequest + { + Provider = "aprovider", + AuthCode = "anauthcode" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } - [Fact] - public void WhenProviderIsSingleSignOnAndAuthCodeIsEmpty_TheThrows() - { - _dto.Provider = AuthenticationConstants.Providers.SingleSignOn; - _dto.AuthCode = string.Empty; + [Fact] + public void WhenProviderIsEmpty_ThenThrows() + { + _dto.Provider = string.Empty; - _validator.Invoking(x => x.ValidateAndThrow(_dto)) - .Should().Throw() - .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidAuthCode); - } + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidProvider); + } - [Fact] - public void WhenProviderIsSingleSignOnAndAuthCodeIsNotEmpty_TheSucceeds() - { - _dto.Provider = AuthenticationConstants.Providers.SingleSignOn; - _dto.AuthCode = "anauthcode"; + [Fact] + public void WhenAuthCodeIsEmpty_ThenThrows() + { + _dto.Provider = "aprovider"; + _dto.AuthCode = string.Empty; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidAuthCode); + } + + [Fact] + public void WhenAuthCodeIsNotEmpty_ThenSucceeds() + { + _dto.Provider = "aprovider"; + _dto.AuthCode = "anauthcode"; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenUsernameIsNotValid_ThenThrows() + { + _dto.Provider = "aprovider"; + _dto.AuthCode = "anauthcode"; + _dto.Username = "notanemail"; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AuthenticateRequestValidator_InvalidUsername); + } + + [Fact] + public void WhenUsernameIsNull_ThenSucceeds() + { + _dto.Provider = "aprovider"; + _dto.AuthCode = "anauthcode"; + _dto.Username = null; + + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenUsernameIsValid_ThenSucceeds() + { + _dto.Provider = "aprovider"; + _dto.AuthCode = "anauthcode"; + _dto.Username = "auser@company.com"; - _validator.ValidateAndThrow(_dto); + _validator.ValidateAndThrow(_dto); + } } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs b/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs index 0d161e05..349af595 100644 --- a/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs +++ b/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs @@ -66,11 +66,17 @@ public async Task WhenAuthenticate_ThenSetsCookies() It.IsAny())) .ReturnsAsync(new AuthenticateTokens { - AccessToken = "anaccesstoken", - RefreshToken = "arefreshtoken", UserId = "auserid", - AccessTokenExpiresOn = accessTokenExpiresOn, - RefreshTokenExpiresOn = refreshTokenExpiresOn + AccessToken = new AuthenticateToken + { + Value = "anaccesstoken", + ExpiresOn = accessTokenExpiresOn + }, + RefreshToken = new AuthenticateToken + { + Value = "arefreshtoken", + ExpiresOn = refreshTokenExpiresOn + } }); await _api.Authenticate(new AuthenticateRequest @@ -126,11 +132,17 @@ public async Task WhenRefreshAndCookieExists_ThenSetsCookies() It.IsAny())) .ReturnsAsync(new AuthenticateTokens { - AccessToken = "anaccesstoken", - RefreshToken = "arefreshtoken", UserId = "auserid", - AccessTokenExpiresOn = accessTokenExpiresOn, - RefreshTokenExpiresOn = refreshTokenExpiresOn + AccessToken = new AuthenticateToken + { + Value = "anaccesstoken", + ExpiresOn = accessTokenExpiresOn + }, + RefreshToken = new AuthenticateToken + { + Value = "arefreshtoken", + ExpiresOn = refreshTokenExpiresOn + } }); await _api.RefreshToken(new RefreshTokenRequest(), CancellationToken.None); @@ -145,6 +157,5 @@ public async Task WhenRefreshAndCookieExists_ThenSetsCookies() c.Append(AuthenticationConstants.Cookies.RefreshToken, "arefreshtoken", It.Is(opt => opt.Expires!.Value.DateTime == refreshTokenExpiresOn ))); - } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs b/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs index 17ef4195..d74b9a2d 100644 --- a/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs +++ b/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs @@ -1,4 +1,5 @@ using Application.Interfaces; +using Application.Resources.Shared; using Common; using FluentAssertions; using Infrastructure.Interfaces; @@ -48,21 +49,30 @@ public async Task WhenAuthenticateWithCredentials_ThenAuthenticates() null, It.IsAny())) .Returns(Task.FromResult>(new AuthenticateResponse { - UserId = "auserid", - AccessToken = "anaccesstoken", - RefreshToken = "arefreshtoken", - AccessTokenExpiresOnUtc = accessTokenExpiresOn, - RefreshTokenExpiresOnUtc = refreshTokenExpiresOn + Tokens = new AuthenticateTokens + { + UserId = "auserid", + AccessToken = new AuthenticateToken + { + Value = "anaccesstoken", + ExpiresOn = accessTokenExpiresOn + }, + RefreshToken = new AuthenticateToken + { + Value = "arefreshtoken", + ExpiresOn = refreshTokenExpiresOn + } + } })); var result = await _application.AuthenticateAsync(_caller.Object, AuthenticationConstants.Providers.Credentials, null, "ausername", "apassword", CancellationToken.None); - result.Value.AccessToken.Should().Be("anaccesstoken"); - result.Value.RefreshToken.Should().Be("arefreshtoken"); - result.Value.AccessTokenExpiresOn.Should().Be(accessTokenExpiresOn); - result.Value.RefreshTokenExpiresOn.Should().Be(refreshTokenExpiresOn); result.Value.UserId.Should().Be("auserid"); + result.Value.AccessToken.Value.Should().Be("anaccesstoken"); + result.Value.AccessToken.ExpiresOn.Should().Be(accessTokenExpiresOn); + result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); + result.Value.RefreshToken.ExpiresOn.Should().Be(refreshTokenExpiresOn); _serviceClient.Verify(sc => sc.PostAsync(_caller.Object, It.Is(req => req.Username == "ausername" && req.Password == "apassword" @@ -80,22 +90,31 @@ public async Task WhenAuthenticateWithSingleSignOn_ThenAuthenticates() null, It.IsAny())) .Returns(Task.FromResult>(new AuthenticateResponse { - UserId = "auserid", - AccessToken = "anaccesstoken", - RefreshToken = "arefreshtoken", - AccessTokenExpiresOnUtc = accessTokenExpiresOn, - RefreshTokenExpiresOnUtc = refreshTokenExpiresOn + Tokens = new AuthenticateTokens + { + UserId = "auserid", + AccessToken = new AuthenticateToken + { + Value = "anaccesstoken", + ExpiresOn = accessTokenExpiresOn + }, + RefreshToken = new AuthenticateToken + { + Value = "arefreshtoken", + ExpiresOn = refreshTokenExpiresOn + } + } + })); - var result = await _application.AuthenticateAsync(_caller.Object, - AuthenticationConstants.Providers.SingleSignOn, - "anauthcode", null, null, CancellationToken.None); + var result = await _application.AuthenticateAsync(_caller.Object, "aprovider", "anauthcode", null, null, + CancellationToken.None); - result.Value.AccessToken.Should().Be("anaccesstoken"); - result.Value.RefreshToken.Should().Be("arefreshtoken"); - result.Value.AccessTokenExpiresOn.Should().Be(accessTokenExpiresOn); - result.Value.RefreshTokenExpiresOn.Should().Be(refreshTokenExpiresOn); result.Value.UserId.Should().Be("auserid"); + result.Value.AccessToken.Value.Should().Be("anaccesstoken"); + result.Value.AccessToken.ExpiresOn.Should().Be(accessTokenExpiresOn); + result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); + result.Value.RefreshToken.ExpiresOn.Should().Be(refreshTokenExpiresOn); _serviceClient.Verify(sc => sc.PostAsync(_caller.Object, It.Is(req => req.AuthCode == "anauthcode" ), null, It.IsAny())); @@ -125,20 +144,30 @@ public async Task WhenRefreshTokenCookieExists_ThenRefreshesAndSetsCookie() null, It.IsAny())) .Returns(Task.FromResult>(new RefreshTokenResponse { - AccessToken = "anaccesstoken", - RefreshToken = "arefreshtoken", - AccessTokenExpiresOnUtc = accessTokenExpiresOn, - RefreshTokenExpiresOnUtc = refreshTokenExpiresOn + Tokens = new AuthenticateTokens + { + UserId = "auserid", + AccessToken = new AuthenticateToken + { + Value = "anaccesstoken", + ExpiresOn = accessTokenExpiresOn + }, + RefreshToken = new AuthenticateToken + { + Value = "arefreshtoken", + ExpiresOn = refreshTokenExpiresOn + } + } })); var result = await _application.RefreshTokenAsync(_caller.Object, "arefreshtoken", CancellationToken.None); result.Should().BeSuccess(); - result.Value.AccessToken.Should().Be("anaccesstoken"); - result.Value.RefreshToken.Should().Be("arefreshtoken"); - result.Value.AccessTokenExpiresOn.Should().Be(accessTokenExpiresOn); - result.Value.RefreshTokenExpiresOn.Should().Be(refreshTokenExpiresOn); - result.Value.UserId.Should().BeNull(); + result.Value.UserId.Should().Be("auserid"); + result.Value.AccessToken.Value.Should().Be("anaccesstoken"); + result.Value.AccessToken.ExpiresOn.Should().Be(accessTokenExpiresOn); + result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); + result.Value.RefreshToken.ExpiresOn.Should().Be(refreshTokenExpiresOn); _serviceClient.Verify( sc => sc.PostAsync(_caller.Object, It.Is(req => req.RefreshToken == "arefreshtoken" diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 47423f9f..723c61c5 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -210,8 +210,8 @@ await Api.PostAsync(new ConfirmRegistrationPersonPasswordRequest Password = password }); - var accessToken = login.Content.Value.AccessToken!; - var refreshToken = login.Content.Value.RefreshToken!; + var accessToken = login.Content.Value.Tokens!.AccessToken.Value; + var refreshToken = login.Content.Value.Tokens.RefreshToken.Value; var user = person.Content.Value.Credential!.User; return new LoginDetails(accessToken, refreshToken, user); diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 0392cffc..00ac5194 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -323,6 +323,7 @@ IANA MFA JWT + SSO <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /></Policy> <Policy Inspect="True" Prefix="When" Suffix="" Style="AaBb_AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /><ExtraRule Prefix="Setup" Suffix="" Style="AaBb" /><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /></Policy> @@ -671,7 +672,7 @@ using Domain.Interfaces.Entities; using Domain.Interfaces.Services; using QueryAny; -// TODO: delete this attribute if your root aggregate is using event sourcing, instead of snapshotting +// TODO: DELETE this attribute if EventSourcing, LEAVE if Snapshotting [EntityName("$name$")] public sealed class $name$Root : AggregateRootBase { @@ -686,19 +687,19 @@ public sealed class $name$Root : AggregateRootBase { } - // TODO: delete this constructor if your root aggregate is using snapshotting, instead of event sourcing + // TODO: LEAVE this constructor if EventSourcing, DELETE if Snapshotting private $name$Root(IRecorder recorder, IIdentifierFactory idFactory, ISingleValueObject<string> identifier) : base(recorder, idFactory, identifier) { } - // TODO: delete this constructor if your root aggregate is using event sourcing, instead of snapshotting + // TODO: DELETE this constructor if EventSourcing, LEAVE if Snapshotting private $name$Root(Identifier identifier, IDependencyContainer container, RehydratingProperties rehydratingProperties) : base(identifier, container, rehydratingProperties) { $propertyname$ = rehydratingProperties.GetValueOrDefault<Name>(nameof($propertyname$)); OrganizationId = rehydratingProperties.GetValueOrDefault<Identifier>(nameof(OrganizationId))!; } - // TODO: delete this method if your root aggregate is using event sourcing, instead of snapshotting + // TODO: DELETE this method if EventSourcing, LEAVE if Snapshotting public override Dictionary<string, object?> Dehydrate() { var properties = base.Dehydrate(); @@ -707,12 +708,13 @@ public sealed class $name$Root : AggregateRootBase return properties; } - // TODO: delete this method if your root aggregate is using event sourcing, instead of snapshotting + // TODO: DELETE this method if EventSourcing, LEAVE if Snapshotting public static AggregateRootFactory<$name$Root> Rehydrate() { return (identifier, container, properties) => new $name$Root(identifier, container, properties); } - // TODO: delete this method if your root aggregate is using snapshotting, instead of event sourcing + + // TODO: LEAVE this method if EventSourcing, DELETE if Snapshotting public static AggregateRootFactory<$name$Root> Rehydrate() { return (identifier, container, properties) => new $name$Root(container.Resolve<IRecorder>(), container.Resolve<IIdentifierFactory>(), identifier); @@ -813,6 +815,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -824,6 +827,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -857,6 +861,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -897,6 +902,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -914,6 +920,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -921,6 +928,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -948,8 +956,10 @@ public void When$condition$_Then$outcome$() True True True + True True True + True True True True @@ -967,6 +977,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True diff --git a/src/WebsiteHost/Api/AuthN/AuthenticateRequestValidator.cs b/src/WebsiteHost/Api/AuthN/AuthenticateRequestValidator.cs index db2336a3..d9cd846a 100644 --- a/src/WebsiteHost/Api/AuthN/AuthenticateRequestValidator.cs +++ b/src/WebsiteHost/Api/AuthN/AuthenticateRequestValidator.cs @@ -1,3 +1,4 @@ +using Common.Extensions; using Domain.Interfaces.Validations; using FluentValidation; using Infrastructure.Interfaces; @@ -13,19 +14,27 @@ public AuthenticateRequestValidator() RuleFor(req => req.Provider) .NotEmpty() .WithMessage(Resources.AuthenticateRequestValidator_InvalidProvider); - RuleFor(req => req.Username) - .NotEmpty() - .IsEmailAddress() - .When(req => req.Provider == AuthenticationConstants.Providers.Credentials) - .WithMessage(Resources.AuthenticateRequestValidator_InvalidUsername); - RuleFor(req => req.Password) - .NotEmpty() - .Matches(CommonValidations.Passwords.Password.Strict) - .When(req => req.Provider == AuthenticationConstants.Providers.Credentials) - .WithMessage(Resources.AuthenticateRequestValidator_InvalidPassword); - RuleFor(req => req.AuthCode) - .NotEmpty() - .When(req => req.Provider != AuthenticationConstants.Providers.Credentials) - .WithMessage(Resources.AuthenticateRequestValidator_InvalidAuthCode); + When(req => req.Provider == AuthenticationConstants.Providers.Credentials, () => + { + RuleFor(req => req.Username) + .NotEmpty() + .IsEmailAddress() + .WithMessage(Resources.AuthenticateRequestValidator_InvalidUsername); + RuleFor(req => req.Password) + .NotEmpty() + .Matches(CommonValidations.Passwords.Password.Strict) + .WithMessage(Resources.AuthenticateRequestValidator_InvalidPassword); + }); + When(req => req.Provider != AuthenticationConstants.Providers.Credentials, () => + { + RuleFor(req => req.AuthCode) + .NotEmpty() + .WithMessage(Resources.AuthenticateRequestValidator_InvalidAuthCode); + RuleFor(req => req.Username) + .NotEmpty() + .IsEmailAddress() + .When(req => req.Username.HasValue()) + .WithMessage(Resources.AuthenticateRequestValidator_InvalidUsername); + }); } } \ No newline at end of file diff --git a/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs b/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs index 54bd8226..5e28f6f0 100644 --- a/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs +++ b/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs @@ -69,10 +69,10 @@ await _authenticationApplication.RefreshTokenAsync(_contextFactory.Create(), ref private static void PopulateCookies(HttpResponse response, AuthenticateTokens tokens) { - response.Cookies.Append(AuthenticationConstants.Cookies.Token, tokens.AccessToken, - GetCookieOptions(tokens.AccessTokenExpiresOn)); - response.Cookies.Append(AuthenticationConstants.Cookies.RefreshToken, tokens.RefreshToken, - GetCookieOptions(tokens.RefreshTokenExpiresOn)); + response.Cookies.Append(AuthenticationConstants.Cookies.Token, tokens.AccessToken.Value, + GetCookieOptions(tokens.AccessToken.ExpiresOn)); + response.Cookies.Append(AuthenticationConstants.Cookies.RefreshToken, tokens.RefreshToken.Value, + GetCookieOptions(tokens.RefreshToken.ExpiresOn)); } private static void DeleteAuthenticationCookies(HttpResponse response) diff --git a/src/WebsiteHost/Application/AuthenticationApplication.cs b/src/WebsiteHost/Application/AuthenticationApplication.cs index a9eb6af5..e862762a 100644 --- a/src/WebsiteHost/Application/AuthenticationApplication.cs +++ b/src/WebsiteHost/Application/AuthenticationApplication.cs @@ -89,19 +89,11 @@ internal static class AuthenticationConversionExtensions { public static AuthenticateTokens ToTokens(this AuthenticateResponse response) { - var tokens = response.Convert(); - tokens.AccessTokenExpiresOn = response.AccessTokenExpiresOnUtc ?? DateTime.UtcNow; - tokens.RefreshTokenExpiresOn = response.RefreshTokenExpiresOnUtc ?? DateTime.UtcNow; - - return tokens; + return response.Tokens!; } public static AuthenticateTokens ToTokens(this RefreshTokenResponse response) { - var tokens = response.Convert(); - tokens.AccessTokenExpiresOn = response.AccessTokenExpiresOnUtc ?? DateTime.UtcNow; - tokens.RefreshTokenExpiresOn = response.RefreshTokenExpiresOnUtc ?? DateTime.UtcNow; - - return tokens; + return response.Tokens!; } } \ No newline at end of file