From 5864ea84e719a5ac26db6ff93e80f51b46751c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Wed, 11 Jan 2023 11:30:42 +0100 Subject: [PATCH] feat(snjs): add authenticator use cases (#2145) * feat(snjs): add authenticator use case * feat(snjs): add use cases for listing, deleting and verifying authenticators * fix(snjs): spec for deleting authenticator --- .../AuthenticatorClientInterface.ts | 10 +- .../Authenticator/AuthenticatorManager.ts | 20 ++- packages/snjs/lib/Application/Application.ts | 64 ++++++-- .../Application/Options/OptionalOptions.ts | 20 +++ .../AddAuthenticator/AddAuthenticator.spec.ts | 146 ++++++++++++++++++ .../AddAuthenticator/AddAuthenticator.ts | 67 ++++++++ .../AddAuthenticator/AddAuthenticatorDTO.ts | 5 + .../DeleteAuthenticator.spec.ts | 41 +++++ .../DeleteAuthenticator.ts | 23 +++ .../DeleteAuthenticatorDTO.ts | 3 + .../ListAuthenticators.spec.ts | 23 +++ .../ListAuthenticators/ListAuthenticators.ts | 12 ++ .../UseCase/UseCaseContainerInterface.ts | 4 + .../VerifyAuthenticator.spec.ts | 70 +++++++++ .../VerifyAuthenticator.ts | 49 ++++++ .../VerifyAuthenticatorDTO.ts | 3 + 16 files changed, 531 insertions(+), 29 deletions(-) create mode 100644 packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.spec.ts create mode 100644 packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.ts create mode 100644 packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticatorDTO.ts create mode 100644 packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.spec.ts create mode 100644 packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.ts create mode 100644 packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticatorDTO.ts create mode 100644 packages/snjs/lib/Domain/UseCase/ListAuthenticators/ListAuthenticators.spec.ts create mode 100644 packages/snjs/lib/Domain/UseCase/ListAuthenticators/ListAuthenticators.ts create mode 100644 packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.spec.ts create mode 100644 packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.ts create mode 100644 packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticatorDTO.ts diff --git a/packages/services/src/Domain/Authenticator/AuthenticatorClientInterface.ts b/packages/services/src/Domain/Authenticator/AuthenticatorClientInterface.ts index aa16f7b6a..df11ab19f 100644 --- a/packages/services/src/Domain/Authenticator/AuthenticatorClientInterface.ts +++ b/packages/services/src/Domain/Authenticator/AuthenticatorClientInterface.ts @@ -1,12 +1,14 @@ +import { Username, Uuid } from '@standardnotes/domain-core' + export interface AuthenticatorClientInterface { list(): Promise> - delete(authenticatorId: string): Promise - generateRegistrationOptions(userUuid: string, username: string): Promise | null> + delete(authenticatorId: Uuid): Promise + generateRegistrationOptions(userUuid: Uuid, username: Username): Promise | null> verifyRegistrationResponse( - userUuid: string, + userUuid: Uuid, name: string, registrationCredential: Record, ): Promise generateAuthenticationOptions(): Promise | null> - verifyAuthenticationResponse(userUuid: string, authenticationCredential: Record): Promise + verifyAuthenticationResponse(userUuid: Uuid, authenticationCredential: Record): Promise } diff --git a/packages/services/src/Domain/Authenticator/AuthenticatorManager.ts b/packages/services/src/Domain/Authenticator/AuthenticatorManager.ts index 201a3c4a2..bd110ea9e 100644 --- a/packages/services/src/Domain/Authenticator/AuthenticatorManager.ts +++ b/packages/services/src/Domain/Authenticator/AuthenticatorManager.ts @@ -1,6 +1,7 @@ /* istanbul ignore file */ import { AuthenticatorApiServiceInterface } from '@standardnotes/api' +import { Username, Uuid } from '@standardnotes/domain-core' import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface' import { AbstractService } from '../Service/AbstractService' @@ -28,9 +29,9 @@ export class AuthenticatorManager extends AbstractService implements Authenticat } } - async delete(authenticatorId: string): Promise { + async delete(authenticatorId: Uuid): Promise { try { - const result = await this.authenticatorApiService.delete(authenticatorId) + const result = await this.authenticatorApiService.delete(authenticatorId.value) if (result.data.error) { return false @@ -42,9 +43,9 @@ export class AuthenticatorManager extends AbstractService implements Authenticat } } - async generateRegistrationOptions(userUuid: string, username: string): Promise | null> { + async generateRegistrationOptions(userUuid: Uuid, username: Username): Promise | null> { try { - const result = await this.authenticatorApiService.generateRegistrationOptions(userUuid, username) + const result = await this.authenticatorApiService.generateRegistrationOptions(userUuid.value, username.value) if (result.data.error) { return null @@ -57,13 +58,13 @@ export class AuthenticatorManager extends AbstractService implements Authenticat } async verifyRegistrationResponse( - userUuid: string, + userUuid: Uuid, name: string, registrationCredential: Record, ): Promise { try { const result = await this.authenticatorApiService.verifyRegistrationResponse( - userUuid, + userUuid.value, name, registrationCredential, ) @@ -93,11 +94,14 @@ export class AuthenticatorManager extends AbstractService implements Authenticat } async verifyAuthenticationResponse( - userUuid: string, + userUuid: Uuid, authenticationCredential: Record, ): Promise { try { - const result = await this.authenticatorApiService.verifyAuthenticationResponse(userUuid, authenticationCredential) + const result = await this.authenticatorApiService.verifyAuthenticationResponse( + userUuid.value, + authenticationCredential, + ) if (result.data.error) { return false diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 223428167..aa1375ac9 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -1,9 +1,7 @@ import { AuthApiService, AuthenticatorApiService, - AuthenticatorApiServiceInterface, AuthenticatorServer, - AuthenticatorServerInterface, AuthServer, HttpService, HttpServiceInterface, @@ -98,6 +96,10 @@ import { LegacySessionStorageMapper } from '@Lib/Services/Mapping/LegacySessionS import { SignInWithRecoveryCodes } from '@Lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes' import { UseCaseContainerInterface } from '@Lib/Domain/UseCase/UseCaseContainerInterface' import { GetRecoveryCodes } from '@Lib/Domain/UseCase/GetRecoveryCodes/GetRecoveryCodes' +import { AddAuthenticator } from '@Lib/Domain/UseCase/AddAuthenticator/AddAuthenticator' +import { ListAuthenticators } from '@Lib/Domain/UseCase/ListAuthenticators/ListAuthenticators' +import { DeleteAuthenticator } from '@Lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator' +import { VerifyAuthenticator } from '@Lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator' /** How often to automatically sync, in milliseconds */ const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 @@ -172,13 +174,15 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private filesBackupService?: FilesBackupService private declare sessionStorageMapper: MapperInterface> private declare legacySessionStorageMapper: MapperInterface> - private declare authenticatorApiService: AuthenticatorApiServiceInterface - private declare authenticatorServer: AuthenticatorServerInterface private declare authenticatorManager: AuthenticatorClientInterface private declare authManager: AuthClientInterface private declare _signInWithRecoveryCodes: SignInWithRecoveryCodes private declare _getRecoveryCodes: GetRecoveryCodes + private declare _addAuthenticator: AddAuthenticator + private declare _listAuthenticators: ListAuthenticators + private declare _deleteAuthenticator: DeleteAuthenticator + private declare _verifyAuthenticator: VerifyAuthenticator private internalEventBus!: ExternalServices.InternalEventBusInterface @@ -269,6 +273,22 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this._getRecoveryCodes } + get addAuthenticator(): UseCaseInterface { + return this._addAuthenticator + } + + get listAuthenticators(): UseCaseInterface> { + return this._listAuthenticators + } + + get deleteAuthenticator(): UseCaseInterface { + return this._deleteAuthenticator + } + + get verifyAuthenticator(): UseCaseInterface { + return this._verifyAuthenticator + } + public get files(): FilesClientInterface { return this.fileService } @@ -1166,8 +1186,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.createMutatorService() this.createListedService() this.createActionsManager() - this.createAuthenticatorServer() - this.createAuthenticatorApiService() this.createAuthenticatorManager() this.createAuthManager() @@ -1219,12 +1237,14 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli ;(this.statusService as unknown) = undefined ;(this.sessionStorageMapper as unknown) = undefined ;(this.legacySessionStorageMapper as unknown) = undefined - ;(this.authenticatorApiService as unknown) = undefined - ;(this.authenticatorServer as unknown) = undefined ;(this.authenticatorManager as unknown) = undefined ;(this.authManager as unknown) = undefined ;(this._signInWithRecoveryCodes as unknown) = undefined ;(this._getRecoveryCodes as unknown) = undefined + ;(this._addAuthenticator as unknown) = undefined + ;(this._listAuthenticators as unknown) = undefined + ;(this._deleteAuthenticator as unknown) = undefined + ;(this._verifyAuthenticator as unknown) = undefined this.services = [] } @@ -1754,16 +1774,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.services.push(this.statusService) } - private createAuthenticatorServer() { - this.authenticatorServer = new AuthenticatorServer(this.httpService) - } - - private createAuthenticatorApiService() { - this.authenticatorApiService = new AuthenticatorApiService(this.authenticatorServer) - } - private createAuthenticatorManager() { - this.authenticatorManager = new AuthenticatorManager(this.authenticatorApiService, this.internalEventBus) + const authenticatorServer = new AuthenticatorServer(this.httpService) + + const authenticatorApiService = new AuthenticatorApiService(authenticatorServer) + + this.authenticatorManager = new AuthenticatorManager(authenticatorApiService, this.internalEventBus) } private createAuthManager() { @@ -1785,5 +1801,19 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli ) this._getRecoveryCodes = new GetRecoveryCodes(this.authManager, this.settingsService) + + this._addAuthenticator = new AddAuthenticator( + this.authenticatorManager, + this.options.u2fAuthenticatorRegistrationPromptFunction, + ) + + this._listAuthenticators = new ListAuthenticators(this.authenticatorManager) + + this._deleteAuthenticator = new DeleteAuthenticator(this.authenticatorManager) + + this._verifyAuthenticator = new VerifyAuthenticator( + this.authenticatorManager, + this.options.u2fAuthenticatorVerificationPromptFunction, + ) } } diff --git a/packages/snjs/lib/Application/Options/OptionalOptions.ts b/packages/snjs/lib/Application/Options/OptionalOptions.ts index 33e51a15b..5d6f2c2ec 100644 --- a/packages/snjs/lib/Application/Options/OptionalOptions.ts +++ b/packages/snjs/lib/Application/Options/OptionalOptions.ts @@ -17,4 +17,24 @@ export interface ApplicationOptionalConfiguratioOptions { * URL for WebSocket providing permissions and roles information. */ webSocketUrl?: string + + /** + * 3rd party library function for prompting U2F authenticator device registration + * + * @param registrationOptions - Registration options generated by the server + * @returns authenticator device response + */ + u2fAuthenticatorRegistrationPromptFunction?: ( + registrationOptions: Record, + ) => Promise> + + /** + * 3rd party library function for prompting U2F authenticator device authentication + * + * @param registrationOptions - Registration options generated by the server + * @returns authenticator device response + */ + u2fAuthenticatorVerificationPromptFunction?: ( + authenticationOptions: Record, + ) => Promise> } diff --git a/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.spec.ts b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.spec.ts new file mode 100644 index 000000000..d229c2baf --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.spec.ts @@ -0,0 +1,146 @@ +import { AuthenticatorClientInterface } from '@standardnotes/services' +import { AddAuthenticator } from './AddAuthenticator' + +describe('AddAuthenticator', () => { + let authenticatorClient: AuthenticatorClientInterface + let authenticatorRegistrationPromptFunction: ( + registrationOptions: Record, + ) => Promise> + + + const createUseCase = () => new AddAuthenticator(authenticatorClient, authenticatorRegistrationPromptFunction) + + beforeEach(() => { + authenticatorClient = {} as jest.Mocked + authenticatorClient.generateRegistrationOptions = jest.fn().mockReturnValue({ foo: 'bar' }) + authenticatorClient.verifyRegistrationResponse = jest.fn().mockReturnValue(true) + + authenticatorRegistrationPromptFunction = jest.fn() + }) + + it('should return error if userUuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: 'invalid', + username: 'username', + authenticatorName: 'authenticatorName', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator registration options: Given value is not a valid uuid: invalid') + }) + + it('should return error if username is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: '', + authenticatorName: 'authenticatorName', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator registration options: Username cannot be empty') + }) + + it('should return error if authenticatorName is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + authenticatorName: '', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator registration options: Given value is empty: ') + }) + + it('should return error if registration options are null', async () => { + authenticatorClient.generateRegistrationOptions = jest.fn().mockReturnValue(null) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + authenticatorName: 'authenticator', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator registration options') + }) + + it('should return error if authenticatorRegistrationPromptFunction throws', async () => { + authenticatorRegistrationPromptFunction = jest.fn().mockRejectedValue(new Error('error')) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + authenticatorName: 'authenticator', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator registration options: error') + }) + + it('should return error if authenticatorRegistrationPromptFunction throws InvalidStateError', async () => { + const error = new Error('error') + error.name = 'InvalidStateError' + authenticatorRegistrationPromptFunction = jest.fn().mockRejectedValue(error) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + authenticatorName: 'authenticator', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Authenticator was probably already registered by user') + }) + + it('should return error if registration response verification returns false', async () => { + authenticatorClient.verifyRegistrationResponse = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + authenticatorName: 'authenticator', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not verify authenticator registration response') + }) + + it('should register an authenticator', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + authenticatorName: 'authenticator', + }) + + expect(result.isFailed()).toBe(false) + }) + + it('should return error if authenticator registration prompt function is not passed', async () => { + const useCase = new AddAuthenticator(authenticatorClient) + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + authenticatorName: 'authenticator', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator registration options: No authenticator registration prompt function provided') + }) +}) diff --git a/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.ts b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.ts new file mode 100644 index 000000000..b7de6ce2f --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticator.ts @@ -0,0 +1,67 @@ +import { AuthenticatorClientInterface } from '@standardnotes/services' +import { Result, UseCaseInterface, Username, Uuid, Validator } from '@standardnotes/domain-core' + +import { AddAuthenticatorDTO } from './AddAuthenticatorDTO' + +export class AddAuthenticator implements UseCaseInterface { + constructor( + private authenticatorClient: AuthenticatorClientInterface, + private authenticatorRegistrationPromptFunction?: ( + registrationOptions: Record, + ) => Promise>, + ) {} + + async execute(dto: AddAuthenticatorDTO): Promise> { + if (!this.authenticatorRegistrationPromptFunction) { + return Result.fail( + 'Could not generate authenticator registration options: No authenticator registration prompt function provided', + ) + } + + const userUuidOrError = Uuid.create(dto.userUuid) + if (userUuidOrError.isFailed()) { + return Result.fail(`Could not generate authenticator registration options: ${userUuidOrError.getError()}`) + } + const userUuid = userUuidOrError.getValue() + + const usernameOrError = Username.create(dto.username) + if (usernameOrError.isFailed()) { + return Result.fail(`Could not generate authenticator registration options: ${usernameOrError.getError()}`) + } + const username = usernameOrError.getValue() + + const authenticatorNameValidatorResult = Validator.isNotEmpty(dto.authenticatorName) + if (authenticatorNameValidatorResult.isFailed()) { + return Result.fail( + `Could not generate authenticator registration options: ${authenticatorNameValidatorResult.getError()}`, + ) + } + + const registrationOptions = await this.authenticatorClient.generateRegistrationOptions(userUuid, username) + if (registrationOptions === null) { + return Result.fail('Could not generate authenticator registration options') + } + + let authenticatorResponse + try { + authenticatorResponse = await this.authenticatorRegistrationPromptFunction(registrationOptions) + } catch (error) { + if ((error as Error).name === 'InvalidStateError') { + return Result.fail('Authenticator was probably already registered by user') + } else { + return Result.fail(`Could not generate authenticator registration options: ${(error as Error).message}`) + } + } + + const verificationResponse = await this.authenticatorClient.verifyRegistrationResponse( + userUuid, + dto.authenticatorName, + authenticatorResponse, + ) + if (!verificationResponse) { + return Result.fail('Could not verify authenticator registration response') + } + + return Result.ok() + } +} diff --git a/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticatorDTO.ts b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticatorDTO.ts new file mode 100644 index 000000000..abe33ab1a --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/AddAuthenticator/AddAuthenticatorDTO.ts @@ -0,0 +1,5 @@ +export interface AddAuthenticatorDTO { + userUuid: string + username: string + authenticatorName: string +} diff --git a/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.spec.ts b/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.spec.ts new file mode 100644 index 000000000..c6f5c3b84 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.spec.ts @@ -0,0 +1,41 @@ +import { AuthenticatorClientInterface } from '@standardnotes/services' + +import { DeleteAuthenticator } from './DeleteAuthenticator' + +describe('DeleteAuthenticator', () => { + let authenticatorClient: AuthenticatorClientInterface + + const createUseCase = () => new DeleteAuthenticator(authenticatorClient) + + beforeEach(() => { + authenticatorClient = {} as jest.Mocked + authenticatorClient.delete = jest.fn().mockReturnValue(true) + }) + + it('should delete authenticator', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ authenticatorId: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBe(false) + }) + + it('should fail if authenticator id is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ authenticatorId: 'invalid' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not delete authenticator: Given value is not a valid uuid: invalid') + }) + + it('should fail if authenticator client fails to delete authenticator', async () => { + authenticatorClient.delete = jest.fn().mockReturnValue(false) + const useCase = createUseCase() + + const result = await useCase.execute({ authenticatorId: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not delete authenticator') + }) +}) diff --git a/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.ts b/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.ts new file mode 100644 index 000000000..31c558702 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.ts @@ -0,0 +1,23 @@ +import { AuthenticatorClientInterface } from '@standardnotes/services' +import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' + +import { DeleteAuthenticatorDTO } from './DeleteAuthenticatorDTO' + +export class DeleteAuthenticator implements UseCaseInterface { + constructor(private authenticatorClient: AuthenticatorClientInterface) {} + + async execute(dto: DeleteAuthenticatorDTO): Promise> { + const authenticatorIdOrError = Uuid.create(dto.authenticatorId) + if (authenticatorIdOrError.isFailed()) { + return Result.fail(`Could not delete authenticator: ${authenticatorIdOrError.getError()}`) + } + const authenticatorId = authenticatorIdOrError.getValue() + + const result = await this.authenticatorClient.delete(authenticatorId) + if (!result) { + return Result.fail('Could not delete authenticator') + } + + return Result.ok() + } +} diff --git a/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticatorDTO.ts b/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticatorDTO.ts new file mode 100644 index 000000000..df7c4f8b0 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticatorDTO.ts @@ -0,0 +1,3 @@ +export interface DeleteAuthenticatorDTO { + authenticatorId: string +} diff --git a/packages/snjs/lib/Domain/UseCase/ListAuthenticators/ListAuthenticators.spec.ts b/packages/snjs/lib/Domain/UseCase/ListAuthenticators/ListAuthenticators.spec.ts new file mode 100644 index 000000000..7404caae5 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/ListAuthenticators/ListAuthenticators.spec.ts @@ -0,0 +1,23 @@ +import { AuthenticatorClientInterface } from '@standardnotes/services' + +import { ListAuthenticators } from './ListAuthenticators' + +describe('ListAuthenticators', () => { + let authenticatorClient: AuthenticatorClientInterface + + const createUseCase = () => new ListAuthenticators(authenticatorClient) + + beforeEach(() => { + authenticatorClient = {} as jest.Mocked + authenticatorClient.list = jest.fn().mockReturnValue([{ id: '1-2-3', name: 'My First Key' }]) + }) + + it('should list authenticators', async () => { + const useCase = createUseCase() + + const result = await useCase.execute() + + expect(result.isFailed()).toBe(false) + expect(result.getValue()).toEqual([{ id: '1-2-3', name: 'My First Key' }]) + }) +}) diff --git a/packages/snjs/lib/Domain/UseCase/ListAuthenticators/ListAuthenticators.ts b/packages/snjs/lib/Domain/UseCase/ListAuthenticators/ListAuthenticators.ts new file mode 100644 index 000000000..ef83948a8 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/ListAuthenticators/ListAuthenticators.ts @@ -0,0 +1,12 @@ +import { AuthenticatorClientInterface } from '@standardnotes/services' +import { Result, UseCaseInterface } from '@standardnotes/domain-core' + +export class ListAuthenticators implements UseCaseInterface> { + constructor(private authenticatorClient: AuthenticatorClientInterface) {} + + async execute(): Promise>> { + const authenticators = await this.authenticatorClient.list() + + return Result.ok(authenticators) + } +} diff --git a/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts b/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts index c86ac3a45..f4be778f0 100644 --- a/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts +++ b/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts @@ -3,4 +3,8 @@ import { UseCaseInterface } from '@standardnotes/domain-core' export interface UseCaseContainerInterface { get signInWithRecoveryCodes(): UseCaseInterface get getRecoveryCodes(): UseCaseInterface + get addAuthenticator(): UseCaseInterface + get listAuthenticators(): UseCaseInterface> + get deleteAuthenticator(): UseCaseInterface + get verifyAuthenticator(): UseCaseInterface } diff --git a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.spec.ts b/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.spec.ts new file mode 100644 index 000000000..cf71cd339 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.spec.ts @@ -0,0 +1,70 @@ +import { AuthenticatorClientInterface } from '@standardnotes/services' + +import { VerifyAuthenticator } from './VerifyAuthenticator' + +describe('VerifyAuthenticator', () => { + let authenticatorClient: AuthenticatorClientInterface + let authenticatorVerificationPromptFunction: ( + authenticationOptions: Record, + ) => Promise> + + const createUseCase = () => new VerifyAuthenticator(authenticatorClient, authenticatorVerificationPromptFunction) + + beforeEach(() => { + authenticatorClient = {} as jest.Mocked + authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue({ foo: 'bar' }) + authenticatorClient.verifyAuthenticationResponse = jest.fn().mockResolvedValue(true) + + authenticatorVerificationPromptFunction = jest.fn() + }) + + it('should return an error if authenticator client fails to generate authentication options', async () => { + authenticatorClient.generateAuthenticationOptions = jest.fn().mockResolvedValue(null) + + const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator authentication options') + }) + + it('should return an error if authenticator verification prompt function fails', async () => { + authenticatorVerificationPromptFunction = jest.fn().mockRejectedValue(new Error('error')) + + const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator authentication options: error') + }) + + it('should return an error if authenticator client fails to verify authentication response', async () => { + authenticatorClient.verifyAuthenticationResponse = jest.fn().mockResolvedValue(false) + + const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator authentication options') + }) + + it('should return ok if authenticator client succeeds to verify authentication response', async () => { + const result = await createUseCase().execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBe(false) + }) + + it('should return an error if user uuid is invalid', async () => { + const result = await createUseCase().execute({ userUuid: 'invalid' }) + + expect(result.isFailed()).toBe(true) + }) + + it('should return error if authenticatorVerificationPromptFunction is not provided', async () => { + const result = await new VerifyAuthenticator(authenticatorClient).execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe( + 'Could not generate authenticator authentication options: No authenticator verification prompt function provided', + ) + }) +}) diff --git a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.ts b/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.ts new file mode 100644 index 000000000..e1f0e732b --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator.ts @@ -0,0 +1,49 @@ +import { AuthenticatorClientInterface } from '@standardnotes/services' +import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' + +import { VerifyAuthenticatorDTO } from './VerifyAuthenticatorDTO' + +export class VerifyAuthenticator implements UseCaseInterface { + constructor( + private authenticatorClient: AuthenticatorClientInterface, + private authenticatorVerificationPromptFunction?: ( + authenticationOptions: Record, + ) => Promise>, + ) {} + + async execute(dto: VerifyAuthenticatorDTO): Promise> { + if (!this.authenticatorVerificationPromptFunction) { + return Result.fail( + 'Could not generate authenticator authentication options: No authenticator verification prompt function provided', + ) + } + + const userUuidOrError = Uuid.create(dto.userUuid) + if (userUuidOrError.isFailed()) { + return Result.fail(`Could not generate authenticator authentication options: ${userUuidOrError.getError()}`) + } + const userUuid = userUuidOrError.getValue() + + const authenticationOptions = await this.authenticatorClient.generateAuthenticationOptions() + if (authenticationOptions === null) { + return Result.fail('Could not generate authenticator authentication options') + } + + let authenticatorResponse + try { + authenticatorResponse = await this.authenticatorVerificationPromptFunction(authenticationOptions) + } catch (error) { + return Result.fail(`Could not generate authenticator authentication options: ${(error as Error).message}`) + } + + const verificationResponse = await this.authenticatorClient.verifyAuthenticationResponse( + userUuid, + authenticatorResponse, + ) + if (!verificationResponse) { + return Result.fail('Could not generate authenticator authentication options') + } + + return Result.ok() + } +} diff --git a/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticatorDTO.ts b/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticatorDTO.ts new file mode 100644 index 000000000..3ea9e5485 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticatorDTO.ts @@ -0,0 +1,3 @@ +export interface VerifyAuthenticatorDTO { + userUuid: string +}