mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
feat: add document auth passkey
This commit is contained in:
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user