From 1ed18059fbd2f349a8b49c719326ad0da394612c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 17 Mar 2024 20:33:11 +0800 Subject: [PATCH] feat: initial reauth passkeys --- .../[token]/document-action-auth-account.tsx | 83 +++++++ .../[token]/document-action-auth-dialog.tsx | 228 ++++-------------- .../[token]/document-action-auth-passkey.tsx | 212 ++++++++++++++++ .../sign/[token]/document-auth-provider.tsx | 65 ++++- .../sign/[token]/signing-field-container.tsx | 2 +- apps/web/src/components/forms/signin.tsx | 2 +- 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 | 122 +++++++++- 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 + 22 files changed, 691 insertions(+), 218 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/20240317092548_add_verification_secondary_id/migration.sql 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..3e63532e2 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx @@ -0,0 +1,83 @@ +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 a904eb062..1dca6afc2 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,6 @@ -/** - * 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 { useMemo } from 'react'; -import { DateTime } from 'luxon'; -import { signOut } from 'next-auth/react'; -import { match } from 'ts-pattern'; +import { P, match } from 'ts-pattern'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { @@ -15,18 +8,16 @@ import { type TRecipientActionAuth, type TRecipientActionAuthTypes, } from '@documenso/lib/types/document-auth'; -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 +25,6 @@ export type DocumentActionAuthDialogProps = { documentAuthType: TRecipientActionAuthTypes; description?: string; actionTarget?: 'FIELD' | 'DOCUMENT'; - isSubmitting?: boolean; open: boolean; onOpenChange: (value: boolean) => void; @@ -44,204 +34,76 @@ export type DocumentActionAuthDialogProps = { onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; }; -// const ZReauthFormSchema = z.object({ -// password: ZCurrentPasswordSchema, -// }); -// type TReauthFormSchema = z.infer; - export const DocumentActionAuthDialog = ({ title, description, documentAuthType, actionTarget = 'FIELD', - // 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]); + const actionVerb = + actionTarget === 'DOCUMENT' ? RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb : 'Sign'; - const defaultRecipientActionVerb = RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb; + const defaultTitleDescription = useMemo(() => { + if (recipient.role === 'VIEWER' && actionTarget === 'DOCUMENT') { + return { + title: 'Mark document as viewed', + description: 'Reauthentication is required to mark this document as viewed.', + }; + } + + return { + title: `${actionVerb} ${actionTarget.toLowerCase()}`, + description: `Reauthentication is required to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}`, + }; + }, [recipient.role, actionVerb, actionTarget]); return ( - - {title || `${defaultRecipientActionVerb} ${actionTarget.toLowerCase()}`} - + {title || defaultTitleDescription.title} - {description || - `Reauthentication is required to ${defaultRecipientActionVerb.toLowerCase()} the ${actionTarget.toLowerCase()}`} + {description || defaultTitleDescription.description} - {match(documentAuthType) - .with(DocumentAuth.ACCOUNT, () => ( -
- - - To {defaultRecipientActionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, - 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 auths 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..070dbc635 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx @@ -0,0 +1,212 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; +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 { 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 { 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({ + preferredPasskeyId: z.string(), +}); + +type TPasskeyAuthFormSchema = z.infer; + +export const DocumentActionAuthPasskey = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentActionAuthPasskeyProps) => { + const { + passkeyData, + preferredPasskeyId, + setPreferredPasskeyId, + isCurrentlyAuthenticating, + setIsCurrentlyAuthenticating, + } = useRequiredDocumentAuthContext(); + + const form = useForm({ + resolver: zodResolver(ZPasskeyAuthFormSchema), + defaultValues: { + preferredPasskeyId: preferredPasskeyId ?? '', + }, + }); + + const { mutateAsync: createPasskeyAuthenticationOptions } = + trpc.auth.createPasskeyAuthenticationOptions.useMutation(); + + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async (values: TPasskeyAuthFormSchema) => { + try { + setPreferredPasskeyId(values.preferredPasskeyId); + setIsCurrentlyAuthenticating(true); + + const { options, tokenReference } = await createPasskeyAuthenticationOptions({ + preferredPasskeyId: values.preferredPasskeyId, + }); + + 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({ + preferredPasskeyId: preferredPasskeyId ?? '', + }); + + setFormErrorCode(null); + }, [open, form, preferredPasskeyId]); + + if (!browserSupportsWebAuthn()) { + return ( +
+ + + Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '} + this {actionTarget.toLowerCase()}. + + + + + + +
+ ); + } + + return ( +
+ +
+ {passkeyData.passkeys.length === 0 && ( +
+ + + You need to setup a passkey to {actionVerb.toLowerCase()} this{' '} + {actionTarget.toLowerCase()}. + + + + + + + {/* Todo */} + + +
+ )} + + {passkeyData.passkeys.length > 0 && ( +
+ ( + + 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 986cfc12e..ef771f19e 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,19 @@ 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, Recipient, User } from '@documenso/prisma/client'; +import type { Document, Passkey, Recipient, 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[]; + isLoading: boolean; + isInitialLoading: boolean; + isLoadingError: boolean; +}; + export type DocumentAuthContextValue = { executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; document: Document; @@ -29,6 +38,11 @@ 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; }; @@ -64,6 +78,26 @@ export const DocumentAuthProvider = ({ const [document, setDocument] = useState(initialDocument); const [recipient, setRecipient] = useState(initialRecipient); + const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); + const [preferredPasskeyId, setPreferredPasskeyId] = useState(null); + + const passkeyQuery = trpc.auth.findPasskeys.useQuery( + { + perPage: MAXIMUM_PASSKEYS, + }, + { + keepPreviousData: true, + enabled: false, + }, + ); + + const passkeyData: PasskeyData = { + passkeys: passkeyQuery.data?.data || [], + isLoading: passkeyQuery.isLoading, + isInitialLoading: passkeyQuery.isInitialLoading, + isLoadingError: passkeyQuery.isLoadingError, + }; + const { documentAuthOption, recipientAuthOption, @@ -78,6 +112,24 @@ export const DocumentAuthProvider = ({ [document, recipient], ); + /** + * By default, select the first passkey since it's pre sorted by most recently used. + */ + useEffect(() => { + if (!preferredPasskeyId && passkeyQuery.data && passkeyQuery.data.data.length > 0) { + setPreferredPasskeyId(passkeyQuery.data.data[0].id); + } + }, [passkeyQuery.data, preferredPasskeyId]); + + /** + * Only fetch passkeys if required. + */ + useEffect(() => { + if (derivedRecipientActionAuth === DocumentAuth.PASSKEY) { + void passkeyQuery.refetch(); + } + }, [derivedRecipientActionAuth, passkeyQuery]); + const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = useState(null); @@ -101,7 +153,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) => { @@ -111,7 +163,7 @@ export const DocumentAuthProvider = ({ return; } - // Run callback with precalculated auth options if avaliable. + // Run callback with precalculated auth options if available. if (preCalculatedActionAuthOptions) { setDocumentAuthDialogPayload(null); await options.onReauthFormSubmit(preCalculatedActionAuthOptions); @@ -143,6 +195,11 @@ export const DocumentAuthProvider = ({ derivedRecipientAccessAuth, derivedRecipientActionAuth, isAuthRedirectRequired, + isCurrentlyAuthenticating, + setIsCurrentlyAuthenticating, + passkeyData, + preferredPasskeyId, + setPreferredPasskeyId, }} > {children} diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index 78a591505..f5e598dab 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -30,7 +30,7 @@ export type SignatureFieldProps = { /** * The function required to be executed to insert the field. * - * The auth values will be passed in if avaliable. + * The auth values will be passed in if available. */ onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise | void; onRemove?: () => Promise | void; 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/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 a88797910..6b475932e 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 bf595fa9b..3a92d3103 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 = { @@ -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 => { + 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, + }, + }); + + 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, + }, + }); +}; 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/20240317092548_add_verification_secondary_id/migration.sql b/packages/prisma/migrations/20240317092548_add_verification_secondary_id/migration.sql new file mode 100644 index 000000000..4e6d6e227 --- /dev/null +++ b/packages/prisma/migrations/20240317092548_add_verification_secondary_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 86d0e3491..02b12424d 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -6,6 +6,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'; @@ -18,6 +19,7 @@ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract- import { authenticatedProcedure, procedure, router } from '../trpc'; import { + ZCreatePasskeyAuthenticationOptionsMutationSchema, ZCreatePasskeyMutationSchema, ZDeletePasskeyMutationSchema, ZFindPasskeysQuerySchema, @@ -114,6 +116,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 6e3cd190f..8f09749ba 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -214,6 +214,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 8e174e578..2e50f1a90 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -280,6 +280,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