From 62dd737cf07b69bd6e3755704edc4b133a80df4a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 27 Mar 2024 16:13:22 +0800 Subject: [PATCH] feat: add document auth passkey --- .../passkeys/create-passkey-dialog.tsx | 4 +- .../[token]/document-action-auth-account.tsx | 79 ++++++ .../[token]/document-action-auth-dialog.tsx | 199 ++------------ .../[token]/document-action-auth-passkey.tsx | 255 ++++++++++++++++++ .../sign/[token]/document-auth-provider.tsx | 67 ++++- apps/web/src/components/forms/signin.tsx | 2 +- .../e2e/document-auth/action-auth.spec.ts | 6 +- packages/lib/constants/document-auth.ts | 8 +- packages/lib/next-auth/auth-options.ts | 4 +- .../create-passkey-authentication-options.ts | 76 ++++++ .../create-passkey-registration-options.ts | 4 +- .../auth/create-passkey-signin-options.ts | 4 +- .../lib/server-only/auth/create-passkey.ts | 4 +- .../lib/server-only/auth/find-passkeys.ts | 9 +- .../document/is-recipient-authorized.ts | 123 ++++++++- packages/lib/types/document-auth.ts | 22 +- packages/lib/utils/authenticator.ts | 2 +- .../migration.sql | 12 + packages/prisma/schema.prisma | 15 +- packages/trpc/server/auth-router/router.ts | 21 ++ packages/trpc/server/auth-router/schema.ts | 6 + .../primitives/document-flow/add-settings.tsx | 4 + .../primitives/document-flow/add-signers.tsx | 4 + 23 files changed, 710 insertions(+), 220 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx create mode 100644 packages/lib/server-only/auth/create-passkey-authentication-options.ts create mode 100644 packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx index c07d638c0..f6db55e10 100644 --- a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx @@ -38,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type CreatePasskeyDialogProps = { trigger?: React.ReactNode; + onSuccess?: () => void; } & Omit; const ZCreatePasskeyFormSchema = z.object({ @@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer; const parser = new UAParser(); -export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { +export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => { const [open, setOpen] = useState(false); const [formError, setFormError] = useState(null); @@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr duration: 5000, }); + onSuccess?.(); setOpen(false); } catch (err) { if (err.name === 'NotAllowedError') { diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx new file mode 100644 index 000000000..fb4a14699 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; + +import { DateTime } from 'luxon'; +import { signOut } from 'next-auth/react'; + +import { RecipientRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuthAccountProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + onOpenChange: (value: boolean) => void; +}; + +export const DocumentActionAuthAccount = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onOpenChange, +}: DocumentActionAuthAccountProps) => { + const { recipient } = useRequiredDocumentAuthContext(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); + + const handleChangeAccount = async (email: string) => { + try { + setIsSigningOut(true); + + const encryptedEmail = await encryptSecondaryData({ + data: email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + await signOut({ + callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, + }); + } catch { + setIsSigningOut(false); + + // Todo: Alert. + } + }; + + return ( +
+ + + {actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? ( + + To mark this document as viewed, you need to be logged in as{' '} + {recipient.email} + + ) : ( + + To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged + in as {recipient.email} + + )} + + + + + + + + +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx index 7ab92f75c..bf4809c8d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx @@ -1,13 +1,4 @@ -/** - * Note: This file has some commented out stuff for password auth which is no longer possible. - * - * Leaving it here until after we add passkeys and 2FA since it can be reused. - */ -import { useState } from 'react'; - -import { DateTime } from 'luxon'; -import { signOut } from 'next-auth/react'; -import { match } from 'ts-pattern'; +import { P, match } from 'ts-pattern'; import { DocumentAuth, @@ -15,18 +6,16 @@ import { type TRecipientActionAuthTypes, } from '@documenso/lib/types/document-auth'; import type { FieldType } from '@documenso/prisma/client'; -import { trpc } from '@documenso/trpc/react'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; -import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; +import { DocumentActionAuthAccount } from './document-action-auth-account'; +import { DocumentActionAuthPasskey } from './document-action-auth-passkey'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; export type DocumentActionAuthDialogProps = { @@ -34,7 +23,6 @@ export type DocumentActionAuthDialogProps = { documentAuthType: TRecipientActionAuthTypes; description?: string; actionTarget: FieldType | 'DOCUMENT'; - isSubmitting?: boolean; open: boolean; onOpenChange: (value: boolean) => void; @@ -44,96 +32,24 @@ export type DocumentActionAuthDialogProps = { onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; }; -// const ZReauthFormSchema = z.object({ -// password: ZCurrentPasswordSchema, -// }); -// type TReauthFormSchema = z.infer; - export const DocumentActionAuthDialog = ({ title, description, documentAuthType, - // onReauthFormSubmit, - isSubmitting, open, onOpenChange, + onReauthFormSubmit, }: DocumentActionAuthDialogProps) => { - const { recipient } = useRequiredDocumentAuthContext(); - - // const form = useForm({ - // resolver: zodResolver(ZReauthFormSchema), - // defaultValues: { - // password: '', - // }, - // }); - - const [isSigningOut, setIsSigningOut] = useState(false); - - const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting; - - const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); - - // const [formErrorCode, setFormErrorCode] = useState(null); - // const onFormSubmit = async (_values: TReauthFormSchema) => { - // const documentAuthValue: TRecipientActionAuth = match(documentAuthType) - // // Todo: Add passkey. - // // .with(DocumentAuthType.PASSKEY, (type) => ({ - // // type, - // // value, - // // })) - // .otherwise((type) => ({ - // type, - // })); - - // try { - // await onReauthFormSubmit(documentAuthValue); - - // onOpenChange(false); - // } catch (e) { - // const error = AppError.parseError(e); - // setFormErrorCode(error.code); - - // // Suppress unauthorized errors since it's handled in this component. - // if (error.code === AppErrorCode.UNAUTHORIZED) { - // return; - // } - - // throw error; - // } - // }; - - const handleChangeAccount = async (email: string) => { - try { - setIsSigningOut(true); - - const encryptedEmail = await encryptSecondaryData({ - data: email, - expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), - }); - - await signOut({ - callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, - }); - } catch { - setIsSigningOut(false); - - // Todo: Alert. - } - }; + const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext(); const handleOnOpenChange = (value: boolean) => { - if (isLoading) { + if (isCurrentlyAuthenticating) { return; } onOpenChange(value); }; - // useEffect(() => { - // form.reset(); - // setFormErrorCode(null); - // }, [open, form]); - return ( @@ -141,100 +57,25 @@ export const DocumentActionAuthDialog = ({ {title || 'Sign field'} - {description || `Reauthentication is required to sign the field`} + {description || 'Reauthentication is required to sign this field'} - {match(documentAuthType) - .with(DocumentAuth.ACCOUNT, () => ( -
- - - To sign this field, you need to be logged in as {recipient.email} - - - - - - - - -
+ {match({ documentAuthType, user }) + .with( + { documentAuthType: DocumentAuth.ACCOUNT }, + { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. + () => , + ) + .with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( + )) - .with(DocumentAuth.EXPLICIT_NONE, () => null) + .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) .exhaustive()} - - {/*
- -
- - Email - - - - - - - ( - - Password - - - - - - - - )} - /> - - {formErrorCode && ( - - {match(formErrorCode) - .with(AppErrorCode.UNAUTHORIZED, () => ( - <> - Unauthorized - - We were unable to verify your details. Please ensure the details are - correct - - - )) - .otherwise(() => ( - <> - Something went wrong - - We were unable to sign this field at this time. Please try again or - contact support. - - - ))} - - )} - - - - - - -
-
- */}
); diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx new file mode 100644 index 000000000..ad8199b84 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { RecipientRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; + +import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; + +export type DocumentActionAuthPasskeyProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const ZPasskeyAuthFormSchema = z.object({ + passkeyId: z.string(), +}); + +type TPasskeyAuthFormSchema = z.infer; + +export const DocumentActionAuthPasskey = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentActionAuthPasskeyProps) => { + const { + recipient, + passkeyData, + preferredPasskeyId, + setPreferredPasskeyId, + isCurrentlyAuthenticating, + setIsCurrentlyAuthenticating, + refetchPasskeys, + } = useRequiredDocumentAuthContext(); + + const form = useForm({ + resolver: zodResolver(ZPasskeyAuthFormSchema), + defaultValues: { + passkeyId: preferredPasskeyId || '', + }, + }); + + const { mutateAsync: createPasskeyAuthenticationOptions } = + trpc.auth.createPasskeyAuthenticationOptions.useMutation(); + + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => { + try { + setPreferredPasskeyId(passkeyId); + setIsCurrentlyAuthenticating(true); + + const { options, tokenReference } = await createPasskeyAuthenticationOptions({ + preferredPasskeyId: passkeyId, + }); + + const authenticationResponse = await startAuthentication(options); + + await onReauthFormSubmit({ + type: DocumentAuth.PASSKEY, + authenticationResponse, + tokenReference, + }); + + setIsCurrentlyAuthenticating(false); + + onOpenChange(false); + } catch (err) { + setIsCurrentlyAuthenticating(false); + + if (err.name === 'NotAllowedError') { + return; + } + + const error = AppError.parseError(err); + setFormErrorCode(error.code); + + // Todo: Alert. + } + }; + + useEffect(() => { + form.reset({ + passkeyId: preferredPasskeyId || '', + }); + + setFormErrorCode(null); + }, [open, form, preferredPasskeyId]); + + if (!browserSupportsWebAuthn()) { + return ( +
+ + + Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '} + this {actionTarget.toLowerCase()}. + + + + + + +
+ ); + } + + if ( + passkeyData.isInitialLoading || + (passkeyData.isRefetching && passkeyData.passkeys.length === 0) + ) { + return ( +
+ +
+ ); + } + + if (passkeyData.isError) { + return ( +
+ + Something went wrong while loading your passkeys. + + + + + + + +
+ ); + } + + if (passkeyData.passkeys.length === 0) { + return ( +
+ + + {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' + ? 'You need to setup a passkey to mark this document as viewed.' + : `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`} + + + + + + + refetchPasskeys()} + trigger={} + /> + +
+ ); + } + + return ( +
+ +
+
+ ( + + Passkey + + + + + + + + )} + /> + + {formErrorCode && ( + + Unauthorized + + We were unable to verify your details. Please try again or contact support + + + )} + + + + + + +
+
+
+ + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx index c216f3905..48e7b44d9 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -1,9 +1,10 @@ 'use client'; -import { createContext, useContext, useMemo, useState } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { match } from 'ts-pattern'; +import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import type { TDocumentAuthOptions, @@ -13,11 +14,25 @@ import type { } from '@documenso/lib/types/document-auth'; import { DocumentAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; -import { type Document, FieldType, type Recipient, type User } from '@documenso/prisma/client'; +import { + type Document, + FieldType, + type Passkey, + type Recipient, + type User, +} from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog'; import { DocumentActionAuthDialog } from './document-action-auth-dialog'; +type PasskeyData = { + passkeys: Omit[]; + isInitialLoading: boolean; + isRefetching: boolean; + isError: boolean; +}; + export type DocumentAuthContextValue = { executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; document: Document; @@ -29,7 +44,13 @@ export type DocumentAuthContextValue = { derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; derivedRecipientActionAuth: TRecipientActionAuthTypes | null; isAuthRedirectRequired: boolean; + isCurrentlyAuthenticating: boolean; + setIsCurrentlyAuthenticating: (_value: boolean) => void; + passkeyData: PasskeyData; + preferredPasskeyId: string | null; + setPreferredPasskeyId: (_value: string | null) => void; user?: User | null; + refetchPasskeys: () => Promise; }; const DocumentAuthContext = createContext(null); @@ -64,6 +85,9 @@ export const DocumentAuthProvider = ({ const [document, setDocument] = useState(initialDocument); const [recipient, setRecipient] = useState(initialRecipient); + const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); + const [preferredPasskeyId, setPreferredPasskeyId] = useState(null); + const { documentAuthOption, recipientAuthOption, @@ -78,6 +102,23 @@ export const DocumentAuthProvider = ({ [document, recipient], ); + const passkeyQuery = trpc.auth.findPasskeys.useQuery( + { + perPage: MAXIMUM_PASSKEYS, + }, + { + keepPreviousData: true, + enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, + }, + ); + + const passkeyData: PasskeyData = { + passkeys: passkeyQuery.data?.data || [], + isInitialLoading: passkeyQuery.isInitialLoading, + isRefetching: passkeyQuery.isRefetching, + isError: passkeyQuery.isError, + }; + const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = useState(null); @@ -101,7 +142,7 @@ export const DocumentAuthProvider = ({ .with(DocumentAuth.EXPLICIT_NONE, () => ({ type: DocumentAuth.EXPLICIT_NONE, })) - .with(null, () => null) + .with(DocumentAuth.PASSKEY, null, () => null) .exhaustive(); const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { @@ -124,11 +165,25 @@ export const DocumentAuthProvider = ({ }); }; + useEffect(() => { + const { passkeys } = passkeyData; + + if (!preferredPasskeyId && passkeys.length > 0) { + setPreferredPasskeyId(passkeys[0].id); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [passkeyData.passkeys]); + const isAuthRedirectRequired = Boolean( DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired && !preCalculatedActionAuthOptions, ); + const refetchPasskeys = async () => { + await passkeyQuery.refetch(); + }; + return ( {children} diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 6fa5492ac..8d4dd7cd0 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -124,7 +124,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign }; const onSignInWithPasskey = async () => { - if (!browserSupportsWebAuthn) { + if (!browserSupportsWebAuthn()) { toast({ title: 'Not supported', description: 'Passkeys are not supported on this browser', diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts index 88ed1ac1d..b263dbd04 100644 --- a/packages/app-tests/e2e/document-auth/action-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -191,7 +191,7 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth' await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } @@ -260,7 +260,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } @@ -371,7 +371,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an await page.locator(`#field-${field.id}`).getByRole('button').click(); await expect(page.getByRole('paragraph')).toContainText( - 'Reauthentication is required to sign the field', + 'Reauthentication is required to sign this field', ); await page.getByRole('button', { name: 'Cancel' }).click(); } diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts index 81f22236e..af40a45df 100644 --- a/packages/lib/constants/document-auth.ts +++ b/packages/lib/constants/document-auth.ts @@ -20,10 +20,10 @@ export const DOCUMENT_AUTH_TYPES: Record = { value: 'Require account', isAuthRedirectRequired: true, }, - // [DocumentAuthType.PASSKEY]: { - // key: DocumentAuthType.PASSKEY, - // value: 'Require passkey', - // }, + [DocumentAuth.PASSKEY]: { + key: DocumentAuth.PASSKEY, + value: 'Require passkey', + }, [DocumentAuth.EXPLICIT_NONE]: { key: DocumentAuth.EXPLICIT_NONE, value: 'None (Overrides global settings)', diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 425c7e70a..6805eedbe 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -22,7 +22,7 @@ import { sendConfirmationToken } from '../server-only/user/send-confirmation-tok import type { TAuthenticationResponseJSONSchema } from '../types/webauthn'; import { ZAuthenticationResponseJSONSchema } from '../types/webauthn'; import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; -import { getAuthenticatorRegistrationOptions } from '../utils/authenticator'; +import { getAuthenticatorOptions } from '../utils/authenticator'; import { ErrorCode } from './error-codes'; export const NEXT_AUTH_OPTIONS: AuthOptions = { @@ -196,7 +196,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { const user = passkey.User; - const { rpId, origin } = getAuthenticatorRegistrationOptions(); + const { rpId, origin } = getAuthenticatorOptions(); const verification = await verifyAuthenticationResponse({ response: requestBodyCrediential, diff --git a/packages/lib/server-only/auth/create-passkey-authentication-options.ts b/packages/lib/server-only/auth/create-passkey-authentication-options.ts new file mode 100644 index 000000000..e7c4178d6 --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey-authentication-options.ts @@ -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 | 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, + }; +}; diff --git a/packages/lib/server-only/auth/create-passkey-registration-options.ts b/packages/lib/server-only/auth/create-passkey-registration-options.ts index 5c9d73b8a..8f2b3d53a 100644 --- a/packages/lib/server-only/auth/create-passkey-registration-options.ts +++ b/packages/lib/server-only/auth/create-passkey-registration-options.ts @@ -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, diff --git a/packages/lib/server-only/auth/create-passkey-signin-options.ts b/packages/lib/server-only/auth/create-passkey-signin-options.ts index 03241edd0..e6f9a7152 100644 --- a/packages/lib/server-only/auth/create-passkey-signin-options.ts +++ b/packages/lib/server-only/auth/create-passkey-signin-options.ts @@ -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, diff --git a/packages/lib/server-only/auth/create-passkey.ts b/packages/lib/server-only/auth/create-passkey.ts index c493d8205..0ec86845d 100644 --- a/packages/lib/server-only/auth/create-passkey.ts +++ b/packages/lib/server-only/auth/create-passkey.ts @@ -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, diff --git a/packages/lib/server-only/auth/find-passkeys.ts b/packages/lib/server-only/auth/find-passkeys.ts index 26eac95c3..8f21c8aa6 100644 --- a/packages/lib/server-only/auth/find-passkeys.ts +++ b/packages/lib/server-only/auth/find-passkeys.ts @@ -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, diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 2c7e9b6e4..1f17fda3b 100644 --- a/packages/lib/server-only/document/is-recipient-authorized.ts +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -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 => { + 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 => { + 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, + }, + }); +}; diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index 730806d0c..d44a17bb0 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; +import { ZAuthenticationResponseJSONSchema } from './webauthn'; + /** * All the available types of document authentication options for both access and action. */ -export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']); +export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'EXPLICIT_NONE']); export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; const ZDocumentAuthAccountSchema = z.object({ @@ -14,12 +16,19 @@ const ZDocumentAuthExplicitNoneSchema = z.object({ type: z.literal(DocumentAuth.EXPLICIT_NONE), }); +const ZDocumentAuthPasskeySchema = z.object({ + type: z.literal(DocumentAuth.PASSKEY), + authenticationResponse: ZAuthenticationResponseJSONSchema, + tokenReference: z.string().min(1), +}); + /** * All the document auth methods for both accessing and actioning. */ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ZDocumentAuthExplicitNoneSchema, + ZDocumentAuthPasskeySchema, ]); /** @@ -35,8 +44,11 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); * * Must keep these two in sync. */ -export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here. -export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); +export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, + ZDocumentAuthPasskeySchema, +]); +export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY]); /** * The recipient access auth methods. @@ -54,11 +66,13 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); * Must keep these two in sync. */ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ - ZDocumentAuthAccountSchema, // Todo: Add passkeys here. + ZDocumentAuthAccountSchema, + ZDocumentAuthPasskeySchema, ZDocumentAuthExplicitNoneSchema, ]); export const ZRecipientActionAuthTypesSchema = z.enum([ DocumentAuth.ACCOUNT, + DocumentAuth.PASSKEY, DocumentAuth.EXPLICIT_NONE, ]); diff --git a/packages/lib/utils/authenticator.ts b/packages/lib/utils/authenticator.ts index b5563a4ed..b689d82e9 100644 --- a/packages/lib/utils/authenticator.ts +++ b/packages/lib/utils/authenticator.ts @@ -4,7 +4,7 @@ import { PASSKEY_TIMEOUT } from '../constants/auth'; /** * Extracts common fields to identify the RP (relying party) */ -export const getAuthenticatorRegistrationOptions = () => { +export const getAuthenticatorOptions = () => { const webAppBaseUrl = new URL(WEBAPP_BASE_URL); const rpId = webAppBaseUrl.hostname; diff --git a/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql b/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql new file mode 100644 index 000000000..4e6d6e227 --- /dev/null +++ b/packages/prisma/migrations/20240327074701_add_secondary_verification_id/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[secondaryId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail. + - The required column `secondaryId` was added to the `VerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- AlterTable +ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d632ae60e..868b8d8e1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -126,13 +126,14 @@ model AnonymousVerificationToken { } model VerificationToken { - id Int @id @default(autoincrement()) - identifier String - token String @unique - expires DateTime - createdAt DateTime @default(now()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + secondaryId String @unique @default(cuid()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum WebhookTriggerEvents { diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 0272e54b9..d87bc143f 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -7,6 +7,7 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; +import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options'; import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; @@ -19,6 +20,7 @@ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract- import { authenticatedProcedure, procedure, router } from '../trpc'; import { + ZCreatePasskeyAuthenticationOptionsMutationSchema, ZCreatePasskeyMutationSchema, ZDeletePasskeyMutationSchema, ZFindPasskeysQuerySchema, @@ -115,6 +117,25 @@ export const authRouter = router({ } }), + createPasskeyAuthenticationOptions: authenticatedProcedure + .input(ZCreatePasskeyAuthenticationOptionsMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + return await createPasskeyAuthenticationOptions({ + userId: ctx.user.id, + preferredPasskeyId: input?.preferredPasskeyId, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to create the authentication options for the passkey. Please try again later.', + }); + } + }), + createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => { try { return await createPasskeyRegistrationOptions({ diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index d78b429fc..b84c5e1c9 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -40,6 +40,12 @@ export const ZCreatePasskeyMutationSchema = z.object({ verificationResponse: ZRegistrationResponseJSONSchema, }); +export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z + .object({ + preferredPasskeyId: z.string().optional(), + }) + .optional(); + export const ZDeletePasskeyMutationSchema = z.object({ passkeyId: z.string().trim().min(1), }); diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index e56640f06..a5c0c5d9e 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -219,6 +219,10 @@ export const AddSettingsFormPartial = ({
  • Require account - The recipient must be signed in
  • +
  • + Require passkey - The recipient must have an account + and passkey configured via their settings +
  • None - No authentication required
  • diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 3d1263914..f815ca4cd 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -287,6 +287,10 @@ export const AddSignersFormPartial = ({ Require account - The recipient must be signed in +
  • + Require passkey - The recipient must have + an account and passkey configured via their settings +
  • None - No authentication required