mirror of
https://github.com/standardnotes/server
synced 2026-10-17 12:03:09 -04:00
feat(auth): add verifying authenticator registration response
This commit is contained in:
+209
@@ -0,0 +1,209 @@
|
||||
import * as simeplWebAuthnServer from '@simplewebauthn/server'
|
||||
import { VerifiedRegistrationResponse } from '@simplewebauthn/server'
|
||||
import { Result } from '@standardnotes/domain-core'
|
||||
import { Authenticator } from '../../Authenticator/Authenticator'
|
||||
|
||||
import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
|
||||
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
|
||||
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
|
||||
import { VerifyAuthenticatorRegistrationResponse } from './VerifyAuthenticatorRegistrationResponse'
|
||||
|
||||
describe('VerifyAuthenticatorRegistrationResponse', () => {
|
||||
let authenticatorRepository: AuthenticatorRepositoryInterface
|
||||
let authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface
|
||||
|
||||
const createUseCase = () =>
|
||||
new VerifyAuthenticatorRegistrationResponse(authenticatorRepository, authenticatorChallengeRepository)
|
||||
|
||||
beforeEach(() => {
|
||||
authenticatorRepository = {} as jest.Mocked<AuthenticatorRepositoryInterface>
|
||||
authenticatorRepository.save = jest.fn()
|
||||
|
||||
authenticatorChallengeRepository = {} as jest.Mocked<AuthenticatorChallengeRepositoryInterface>
|
||||
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
|
||||
props: {
|
||||
challenge: Buffer.from('challenge'),
|
||||
},
|
||||
} as jest.Mocked<AuthenticatorChallenge>)
|
||||
})
|
||||
|
||||
it('should return error if user uuid is invalid', async () => {
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
userUuid: 'invalid',
|
||||
challenge: Buffer.from('challenge'),
|
||||
registrationCredential: {
|
||||
id: Buffer.from('id'),
|
||||
rawId: Buffer.from('rawId'),
|
||||
response: {
|
||||
attestationObject: Buffer.from('attestationObject'),
|
||||
clientDataJSON: Buffer.from('clientDataJSON'),
|
||||
},
|
||||
type: 'type',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toEqual(
|
||||
'Could not verify authenticator registration response: Given value is not a valid uuid: invalid',
|
||||
)
|
||||
})
|
||||
|
||||
it('should return error if challenge is not found', async () => {
|
||||
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue(null)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
userUuid: '00000000-0000-0000-0000-000000000000',
|
||||
challenge: Buffer.from('challenge'),
|
||||
registrationCredential: {
|
||||
id: Buffer.from('id'),
|
||||
rawId: Buffer.from('rawId'),
|
||||
response: {
|
||||
attestationObject: Buffer.from('attestationObject'),
|
||||
clientDataJSON: Buffer.from('clientDataJSON'),
|
||||
},
|
||||
type: 'type',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toEqual('Could not verify authenticator registration response: challenge not found')
|
||||
})
|
||||
|
||||
it('should return error if verification could not verify', async () => {
|
||||
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
|
||||
props: {
|
||||
challenge: Buffer.from('challenge'),
|
||||
},
|
||||
} as jest.Mocked<AuthenticatorChallenge>)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
|
||||
mock.mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
verified: false,
|
||||
registrationInfo: {
|
||||
counter: 1,
|
||||
credentialBackedUp: true,
|
||||
credentialDeviceType: 'singleDevice',
|
||||
credentialID: Buffer.from('test'),
|
||||
credentialPublicKey: Buffer.from('test'),
|
||||
},
|
||||
} as jest.Mocked<VerifiedRegistrationResponse>)
|
||||
})
|
||||
|
||||
const result = await useCase.execute({
|
||||
userUuid: '00000000-0000-0000-0000-000000000000',
|
||||
challenge: Buffer.from('invalid'),
|
||||
registrationCredential: {
|
||||
id: Buffer.from('id'),
|
||||
rawId: Buffer.from('rawId'),
|
||||
response: {
|
||||
attestationObject: Buffer.from('attestationObject'),
|
||||
clientDataJSON: Buffer.from('clientDataJSON'),
|
||||
},
|
||||
type: 'type',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toEqual('Could not verify authenticator registration response: verification failed')
|
||||
|
||||
mock.mockRestore()
|
||||
})
|
||||
|
||||
it('should return error if authenticator could not be created', async () => {
|
||||
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
|
||||
props: {
|
||||
challenge: Buffer.from('challenge'),
|
||||
},
|
||||
} as jest.Mocked<AuthenticatorChallenge>)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
|
||||
mock.mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
counter: 1,
|
||||
credentialBackedUp: true,
|
||||
credentialDeviceType: 'singleDevice',
|
||||
credentialID: Buffer.from('test'),
|
||||
credentialPublicKey: Buffer.from('test'),
|
||||
},
|
||||
} as jest.Mocked<VerifiedRegistrationResponse>)
|
||||
})
|
||||
|
||||
const mockAuthenticator = jest.spyOn(Authenticator, 'create')
|
||||
mockAuthenticator.mockImplementation(() => {
|
||||
return Result.fail('Oops')
|
||||
})
|
||||
|
||||
const result = await useCase.execute({
|
||||
userUuid: '00000000-0000-0000-0000-000000000000',
|
||||
challenge: Buffer.from('invalid'),
|
||||
registrationCredential: {
|
||||
id: Buffer.from('id'),
|
||||
rawId: Buffer.from('rawId'),
|
||||
response: {
|
||||
attestationObject: Buffer.from('attestationObject'),
|
||||
clientDataJSON: Buffer.from('clientDataJSON'),
|
||||
},
|
||||
type: 'type',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toEqual('Could not verify authenticator registration response: Oops')
|
||||
|
||||
mock.mockRestore()
|
||||
mockAuthenticator.mockRestore()
|
||||
})
|
||||
|
||||
it('should verify authenticator registration response', async () => {
|
||||
authenticatorChallengeRepository.findByUserUuidAndChallenge = jest.fn().mockReturnValue({
|
||||
props: {
|
||||
challenge: Buffer.from('challenge'),
|
||||
},
|
||||
} as jest.Mocked<AuthenticatorChallenge>)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
|
||||
mock.mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
counter: 1,
|
||||
credentialBackedUp: true,
|
||||
credentialDeviceType: 'singleDevice',
|
||||
credentialID: Buffer.from('test'),
|
||||
credentialPublicKey: Buffer.from('test'),
|
||||
},
|
||||
} as jest.Mocked<VerifiedRegistrationResponse>)
|
||||
})
|
||||
|
||||
const result = await useCase.execute({
|
||||
userUuid: '00000000-0000-0000-0000-000000000000',
|
||||
challenge: Buffer.from('invalid'),
|
||||
registrationCredential: {
|
||||
id: Buffer.from('id'),
|
||||
rawId: Buffer.from('rawId'),
|
||||
response: {
|
||||
attestationObject: Buffer.from('attestationObject'),
|
||||
clientDataJSON: Buffer.from('clientDataJSON'),
|
||||
},
|
||||
type: 'type',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
|
||||
mock.mockRestore()
|
||||
})
|
||||
})
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Dates, Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
||||
|
||||
import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface'
|
||||
import { RelyingParty } from '../../Authenticator/RelyingParty'
|
||||
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
|
||||
import { Authenticator } from '../../Authenticator/Authenticator'
|
||||
import { VerifyAuthenticatorRegistrationResponseDTO } from './VerifyAuthenticatorRegistrationResponseDTO'
|
||||
|
||||
export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface<boolean> {
|
||||
constructor(
|
||||
private authenticatorRepository: AuthenticatorRepositoryInterface,
|
||||
private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface,
|
||||
) {}
|
||||
|
||||
async execute(dto: VerifyAuthenticatorRegistrationResponseDTO): Promise<Result<boolean>> {
|
||||
const userUuidOrError = Uuid.create(dto.userUuid)
|
||||
if (userUuidOrError.isFailed()) {
|
||||
return Result.fail(`Could not verify authenticator registration response: ${userUuidOrError.getError()}`)
|
||||
}
|
||||
const userUuid = userUuidOrError.getValue()
|
||||
|
||||
const authenticatorChallenge = await this.authenticatorChallengeRepository.findByUserUuidAndChallenge(
|
||||
userUuid,
|
||||
dto.challenge,
|
||||
)
|
||||
if (!authenticatorChallenge) {
|
||||
return Result.fail('Could not verify authenticator registration response: challenge not found')
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
credential: dto.registrationCredential,
|
||||
expectedChallenge: authenticatorChallenge.props.challenge.toString(),
|
||||
expectedOrigin: `https://${RelyingParty.RP_ID}`,
|
||||
expectedRPID: RelyingParty.RP_ID,
|
||||
})
|
||||
|
||||
if (!verification.verified) {
|
||||
return Result.fail('Could not verify authenticator registration response: verification failed')
|
||||
}
|
||||
|
||||
const authenticatorOrError = Authenticator.create({
|
||||
userUuid,
|
||||
counter: verification.registrationInfo?.counter as number,
|
||||
credentialBackedUp: verification.registrationInfo?.credentialBackedUp as boolean,
|
||||
credentialDeviceType: verification.registrationInfo?.credentialDeviceType,
|
||||
credentialId: verification.registrationInfo?.credentialID as Buffer,
|
||||
credentialPublicKey: verification.registrationInfo?.credentialPublicKey as Buffer,
|
||||
dates: Dates.create(new Date(), new Date()).getValue(),
|
||||
})
|
||||
|
||||
if (authenticatorOrError.isFailed()) {
|
||||
return Result.fail(`Could not verify authenticator registration response: ${authenticatorOrError.getError()}`)
|
||||
}
|
||||
const authenticator = authenticatorOrError.getValue()
|
||||
|
||||
await this.authenticatorRepository.save(authenticator)
|
||||
|
||||
return Result.ok(true)
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export interface VerifyAuthenticatorRegistrationResponseDTO {
|
||||
userUuid: string
|
||||
challenge: Buffer
|
||||
registrationCredential: Record<string, unknown>
|
||||
}
|
||||
Reference in New Issue
Block a user