Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(snjs): add authenticator use cases #2145

Merged
merged 3 commits into from
Jan 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Username, Uuid } from '@standardnotes/domain-core'

export interface AuthenticatorClientInterface {
list(): Promise<Array<{ id: string; name: string }>>
delete(authenticatorId: string): Promise<boolean>
generateRegistrationOptions(userUuid: string, username: string): Promise<Record<string, unknown> | null>
delete(authenticatorId: Uuid): Promise<boolean>
generateRegistrationOptions(userUuid: Uuid, username: Username): Promise<Record<string, unknown> | null>
verifyRegistrationResponse(
userUuid: string,
userUuid: Uuid,
name: string,
registrationCredential: Record<string, unknown>,
): Promise<boolean>
generateAuthenticationOptions(): Promise<Record<string, unknown> | null>
verifyAuthenticationResponse(userUuid: string, authenticationCredential: Record<string, unknown>): Promise<boolean>
verifyAuthenticationResponse(userUuid: Uuid, authenticationCredential: Record<string, unknown>): Promise<boolean>
}
20 changes: 12 additions & 8 deletions packages/services/src/Domain/Authenticator/AuthenticatorManager.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -28,9 +29,9 @@ export class AuthenticatorManager extends AbstractService implements Authenticat
}
}

async delete(authenticatorId: string): Promise<boolean> {
async delete(authenticatorId: Uuid): Promise<boolean> {
try {
const result = await this.authenticatorApiService.delete(authenticatorId)
const result = await this.authenticatorApiService.delete(authenticatorId.value)

if (result.data.error) {
return false
Expand All @@ -42,9 +43,9 @@ export class AuthenticatorManager extends AbstractService implements Authenticat
}
}

async generateRegistrationOptions(userUuid: string, username: string): Promise<Record<string, unknown> | null> {
async generateRegistrationOptions(userUuid: Uuid, username: Username): Promise<Record<string, unknown> | 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
Expand All @@ -57,13 +58,13 @@ export class AuthenticatorManager extends AbstractService implements Authenticat
}

async verifyRegistrationResponse(
userUuid: string,
userUuid: Uuid,
name: string,
registrationCredential: Record<string, unknown>,
): Promise<boolean> {
try {
const result = await this.authenticatorApiService.verifyRegistrationResponse(
userUuid,
userUuid.value,
name,
registrationCredential,
)
Expand Down Expand Up @@ -93,11 +94,14 @@ export class AuthenticatorManager extends AbstractService implements Authenticat
}

async verifyAuthenticationResponse(
userUuid: string,
userUuid: Uuid,
authenticationCredential: Record<string, unknown>,
): Promise<boolean> {
try {
const result = await this.authenticatorApiService.verifyAuthenticationResponse(userUuid, authenticationCredential)
const result = await this.authenticatorApiService.verifyAuthenticationResponse(
userUuid.value,
authenticationCredential,
)

if (result.data.error) {
return false
Expand Down
62 changes: 46 additions & 16 deletions packages/snjs/lib/Application/Application.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import {
AuthApiService,
AuthenticatorApiService,
AuthenticatorApiServiceInterface,
AuthenticatorServer,
AuthenticatorServerInterface,
AuthServer,
HttpService,
HttpServiceInterface,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -172,13 +174,15 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private filesBackupService?: FilesBackupService
private declare sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>
private declare legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
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

Expand Down Expand Up @@ -269,6 +273,22 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this._getRecoveryCodes
}

get addAuthenticator(): UseCaseInterface<void> {
return this._addAuthenticator
}

get listAuthenticators(): UseCaseInterface<Array<{ id: string; name: string }>> {
return this._listAuthenticators
}

get deleteAuthenticator(): UseCaseInterface<void> {
return this._deleteAuthenticator
}

get verifyAuthenticator(): UseCaseInterface<void> {
return this._verifyAuthenticator
}

public get files(): FilesClientInterface {
return this.fileService
}
Expand Down Expand Up @@ -1166,8 +1186,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.createMutatorService()
this.createListedService()
this.createActionsManager()
this.createAuthenticatorServer()
this.createAuthenticatorApiService()
this.createAuthenticatorManager()
this.createAuthManager()

Expand Down Expand Up @@ -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 = []
}
Expand Down Expand Up @@ -1754,16 +1774,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.services.push(this.statusService)
}

private createAuthenticatorServer() {
this.authenticatorServer = new AuthenticatorServer(this.httpService)
}
private createAuthenticatorManager() {
const authenticatorServer = new AuthenticatorServer(this.httpService)

private createAuthenticatorApiService() {
this.authenticatorApiService = new AuthenticatorApiService(this.authenticatorServer)
}
const authenticatorApiService = new AuthenticatorApiService(authenticatorServer)

private createAuthenticatorManager() {
this.authenticatorManager = new AuthenticatorManager(this.authenticatorApiService, this.internalEventBus)
this.authenticatorManager = new AuthenticatorManager(authenticatorApiService, this.internalEventBus)
}

private createAuthManager() {
Expand All @@ -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,
)
}
}
20 changes: 20 additions & 0 deletions packages/snjs/lib/Application/Options/OptionalOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
) => Promise<Record<string, unknown>>

/**
* 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<string, unknown>,
) => Promise<Record<string, unknown>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { AuthenticatorClientInterface } from '@standardnotes/services'
import { AddAuthenticator } from './AddAuthenticator'

describe('AddAuthenticator', () => {
let authenticatorClient: AuthenticatorClientInterface
let authenticatorRegistrationPromptFunction: (
registrationOptions: Record<string, unknown>,
) => Promise<Record<string, unknown>>


const createUseCase = () => new AddAuthenticator(authenticatorClient, authenticatorRegistrationPromptFunction)

beforeEach(() => {
authenticatorClient = {} as jest.Mocked<AuthenticatorClientInterface>
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')
})
})