From ba1b75264ff5f4921cb3d5026fb63a28ad201fda Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Thu, 15 Aug 2024 16:36:59 +1200 Subject: [PATCH] Updated the ISSOService to support querying all provider tokens, and refreshing them, when expired. Closes #48. --- src/Application.Interfaces/Audits.Designer.cs | 47 ++- src/Application.Interfaces/Audits.resx | 29 +- src/Application.Resources.Shared/Identity.cs | 23 +- .../ISSOService.cs | 15 + src/Common/Error.cs | 90 ++++-- .../{TokensUpdated.cs => DetailsAdded.cs} | 8 +- .../Identities/SSOUsers/TokensChanged.cs | 28 ++ .../SSOProvidersServiceSpec.cs | 280 ++++++++++++++++-- .../SingleSignOnApplicationSpec.cs | 260 +++++++++++++++- .../ISSOAuthenticationProvider.cs | 18 +- .../ISSOProvidersService.cs | 18 +- .../SSOProvidersService.cs | 275 ++++++++++++++++- .../AuthTokensApplication.cs | 20 +- .../ISingleSignOnApplication.cs | 9 +- .../PasswordCredentialsApplication.cs | 12 +- .../Persistence/ISSOUsersRepository.cs | 2 +- .../Persistence/ReadModels/SSOUser.cs | 3 +- .../SingleSignOnApplication.cs | 111 ++++++- .../SSOAuthTokenSpec.cs | 15 +- .../SSOUserRootSpec.cs | 56 +++- src/IdentityDomain/Events.cs | 26 +- src/IdentityDomain/Resources.Designer.cs | 9 + src/IdentityDomain/Resources.resx | 4 +- src/IdentityDomain/SSOAuthToken.cs | 34 ++- src/IdentityDomain/SSOAuthTokenType.cs | 6 +- src/IdentityDomain/SSOAuthTokens.cs | 28 ++ src/IdentityDomain/SSOUserRoot.cs | 89 ++++-- .../SingleSignOnApiSpec.cs | 3 +- .../FakeSSOAuthenticationProviderSpec.cs | 22 +- .../ApplicationServices/FakeOAuth2Service.cs | 55 +++- .../FakeSSOAuthenticationProvider.cs | 26 +- .../SSOInProcessServiceClient.cs | 31 ++ src/IdentityInfrastructure/IdentityModule.cs | 1 + .../ReadModels/SSOUserProjection.cs | 7 +- .../Persistence/SSOUsersRepository.cs | 2 +- .../Resources.Designer.cs | 15 +- src/IdentityInfrastructure/Resources.resx | 5 +- .../OAuth2HttpServiceClient.cs | 42 ++- src/Infrastructure.Shared/IOAuth2Service.cs | 35 ++- .../Extensions/ErrorExtensions.cs | 7 +- .../HttpError.cs | 5 +- ....cs => OAuth2GrantAuthorizationRequest.cs} | 8 +- ...cs => OAuth2GrantAuthorizationResponse.cs} | 4 +- .../Api/AuthN/AuthenticationApiSpec.cs | 20 +- .../AuthenticationApplicationSpec.cs | 33 ++- src/SaaStack.sln.DotSettings | 3 + .../Api/AuthN/AuthenticationApi.cs | 6 +- .../Application/AuthenticationApplication.cs | 2 - 48 files changed, 1592 insertions(+), 255 deletions(-) create mode 100644 src/Application.Services.Shared/ISSOService.cs rename src/Domain.Events.Shared/Identities/SSOUsers/{TokensUpdated.cs => DetailsAdded.cs} (71%) create mode 100644 src/Domain.Events.Shared/Identities/SSOUsers/TokensChanged.cs create mode 100644 src/IdentityInfrastructure/ApplicationServices/SSOInProcessServiceClient.cs rename src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/{ExchangeOAuth2CodeForTokensRequest.cs => OAuth2GrantAuthorizationRequest.cs} (72%) rename src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/{ExchangeOAuth2CodeForTokensResponse.cs => OAuth2GrantAuthorizationResponse.cs} (83%) diff --git a/src/Application.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs index e1646104..01e8844b 100644 --- a/src/Application.Interfaces/Audits.Designer.cs +++ b/src/Application.Interfaces/Audits.Designer.cs @@ -60,7 +60,7 @@ internal Audits() { } /// - /// Looks up a localized string similar to Authentication.Failed.AccountSuspended. + /// Looks up a localized string similar to Authentication.APIKey.Failed.AccountSuspended. /// public static string APIKeysApplication_Authenticate_AccountSuspended { get { @@ -69,7 +69,7 @@ public static string APIKeysApplication_Authenticate_AccountSuspended { } /// - /// Looks up a localized string similar to Authentication.Passed. + /// Looks up a localized string similar to Authentication.APIKey.Passed. /// public static string APIKeysApplication_Authenticate_Succeeded { get { @@ -77,6 +77,15 @@ public static string APIKeysApplication_Authenticate_Succeeded { } } + /// + /// Looks up a localized string similar to Authentication.Any.Refreshed.Passed. + /// + public static string AuthTokensApplication_Refresh_Succeeded { + get { + return ResourceManager.GetString("AuthTokensApplication_Refresh_Succeeded", resourceCulture); + } + } + /// /// Looks up a localized string similar to CSRFProtection.Failed. /// @@ -168,7 +177,7 @@ public static string EndUsersApplication_User_Registered_TermsAccepted { } /// - /// Looks up a localized string similar to Mailgun.Authentication.Failed. + /// Looks up a localized string similar to Authentication.Mailgun.Failed. /// public static string MailgunApi_WebhookAuthentication_Failed { get { @@ -186,7 +195,7 @@ public static string OrganizationsApplication_OrganizationDeleted { } /// - /// Looks up a localized string similar to Authentication.Failed.AccountLocked. + /// Looks up a localized string similar to Authentication.Password.Failed.AccountLocked. /// public static string PasswordCredentialsApplication_Authenticate_AccountLocked { get { @@ -195,7 +204,7 @@ public static string PasswordCredentialsApplication_Authenticate_AccountLocked { } /// - /// Looks up a localized string similar to Authentication.Failed.AccountSuspended. + /// Looks up a localized string similar to Authentication.Password.Failed.AccountSuspended. /// public static string PasswordCredentialsApplication_Authenticate_AccountSuspended { get { @@ -204,7 +213,7 @@ public static string PasswordCredentialsApplication_Authenticate_AccountSuspende } /// - /// Looks up a localized string similar to Authentication.Failed.BeforeVerified. + /// Looks up a localized string similar to Authentication.Password.Failed.BeforeVerified. /// public static string PasswordCredentialsApplication_Authenticate_BeforeVerified { get { @@ -213,7 +222,7 @@ public static string PasswordCredentialsApplication_Authenticate_BeforeVerified } /// - /// Looks up a localized string similar to Authentication.Failed.InvalidCredentials. + /// Looks up a localized string similar to Authentication.Password.Failed.InvalidCredentials. /// public static string PasswordCredentialsApplication_Authenticate_InvalidCredentials { get { @@ -222,7 +231,7 @@ public static string PasswordCredentialsApplication_Authenticate_InvalidCredenti } /// - /// Looks up a localized string similar to Authentication.Passed. + /// Looks up a localized string similar to Authentication.Password.Passed. /// public static string PasswordCredentialsApplication_Authenticate_Succeeded { get { @@ -240,7 +249,7 @@ public static string SingleSignOnApplication_Authenticate_AccountOnboarded { } /// - /// Looks up a localized string similar to Authentication.Failed.AccountSuspended. + /// Looks up a localized string similar to Authentication.SSO.Failed.AccountSuspended. /// public static string SingleSignOnApplication_Authenticate_AccountSuspended { get { @@ -249,7 +258,7 @@ public static string SingleSignOnApplication_Authenticate_AccountSuspended { } /// - /// Looks up a localized string similar to Authentication.Passed. + /// Looks up a localized string similar to Authentication.SSO.Passed. /// public static string SingleSignOnApplication_Authenticate_Succeeded { get { @@ -257,6 +266,24 @@ public static string SingleSignOnApplication_Authenticate_Succeeded { } } + /// + /// Looks up a localized string similar to Authentication.SSO.Refreshed.Failed.AccountSuspended. + /// + public static string SingleSignOnApplication_Refresh_AccountSuspended { + get { + return ResourceManager.GetString("SingleSignOnApplication_Refresh_AccountSuspended", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authentication.SSO.Refreshed.Passed. + /// + public static string SingleSignOnApplication_Refresh_Succeeded { + get { + return ResourceManager.GetString("SingleSignOnApplication_Refresh_Succeeded", resourceCulture); + } + } + /// /// Looks up a localized string similar to Subscription.BuyerTransferred. /// diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx index c788949e..8c1f4906 100644 --- a/src/Application.Interfaces/Audits.resx +++ b/src/Application.Interfaces/Audits.resx @@ -25,19 +25,19 @@ - Authentication.Failed.AccountSuspended + Authentication.Password.Failed.AccountSuspended - Authentication.Failed.AccountLocked + Authentication.Password.Failed.AccountLocked - Authentication.Failed.InvalidCredentials + Authentication.Password.Failed.InvalidCredentials - Authentication.Failed.BeforeVerified + Authentication.Password.Failed.BeforeVerified - Authentication.Passed + Authentication.Password.Passed EndUser.Registered.TermsAccepted @@ -58,10 +58,10 @@ SingleSignOn.AutoRegistered - Authentication.Failed.AccountSuspended + Authentication.SSO.Failed.AccountSuspended - Authentication.Passed + Authentication.SSO.Passed CSRFProtection.Failed @@ -85,12 +85,21 @@ Organization.Deleted - Authentication.Failed.AccountSuspended + Authentication.APIKey.Failed.AccountSuspended - Authentication.Passed + Authentication.APIKey.Passed - Mailgun.Authentication.Failed + Authentication.Mailgun.Failed + + + Authentication.SSO.Refreshed.Passed + + + Authentication.Any.Refreshed.Passed + + + Authentication.SSO.Refreshed.Failed.AccountSuspended \ No newline at end of file diff --git a/src/Application.Resources.Shared/Identity.cs b/src/Application.Resources.Shared/Identity.cs index 627f3ea5..6efc940d 100644 --- a/src/Application.Resources.Shared/Identity.cs +++ b/src/Application.Resources.Shared/Identity.cs @@ -4,16 +4,29 @@ namespace Application.Resources.Shared; public class AuthenticateTokens { - public required AuthenticateToken AccessToken { get; set; } + public required AuthenticationToken AccessToken { get; set; } - public required AuthenticateToken RefreshToken { get; set; } + public required AuthenticationToken RefreshToken { get; set; } public required string UserId { get; set; } } -public class AuthenticateToken +public class ProviderAuthenticationTokens { - public required DateTime ExpiresOn { get; set; } + public required AuthenticationToken AccessToken { get; set; } + + public required List OtherTokens { get; set; } + + public required string Provider { get; set; } + + public required AuthenticationToken? RefreshToken { get; set; } +} + +public class AuthenticationToken +{ + public required DateTime? ExpiresOn { get; set; } + + public required TokenType Type { get; set; } public required string Value { get; set; } } @@ -49,7 +62,7 @@ public AuthToken(TokenType type, string value, DateTime? expiresOn) public enum TokenType { + OtherToken = 0, AccessToken = 1, RefreshToken = 2, - IdToken = 3 } \ No newline at end of file diff --git a/src/Application.Services.Shared/ISSOService.cs b/src/Application.Services.Shared/ISSOService.cs new file mode 100644 index 00000000..24cc49f4 --- /dev/null +++ b/src/Application.Services.Shared/ISSOService.cs @@ -0,0 +1,15 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace Application.Services.Shared; + +public interface ISSOService +{ + Task, Error>> GetTokensAsync(ICallerContext caller, + string userId, + CancellationToken cancellationToken); + + Task> RefreshTokenAsync(ICallerContext caller, string userId, + string providerName, string refreshToken, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Common/Error.cs b/src/Common/Error.cs index a9eedd91..2c648e7b 100644 --- a/src/Common/Error.cs +++ b/src/Common/Error.cs @@ -15,47 +15,79 @@ public Error() { Code = ErrorCode.NoError; Message = NoErrorMessage; + AdditionalData = null; } - internal Error(ErrorCode code, string? message = null) + internal Error(ErrorCode code, string? message = null, Dictionary? additionalData = null) { Code = code; Message = message ?? NoErrorMessage; + AdditionalData = additionalData; } public string Message { get; } + public Dictionary? AdditionalData { get; } + public ErrorCode Code { get; } #if !ANALYZERS_NONPLATFORM /// /// Wraps the existing message within the specified message /// - public Error Wrap(string message) + public Error Wrap(string message, Dictionary? additionalData = null) { + var additional = AdditionalData; + if (additionalData.Exists()) + { + if (AdditionalData.Exists()) + { + AdditionalData.Merge(additionalData); + additional = AdditionalData; + } + else + { + additional = additionalData; + } + } + if (message.HasNoValue()) { - return new Error(Code, Message); + return new Error(Code, Message, additional); } return new Error(Code, Message.HasValue() && Message != NoErrorMessage ? $"{message}{Environment.NewLine}\t{Message}" - : message); + : message, additional); } /// /// Wraps the existing message within the specified message, for the specified code /// - public Error Wrap(ErrorCode code, string message) + public Error Wrap(ErrorCode code, string message, Dictionary? additionalData = null) { + var additional = AdditionalData; + if (additionalData.Exists()) + { + if (AdditionalData.Exists()) + { + AdditionalData.Merge(additionalData); + additional = AdditionalData; + } + else + { + additional = additionalData; + } + } + if (message.HasNoValue()) { - return new Error(code, Message); + return new Error(code, Message, additional); } return new Error(code, Message.HasValue() && Message != NoErrorMessage ? $"{Code}: {message}{Environment.NewLine}\t{Message}" - : $"{Code}: {message}"); + : $"{Code}: {message}", additional); } #endif @@ -76,89 +108,89 @@ public bool Is(ErrorCode code, string? message = null) /// /// Creates a error /// - public static Error Validation(string? message = null) + public static Error Validation(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.Validation, message); + return new Error(ErrorCode.Validation, message, additionalData); } /// /// Creates a error /// - public static Error RuleViolation(string? message = null) + public static Error RuleViolation(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.RuleViolation, message); + return new Error(ErrorCode.RuleViolation, message, additionalData); } /// /// Creates a error /// - public static Error RoleViolation(string? message = null) + public static Error RoleViolation(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.RoleViolation, message); + return new Error(ErrorCode.RoleViolation, message, additionalData); } /// /// Creates a error /// - public static Error PreconditionViolation(string? message = null) + public static Error PreconditionViolation(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.PreconditionViolation, message); + return new Error(ErrorCode.PreconditionViolation, message, additionalData); } /// /// Creates a error /// - public static Error EntityNotFound(string? message = null) + public static Error EntityNotFound(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.EntityNotFound, message); + return new Error(ErrorCode.EntityNotFound, message, additionalData); } /// /// Creates a error /// - public static Error EntityExists(string? message = null) + public static Error EntityExists(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.EntityExists, message); + return new Error(ErrorCode.EntityExists, message, additionalData); } /// /// Creates a error /// - public static Error NotAuthenticated(string? message = null) + public static Error NotAuthenticated(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.NotAuthenticated, message); + return new Error(ErrorCode.NotAuthenticated, message, additionalData); } /// /// Creates a error /// - public static Error ForbiddenAccess(string? message = null) + public static Error ForbiddenAccess(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.ForbiddenAccess, message); + return new Error(ErrorCode.ForbiddenAccess, message, additionalData); } /// /// Creates a error /// - public static Error FeatureViolation(string? message = null) + public static Error FeatureViolation(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.FeatureViolation, message); + return new Error(ErrorCode.FeatureViolation, message, additionalData); } /// /// Creates a error /// - public static Error Unexpected(string? message = null) + public static Error Unexpected(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.Unexpected, message); + return new Error(ErrorCode.Unexpected, message, additionalData); } /// /// Creates a error /// - public static Error EntityDeleted(string? message = null) + public static Error EntityDeleted(string? message = null, Dictionary? additionalData = null) { - return new Error(ErrorCode.EntityDeleted, message); + return new Error(ErrorCode.EntityDeleted, message, additionalData); } public override string ToString() diff --git a/src/Domain.Events.Shared/Identities/SSOUsers/TokensUpdated.cs b/src/Domain.Events.Shared/Identities/SSOUsers/DetailsAdded.cs similarity index 71% rename from src/Domain.Events.Shared/Identities/SSOUsers/TokensUpdated.cs rename to src/Domain.Events.Shared/Identities/SSOUsers/DetailsAdded.cs index 8bb8457a..bccf9299 100644 --- a/src/Domain.Events.Shared/Identities/SSOUsers/TokensUpdated.cs +++ b/src/Domain.Events.Shared/Identities/SSOUsers/DetailsAdded.cs @@ -4,14 +4,14 @@ namespace Domain.Events.Shared.Identities.SSOUsers; -public sealed class TokensUpdated : DomainEvent +public sealed class DetailsAdded : DomainEvent { - public TokensUpdated(Identifier id) : base(id) + public DetailsAdded(Identifier id) : base(id) { } [UsedImplicitly] - public TokensUpdated() + public DetailsAdded() { } @@ -24,6 +24,4 @@ public TokensUpdated() public string? LastName { get; set; } public required string Timezone { get; set; } - - public required string Tokens { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Identities/SSOUsers/TokensChanged.cs b/src/Domain.Events.Shared/Identities/SSOUsers/TokensChanged.cs new file mode 100644 index 00000000..c75a90ef --- /dev/null +++ b/src/Domain.Events.Shared/Identities/SSOUsers/TokensChanged.cs @@ -0,0 +1,28 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.Identities.SSOUsers; + +public sealed class TokensChanged : DomainEvent +{ + public TokensChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public TokensChanged() + { + } + + public required List Tokens { get; set; } +} + +public class SSOToken +{ + public required string EncryptedValue { get; set; } + + public DateTime? ExpiresOn { get; set; } + + public required string Type { get; set; } +} \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs b/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs index d48d3f48..c7e2e3ad 100644 --- a/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs +++ b/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs @@ -24,18 +24,16 @@ public class SSOProvidersServiceSpec [Trait("Category", "Unit")] public class GivenNoAuthProviders { + private readonly Mock _caller; private readonly SSOProvidersService _service; public GivenNoAuthProviders() { + _caller = new Mock(); 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(), @@ -45,7 +43,7 @@ public GivenNoAuthProviders() [Fact] public async Task WhenFindByNameAsyncAndNotRegistered_ThenReturnsNone() { - var result = await _service.FindByNameAsync("aname", CancellationToken.None); + var result = await _service.FindByProviderNameAsync("aname", CancellationToken.None); result.Should().BeSuccess(); result.Value.Should().BeNone(); @@ -58,7 +56,8 @@ public async Task WhenSaveUserInfoAsyncAndProviderNotRegistered_ThenReturnsError Timezones.Default, CountryCodes.Default); var result = - await _service.SaveUserInfoAsync("aprovidername", "auserid".ToId(), userInfo, CancellationToken.None); + await _service.SaveUserInfoAsync(_caller.Object, "aprovidername", "auserid".ToId(), userInfo, + CancellationToken.None); result.Should().BeError(ErrorCode.EntityNotFound, Resources.SSOProvidersService_UnknownProvider.Format("aprovidername")); @@ -68,7 +67,6 @@ public async Task WhenSaveUserInfoAsyncAndProviderNotRegistered_ThenReturnsError [Trait("Category", "Unit")] public class GivenAuthProviders { - private readonly Mock _repository; private readonly SSOProvidersService _service; public GivenAuthProviders() @@ -77,25 +75,21 @@ public GivenAuthProviders() var idFactory = new Mock(); idFactory.Setup(idf => idf.Create(It.IsAny())) .Returns("anid".ToId()); - _repository = 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 { new TestSSOAuthenticationProvider() }, - _repository.Object); + repository.Object); } [Fact] public async Task WhenFindByNameAsyncAndNotRegistered_ThenReturnsNone() { - var result = await _service.FindByNameAsync("aname", CancellationToken.None); + var result = await _service.FindByProviderNameAsync("aname", CancellationToken.None); result.Should().BeSuccess(); result.Value.Should().BeNone(); @@ -104,11 +98,47 @@ public async Task WhenFindByNameAsyncAndNotRegistered_ThenReturnsNone() [Fact] public async Task WhenFindByNameAsyncRegistered_ThenReturnsProvider() { - var result = await _service.FindByNameAsync(TestSSOAuthenticationProvider.Name, CancellationToken.None); + var result = + await _service.FindByProviderNameAsync(TestSSOAuthenticationProvider.Name, CancellationToken.None); result.Should().BeSuccess(); result.Value.Value.Should().BeOfType(); } + } + + [Trait("Category", "Unit")] + public class GivenAnAuthProvider + { + private readonly Mock _caller; + private readonly Mock _encryptionService; + private readonly Mock _idFactory; + private readonly Mock _recorder; + private readonly Mock _repository; + private readonly SSOProvidersService _service; + + public GivenAnAuthProvider() + { + _caller = new Mock(); + _recorder = new Mock(); + _idFactory = new Mock(); + _idFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns("anid".ToId()); + _repository = new Mock(); + _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((SSOUserRoot root, CancellationToken _) => root); + _encryptionService = new Mock(); + _encryptionService.Setup(es => es.Encrypt(It.IsAny())) + .Returns((string _) => "anencryptedvalue"); + _encryptionService.Setup(es => es.Decrypt(It.IsAny())) + .Returns((string _) => "adecryptedvalue"); + + _service = new SSOProvidersService(_recorder.Object, _idFactory.Object, _encryptionService.Object, + new List + { + new TestSSOAuthenticationProvider() + }, + _repository.Object); + } [Fact] public async Task WhenSaveUserInfoAsyncAndProviderNotRegistered_ThenReturnsError() @@ -117,7 +147,8 @@ public async Task WhenSaveUserInfoAsyncAndProviderNotRegistered_ThenReturnsError Timezones.Default, CountryCodes.Default); var result = - await _service.SaveUserInfoAsync("aprovidername", "auserid".ToId(), userInfo, CancellationToken.None); + await _service.SaveUserInfoAsync(_caller.Object, "aprovidername", "auserid".ToId(), userInfo, + CancellationToken.None); result.Should().BeError(ErrorCode.EntityNotFound, Resources.SSOProvidersService_UnknownProvider.Format("aprovidername")); @@ -129,17 +160,19 @@ public async Task WhenSaveUserInfoAsyncAndUserNotExists_ThenCreatesAndSavesDetai var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, Timezones.Default, CountryCodes.Default); _repository.Setup(rep => - rep.FindUserInfoByUserIdAsync(It.IsAny(), It.IsAny(), + rep.FindByUserIdAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); var result = - await _service.SaveUserInfoAsync(TestSSOAuthenticationProvider.Name, "auserid".ToId(), userInfo, + await _service.SaveUserInfoAsync(_caller.Object, TestSSOAuthenticationProvider.Name, "auserid".ToId(), + userInfo, CancellationToken.None); result.Should().BeSuccess(); _repository.Verify(rep => rep.SaveAsync(It.Is(user => user.Id == "anid" + && user.ProviderName == TestSSOAuthenticationProvider.Name && user.UserId == "auserid" && user.EmailAddress.Value.Address == "auser@company.com" && user.Name.Value.FirstName == "afirstname" @@ -148,6 +181,211 @@ await _service.SaveUserInfoAsync(TestSSOAuthenticationProvider.Name, "auserid".T && user.Address.Value.CountryCode == CountryCodes.Default ), It.IsAny())); } + + [Fact] + public async Task WhenSaveUserTokensAsyncAndProviderNotRegistered_ThenReturnsError() + { + var tokens = new ProviderAuthenticationTokens + { + Provider = "aprovidername", + AccessToken = new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.AccessToken, + Value = "anaccesstoken" + }, + RefreshToken = new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.RefreshToken, + Value = "arefreshtoken" + }, + OtherTokens = + [ + new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.OtherToken, + Value = "anothertoken" + } + ] + }; + + var result = + await _service.SaveUserTokensAsync(_caller.Object, "aprovidername", "auserid".ToId(), tokens, + CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound, + Resources.SSOProvidersService_UnknownProvider.Format("aprovidername")); + _repository.Verify( + rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenSaveUserTokensAsyncAndUserNotExists_ThenReturnsError() + { + var tokens = new ProviderAuthenticationTokens + { + Provider = "aprovidername", + AccessToken = new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.AccessToken, + Value = "anaccesstoken" + }, + RefreshToken = new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.RefreshToken, + Value = "arefreshtoken" + }, + OtherTokens = + [ + new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.OtherToken, + Value = "anothertoken" + } + ] + }; + _repository.Setup(rep => + rep.FindByUserIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = + await _service.SaveUserTokensAsync(_caller.Object, TestSSOAuthenticationProvider.Name, "auserid".ToId(), + tokens, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound); + _repository.Verify(rep => rep.FindByUserIdAsync(TestSSOAuthenticationProvider.Name, "auserid".ToId(), + It.IsAny())); + } + + [Fact] + public async Task WhenSaveUserTokensAsync_ThenSavesTokens() + { + _caller.Setup(cc => cc.CallerId) + .Returns("auserid"); + var datum = DateTime.UtcNow; + var tokens = new ProviderAuthenticationTokens + { + Provider = "aprovidername", + AccessToken = new AuthenticationToken + { + ExpiresOn = datum, + Type = TokenType.AccessToken, + Value = "anaccesstoken" + }, + RefreshToken = new AuthenticationToken + { + ExpiresOn = datum, + Type = TokenType.RefreshToken, + Value = "arefreshtoken" + }, + OtherTokens = + [ + new AuthenticationToken + { + ExpiresOn = datum, + Type = TokenType.OtherToken, + Value = "anothertoken" + } + ] + }; + var ssoUser = SSOUserRoot.Create(_recorder.Object, _idFactory.Object, + "aprovidername", "auserid".ToId()).Value; + _repository.Setup(rep => + rep.FindByUserIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ssoUser.ToOptional()); + + var result = + await _service.SaveUserTokensAsync(_caller.Object, TestSSOAuthenticationProvider.Name, "auserid".ToId(), + tokens, CancellationToken.None); + + var expectedTokens = SSOAuthTokens.Create([ + SSOAuthToken.Create(SSOAuthTokenType.AccessToken, "anencryptedvalue", datum).Value, + SSOAuthToken.Create(SSOAuthTokenType.RefreshToken, "anencryptedvalue", datum).Value, + SSOAuthToken.Create(SSOAuthTokenType.OtherToken, "anencryptedvalue", datum).Value + ]).Value; + result.Should().BeSuccess(); + _repository.Verify(rep => rep.FindByUserIdAsync(TestSSOAuthenticationProvider.Name, "auserid".ToId(), + It.IsAny())); + _repository.Verify(rep => rep.SaveAsync(It.Is(user => + user.UserId == "auserid" + && user.ProviderName == "aprovidername" + && user.Tokens == expectedTokens + ), It.IsAny())); + } + // + // [Fact] + // public async Task WhenGetTokensAsyncAndNotOwner_ThenReturnsError() + // { + // var result = await _service.GetTokensAsync(_caller.Object, "auserid".ToId(), CancellationToken.None); + // + // result.Should().BeError(ErrorCode.RoleViolation, IdentityDomain.Resources.SSOUserRoot_NotOwner); + // } + + [Fact] + public async Task WhenGetTokensAsyncAndNoTokens_ThenReturnsNone() + { + _caller.Setup(cc => cc.CallerId) + .Returns("auserid"); + var ssoUser = SSOUserRoot.Create(_recorder.Object, _idFactory.Object, + "aprovidername", "auserid".ToId()).Value; + _repository.Setup(rep => + rep.FindByUserIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ssoUser.ToOptional()); + + var result = await _service.GetTokensAsync(_caller.Object, "auserid".ToId(), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Count.Should().Be(0); + _repository.Verify(rep => rep.FindByUserIdAsync(TestSSOAuthenticationProvider.Name, "auserid".ToId(), + It.IsAny())); + } + + [Fact] + public async Task WhenGetTokensAsync_ThenReturnsError() + { + _caller.Setup(cc => cc.CallerId) + .Returns("auserid"); + var datum = DateTime.UtcNow; + var ssoUser = SSOUserRoot.Create(_recorder.Object, _idFactory.Object, + "aprovidername", "auserid".ToId()).Value; + ssoUser.ChangeTokens("auserid".ToId(), SSOAuthTokens.Create([ + SSOAuthToken.Create(SSOAuthTokenType.AccessToken, "anencryptedvalue", datum).Value, + SSOAuthToken.Create(SSOAuthTokenType.RefreshToken, "anencryptedvalue", datum).Value, + SSOAuthToken.Create(SSOAuthTokenType.OtherToken, "anencryptedvalue", datum).Value + ]).Value); + _repository.Setup(rep => + rep.FindByUserIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ssoUser.ToOptional()); + + var result = await _service.GetTokensAsync(_caller.Object, "auserid".ToId(), CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Count.Should().Be(1); + result.Value[0].Provider.Should().Be(TestSSOAuthenticationProvider.Name); + result.Value[0].AccessToken.ExpiresOn.Should().Be(datum); + result.Value[0].AccessToken.Type.Should().Be(TokenType.AccessToken); + result.Value[0].AccessToken.Value.Should().Be("adecryptedvalue"); + result.Value[0].RefreshToken!.ExpiresOn.Should().Be(datum); + result.Value[0].RefreshToken!.Type.Should().Be(TokenType.RefreshToken); + result.Value[0].RefreshToken!.Value.Should().Be("adecryptedvalue"); + result.Value[0].OtherTokens.Count.Should().Be(1); + result.Value[0].OtherTokens[0].Type.Should().Be(TokenType.OtherToken); + result.Value[0].OtherTokens[0].ExpiresOn.Should().Be(datum); + result.Value[0].OtherTokens[0].Value.Should().Be("adecryptedvalue"); + _encryptionService.Verify(es => es.Decrypt("anencryptedvalue"), Times.Exactly(3)); + _repository.Verify(rep => rep.FindByUserIdAsync(TestSSOAuthenticationProvider.Name, "auserid".ToId(), + It.IsAny())); + } } } @@ -163,4 +401,10 @@ public Task> AuthenticateAsync(ICallerContext caller, } public string ProviderName => Name; + + public Task> RefreshTokenAsync(ICallerContext caller, + string refreshToken, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs index 5e58893d..14aedd13 100644 --- a/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/SingleSignOnApplicationSpec.cs @@ -28,7 +28,12 @@ public SingleSignOnApplicationSpec() _endUsersService = new Mock(); _ssoProvider = new Mock(); _ssoProvidersService = new Mock(); - _ssoProvidersService.Setup(sps => sps.FindByNameAsync(It.IsAny(), It.IsAny())) + _ssoProvidersService + .Setup(sps => sps.FindByProviderNameAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(_ssoProvider.Object.ToOptional()); + _ssoProvidersService.Setup(sps => + sps.FindByUserIdAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(_ssoProvider.Object.ToOptional()); _authTokensService = new Mock(); @@ -40,7 +45,7 @@ public SingleSignOnApplicationSpec() [Fact] public async Task WhenAuthenticateAndNoProvider_ThenReturnsError() { - _ssoProvidersService.Setup(sp => sp.FindByNameAsync(It.IsAny(), It.IsAny())) + _ssoProvidersService.Setup(sp => sp.FindByProviderNameAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Optional.None); var result = await _application.AuthenticateAsync(_caller.Object, "aninvitationtoken", "aprovidername", @@ -65,7 +70,7 @@ public async Task WhenAuthenticateAndProviderErrors_ThenReturnsError() "anauthcode", null, CancellationToken.None); - result.Should().BeError(ErrorCode.Unexpected, "amessage"); + result.Should().BeError(ErrorCode.NotAuthenticated); _endUsersService.Verify( eus => eus.FindPersonByEmailPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -115,7 +120,8 @@ public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesT 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(), + sps => sps.SaveUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); _endUsersService.Verify(eus => eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); @@ -125,7 +131,7 @@ public async Task WhenAuthenticateAndPersonExistsButNotRegisteredYet_ThenIssuesT } [Fact] - public async Task WhenAuthenticateAndPersonExistsButSuspended_ThenIssuesToken() + public async Task WhenAuthenticateAndPersonIsSuspended_ThenIssuesToken() { var userInfo = new SSOUserInfo(new List(), "auser@company.com", "afirstname", null, Timezones.Default, @@ -168,7 +174,8 @@ public async Task WhenAuthenticateAndPersonExistsButSuspended_ThenIssuesToken() 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(), + sps => sps.SaveUserInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); _endUsersService.Verify(eus => eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); @@ -234,7 +241,8 @@ public async Task WhenAuthenticateAndPersonNotExists_ThenRegistersPersonAndIssue _endUsersService.Verify(eus => eus.RegisterPersonPrivateAsync(_caller.Object, "aninvitationtoken", "auser@company.com", "afirstname", null, Timezones.Sydney.ToString(), CountryCodes.Australia.ToString(), true, It.IsAny())); - _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync("aprovidername", "aregistereduserid".ToId(), + _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync(_caller.Object, "aprovidername", + "aregistereduserid".ToId(), It.Is(ui => ui == userInfo), It.IsAny())); _endUsersService.Verify(eus => eus.GetMembershipsPrivateAsync(_caller.Object, "aregistereduserid", It.IsAny())); @@ -300,7 +308,8 @@ public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() eus => eus.RegisterPersonPrivateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync("aprovidername", "anexistinguserid".ToId(), + _ssoProvidersService.Verify(sps => sps.SaveUserInfoAsync(_caller.Object, "aprovidername", + "anexistinguserid".ToId(), It.Is(ui => ui == userInfo), It.IsAny())); _endUsersService.Verify(eus => eus.GetMembershipsPrivateAsync(_caller.Object, "anexistinguserid", It.IsAny())); @@ -308,4 +317,239 @@ public async Task WhenAuthenticateAndPersonExists_ThenIssuesToken() eu.Id == "amembershipsuserid" ), It.IsAny())); } + + [Fact] + public async Task WhenRefreshTokenAsyncAndProviderUserNotExists_ThenReturnsError() + { + _ssoProvidersService.Setup(sp => + sp.FindByUserIdAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Optional.None); + + var result = await _application.RefreshTokenAsync(_caller.Object, "auserid", "aprovidername", + "arefreshtoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + result.Error.AdditionalData.Should().OnlyContain(x => + x.Key == SingleSignOnApplication.AuthErrorProviderName && (string)x.Value == "aprovidername"); + _endUsersService.Verify( + eus => eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _ssoProvidersService.Verify( + sps => sps.SaveUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenRefreshTokenAsyncAndEndUserNotExists_ThenReturnsError() + { + _endUsersService.Setup(eus => + eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.RefreshTokenAsync(_caller.Object, "auserid", "aprovidername", + "arefreshtoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + result.Error.AdditionalData.Should().OnlyContain(x => + x.Key == SingleSignOnApplication.AuthErrorProviderName && (string)x.Value == "aprovidername"); + _endUsersService.Verify( + eus => eus.GetUserPrivateAsync(_caller.Object, It.IsAny(), + It.IsAny())); + _ssoProvider.Verify( + sop => sop.RefreshTokenAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _ssoProvidersService.Verify( + sps => sps.SaveUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenRefreshTokenAsyncAndPersonIsSuspended_ThenReturnsError() + { + _endUsersService.Setup(eus => + eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUser + { + Id = "auserid", + Classification = EndUserClassification.Person, + Access = EndUserAccess.Suspended + }); + _ssoProvider.Setup(sp => + sp.RefreshTokenAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.Unexpected("amessage")); + + var result = await _application.RefreshTokenAsync(_caller.Object, "auserid", "aprovidername", + "arefreshtoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityExists, Resources.SingleSignOnApplication_AccountSuspended); + _endUsersService.Verify( + eus => eus.GetUserPrivateAsync(_caller.Object, It.IsAny(), + It.IsAny())); + _ssoProvider.Verify( + sop => sop.RefreshTokenAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _ssoProvidersService.Verify( + sps => sps.SaveUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenRefreshTokenAsyncAndRefreshErrors_ThenReturnsError() + { + _endUsersService.Setup(eus => + eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUser + { + Id = "auserid", + Classification = EndUserClassification.Person, + Access = EndUserAccess.Enabled + }); + _ssoProvider.Setup(sp => + sp.RefreshTokenAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.Unexpected("amessage")); + + var result = await _application.RefreshTokenAsync(_caller.Object, "auserid", "aprovidername", + "arefreshtoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + result.Error.AdditionalData.Should().OnlyContain(x => + x.Key == SingleSignOnApplication.AuthErrorProviderName && (string)x.Value == "aprovidername"); + _endUsersService.Verify( + eus => eus.GetUserPrivateAsync(_caller.Object, It.IsAny(), + It.IsAny())); + _ssoProvider.Verify( + sop => sop.RefreshTokenAsync(_caller.Object, It.IsAny(), + It.IsAny())); + _ssoProvidersService.Verify( + sps => sps.SaveUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenRefreshTokenAsync_ThenRefreshed() + { + _endUsersService.Setup(eus => + eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUser + { + Id = "auserid", + Classification = EndUserClassification.Person, + Access = EndUserAccess.Enabled + }); + _ssoProvider.Setup(sp => + sp.RefreshTokenAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ProviderAuthenticationTokens + { + Provider = "aprovidername", + AccessToken = new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.AccessToken, + Value = "anaccesstoken" + }, + RefreshToken = new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.RefreshToken, + Value = "arefreshtoken" + }, + OtherTokens = + [ + new AuthenticationToken + { + ExpiresOn = default, + Type = TokenType.OtherToken, + Value = "anothertoken" + } + ] + }); + + var result = await _application.RefreshTokenAsync(_caller.Object, "auserid", "aprovidername", + "arefreshtoken", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Provider.Should().Be("aprovidername"); + result.Value.AccessToken.Type.Should().Be(TokenType.AccessToken); + result.Value.AccessToken.Value.Should().Be("anaccesstoken"); + result.Value.RefreshToken!.Type.Should().Be(TokenType.RefreshToken); + result.Value.RefreshToken.Value.Should().Be("arefreshtoken"); + result.Value.OtherTokens.Count.Should().Be(1); + result.Value.OtherTokens[0].Type.Should().Be(TokenType.OtherToken); + result.Value.OtherTokens[0].Value.Should().Be("anothertoken"); + _endUsersService.Verify( + eus => eus.GetUserPrivateAsync(_caller.Object, It.IsAny(), + It.IsAny())); + _ssoProvider.Verify( + sop => sop.RefreshTokenAsync(_caller.Object, It.IsAny(), + It.IsAny())); + _ssoProvidersService.Verify( + sps => sps.SaveUserTokensAsync(_caller.Object, "aprovidername", "auserid".ToId(), + It.Is(token => + token.Provider == "aprovidername" + && token.AccessToken.Value == "anaccesstoken" + && token.RefreshToken!.Value == "arefreshtoken" + && token.OtherTokens.Count == 1 + && token.OtherTokens[0].Value == "anothertoken" + ), It.IsAny())); + } + + [Fact] + public async Task WhenGetTokensAsync_ThenReturnsTokens() + { + var datum = DateTime.UtcNow; + _ssoProvidersService.Setup(sps => + sps.GetTokensAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Provider = "aprovidername", + AccessToken = new AuthenticationToken + { + ExpiresOn = datum, + Type = TokenType.AccessToken, + Value = "anaccesstoken" + }, + RefreshToken = new AuthenticationToken + { + ExpiresOn = datum, + Type = TokenType.RefreshToken, + Value = "arefreshtoken" + }, + OtherTokens = + [ + new AuthenticationToken + { + ExpiresOn = datum, + Type = TokenType.OtherToken, + Value = "anothertoken" + } + ] + } + }); + + var result = await _application.GetTokensAsync(_caller.Object, "auserid", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Count.Should().Be(1); + result.Value[0].Provider.Should().Be("aprovidername"); + result.Value[0].AccessToken.ExpiresOn.Should().Be(datum); + result.Value[0].AccessToken.Type.Should().Be(TokenType.AccessToken); + result.Value[0].AccessToken.Value.Should().Be("anaccesstoken"); + result.Value[0].RefreshToken!.ExpiresOn.Should().Be(datum); + result.Value[0].RefreshToken!.Type.Should().Be(TokenType.RefreshToken); + result.Value[0].RefreshToken!.Value.Should().Be("arefreshtoken"); + result.Value[0].OtherTokens.Count.Should().Be(1); + result.Value[0].OtherTokens[0].ExpiresOn.Should().Be(datum); + result.Value[0].OtherTokens[0].Type.Should().Be(TokenType.OtherToken); + result.Value[0].OtherTokens[0].Value.Should().Be("anothertoken"); + } } \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs b/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs index 25243fc6..acbd354b 100644 --- a/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs +++ b/src/IdentityApplication/ApplicationServices/ISSOAuthenticationProvider.cs @@ -12,8 +12,18 @@ public interface ISSOAuthenticationProvider { string ProviderName { get; } + /// + /// Returns the authenticated user with the specified for the specified + /// + /// Task> AuthenticateAsync(ICallerContext caller, string authCode, string? emailAddress, CancellationToken cancellationToken); + + /// + /// Returns the refreshed token, with new access tokens + /// + Task> RefreshTokenAsync(ICallerContext caller, string refreshToken, + CancellationToken cancellationToken); } /// @@ -38,13 +48,13 @@ public SSOUserInfo(IReadOnlyList tokens, string emailAddress, string public string FirstName { get; } + public string FullName => LastName.HasValue() + ? $"{FirstName} {LastName}" + : FirstName; + public string? LastName { get; } public TimezoneIANA Timezone { get; } public IReadOnlyList Tokens { get; } - - public string FullName => LastName.HasValue() - ? $"{FirstName} {LastName}" - : FirstName; } \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs b/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs index c396cb2f..31363efd 100644 --- a/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs +++ b/src/IdentityApplication/ApplicationServices/ISSOProvidersService.cs @@ -1,3 +1,5 @@ +using Application.Interfaces; +using Application.Resources.Shared; using Common; using Domain.Common.ValueObjects; @@ -8,9 +10,21 @@ namespace IdentityApplication.ApplicationServices; /// public interface ISSOProvidersService { - Task, Error>> FindByNameAsync(string name, + Task, Error>> FindByProviderNameAsync(string providerName, CancellationToken cancellationToken); - Task> SaveUserInfoAsync(string providerName, Identifier userId, SSOUserInfo userInfo, + Task, Error>> FindByUserIdAsync(ICallerContext caller, + Identifier userId, string providerName, + CancellationToken cancellationToken); + + Task, Error>> GetTokensAsync(ICallerContext caller, + Identifier userId, CancellationToken cancellationToken); + + Task> SaveUserInfoAsync(ICallerContext caller, string providerName, Identifier userId, + SSOUserInfo userInfo, + CancellationToken cancellationToken); + + Task> SaveUserTokensAsync(ICallerContext caller, string providerName, Identifier userId, + ProviderAuthenticationTokens tokens, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs b/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs index 11696624..f934f307 100644 --- a/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs +++ b/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs @@ -1,3 +1,6 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Resources.Shared; using Common; using Common.Extensions; using Domain.Common.Identity; @@ -33,18 +36,86 @@ public SSOProvidersService(IRecorder recorder, IIdentifierFactory identifierFact _authenticationProviders = authenticationProviders; } - public Task, Error>> FindByNameAsync(string name, + public Task, Error>> FindByProviderNameAsync(string providerName, CancellationToken cancellationToken) { var provider = - _authenticationProviders.FirstOrDefault(provider => provider.ProviderName.EqualsIgnoreCase(name)); + _authenticationProviders.FirstOrDefault(provider => provider.ProviderName.EqualsIgnoreCase(providerName)); return Task.FromResult, Error>>(provider.ToOptional()); } - public async Task> SaveUserInfoAsync(string providerName, Identifier userId, SSOUserInfo userInfo, - CancellationToken cancellationToken) + public async Task, Error>> FindByUserIdAsync(ICallerContext caller, + Identifier userId, string providerName, CancellationToken cancellationToken) + { + var provider = + _authenticationProviders.FirstOrDefault(provider => provider.ProviderName.EqualsIgnoreCase(providerName)); + if (provider.NotExists()) + { + return Optional.None; + } + + var retrieved = await _repository.FindByUserIdAsync(provider.ProviderName, userId, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Optional.None; + } + + var user = retrieved.Value.Value; + + var viewed = user.ViewUser(caller.ToCallerId()); + if (viewed.IsFailure) + { + return viewed.Error; + } + + return provider.ToOptional(); + } + + public async Task, Error>> GetTokensAsync(ICallerContext caller, + Identifier userId, CancellationToken cancellationToken) { - var retrievedProvider = await FindByNameAsync(providerName, cancellationToken); + var allTokens = new List(); + foreach (var provider in _authenticationProviders) + { + var retrievedUser = await _repository.FindByUserIdAsync(provider.ProviderName, userId, cancellationToken); + if (retrievedUser.IsFailure) + { + return retrievedUser.Error; + } + + if (!retrievedUser.Value.HasValue) + { + continue; + } + + var user = retrievedUser.Value.Value; + var viewed = user.ViewUser(caller.ToCallerId()); + if (viewed.IsFailure) + { + return viewed.Error; + } + + if (!user.Tokens.HasValue) + { + continue; + } + + var tokens = user.Tokens.Value; + allTokens.Add(tokens.ToProviderAuthenticationTokens(provider.ProviderName, _encryptionService)); + } + + return allTokens; + } + + public async Task> SaveUserInfoAsync(ICallerContext caller, string providerName, Identifier userId, + SSOUserInfo userInfo, CancellationToken cancellationToken) + { + var retrievedProvider = await FindByProviderNameAsync(providerName, cancellationToken); if (retrievedProvider.IsFailure) { return retrievedProvider.Error; @@ -57,7 +128,7 @@ public async Task> SaveUserInfoAsync(string providerName, Identifi var provider = retrievedProvider.Value.Value; var retrievedUser = - await _repository.FindUserInfoByUserIdAsync(provider.ProviderName, userId, cancellationToken); + await _repository.FindByUserIdAsync(provider.ProviderName, userId, cancellationToken); if (retrievedUser.IsFailure) { return retrievedUser.Error; @@ -70,7 +141,7 @@ public async Task> SaveUserInfoAsync(string providerName, Identifi } else { - var created = SSOUserRoot.Create(_recorder, _identifierFactory, _encryptionService, providerName, userId); + var created = SSOUserRoot.Create(_recorder, _identifierFactory, providerName, userId); if (created.IsFailure) { return created.Error; @@ -103,17 +174,19 @@ public async Task> SaveUserInfoAsync(string providerName, Identifi return address.Error; } - var tokens = SSOAuthTokens.Create(userInfo.Tokens - .Select(tok => - SSOAuthToken.Create(tok.Type.ToEnumOrDefault(SSOAuthTokenType.AccessToken), tok.Value, tok.ExpiresOn) - .Value) - .ToList()); + var toks = userInfo.Tokens.ToAuthTokens(_encryptionService); + if (toks.IsFailure) + { + return toks.Error; + } + + var tokens = SSOAuthTokens.Create(toks.Value); if (tokens.IsFailure) { return tokens.Error; } - var updated = user.UpdateDetails(tokens.Value, emailAddress.Value, name.Value, timezone.Value, address.Value); + var updated = user.AddDetails(tokens.Value, emailAddress.Value, name.Value, timezone.Value, address.Value); if (updated.IsFailure) { return updated.Error; @@ -125,6 +198,182 @@ public async Task> SaveUserInfoAsync(string providerName, Identifi return saved.Error; } + user = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "SSO User {UserId} updated with user information", + user.UserId); + + return Result.Ok; + } + + public async Task> SaveUserTokensAsync(ICallerContext caller, string providerName, Identifier userId, + ProviderAuthenticationTokens tokens, CancellationToken cancellationToken) + { + var retrievedProvider = await FindByProviderNameAsync(providerName, cancellationToken); + if (retrievedProvider.IsFailure) + { + return retrievedProvider.Error; + } + + if (!retrievedProvider.Value.HasValue) + { + return Error.EntityNotFound(Resources.SSOProvidersService_UnknownProvider.Format(providerName)); + } + + var provider = retrievedProvider.Value.Value; + var retrievedUser = + await _repository.FindByUserIdAsync(provider.ProviderName, userId, cancellationToken); + if (retrievedUser.IsFailure) + { + return retrievedUser.Error; + } + + if (!retrievedUser.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var user = retrievedUser.Value.Value; + var toks = tokens.ToAuthTokens(_encryptionService); + if (toks.IsFailure) + { + return toks.Error; + } + + var ssoTokens = SSOAuthTokens.Create(toks.Value); + if (ssoTokens.IsFailure) + { + return ssoTokens.Error; + } + + var changed = user.ChangeTokens(caller.ToCallerId(), ssoTokens.Value); + if (changed.IsFailure) + { + return changed.Error; + } + + var saved = await _repository.SaveAsync(user, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + user = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "SSO User {UserId} changed tokens", + user.UserId); + return Result.Ok; } +} + +internal static class SSOProvidersServiceConversionExtensions +{ + public static Result, Error> ToAuthTokens(this IReadOnlyList tokens, + IEncryptionService encryptionService) + { + var list = new List(); + foreach (var token in tokens) + { + var tok = SSOAuthToken.Create(token.Type.ToEnumOrDefault(SSOAuthTokenType.AccessToken), token.Value, + token.ExpiresOn, + encryptionService); + if (tok.IsFailure) + { + return tok.Error; + } + + list.Add(tok.Value); + } + + return list; + } + + public static Result, Error> ToAuthTokens(this ProviderAuthenticationTokens tokens, + IEncryptionService encryptionService) + { + var list = new List(); + if (tokens.AccessToken.Exists()) + { + var tok = SSOAuthToken.Create(SSOAuthTokenType.AccessToken, tokens.AccessToken.Value, + tokens.AccessToken.ExpiresOn, encryptionService); + if (tok.IsFailure) + { + return tok.Error; + } + + list.Add(tok.Value); + } + + if (tokens.RefreshToken.Exists()) + { + var tok = SSOAuthToken.Create(SSOAuthTokenType.RefreshToken, tokens.RefreshToken.Value, + tokens.RefreshToken.ExpiresOn, encryptionService); + if (tok.IsFailure) + { + return tok.Error; + } + + list.Add(tok.Value); + } + + if (tokens.OtherTokens.HasAny()) + { + foreach (var token in tokens.OtherTokens) + { + var tok = SSOAuthToken.Create(SSOAuthTokenType.OtherToken, token.Value, + token.ExpiresOn, encryptionService); + if (tok.IsFailure) + { + return tok.Error; + } + + list.Add(tok.Value); + } + } + + return list; + } + + public static ProviderAuthenticationTokens ToProviderAuthenticationTokens(this SSOAuthTokens tokens, + string providerName, IEncryptionService encryptionService) + { + var accessToken = tokens + .ToList() + .Single(tok => tok.Type == SSOAuthTokenType.AccessToken); + var refreshToken = tokens + .ToList() + .FirstOrDefault(tok => tok.Type == SSOAuthTokenType.RefreshToken); + var otherTokens = tokens + .ToList() + .Where(tok => tok.Type == SSOAuthTokenType.OtherToken) + .ToList(); + + var providerTokens = new ProviderAuthenticationTokens + { + Provider = providerName, + AccessToken = new AuthenticationToken + { + ExpiresOn = accessToken.ExpiresOn, + Type = TokenType.AccessToken, + Value = encryptionService.Decrypt(accessToken.EncryptedValue) + }, + RefreshToken = refreshToken.Exists() + ? new AuthenticationToken + { + ExpiresOn = refreshToken.ExpiresOn, + Type = TokenType.RefreshToken, + Value = encryptionService.Decrypt(refreshToken.EncryptedValue) + } + : null, + OtherTokens = otherTokens.HasAny() + ? otherTokens.Select(otherToken => new AuthenticationToken + { + ExpiresOn = otherToken.ExpiresOn, + Type = TokenType.OtherToken, + Value = encryptionService.Decrypt(otherToken.EncryptedValue) + }).ToList() + : [] + }; + + return providerTokens; + } } \ No newline at end of file diff --git a/src/IdentityApplication/AuthTokensApplication.cs b/src/IdentityApplication/AuthTokensApplication.cs index 79cb0eb2..c0d9db69 100644 --- a/src/IdentityApplication/AuthTokensApplication.cs +++ b/src/IdentityApplication/AuthTokensApplication.cs @@ -125,19 +125,31 @@ public async Task> RefreshTokenAsync(ICallerCo authTokens = saved.Value; _recorder.TraceInformation(caller.ToCall(), "AuthTokens were refreshed for {Id}", authTokens.Id); + _recorder.AuditAgainst(caller.ToCall(), user.Id, + Audits.AuthTokensApplication_Refresh_Succeeded, + "User {Id} succeeded to refresh token", user.Id); + _recorder.TrackUsageFor(caller.ToCall(), user.Id, + UsageConstants.Events.UsageScenarios.Generic.UserExtendedLogin, + new Dictionary + { + { UsageConstants.Properties.AuthProvider, PasswordCredentialsApplication.ProviderName }, + { UsageConstants.Properties.UserIdOverride, user.Id } + }); return new AuthenticateTokens { UserId = user.Id, - AccessToken = new AuthenticateToken + AccessToken = new AuthenticationToken { Value = tokens.AccessToken, - ExpiresOn = tokens.AccessTokenExpiresOn + ExpiresOn = tokens.AccessTokenExpiresOn, + Type = TokenType.AccessToken }, - RefreshToken = new AuthenticateToken + RefreshToken = new AuthenticationToken { Value = tokens.RefreshToken, - ExpiresOn = tokens.RefreshTokenExpiresOn + ExpiresOn = tokens.RefreshTokenExpiresOn, + Type = TokenType.RefreshToken } }; } diff --git a/src/IdentityApplication/ISingleSignOnApplication.cs b/src/IdentityApplication/ISingleSignOnApplication.cs index c34112c8..d69f478c 100644 --- a/src/IdentityApplication/ISingleSignOnApplication.cs +++ b/src/IdentityApplication/ISingleSignOnApplication.cs @@ -7,6 +7,13 @@ namespace IdentityApplication; public interface ISingleSignOnApplication { Task> AuthenticateAsync(ICallerContext caller, string? invitationToken, + string providerName, string authCode, string? username, CancellationToken cancellationToken); + + Task, Error>> GetTokensAsync(ICallerContext caller, + string userId, + CancellationToken cancellationToken); + + Task> RefreshTokenAsync(ICallerContext caller, string userId, string providerName, - string authCode, string? username, CancellationToken cancellationToken); + string refreshToken, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs index c5594f3d..ac965729 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -20,7 +20,7 @@ namespace IdentityApplication; public class PasswordCredentialsApplication : IPasswordCredentialsApplication { - private const string ProviderName = "credentials"; + public const string ProviderName = "credentials"; #if TESTINGONLY private const double MinAuthenticateDelayInSecs = 0; private const double MaxAuthenticateDelayInSecs = 0; @@ -180,15 +180,17 @@ public async Task> AuthenticateAsync(ICallerCo var tokens = issued.Value; return new Result(new AuthenticateTokens { - AccessToken = new AuthenticateToken + AccessToken = new AuthenticationToken { Value = tokens.AccessToken, - ExpiresOn = tokens.AccessTokenExpiresOn + ExpiresOn = tokens.AccessTokenExpiresOn, + Type = TokenType.AccessToken }, - RefreshToken = new AuthenticateToken + RefreshToken = new AuthenticationToken { Value = tokens.RefreshToken, - ExpiresOn = tokens.RefreshTokenExpiresOn + ExpiresOn = tokens.RefreshTokenExpiresOn, + Type = TokenType.RefreshToken }, UserId = user.Id }); diff --git a/src/IdentityApplication/Persistence/ISSOUsersRepository.cs b/src/IdentityApplication/Persistence/ISSOUsersRepository.cs index a3152ae3..5ca0d24e 100644 --- a/src/IdentityApplication/Persistence/ISSOUsersRepository.cs +++ b/src/IdentityApplication/Persistence/ISSOUsersRepository.cs @@ -7,7 +7,7 @@ namespace IdentityApplication.Persistence; public interface ISSOUsersRepository : IApplicationRepository { - Task, Error>> FindUserInfoByUserIdAsync(string providerName, Identifier userId, + Task, Error>> FindByUserIdAsync(string providerName, Identifier userId, CancellationToken cancellationToken); Task> SaveAsync(SSOUserRoot user, CancellationToken cancellationToken); diff --git a/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs b/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs index fd46ad49..56273390 100644 --- a/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs +++ b/src/IdentityApplication/Persistence/ReadModels/SSOUser.cs @@ -1,5 +1,6 @@ using Application.Persistence.Common; using Common; +using IdentityDomain; using QueryAny; namespace IdentityApplication.Persistence.ReadModels; @@ -19,7 +20,7 @@ public class SSOUser : ReadModelEntity public Optional Timezone { get; set; } - public Optional Tokens { get; set; } + public Optional Tokens { get; set; } public Optional UserId { get; set; } } \ No newline at end of file diff --git a/src/IdentityApplication/SingleSignOnApplication.cs b/src/IdentityApplication/SingleSignOnApplication.cs index b35988fd..bb8249d7 100644 --- a/src/IdentityApplication/SingleSignOnApplication.cs +++ b/src/IdentityApplication/SingleSignOnApplication.cs @@ -11,6 +11,7 @@ namespace IdentityApplication; public class SingleSignOnApplication : ISingleSignOnApplication { + public const string AuthErrorProviderName = "provider"; private readonly IAuthTokensService _authTokensService; private readonly IEndUsersService _endUsersService; private readonly IRecorder _recorder; @@ -26,10 +27,10 @@ public SingleSignOnApplication(IRecorder recorder, IEndUsersService endUsersServ } public async Task> AuthenticateAsync(ICallerContext caller, - string? invitationToken, string providerName, - string authCode, string? username, CancellationToken cancellationToken) + string? invitationToken, string providerName, string authCode, string? username, + CancellationToken cancellationToken) { - var retrievedProvider = await _ssoProvidersService.FindByNameAsync(providerName, cancellationToken); + var retrievedProvider = await _ssoProvidersService.FindByProviderNameAsync(providerName, cancellationToken); if (retrievedProvider.IsFailure) { return retrievedProvider.Error; @@ -37,14 +38,14 @@ public async Task> AuthenticateAsync(ICallerCo if (!retrievedProvider.Value.HasValue) { - return Error.NotAuthenticated(); + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); } var provider = retrievedProvider.Value.Value; var authenticated = await provider.AuthenticateAsync(caller, authCode, username, cancellationToken); if (authenticated.IsFailure) { - return authenticated.Error; + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); } var userInfo = authenticated.Value; @@ -82,13 +83,18 @@ public async Task> AuthenticateAsync(ICallerCo await _endUsersService.GetMembershipsPrivateAsync(caller, registeredUserId, cancellationToken); if (retrievedUser.IsFailure) { - return Error.NotAuthenticated(); + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); } var user = retrievedUser.Value; + if (user.Classification != EndUserClassification.Person) + { + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); + } + if (user.Status != EndUserStatus.Registered) { - return Error.NotAuthenticated(); + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); } if (user.Access == EndUserAccess.Suspended) @@ -99,7 +105,8 @@ public async Task> AuthenticateAsync(ICallerCo return Error.EntityExists(Resources.SingleSignOnApplication_AccountSuspended); } - var saved = await _ssoProvidersService.SaveUserInfoAsync(providerName, registeredUserId.ToId(), userInfo, + var saved = await _ssoProvidersService.SaveUserInfoAsync(caller, providerName, registeredUserId.ToId(), + userInfo, cancellationToken); if (saved.IsFailure) { @@ -121,19 +128,99 @@ public async Task> AuthenticateAsync(ICallerCo var tokens = issued.Value; return new Result(new AuthenticateTokens { - AccessToken = new AuthenticateToken + AccessToken = new AuthenticationToken { Value = tokens.AccessToken, - ExpiresOn = tokens.AccessTokenExpiresOn + ExpiresOn = tokens.AccessTokenExpiresOn, + Type = TokenType.AccessToken }, - RefreshToken = new AuthenticateToken + RefreshToken = new AuthenticationToken { Value = tokens.RefreshToken, - ExpiresOn = tokens.RefreshTokenExpiresOn + ExpiresOn = tokens.RefreshTokenExpiresOn, + Type = TokenType.RefreshToken }, UserId = user.Id }); } + + public async Task, Error>> GetTokensAsync(ICallerContext caller, + string userId, + CancellationToken cancellationToken) + { + return await _ssoProvidersService.GetTokensAsync(caller, userId.ToId(), cancellationToken); + } + + public async Task> RefreshTokenAsync(ICallerContext caller, + string userId, string providerName, string refreshToken, CancellationToken cancellationToken) + { + var retrievedProvider = + await _ssoProvidersService.FindByUserIdAsync(caller, userId.ToId(), providerName, cancellationToken); + if (retrievedProvider.IsFailure) + { + return retrievedProvider.Error; + } + + if (!retrievedProvider.Value.HasValue) + { + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); + } + + var provider = retrievedProvider.Value.Value; + var retrievedUser = + await _endUsersService.GetUserPrivateAsync(caller, userId, cancellationToken); + if (retrievedUser.IsFailure) + { + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); + } + + var user = retrievedUser.Value; + if (user.Classification != EndUserClassification.Person) + { + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); + } + + if (user.Access == EndUserAccess.Suspended) + { + _recorder.AuditAgainst(caller.ToCall(), user.Id, + Audits.SingleSignOnApplication_Refresh_AccountSuspended, + "User {Id} tried to refresh tokens with SSO {Provider} with a suspended account", user.Id, + providerName); + return Error.EntityExists(Resources.SingleSignOnApplication_AccountSuspended); + } + + var refreshed = await provider.RefreshTokenAsync(caller, refreshToken, cancellationToken); + if (refreshed.IsFailure) + { + return Error.NotAuthenticated(additionalData: GetAuthenticationErrorData(providerName)); + } + + var tokens = refreshed.Value; + var saved = await _ssoProvidersService.SaveUserTokensAsync(caller, providerName, userId.ToId(), tokens, + cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + _recorder.AuditAgainst(caller.ToCall(), user.Id, + Audits.SingleSignOnApplication_Refresh_Succeeded, + "User {Id} succeeded to refresh with SSO {Provider}", user.Id, providerName); + _recorder.TrackUsageFor(caller.ToCall(), user.Id, + UsageConstants.Events.UsageScenarios.Generic.UserExtendedLogin, + new Dictionary + { + { UsageConstants.Properties.AuthProvider, providerName }, + { UsageConstants.Properties.UserIdOverride, user.Id } + }); + + return tokens; + } + + private static Dictionary GetAuthenticationErrorData(string providerName) + { + return new Dictionary { { AuthErrorProviderName, providerName } }; + } } internal static class SingleSignOnApplicationExtensions diff --git a/src/IdentityDomain.UnitTests/SSOAuthTokenSpec.cs b/src/IdentityDomain.UnitTests/SSOAuthTokenSpec.cs index b9264b5c..16be2348 100644 --- a/src/IdentityDomain.UnitTests/SSOAuthTokenSpec.cs +++ b/src/IdentityDomain.UnitTests/SSOAuthTokenSpec.cs @@ -1,5 +1,7 @@ using Common; +using Domain.Services.Shared; using FluentAssertions; +using Moq; using UnitTesting.Common; using Xunit; @@ -11,7 +13,11 @@ public class SSOAuthTokenSpec [Fact] public void WhenCreateAndValueIsEmpty_ThenReturnsError() { - var result = SSOAuthToken.Create(SSOAuthTokenType.AccessToken, string.Empty, null); + var encryptionService = new Mock(); + encryptionService.Setup(es => es.Encrypt(It.IsAny())) + .Returns((string _) => "anencryptedvalue"); + + var result = SSOAuthToken.Create(SSOAuthTokenType.AccessToken, string.Empty, null, encryptionService.Object); result.Should().BeError(ErrorCode.Validation); } @@ -19,12 +25,15 @@ public void WhenCreateAndValueIsEmpty_ThenReturnsError() [Fact] public void WhenCreate_ThenReturns() { + var encryptionService = new Mock(); + encryptionService.Setup(es => es.Encrypt(It.IsAny())) + .Returns((string _) => "anencryptedvalue"); var expiresOn = DateTime.UtcNow; - var result = SSOAuthToken.Create(SSOAuthTokenType.AccessToken, "atoken", expiresOn); + var result = SSOAuthToken.Create(SSOAuthTokenType.AccessToken, "atoken", expiresOn, encryptionService.Object); result.Should().BeSuccess(); result.Value.Type.Should().Be(SSOAuthTokenType.AccessToken); - result.Value.Value.Should().Be("atoken"); + result.Value.EncryptedValue.Should().Be("anencryptedvalue"); 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 index cbb86b20..4e49cf6e 100644 --- a/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs +++ b/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs @@ -16,6 +16,7 @@ namespace IdentityDomain.UnitTests; [Trait("Category", "Unit")] public class SSOUserRootSpec { + private readonly Mock _encryptionService; private readonly SSOUserRoot _user; public SSOUserRootSpec() @@ -24,12 +25,12 @@ public SSOUserRootSpec() 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())) + _encryptionService = new Mock(); + _encryptionService.Setup(es => es.Encrypt(It.IsAny())) .Returns((string value) => value); - encryptionService.Setup(es => es.Decrypt(It.IsAny())) + _encryptionService.Setup(es => es.Decrypt(It.IsAny())) .Returns((string value) => value); - _user = SSOUserRoot.Create(recorder.Object, idFactory.Object, encryptionService.Object, "aprovidername", + _user = SSOUserRoot.Create(recorder.Object, idFactory.Object, "aprovidername", "auserid".ToId()).Value; } @@ -41,23 +42,56 @@ public void WhenConstructed_ThenAssigned() } [Fact] - public void WhenUpdateDetails_ThenUpdates() + public void WhenAddedDetails_ThenAdds() { var expiresOn = DateTime.UtcNow; - var tokens = SSOAuthTokens.Create(new List - { - SSOAuthToken.Create(SSOAuthTokenType.AccessToken, "anaccesstoken", expiresOn).Value - }).Value; + var token = SSOAuthToken + .Create(SSOAuthTokenType.AccessToken, "anaccesstoken", expiresOn, _encryptionService.Object) + .Value; + var tokens = SSOAuthTokens.Create([token]).Value; - _user.UpdateDetails(tokens, EmailAddress.Create("auser@company.com").Value, + var result = _user.AddDetails(tokens, EmailAddress.Create("auser@company.com").Value, PersonName.Create("afirstname", null).Value, Timezone.Default, Address.Default); + result.Should().BeSuccess(); _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(); + _user.Tokens.Value.ToList()[0].Should().Be(token); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenChangedTokensByAnotherUser_ThenReturnsError() + { + var expiresOn = DateTime.UtcNow; + var token = SSOAuthToken + .Create(SSOAuthTokenType.AccessToken, "anaccesstoken", expiresOn, _encryptionService.Object) + .Value; + var tokens = SSOAuthTokens.Create([token]).Value; + + var result = _user.ChangeTokens("anotheruserid".ToId(), tokens); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.SSOUserRoot_NotOwner); + } + + [Fact] + public void WhenChangedTokens_ThenChanges() + { + var expiresOn = DateTime.UtcNow; + var token = SSOAuthToken + .Create(SSOAuthTokenType.AccessToken, "anaccesstoken", expiresOn, _encryptionService.Object) + .Value; + var tokens = SSOAuthTokens.Create([token]).Value; + + var result = _user.ChangeTokens("auserid".ToId(), tokens); + + result.Should().BeSuccess(); + _user.UserId.Should().Be("auserid".ToId()); + _user.Tokens.Value.ToList()[0].Should().Be(token); + _user.Events.Last().Should().BeOfType(); } } \ No newline at end of file diff --git a/src/IdentityDomain/Events.cs b/src/IdentityDomain/Events.cs index ab0b2eaf..ac81f66c 100644 --- a/src/IdentityDomain/Events.cs +++ b/src/IdentityDomain/Events.cs @@ -5,6 +5,7 @@ using Domain.Events.Shared.Identities.SSOUsers; using Domain.Shared; using Created = Domain.Events.Shared.Identities.AuthTokens.Created; +using TokensChanged = Domain.Events.Shared.Identities.SSOUsers.TokensChanged; namespace IdentityDomain; @@ -20,11 +21,12 @@ public static Created Created(Identifier id, Identifier userId) }; } - public static TokensChanged TokensChanged(Identifier id, Identifier userId, string accessToken, + public static Domain.Events.Shared.Identities.AuthTokens.TokensChanged TokensChanged(Identifier id, + Identifier userId, string accessToken, DateTime accessTokenExpiresOn, string refreshToken, DateTime refreshTokenExpiresOn) { - return new TokensChanged(id) + return new Domain.Events.Shared.Identities.AuthTokens.TokensChanged(id) { UserId = userId, AccessToken = accessToken, @@ -186,12 +188,11 @@ public static Domain.Events.Shared.Identities.SSOUsers.Created Created(Identifie }; } - public static TokensUpdated TokensUpdated(Identifier id, string tokens, EmailAddress emailAddress, + public static DetailsAdded DetailsAdded(Identifier id, EmailAddress emailAddress, PersonName name, Timezone timezone, Address address) { - return new TokensUpdated(id) + return new DetailsAdded(id) { - Tokens = tokens, EmailAddress = emailAddress, FirstName = name.FirstName, LastName = name.LastName.ValueOrDefault?.Text, @@ -199,5 +200,20 @@ public static TokensUpdated TokensUpdated(Identifier id, string tokens, EmailAdd CountryCode = address.CountryCode.ToString() }; } + + public static TokensChanged TokensChanged(Identifier id, SSOAuthTokens tokens) + { + return new TokensChanged(id) + { + Tokens = tokens + .ToList() + .Select(tok => new SSOToken + { + Type = tok.Type.ToString(), + EncryptedValue = tok.EncryptedValue, + ExpiresOn = tok.ExpiresOn + }).ToList() + }; + } } } \ No newline at end of file diff --git a/src/IdentityDomain/Resources.Designer.cs b/src/IdentityDomain/Resources.Designer.cs index f8631575..f838edeb 100644 --- a/src/IdentityDomain/Resources.Designer.cs +++ b/src/IdentityDomain/Resources.Designer.cs @@ -329,6 +329,15 @@ internal static string PasswordKeep_TokensNotMatch { } } + /// + /// Looks up a localized string similar to Only the user can update their own tokens. + /// + internal static string SSOUserRoot_NotOwner { + get { + return ResourceManager.GetString("SSOUserRoot_NotOwner", resourceCulture); + } + } + /// /// Looks up a localized string similar to The verification token is either missing or invalid. /// diff --git a/src/IdentityDomain/Resources.resx b/src/IdentityDomain/Resources.resx index de82ace1..4b8c5d2c 100644 --- a/src/IdentityDomain/Resources.resx +++ b/src/IdentityDomain/Resources.resx @@ -117,5 +117,7 @@ The verification token is either missing or invalid - + + Only the user can update their own tokens + \ No newline at end of file diff --git a/src/IdentityDomain/SSOAuthToken.cs b/src/IdentityDomain/SSOAuthToken.cs index 675713b8..df48c34c 100644 --- a/src/IdentityDomain/SSOAuthToken.cs +++ b/src/IdentityDomain/SSOAuthToken.cs @@ -2,34 +2,48 @@ using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Interfaces; +using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared; namespace IdentityDomain; public sealed class SSOAuthToken : ValueObjectBase { - public static Result Create(SSOAuthTokenType type, string value, DateTime? expiresOn) + public static Result Create(SSOAuthTokenType type, string value, DateTime? expiresOn, + IEncryptionService encryptionService) { if (value.IsNotValuedParameter(nameof(value), out var error1)) { return error1; } - return new SSOAuthToken(type, value, expiresOn); + var encrypted = encryptionService.Encrypt(value); + return Create(type, encrypted, expiresOn); } - private SSOAuthToken(SSOAuthTokenType type, string value, DateTime? expiresOn) + public static Result Create(SSOAuthTokenType type, string encryptedValue, DateTime? expiresOn) + { + if (encryptedValue.IsNotValuedParameter(nameof(encryptedValue), out var error1)) + { + return error1; + } + + return new SSOAuthToken(type, encryptedValue, expiresOn); + } + + private SSOAuthToken(SSOAuthTokenType type, string encryptedValue, DateTime? expiresOn) { Type = type; - Value = value; + EncryptedValue = encryptedValue; ExpiresOn = expiresOn; } + public string EncryptedValue { get; } + public DateTime? ExpiresOn { get; } public SSOAuthTokenType Type { get; } - public string Value { get; } - public static ValueObjectFactory Rehydrate() { return (property, _) => @@ -42,6 +56,12 @@ public static ValueObjectFactory Rehydrate() protected override IEnumerable GetAtomicValues() { - return new object?[] { Type, Value, ExpiresOn }; + return new object?[] { Type, EncryptedValue, ExpiresOn }; + } + + [SkipImmutabilityCheck] + public string GetValue(IEncryptionService encryptionService) + { + return encryptionService.Decrypt(EncryptedValue); } } \ No newline at end of file diff --git a/src/IdentityDomain/SSOAuthTokenType.cs b/src/IdentityDomain/SSOAuthTokenType.cs index 3008f3be..d84d26f6 100644 --- a/src/IdentityDomain/SSOAuthTokenType.cs +++ b/src/IdentityDomain/SSOAuthTokenType.cs @@ -2,7 +2,7 @@ namespace IdentityDomain; public enum SSOAuthTokenType { - AccessToken = 0, - RefreshToken = 1, - IdToken = 2 + OtherToken = 0, + AccessToken = 1, + RefreshToken = 2 } \ No newline at end of file diff --git a/src/IdentityDomain/SSOAuthTokens.cs b/src/IdentityDomain/SSOAuthTokens.cs index 8b5bd86b..6aefead2 100644 --- a/src/IdentityDomain/SSOAuthTokens.cs +++ b/src/IdentityDomain/SSOAuthTokens.cs @@ -1,11 +1,33 @@ using Common; +using Common.Extensions; using Domain.Common.ValueObjects; +using Domain.Events.Shared.Identities.SSOUsers; using Domain.Interfaces; +using Domain.Interfaces.ValueObjects; namespace IdentityDomain; public sealed class SSOAuthTokens : SingleValueObjectBase> { + public static Result Create(List tokens) + { + var list = new List(); + foreach (var token in tokens) + { + var tok = SSOAuthToken.Create(token.Type.ToEnumOrDefault(SSOAuthTokenType.AccessToken), + token.EncryptedValue, + token.ExpiresOn); + if (tok.IsFailure) + { + return tok.Error; + } + + list.Add(tok.Value); + } + + return Create(list); + } + public static Result Create(List value) { return new SSOAuthTokens(value); @@ -23,4 +45,10 @@ public static ValueObjectFactory Rehydrate() return new SSOAuthTokens(items.Select(item => SSOAuthToken.Rehydrate()(item!, container)).ToList()); }; } + + [SkipImmutabilityCheck] + public List ToList() + { + return Value; + } } \ No newline at end of file diff --git a/src/IdentityDomain/SSOUserRoot.cs b/src/IdentityDomain/SSOUserRoot.cs index 1f36175f..c1ee0183 100644 --- a/src/IdentityDomain/SSOUserRoot.cs +++ b/src/IdentityDomain/SSOUserRoot.cs @@ -1,5 +1,4 @@ using Common; -using Common.Extensions; using Domain.Common.Entities; using Domain.Common.Identity; using Domain.Common.ValueObjects; @@ -7,35 +6,28 @@ using Domain.Interfaces; using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; -using Domain.Services.Shared; 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); + var root = new SSOUserRoot(recorder, idFactory); root.RaiseCreateEvent(IdentityDomain.Events.SSOUsers.Created(root.Id, providerName, userId)); return root; } - private SSOUserRoot(IRecorder recorder, IIdentifierFactory idFactory, IEncryptionService encryptionService) : base( + private SSOUserRoot(IRecorder recorder, IIdentifierFactory idFactory) : base( recorder, idFactory) { - _encryptionService = encryptionService; } - private SSOUserRoot(IRecorder recorder, IIdentifierFactory idFactory, IEncryptionService encryptionService, - ISingleValueObject identifier) : base( + private SSOUserRoot(IRecorder recorder, IIdentifierFactory idFactory, ISingleValueObject identifier) : base( recorder, idFactory, identifier) { - _encryptionService = encryptionService; } public Optional
Address { get; private set; } @@ -48,14 +40,14 @@ private SSOUserRoot(IRecorder recorder, IIdentifierFactory idFactory, IEncryptio public Optional Timezone { get; private set; } - public Optional Tokens { 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.GetRequiredService(), - container.GetRequiredService(), container.GetRequiredService(), + container.GetRequiredService(), identifier); } @@ -81,39 +73,50 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } - case TokensUpdated changed: + case TokensChanged changed: { - var emailAddress = Domain.Shared.EmailAddress.Create(changed.EmailAddress); + var tokens = SSOAuthTokens.Create(changed.Tokens); + if (tokens.IsFailure) + { + return tokens.Error; + } + + Tokens = tokens.Value; + Recorder.TraceDebug(null, "User {Id} has changed their tokens", Id); + return Result.Ok; + } + + case DetailsAdded added: + { + var emailAddress = Domain.Shared.EmailAddress.Create(added.EmailAddress); if (emailAddress.IsFailure) { return emailAddress.Error; } - var name = PersonName.Create(changed.FirstName, changed.LastName); + EmailAddress = emailAddress.Value; + var name = PersonName.Create(added.FirstName, added.LastName); if (name.IsFailure) { return name.Error; } - var timezone = Domain.Shared.Timezone.Create(changed.Timezone); + Name = name.Value; + var timezone = Domain.Shared.Timezone.Create(added.Timezone); if (timezone.IsFailure) { return timezone.Error; } - var address = Domain.Shared.Address.Create(CountryCodes.FindOrDefault(changed.CountryCode)); + Timezone = timezone.Value; + var address = Domain.Shared.Address.Create(CountryCodes.FindOrDefault(added.CountryCode)); if (address.IsFailure) { 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); + Recorder.TraceDebug(null, "User {Id} has added their details", Id); return Result.Ok; } @@ -122,12 +125,42 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco } } - public Result UpdateDetails(SSOAuthTokens tokens, EmailAddress emailAddress, + public Result AddDetails(SSOAuthTokens tokens, EmailAddress emailAddress, PersonName name, Timezone timezone, Address address) { - var secureTokens = _encryptionService.Encrypt(tokens.ToJson(false)!); - return RaiseChangeEvent( - IdentityDomain.Events.SSOUsers.TokensUpdated(Id, secureTokens, emailAddress, name, timezone, + var detailsUpdated = RaiseChangeEvent( + IdentityDomain.Events.SSOUsers.DetailsAdded(Id, emailAddress, name, timezone, address)); + if (detailsUpdated.IsFailure) + { + return detailsUpdated.Error; + } + + return RaiseChangeEvent(IdentityDomain.Events.SSOUsers.TokensChanged(Id, tokens)); + } + + public Result ChangeTokens(Identifier modifierId, SSOAuthTokens tokens) + { + if (!IsOwner(modifierId)) + { + return Error.RoleViolation(Resources.SSOUserRoot_NotOwner); + } + + return RaiseChangeEvent(IdentityDomain.Events.SSOUsers.TokensChanged(Id, tokens)); + } + + public Result ViewUser(Identifier viewerId) + { + if (!IsOwner(viewerId)) + { + return Error.RoleViolation(Resources.SSOUserRoot_NotOwner); + } + + return Result.Ok; + } + + private bool IsOwner(Identifier userId) + { + return userId == UserId; } } \ No newline at end of file diff --git a/src/IdentityInfrastructure.IntegrationTests/SingleSignOnApiSpec.cs b/src/IdentityInfrastructure.IntegrationTests/SingleSignOnApiSpec.cs index c2b72212..21479d01 100644 --- a/src/IdentityInfrastructure.IntegrationTests/SingleSignOnApiSpec.cs +++ b/src/IdentityInfrastructure.IntegrationTests/SingleSignOnApiSpec.cs @@ -45,8 +45,7 @@ public async Task WhenAuthenticateAndNoUsername_ThenReturnsError() AuthCode = "1234567890" }); - result.Content.Error.Status.Should().Be((int)HttpStatusCode.BadRequest); - result.Content.Error.Detail.Should().Be(Resources.TestSSOAuthenticationProvider_MissingUsername); + result.Content.Error.Status.Should().Be((int)HttpStatusCode.Unauthorized); #endif } diff --git a/src/IdentityInfrastructure.UnitTests/ApplicationServices/FakeSSOAuthenticationProviderSpec.cs b/src/IdentityInfrastructure.UnitTests/ApplicationServices/FakeSSOAuthenticationProviderSpec.cs index 51d46dd1..ef6ad58b 100644 --- a/src/IdentityInfrastructure.UnitTests/ApplicationServices/FakeSSOAuthenticationProviderSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/ApplicationServices/FakeSSOAuthenticationProviderSpec.cs @@ -29,7 +29,7 @@ public async Task WhenAuthenticateAsyncAndNoUsername_ThenReturnsError() var result = await _provider.AuthenticateAsync(_caller.Object, "1234567890", null, CancellationToken.None); - result.Should().BeError(ErrorCode.RuleViolation, Resources.TestSSOAuthenticationProvider_MissingUsername); + result.Should().BeError(ErrorCode.RuleViolation, Resources.FakeSSOAuthenticationProvider_MissingUsername); } [Fact] @@ -38,7 +38,7 @@ public async Task WhenAuthenticateAsyncAndWrongAuthCode_ThenReturnsError() var result = await _provider.AuthenticateAsync(_caller.Object, "awrongcode", null, CancellationToken.None); - result.Should().BeError(ErrorCode.RuleViolation, Resources.TestSSOAuthenticationProvider_MissingUsername); + result.Should().BeError(ErrorCode.RuleViolation, Resources.FakeSSOAuthenticationProvider_MissingUsername); } [Fact] @@ -60,5 +60,23 @@ public async Task WhenAuthenticateAsync_ThenReturnsTokens() result.Value.Timezone.Should().Be(Timezones.Default); result.Value.CountryCode.Should().Be(CountryCodes.Default); } + + [Fact] + public async Task WhenRefreshTokenAsyncAndNoRefreshToken_ThenReturnsError() + { + var result = + await _provider.RefreshTokenAsync(_caller.Object, string.Empty, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.TestSSOAuthenticationProvider_MissingRefreshToken); + } + + [Fact] + public async Task WhenRefreshTokenAsync_ThenReturnsError() + { + var result = + await _provider.RefreshTokenAsync(_caller.Object, "arefreshtoken", CancellationToken.None); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } } #endif \ No newline at end of file diff --git a/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs b/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs index 75bd6069..5f0ea70a 100644 --- a/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs +++ b/src/IdentityInfrastructure/ApplicationServices/FakeOAuth2Service.cs @@ -30,25 +30,32 @@ public Task, Error>> ExchangeCodeForTokensAsync(ICallerCo }); } - public static AuthToken CreateAccessToken(OAuth2CodeTokenExchangeOptions options) + public Task, Error>> RefreshTokenAsync(ICallerContext caller, + OAuth2RefreshTokenOptions options, CancellationToken cancellationToken) { - 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 Task.FromResult, Error>>(Error.PreconditionViolation("Not Supported")); + } - return new AuthToken(TokenType.AccessToken, accessToken, expiresOn); + public static Result GetProviderTokensFromTokens(string providerName, + List tokens) + { + var accessToken = tokens.Single(tok => tok.Type == TokenType.AccessToken); + return new ProviderAuthenticationTokens + { + Provider = providerName, + AccessToken = new AuthenticationToken + { + ExpiresOn = accessToken.ExpiresOn + ?? DateTime.UtcNow.Add(AuthenticationConstants.Tokens.DefaultAccessTokenExpiry), + Type = TokenType.AccessToken, + Value = accessToken.Value + }, + RefreshToken = null, + OtherTokens = [] + }; } - public static SSOUserInfo GetInfoFromToken(List tokens) + public static SSOUserInfo GetUserInfoFromTokens(List tokens) { var accessToken = tokens.Single(tok => tok.Type == TokenType.AccessToken).Value; @@ -62,5 +69,23 @@ public static SSOUserInfo GetInfoFromToken(List tokens) return new SSOUserInfo(tokens, emailAddress, firstName, lastName, timezone, country); } + + private 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); + } } #endif \ No newline at end of file diff --git a/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs b/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs index ae4e9b68..9058f53b 100644 --- a/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs +++ b/src/IdentityInfrastructure/ApplicationServices/FakeSSOAuthenticationProvider.cs @@ -1,5 +1,6 @@ #if TESTINGONLY using Application.Interfaces; +using Application.Resources.Shared; using Common; using Common.Extensions; using IdentityApplication.ApplicationServices; @@ -31,7 +32,7 @@ public async Task> AuthenticateAsync(ICallerContext c { if (emailAddress.HasNoValue()) { - return Error.RuleViolation(Resources.TestSSOAuthenticationProvider_MissingUsername); + return Error.RuleViolation(Resources.FakeSSOAuthenticationProvider_MissingUsername); } var retrievedTokens = @@ -45,9 +46,30 @@ await _auth2Service.ExchangeCodeForTokensAsync(caller, var tokens = retrievedTokens.Value; - return FakeOAuth2Service.GetInfoFromToken(tokens); + return FakeOAuth2Service.GetUserInfoFromTokens(tokens); } public string ProviderName => SSOName; + + public async Task> RefreshTokenAsync(ICallerContext caller, + string refreshToken, CancellationToken cancellationToken) + { + if (refreshToken.HasNoValue()) + { + return Error.RuleViolation(Resources.TestSSOAuthenticationProvider_MissingRefreshToken); + } + + var retrievedTokens = + await _auth2Service.RefreshTokenAsync(caller, + new OAuth2RefreshTokenOptions(ServiceName, refreshToken), + cancellationToken); + if (retrievedTokens.IsFailure) + { + return Error.NotAuthenticated(); + } + + var tokens = retrievedTokens.Value; + return FakeOAuth2Service.GetProviderTokensFromTokens(SSOName, tokens); + } } #endif \ No newline at end of file diff --git a/src/IdentityInfrastructure/ApplicationServices/SSOInProcessServiceClient.cs b/src/IdentityInfrastructure/ApplicationServices/SSOInProcessServiceClient.cs new file mode 100644 index 00000000..be12e117 --- /dev/null +++ b/src/IdentityInfrastructure/ApplicationServices/SSOInProcessServiceClient.cs @@ -0,0 +1,31 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using IdentityApplication; + +namespace IdentityInfrastructure.ApplicationServices; + +public class SSOInProcessServiceClient : ISSOService +{ + private readonly ISingleSignOnApplication _singleSignOnApplication; + + public SSOInProcessServiceClient(ISingleSignOnApplication singleSignOnApplication) + { + _singleSignOnApplication = singleSignOnApplication; + } + + public Task, Error>> GetTokensAsync(ICallerContext caller, + string userId, + CancellationToken cancellationToken) + { + return _singleSignOnApplication.GetTokensAsync(caller, userId, cancellationToken); + } + + public Task> RefreshTokenAsync(ICallerContext caller, string userId, + string providerName, string refreshToken, CancellationToken cancellationToken) + { + return _singleSignOnApplication.RefreshTokenAsync(caller, userId, providerName, refreshToken, + cancellationToken); + } +} \ No newline at end of file diff --git a/src/IdentityInfrastructure/IdentityModule.cs b/src/IdentityInfrastructure/IdentityModule.cs index 18f11420..a0b79bb9 100644 --- a/src/IdentityInfrastructure/IdentityModule.cs +++ b/src/IdentityInfrastructure/IdentityModule.cs @@ -120,6 +120,7 @@ public Action RegisterServices services.AddPerHttpRequest(); services.AddPerHttpRequest(); + services.AddPerHttpRequest(); services.AddPerHttpRequest(); #if TESTINGONLY // EXTEND: replace these registrations with your own OAuth2 implementations diff --git a/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs b/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs index 602d157f..5bd651b0 100644 --- a/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs +++ b/src/IdentityInfrastructure/Persistence/ReadModels/SSOUserProjection.cs @@ -33,10 +33,9 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); - case TokensUpdated e: + case DetailsAdded e: return await _users.HandleUpdateAsync(e.RootId, dto => { - dto.Tokens = e.Tokens; dto.EmailAddress = e.EmailAddress; dto.FirstName = e.FirstName; dto.LastName = e.LastName; @@ -44,6 +43,10 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven dto.CountryCode = e.CountryCode; }, cancellationToken); + case TokensChanged e: + return await _users.HandleUpdateAsync(e.RootId, + dto => { dto.Tokens = SSOAuthTokens.Create(e.Tokens).Value; }, cancellationToken); + default: return false; } diff --git a/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs b/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs index 65df34ea..5fdc7936 100644 --- a/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs +++ b/src/IdentityInfrastructure/Persistence/SSOUsersRepository.cs @@ -33,7 +33,7 @@ public async Task> DestroyAllAsync(CancellationToken cancellationT } #endif - public async Task, Error>> FindUserInfoByUserIdAsync(string providerName, + public async Task, Error>> FindByUserIdAsync(string providerName, Identifier userId, CancellationToken cancellationToken) { var query = Query.From() diff --git a/src/IdentityInfrastructure/Resources.Designer.cs b/src/IdentityInfrastructure/Resources.Designer.cs index ac197b90..9203862f 100644 --- a/src/IdentityInfrastructure/Resources.Designer.cs +++ b/src/IdentityInfrastructure/Resources.Designer.cs @@ -149,6 +149,15 @@ internal static string CreateAPIKeyRequestValidator_InvalidExpiresOn { } } + /// + /// Looks up a localized string similar to The 'Username' must be provided for this authentication attempt. + /// + internal static string FakeSSOAuthenticationProvider_MissingUsername { + get { + return ResourceManager.GetString("FakeSSOAuthenticationProvider_MissingUsername", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'EmailAddress' is either missing or invalid. /// @@ -267,11 +276,11 @@ internal static string RevokeRefreshTokenRequestValidator_InvalidToken { } /// - /// Looks up a localized string similar to The 'Username' must be provided for this authentication attempt. + /// Looks up a localized string similar to The 'RefreshToken' must be provided for this refresh attempt. /// - internal static string TestSSOAuthenticationProvider_MissingUsername { + internal static string TestSSOAuthenticationProvider_MissingRefreshToken { get { - return ResourceManager.GetString("TestSSOAuthenticationProvider_MissingUsername", resourceCulture); + return ResourceManager.GetString("TestSSOAuthenticationProvider_MissingRefreshToken", resourceCulture); } } } diff --git a/src/IdentityInfrastructure/Resources.resx b/src/IdentityInfrastructure/Resources.resx index 7039273d..6827b97b 100644 --- a/src/IdentityInfrastructure/Resources.resx +++ b/src/IdentityInfrastructure/Resources.resx @@ -78,9 +78,12 @@ The 'Username' is invalid - + The 'Username' must be provided for this authentication attempt + + The 'RefreshToken' must be provided for this refresh attempt + The 'InvitationToken' is either missing or invalid diff --git a/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs index 3cb9553e..8cebfb01 100644 --- a/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs +++ b/src/Infrastructure.Shared/ApplicationServices/OAuth2HttpServiceClient.cs @@ -35,7 +35,7 @@ public async Task, Error>> ExchangeCodeForTokensAsync(ICa { try { - var response = await _serviceClient.PostAsync(caller, new ExchangeOAuth2CodeForTokensRequest + var response = await _serviceClient.PostAsync(caller, new OAuth2GrantAuthorizationRequest { GrantType = "authorization_code", Code = options.Code, @@ -55,7 +55,7 @@ public async Task, Error>> ExchangeCodeForTokensAsync(ICa 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)); + tokens.Add(new AuthToken(TokenType.RefreshToken, response.Value.RefreshToken!, null)); } return tokens; @@ -67,4 +67,42 @@ public async Task, Error>> ExchangeCodeForTokensAsync(ICa return Error.Unexpected(ex.Message); } } + + public async Task, Error>> RefreshTokenAsync(ICallerContext caller, + OAuth2RefreshTokenOptions options, + CancellationToken cancellationToken) + { + try + { + var response = await _serviceClient.PostAsync(caller, new OAuth2GrantAuthorizationRequest + { + GrantType = "refresh_token", + ClientId = _clientId, + ClientSecret = _clientSecret, + RefreshToken = options.RefreshToken + }, null, cancellationToken); + + var tokens = new List(); + if (response.IsFailure) + { + 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.RefreshToken!, null)); + } + + return tokens; + } + catch (Exception ex) + { + _recorder.TraceError(caller.ToCall(), ex, + "Failed to refresh OAuth2 refresh token 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 index 20810776..722f02fa 100644 --- a/src/Infrastructure.Shared/IOAuth2Service.cs +++ b/src/Infrastructure.Shared/IOAuth2Service.cs @@ -6,13 +6,24 @@ namespace Infrastructure.Shared; /// -/// Defines a service for exchanging OAuth2 codes for tokens +/// Defines a generic service for exchanging OAuth2 codes for tokens. +/// See RFC6749: /// public interface IOAuth2Service { + /// + /// Exchanges an authorization code for a set of tokens, including the access_token, and possibly a refresh_token + /// See: + /// Task, Error>> ExchangeCodeForTokensAsync(ICallerContext caller, - OAuth2CodeTokenExchangeOptions options, - CancellationToken cancellationToken); + OAuth2CodeTokenExchangeOptions options, CancellationToken cancellationToken); + + /// + /// Refreshes a refresh token + /// See: + /// + Task, Error>> RefreshTokenAsync(ICallerContext caller, + OAuth2RefreshTokenOptions options, CancellationToken cancellationToken); } /// @@ -37,5 +48,23 @@ public OAuth2CodeTokenExchangeOptions(string serviceName, string code, string? c public string? Scope { get; } + public string ServiceName { get; set; } +} + +/// +/// Defines options for refreshing tokens +/// +public class OAuth2RefreshTokenOptions +{ + public OAuth2RefreshTokenOptions(string serviceName, string refreshToken) + { + serviceName.ThrowIfNotValuedParameter(nameof(serviceName)); + refreshToken.ThrowIfNotValuedParameter(nameof(refreshToken)); + ServiceName = serviceName; + RefreshToken = refreshToken; + } + + public string RefreshToken { get; } + public string ServiceName { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common/Extensions/ErrorExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/ErrorExtensions.cs index 52414967..521663ed 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/ErrorExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/ErrorExtensions.cs @@ -17,11 +17,11 @@ public static HttpError ToHttpError(this Error error) .FirstOrDefault(c => c.Value.Contains(error.Code)); if (httpStatusCode.NotExists()) { - return new HttpError(HttpErrorCode.InternalServerError, error.Message); + return new HttpError(HttpErrorCode.InternalServerError, error.Message, error.AdditionalData); } return new HttpError(httpStatusCode.Key.ToStatusCode().HttpErrorCode ?? HttpErrorCode.InternalServerError, - error.Message); + error.Message, error.AdditionalData); } /// @@ -30,6 +30,7 @@ public static HttpError ToHttpError(this Error error) public static ProblemHttpResult ToProblem(this Error error) { var httpError = error.ToHttpError(); - return (ProblemHttpResult)Results.Problem(statusCode: (int)httpError.Code, detail: httpError.Message); + return (ProblemHttpResult)Results.Problem(statusCode: (int)httpError.Code, detail: httpError.Message, + extensions: httpError.AdditionalData!); } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Interfaces/HttpError.cs b/src/Infrastructure.Web.Api.Interfaces/HttpError.cs index 24418640..265ce6cf 100644 --- a/src/Infrastructure.Web.Api.Interfaces/HttpError.cs +++ b/src/Infrastructure.Web.Api.Interfaces/HttpError.cs @@ -8,15 +8,18 @@ namespace Infrastructure.Web.Api.Interfaces; /// public struct HttpError { - public HttpError(HttpErrorCode code, string? message) + public HttpError(HttpErrorCode code, string? message = null, Dictionary? additionalData = null) { Message = message; Code = code; + AdditionalData = additionalData; } public HttpErrorCode Code { get; } public string? Message { get; } + + public Dictionary? AdditionalData { get; } } /// diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationRequest.cs similarity index 72% rename from src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensRequest.cs rename to src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationRequest.cs index f2143e6f..efb21979 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationRequest.cs @@ -4,12 +4,12 @@ namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.OAuth2; /// -/// Exchanges an OAuth2 code for tokens +/// Makes an OAuth2 authorization grant request. /// [Route("/auth/token", OperationMethod.Post)] public class - ExchangeOAuth2CodeForTokensRequest : WebRequest + OAuth2GrantAuthorizationRequest : WebRequest { [JsonPropertyName("client_id")] public string? ClientId { get; set; } @@ -21,5 +21,7 @@ public class [JsonPropertyName("redirect_uri")] public string? RedirectUri { get; set; } + [JsonPropertyName("refresh_token")] public string? RefreshToken { 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/OAuth2/ExchangeOAuth2CodeForTokensResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationResponse.cs similarity index 83% rename from src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensResponse.cs rename to src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationResponse.cs index a5a295cb..9277c890 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/ExchangeOAuth2CodeForTokensResponse.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationResponse.cs @@ -3,7 +3,7 @@ namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.OAuth2; -public class ExchangeOAuth2CodeForTokensResponse : IWebResponse +public class OAuth2GrantAuthorizationResponse : IWebResponse { [JsonPropertyName("access_token")] public string? AccessToken { get; set; } @@ -11,5 +11,5 @@ public class ExchangeOAuth2CodeForTokensResponse : IWebResponse [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } - [JsonPropertyName("token_type")] public string? TokenType { get; set; } + [JsonPropertyName("token_type")] public string? TokenType { get; set; } //i.e. bearer } \ 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 b55ded08..437a4865 100644 --- a/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs +++ b/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs @@ -67,15 +67,17 @@ public async Task WhenAuthenticate_ThenSetsCookies() .ReturnsAsync(new AuthenticateTokens { UserId = "auserid", - AccessToken = new AuthenticateToken + AccessToken = new AuthenticationToken { Value = "anaccesstoken", - ExpiresOn = accessTokenExpiresOn + ExpiresOn = accessTokenExpiresOn, + Type = TokenType.AccessToken }, - RefreshToken = new AuthenticateToken + RefreshToken = new AuthenticationToken { Value = "arefreshtoken", - ExpiresOn = refreshTokenExpiresOn + ExpiresOn = refreshTokenExpiresOn, + Type = TokenType.RefreshToken } }); @@ -133,15 +135,17 @@ public async Task WhenRefreshAndCookieExists_ThenSetsCookies() .ReturnsAsync(new AuthenticateTokens { UserId = "auserid", - AccessToken = new AuthenticateToken + AccessToken = new AuthenticationToken { Value = "anaccesstoken", - ExpiresOn = accessTokenExpiresOn + ExpiresOn = accessTokenExpiresOn, + Type = TokenType.AccessToken }, - RefreshToken = new AuthenticateToken + RefreshToken = new AuthenticationToken { Value = "arefreshtoken", - ExpiresOn = refreshTokenExpiresOn + ExpiresOn = refreshTokenExpiresOn, + Type = TokenType.RefreshToken } }); diff --git a/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs b/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs index 3b2ac481..c6431c46 100644 --- a/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs +++ b/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs @@ -52,15 +52,17 @@ public async Task WhenAuthenticateWithCredentials_ThenAuthenticates() Tokens = new AuthenticateTokens { UserId = "auserid", - AccessToken = new AuthenticateToken + AccessToken = new AuthenticationToken { Value = "anaccesstoken", - ExpiresOn = accessTokenExpiresOn + ExpiresOn = accessTokenExpiresOn, + Type = TokenType.AccessToken }, - RefreshToken = new AuthenticateToken + RefreshToken = new AuthenticationToken { Value = "arefreshtoken", - ExpiresOn = refreshTokenExpiresOn + ExpiresOn = refreshTokenExpiresOn, + Type = TokenType.RefreshToken } } }); @@ -91,15 +93,17 @@ public async Task WhenAuthenticateWithSingleSignOn_ThenAuthenticates() Tokens = new AuthenticateTokens { UserId = "auserid", - AccessToken = new AuthenticateToken + AccessToken = new AuthenticationToken { Value = "anaccesstoken", - ExpiresOn = accessTokenExpiresOn + ExpiresOn = accessTokenExpiresOn, + Type = TokenType.AccessToken }, - RefreshToken = new AuthenticateToken + RefreshToken = new AuthenticationToken { Value = "arefreshtoken", - ExpiresOn = refreshTokenExpiresOn + ExpiresOn = refreshTokenExpiresOn, + Type = TokenType.RefreshToken } } }); @@ -142,15 +146,17 @@ public async Task WhenRefreshTokenCookieExists_ThenRefreshesAndSetsCookie() Tokens = new AuthenticateTokens { UserId = "auserid", - AccessToken = new AuthenticateToken + AccessToken = new AuthenticationToken { Value = "anaccesstoken", - ExpiresOn = accessTokenExpiresOn + ExpiresOn = accessTokenExpiresOn, + Type = TokenType.AccessToken }, - RefreshToken = new AuthenticateToken + RefreshToken = new AuthenticationToken { Value = "arefreshtoken", - ExpiresOn = refreshTokenExpiresOn + ExpiresOn = refreshTokenExpiresOn, + Type = TokenType.RefreshToken } } }); @@ -167,8 +173,5 @@ public async Task WhenRefreshTokenCookieExists_ThenRefreshesAndSetsCookie() sc => sc.PostAsync(_caller.Object, It.Is(req => req.RefreshToken == "arefreshtoken" ), null, It.IsAny())); - _recorder.Verify(rec => - rec.TrackUsage(It.IsAny(), UsageConstants.Events.UsageScenarios.Generic.UserExtendedLogin, - null)); } } \ No newline at end of file diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index dcd0791f..c62bb8ba 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -1553,6 +1553,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1624,6 +1625,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1805,6 +1807,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True diff --git a/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs b/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs index 858b53a3..808472f4 100644 --- a/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs +++ b/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs @@ -91,7 +91,7 @@ private static Optional GetRefreshTokenCookie(HttpRequest request) return Optional.None; } - private static CookieOptions GetCookieOptions(DateTime expires) + private static CookieOptions GetCookieOptions(DateTime? expires) { var options = new CookieOptions { @@ -99,7 +99,9 @@ private static CookieOptions GetCookieOptions(DateTime expires) HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, - Expires = new DateTimeOffset(expires) + Expires = expires.HasValue + ? new DateTimeOffset(expires.Value) + : null }; return options; diff --git a/src/WebsiteHost/Application/AuthenticationApplication.cs b/src/WebsiteHost/Application/AuthenticationApplication.cs index 7b2036dc..e56dc317 100644 --- a/src/WebsiteHost/Application/AuthenticationApplication.cs +++ b/src/WebsiteHost/Application/AuthenticationApplication.cs @@ -77,8 +77,6 @@ public async Task> RefreshTokenAsync(ICallerCo return refreshed.Error.ToError(); } - _recorder.TrackUsage(caller.ToCall(), UsageConstants.Events.UsageScenarios.Generic.UserExtendedLogin); - return refreshed.Value.ToTokens(); } }