feat: add document auth passkey

This commit is contained in:
David Nguyen
2024-03-27 16:13:22 +08:00
parent 0d510cf280
commit 62dd737cf0
23 changed files with 710 additions and 220 deletions

View File

@ -1,10 +1,14 @@
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Recipient } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn';
import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
@ -63,17 +67,20 @@ export const isRecipientAuthorized = async ({
return true;
}
// Create auth options when none are passed for account.
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
authOptions = {
type: DocumentAuth.ACCOUNT,
};
}
// Authentication required does not match provided method.
if (authOptions && authOptions.type !== authMethod) {
if (!authOptions || authOptions.type !== authMethod || !userId) {
return false;
}
return await match(authMethod)
.with(DocumentAuth.ACCOUNT, async () => {
if (userId === undefined) {
return false;
}
return await match(authOptions)
.with({ type: DocumentAuth.ACCOUNT }, async () => {
const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) {
@ -82,5 +89,107 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId;
})
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
return await isPasskeyAuthValid({
userId,
authenticationResponse,
tokenReference,
});
})
.exhaustive();
};
type VerifyPasskeyOptions = {
/**
* The ID of the user who initiated the request.
*/
userId: number;
/**
* The secondary ID of the verification token.
*/
tokenReference: string;
/**
* The response from the passkey authenticator.
*/
authenticationResponse: TAuthenticationResponseJSONSchema;
};
/**
* Whether the provided passkey authenticator response is valid and the user is
* authenticated.
*/
const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise<boolean> => {
return verifyPasskey(options)
.then(() => true)
.catch(() => false);
};
/**
* Verifies whether the provided passkey authenticator is valid and the user is
* authenticated.
*
* Will throw an error if the user should not be authenticated.
*/
const verifyPasskey = async ({
userId,
tokenReference,
authenticationResponse,
}: VerifyPasskeyOptions): Promise<void> => {
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
userId,
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
}
const verificationToken = await prisma.verificationToken
.delete({
where: {
userId,
secondaryId: tokenReference,
},
})
.catch(() => null);
if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
}
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
}
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: verificationToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null); // May want to log this for insights.
if (verification?.verified !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
};