feat: initial reauth passkeys

This commit is contained in:
David Nguyen
2024-03-17 20:33:11 +08:00
parent d45bed6930
commit 1ed18059fb
22 changed files with 691 additions and 218 deletions

View File

@ -0,0 +1,76 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { Passkey } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyAuthenticationOptions = {
userId: number;
/**
* The ID of the passkey to request authentication for.
*
* If not set, we allow the browser client to handle choosing.
*/
preferredPasskeyId?: string;
};
export const createPasskeyAuthenticationOptions = async ({
userId,
preferredPasskeyId,
}: CreatePasskeyAuthenticationOptions) => {
const { rpId, timeout } = getAuthenticatorOptions();
let preferredPasskey: Pick<Passkey, 'credentialId' | 'transports'> | null = null;
if (preferredPasskeyId) {
preferredPasskey = await prisma.passkey.findFirst({
where: {
userId,
id: preferredPasskeyId,
},
select: {
credentialId: true,
transports: true,
},
});
if (!preferredPasskey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found');
}
}
const options = await generateAuthenticationOptions({
rpID: rpId,
userVerification: 'preferred',
timeout,
allowCredentials: preferredPasskey
? [
{
id: preferredPasskey.credentialId,
type: 'public-key',
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
transports: preferredPasskey.transports as AuthenticatorTransportFuture[],
},
]
: undefined,
});
const { secondaryId } = await prisma.verificationToken.create({
data: {
userId,
token: options.challenge,
expires: DateTime.now().plus({ minutes: 2 }).toJSDate(),
identifier: 'PASSKEY_CHALLENGE',
},
});
return {
tokenReference: secondaryId,
options,
};
};

View File

@ -5,7 +5,7 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { PASSKEY_TIMEOUT } from '../../constants/auth';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyRegistrationOptions = {
userId: number;
@ -27,7 +27,7 @@ export const createPasskeyRegistrationOptions = async ({
const { passkeys } = user;
const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions();
const { rpName, rpId: rpID } = getAuthenticatorOptions();
const options = await generateRegistrationOptions({
rpName,

View File

@ -3,14 +3,14 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeySigninOptions = {
sessionId: string;
};
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
const { rpId, timeout } = getAuthenticatorRegistrationOptions();
const { rpId, timeout } = getAuthenticatorOptions();
const options = await generateAuthenticationOptions({
rpID: rpId,

View File

@ -7,7 +7,7 @@ import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { MAXIMUM_PASSKEYS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyOptions = {
userId: number;
@ -64,7 +64,7 @@ export const createPasskey = async ({
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
}
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions();
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
const verification = await verifyRegistrationResponse({
response: verificationResponse,

View File

@ -11,6 +11,7 @@ export interface FindPasskeysOptions {
orderBy?: {
column: keyof Passkey;
direction: 'asc' | 'desc';
nulls?: Prisma.NullsOrder;
};
}
@ -21,8 +22,9 @@ export const findPasskeys = async ({
perPage = 10,
orderBy,
}: FindPasskeysOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByColumn = orderBy?.column ?? 'lastUsedAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last';
const whereClause: Prisma.PasskeyWhereInput = {
userId,
@ -41,7 +43,10 @@ export const findPasskeys = async ({
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
[orderByColumn]: {
sort: orderByDirection,
nulls: orderByNulls,
},
},
select: {
id: true,

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 = {
@ -64,12 +68,12 @@ export const isRecipientAuthorized = async ({
}
// Authentication required does not match provided method.
if (authOptions && authOptions.type !== authMethod) {
if (!authOptions || authOptions.type !== authMethod) {
return false;
}
return await match(authMethod)
.with(DocumentAuth.ACCOUNT, async () => {
return await match(authOptions)
.with({ type: DocumentAuth.ACCOUNT }, async () => {
if (userId === undefined) {
return false;
}
@ -82,5 +86,117 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId;
})
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
if (!userId) {
return false;
}
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 to throw errors when the user fails verification instead of returning
* false.
*/
throwError?: boolean;
};
/**
* 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,
},
});
const verificationToken = await prisma.verificationToken
.delete({
where: {
userId,
secondaryId: tokenReference,
},
})
.catch(() => null);
if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
}
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,
},
});
};