diff --git a/apps/web/package.json b/apps/web/package.json index efd524992..b01048047 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,8 @@ "@documenso/trpc": "*", "@documenso/ui": "*", "@hookform/resolvers": "^3.1.0", + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.3", "@tanstack/react-query": "^4.29.5", "formidable": "^2.1.1", "framer-motion": "^10.12.8", @@ -52,6 +54,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@simplewebauthn/types": "^9.0.1", "@types/formidable": "^2.0.6", "@types/luxon": "^3.3.1", "@types/node": "20.1.0", diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx index 2b5906177..a2b850412 100644 --- a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx @@ -15,15 +15,14 @@ export default function SettingsSecurityActivityPage() { -
- -
+
-
- - +
+ +
); } diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index f46784aed..528454840 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; @@ -18,6 +19,8 @@ export const metadata: Metadata = { export default async function SecuritySettingsPage() { const { user } = await getRequiredServerComponentSession(); + const isPasskeyEnabled = await getServerComponentFlag('app_passkey'); + return (
+ {isPasskeyEnabled && ( + +
+ Passkeys + + + Allows authenticating using biometrics, password managers, hardware keys, etc. + +
+ + +
+ )} + {user.twoFactorEnabled && (
- 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 new file mode 100644 index 000000000..43504845d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { startRegistration } from '@simplewebauthn/browser'; +import { KeyRoundIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { UAParser } from 'ua-parser-js'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type CreatePasskeyDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZCreatePasskeyFormSchema = z.object({ + passkeyName: z.string().min(3), +}); + +type TCreatePasskeyFormSchema = z.infer; + +const parser = new UAParser(); + +export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { + const [open, setOpen] = useState(false); + const [formError, setFormError] = useState(null); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZCreatePasskeyFormSchema), + defaultValues: { + passkeyName: '', + }, + }); + + const { mutateAsync: createPasskeyRegistrationOptions, isLoading } = + trpc.auth.createPasskeyRegistrationOptions.useMutation(); + + const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation(); + + const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => { + setFormError(null); + + try { + const passkeyRegistrationOptions = await createPasskeyRegistrationOptions(); + + const registrationResult = await startRegistration(passkeyRegistrationOptions); + + await createPasskey({ + passkeyName, + verificationResponse: registrationResult, + }); + + toast({ + description: 'Successfully created passkey', + duration: 5000, + }); + + setOpen(false); + } catch (err) { + if (err.name === 'NotAllowedError') { + return; + } + + const error = AppError.parseError(err); + + setFormError(err.code || error.code); + } + }; + + const extractDefaultPasskeyName = () => { + if (!window || !window.navigator) { + return; + } + + parser.setUA(window.navigator.userAgent); + + const result = parser.getResult(); + const operatingSystem = result.os.name; + const browser = result.browser.name; + + let passkeyName = ''; + + if (operatingSystem && browser) { + passkeyName = `${browser} (${operatingSystem})`; + } + + return passkeyName; + }; + + useEffect(() => { + if (!open) { + const defaultPasskeyName = extractDefaultPasskeyName(); + + form.reset({ + passkeyName: defaultPasskeyName, + }); + + setFormError(null); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + Add passkey + + + Passkeys allow you to sign in and authenticate using biometrics, password managers, etc. + + + +
+ +
+ ( + + Passkey name + + + + + + )} + /> + + {formError && ( + + {match(formError) + .with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => ( + This passkey has already been registered. + )) + .with('InvalidStateError', () => ( + <> + + Passkey creation cancelled due to one of the following reasons: + + +
    +
  • Cancelled by user
  • +
  • Passkey already exists for the provided authenticator
  • +
  • Exceeded timeout
  • +
+
+ + )) + .otherwise(() => ( + + Something went wrong. Please try again or contact support. + + ))} +
+ )} + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/page.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/page.tsx new file mode 100644 index 000000000..724177b39 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; + +import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; + +import { CreatePasskeyDialog } from './create-passkey-dialog'; +import { UserPasskeysDataTable } from './user-passkeys-data-table'; + +export const metadata: Metadata = { + title: 'Manage passkeys', +}; + +export default async function SettingsManagePasskeysPage() { + const isPasskeyEnabled = await getServerComponentFlag('app_passkey'); + + if (!isPasskeyEnabled) { + redirect('/settings/security'); + } + + return ( +
+ + + + +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table-actions.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table-actions.tsx new file mode 100644 index 000000000..84008ab5b --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table-actions.tsx @@ -0,0 +1,202 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type UserPasskeysDataTableActionsProps = { + className?: string; + passkeyId: string; + passkeyName: string; +}; + +const ZUpdatePasskeySchema = z.object({ + name: z.string(), +}); + +type TUpdatePasskeySchema = z.infer; + +export const UserPasskeysDataTableActions = ({ + className, + passkeyId, + passkeyName, +}: UserPasskeysDataTableActionsProps) => { + const { toast } = useToast(); + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(ZUpdatePasskeySchema), + defaultValues: { + name: passkeyName, + }, + }); + + const { mutateAsync: updatePasskey, isLoading: isUpdatingPasskey } = + trpc.auth.updatePasskey.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Passkey has been updated', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: + 'We are unable to update this passkey at the moment. Please try again later.', + duration: 10000, + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: deletePasskey, isLoading: isDeletingPasskey } = + trpc.auth.deletePasskey.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Passkey has been removed', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: + 'We are unable to remove this passkey at the moment. Please try again later.', + duration: 10000, + variant: 'destructive', + }); + }, + }); + + return ( +
+ !isUpdatingPasskey && setIsUpdateDialogOpen(value)} + > + e.stopPropagation()} asChild> + + + + + + Update passkey + + + You are currently updating the {passkeyName} passkey. + + + +
+ + updatePasskey({ + passkeyId, + name, + }), + )} + > +
+ ( + + Name + + + + + + )} + /> + + + + + + + + +
+
+ +
+
+ + !isDeletingPasskey && setIsDeleteDialogOpen(value)} + > + e.stopPropagation()} asChild={true}> + + + + + + Delete passkey + + + Are you sure you want to remove the {passkeyName} passkey. + + + +
{ + e.preventDefault(); + + void deletePasskey({ + passkeyId, + }); + }} + > +
+ + + + + + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table.tsx new file mode 100644 index 000000000..edc14cb43 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import { DateTime } from 'luxon'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions'; + +export const UserPasskeysDataTable = () => { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery( + { + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + DateTime.fromJSDate(row.original.createdAt).toRelative(), + }, + + { + header: 'Last used', + accessorKey: 'updatedAt', + cell: ({ row }) => + row.original.lastUsedAt + ? DateTime.fromJSDate(row.original.lastUsedAt).toRelative() + : 'Never', + }, + { + id: 'actions', + cell: ({ row }) => ( + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined} + onClearFilters={() => router.push(pathname ?? '/')} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + + + + + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/web/src/components/(dashboard)/settings/layout/header.tsx b/apps/web/src/components/(dashboard)/settings/layout/header.tsx index 5722d1985..6f5ae28bc 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/header.tsx @@ -5,11 +5,18 @@ import { cn } from '@documenso/ui/lib/utils'; export type SettingsHeaderProps = { title: string; subtitle: string; + hideDivider?: boolean; children?: React.ReactNode; className?: string; }; -export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => { +export const SettingsHeader = ({ + children, + title, + subtitle, + className, + hideDivider, +}: SettingsHeaderProps) => { return ( <>
@@ -22,7 +29,7 @@ export const SettingsHeader = ({ children, title, subtitle, className }: Setting {children}
-
+ {!hideDivider &&
} ); }; diff --git a/apps/web/src/components/forms/2fa/recovery-codes.tsx b/apps/web/src/components/forms/2fa/recovery-codes.tsx index 29834c74a..021e43f76 100644 --- a/apps/web/src/components/forms/2fa/recovery-codes.tsx +++ b/apps/web/src/components/forms/2fa/recovery-codes.tsx @@ -16,7 +16,8 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => { return ( <> - {isGoogleSSOEnabled && ( - <> -
-
- Or continue with -
-
+ {(isGoogleSSOEnabled || isPasskeyEnabled) && ( +
+
+ Or continue with +
+
+ )} - - + {isGoogleSSOEnabled && ( + + )} + + {isPasskeyEnabled && ( + )} diff --git a/package-lock.json b/package-lock.json index 27227172c..2144c38dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139,6 +139,8 @@ "@documenso/trpc": "*", "@documenso/ui": "*", "@hookform/resolvers": "^3.1.0", + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.3", "@tanstack/react-query": "^4.29.5", "formidable": "^2.1.1", "framer-motion": "^10.12.8", @@ -168,6 +170,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@simplewebauthn/types": "^9.0.1", "@types/formidable": "^2.0.6", "@types/luxon": "^3.3.1", "@types/node": "20.1.0", @@ -2221,6 +2224,11 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" + }, "node_modules/@hookform/resolvers": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", @@ -2833,6 +2841,11 @@ "node": ">=12" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.2.tgz", + "integrity": "sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw==" + }, "node_modules/@manypkg/find-root": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.1.tgz", @@ -3939,6 +3952,60 @@ "pako": "^1.0.10" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.10.tgz", + "integrity": "sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz", + "integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz", + "integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", + "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz", + "integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.1.0", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5600,6 +5667,38 @@ "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, + "node_modules/@simplewebauthn/browser": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz", + "integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==", + "dependencies": { + "@simplewebauthn/types": "^9.0.1" + } + }, + "node_modules/@simplewebauthn/server": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz", + "integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^9.0.1", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", + "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==" + }, "node_modules/@sindresorhus/slugify": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", @@ -7777,6 +7876,19 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -9134,6 +9246,33 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "devOptional": true }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -12441,6 +12580,14 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -16543,6 +16690,22 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qs": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 1918e2db0..1587c1780 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -16,10 +16,19 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s [UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated', [UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled', [UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled', + [UserSecurityAuditLogType.PASSKEY_CREATED]: 'Passkey created', + [UserSecurityAuditLogType.PASSKEY_DELETED]: 'Passkey deleted', + [UserSecurityAuditLogType.PASSKEY_UPDATED]: 'Passkey updated', [UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset', [UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated', [UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out', [UserSecurityAuditLogType.SIGN_IN]: 'Signed In', [UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed', + [UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL]: 'Passkey sign in failed', [UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed', }; + +/** + * The duration to wait for a passkey to be verified in MS. + */ +export const PASSKEY_TIMEOUT = 60000; diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts index 3b58e047a..83137217f 100644 --- a/packages/lib/constants/feature-flags.ts +++ b/packages/lib/constants/feature-flags.ts @@ -1,6 +1,6 @@ import { env } from 'next-runtime-env'; -import { APP_BASE_URL } from './app'; +import { APP_BASE_URL, WEBAPP_BASE_URL } from './app'; const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED'); const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY'); @@ -23,6 +23,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000; export const LOCAL_FEATURE_FLAGS: Record = { app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true', app_document_page_view_history_sheet: false, + app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag. marketing_header_single_player_mode: false, marketing_profiles_announcement_bar: true, } as const; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 4a394c0a3..c9cb56381 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -1,5 +1,6 @@ /// import { PrismaAdapter } from '@next-auth/prisma-adapter'; +import { verifyAuthenticationResponse } from '@simplewebauthn/server'; import { compare } from 'bcrypt'; import { DateTime } from 'luxon'; import type { AuthOptions, Session, User } from 'next-auth'; @@ -12,12 +13,16 @@ import { env } from 'next-runtime-env'; import { prisma } from '@documenso/prisma'; import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '../errors/app-error'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id'; import { getUserByEmail } from '../server-only/user/get-user-by-email'; import { sendConfirmationToken } from '../server-only/user/send-confirmation-token'; +import type { TAuthenticationResponseJSONSchema } from '../types/webauthn'; +import { ZAuthenticationResponseJSONSchema } from '../types/webauthn'; import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; +import { getAuthenticatorRegistrationOptions } from '../utils/authenticator'; import { ErrorCode } from './error-codes'; export const NEXT_AUTH_OPTIONS: AuthOptions = { @@ -131,6 +136,114 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { }; }, }), + CredentialsProvider({ + id: 'webauthn', + name: 'Keypass', + credentials: { + csrfToken: { label: 'csrfToken', type: 'csrfToken' }, + }, + async authorize(credentials, req) { + const csrfToken = credentials?.csrfToken; + + if (typeof csrfToken !== 'string' || csrfToken.length === 0) { + throw new AppError(AppErrorCode.INVALID_REQUEST); + } + + let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null; + + try { + const parsedBodyCredential = JSON.parse(req.body?.credential); + requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential); + } catch { + throw new AppError(AppErrorCode.INVALID_REQUEST); + } + + const challengeToken = await prisma.anonymousVerificationToken + .delete({ + where: { + id: csrfToken, + }, + }) + .catch(() => null); + + if (!challengeToken) { + return null; + } + + if (challengeToken.expiresAt < new Date()) { + throw new AppError(AppErrorCode.EXPIRED_CODE); + } + + const passkey = await prisma.passkey.findFirst({ + where: { + credentialId: Buffer.from(requestBodyCrediential.id, 'base64'), + }, + include: { + User: { + select: { + id: true, + email: true, + name: true, + emailVerified: true, + }, + }, + }, + }); + + if (!passkey) { + throw new AppError(AppErrorCode.NOT_SETUP); + } + + const user = passkey.User; + + const { rpId, origin } = getAuthenticatorRegistrationOptions(); + + const verification = await verifyAuthenticationResponse({ + response: requestBodyCrediential, + expectedChallenge: challengeToken.token, + expectedOrigin: origin, + expectedRPID: rpId, + authenticator: { + credentialID: new Uint8Array(Array.from(passkey.credentialId)), + credentialPublicKey: new Uint8Array(passkey.credentialPublicKey), + counter: Number(passkey.counter), + }, + }).catch(() => null); + + const requestMetadata = extractNextAuthRequestMetadata(req); + + // Explicit success state to reduce chances of bugs. + if (verification?.verified === true) { + await prisma.passkey.update({ + where: { + id: passkey.id, + }, + data: { + lastUsedAt: new Date(), + counter: verification.authenticationInfo.newCounter, + }, + }); + + return { + id: Number(user.id), + email: user.email, + name: user.name, + emailVerified: user.emailVerified?.toISOString() ?? null, + } satisfies User; + } + + await prisma.userSecurityAuditLog.create({ + data: { + userId: user.id, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL, + }, + }); + + return null; + }, + }), ], callbacks: { async jwt({ token, user, trigger, account }) { diff --git a/packages/lib/server-only/auth/create-passkey-registration-options.ts b/packages/lib/server-only/auth/create-passkey-registration-options.ts new file mode 100644 index 000000000..5c9d73b8a --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey-registration-options.ts @@ -0,0 +1,58 @@ +import { generateRegistrationOptions } from '@simplewebauthn/server'; +import type { AuthenticatorTransportFuture } from '@simplewebauthn/types'; +import { DateTime } from 'luxon'; + +import { prisma } from '@documenso/prisma'; + +import { PASSKEY_TIMEOUT } from '../../constants/auth'; +import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; + +type CreatePasskeyRegistrationOptions = { + userId: number; +}; + +export const createPasskeyRegistrationOptions = async ({ + userId, +}: CreatePasskeyRegistrationOptions) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + select: { + name: true, + email: true, + passkeys: true, + }, + }); + + const { passkeys } = user; + + const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions(); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userID: userId.toString(), + userName: user.email, + userDisplayName: user.name ?? undefined, + timeout: PASSKEY_TIMEOUT, + attestationType: 'none', + excludeCredentials: passkeys.map((passkey) => ({ + id: passkey.credentialId, + type: 'public-key', + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + transports: passkey.transports as AuthenticatorTransportFuture[], + })), + }); + + await prisma.verificationToken.create({ + data: { + userId, + token: options.challenge, + expires: DateTime.now().plus({ minutes: 2 }).toJSDate(), + identifier: 'PASSKEY_CHALLENGE', + }, + }); + + return options; +}; diff --git a/packages/lib/server-only/auth/create-passkey-signin-options.ts b/packages/lib/server-only/auth/create-passkey-signin-options.ts new file mode 100644 index 000000000..03241edd0 --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey-signin-options.ts @@ -0,0 +1,41 @@ +import { generateAuthenticationOptions } from '@simplewebauthn/server'; +import { DateTime } from 'luxon'; + +import { prisma } from '@documenso/prisma'; + +import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; + +type CreatePasskeySigninOptions = { + sessionId: string; +}; + +export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => { + const { rpId, timeout } = getAuthenticatorRegistrationOptions(); + + const options = await generateAuthenticationOptions({ + rpID: rpId, + userVerification: 'preferred', + timeout, + }); + + const { challenge } = options; + + await prisma.anonymousVerificationToken.upsert({ + where: { + id: sessionId, + }, + update: { + token: challenge, + expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(), + createdAt: new Date(), + }, + create: { + id: sessionId, + token: challenge, + expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(), + createdAt: new Date(), + }, + }); + + return options; +}; diff --git a/packages/lib/server-only/auth/create-passkey.ts b/packages/lib/server-only/auth/create-passkey.ts new file mode 100644 index 000000000..98f3287b9 --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey.ts @@ -0,0 +1,94 @@ +import { verifyRegistrationResponse } from '@simplewebauthn/server'; +import type { RegistrationResponseJSON } from '@simplewebauthn/types'; + +import { prisma } from '@documenso/prisma'; +import { UserSecurityAuditLogType } from '@documenso/prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; + +type CreatePasskeyOptions = { + userId: number; + passkeyName: string; + verificationResponse: RegistrationResponseJSON; + requestMetadata?: RequestMetadata; +}; + +export const createPasskey = async ({ + userId, + passkeyName, + verificationResponse, + requestMetadata, +}: CreatePasskeyOptions) => { + await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + userId, + identifier: 'PASSKEY_CHALLENGE', + }, + orderBy: { + createdAt: 'desc', + }, + }); + + if (!verificationToken) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Challenge token not found'); + } + + await prisma.verificationToken.deleteMany({ + where: { + userId, + identifier: 'PASSKEY_CHALLENGE', + }, + }); + + if (verificationToken.expires < new Date()) { + throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired'); + } + + const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions(); + + const verification = await verifyRegistrationResponse({ + response: verificationResponse, + expectedChallenge: verificationToken.token, + expectedOrigin, + expectedRPID, + }); + + if (!verification.verified || !verification.registrationInfo) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Verification failed'); + } + + const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } = + verification.registrationInfo; + + await prisma.$transaction(async (tx) => { + await tx.passkey.create({ + data: { + userId, + name: passkeyName, + credentialId: Buffer.from(credentialID), + credentialPublicKey: Buffer.from(credentialPublicKey), + counter, + credentialDeviceType, + credentialBackedUp, + transports: verificationResponse.response.transports, + }, + }); + + await tx.userSecurityAuditLog.create({ + data: { + userId, + type: UserSecurityAuditLogType.PASSKEY_CREATED, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); + }); +}; diff --git a/packages/lib/server-only/auth/delete-passkey.ts b/packages/lib/server-only/auth/delete-passkey.ts new file mode 100644 index 000000000..cb7edc4cf --- /dev/null +++ b/packages/lib/server-only/auth/delete-passkey.ts @@ -0,0 +1,41 @@ +import { prisma } from '@documenso/prisma'; +import { UserSecurityAuditLogType } from '@documenso/prisma/client'; + +import type { RequestMetadata } from '../../universal/extract-request-metadata'; + +export interface DeletePasskeyOptions { + userId: number; + passkeyId: string; + requestMetadata?: RequestMetadata; +} + +export const deletePasskey = async ({ + userId, + passkeyId, + requestMetadata, +}: DeletePasskeyOptions) => { + await prisma.passkey.findFirstOrThrow({ + where: { + id: passkeyId, + userId, + }, + }); + + await prisma.$transaction(async (tx) => { + await tx.passkey.delete({ + where: { + id: passkeyId, + userId, + }, + }); + + await tx.userSecurityAuditLog.create({ + data: { + userId, + type: UserSecurityAuditLogType.PASSKEY_DELETED, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); + }); +}; diff --git a/packages/lib/server-only/auth/find-passkeys.ts b/packages/lib/server-only/auth/find-passkeys.ts new file mode 100644 index 000000000..26eac95c3 --- /dev/null +++ b/packages/lib/server-only/auth/find-passkeys.ts @@ -0,0 +1,71 @@ +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { prisma } from '@documenso/prisma'; +import type { Passkey } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindPasskeysOptions { + userId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Passkey; + direction: 'asc' | 'desc'; + }; +} + +export const findPasskeys = async ({ + userId, + term = '', + page = 1, + perPage = 10, + orderBy, +}: FindPasskeysOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause: Prisma.PasskeyWhereInput = { + userId, + }; + + if (term.length > 0) { + whereClause.name = { + contains: term, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.passkey.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + select: { + id: true, + userId: true, + name: true, + createdAt: true, + updatedAt: true, + lastUsedAt: true, + counter: true, + credentialDeviceType: true, + credentialBackedUp: true, + transports: true, + }, + }), + prisma.passkey.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/auth/update-passkey.ts b/packages/lib/server-only/auth/update-passkey.ts new file mode 100644 index 000000000..b08eca233 --- /dev/null +++ b/packages/lib/server-only/auth/update-passkey.ts @@ -0,0 +1,51 @@ +import { prisma } from '@documenso/prisma'; +import { UserSecurityAuditLogType } from '@documenso/prisma/client'; + +import type { RequestMetadata } from '../../universal/extract-request-metadata'; + +export interface UpdateAuthenticatorsOptions { + userId: number; + passkeyId: string; + name: string; + requestMetadata?: RequestMetadata; +} + +export const updatePasskey = async ({ + userId, + passkeyId, + name, + requestMetadata, +}: UpdateAuthenticatorsOptions) => { + const passkey = await prisma.passkey.findFirstOrThrow({ + where: { + id: passkeyId, + userId, + }, + }); + + if (passkey.name === name) { + return; + } + + await prisma.$transaction(async (tx) => { + await tx.passkey.update({ + where: { + id: passkeyId, + userId, + }, + data: { + name, + updatedAt: new Date(), + }, + }); + + await tx.userSecurityAuditLog.create({ + data: { + userId, + type: UserSecurityAuditLogType.PASSKEY_UPDATED, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); + }); +}; diff --git a/packages/lib/types/webauthn.ts b/packages/lib/types/webauthn.ts new file mode 100644 index 000000000..af409ec89 --- /dev/null +++ b/packages/lib/types/webauthn.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +const ZClientExtensionResults = z.object({ + appid: z.boolean().optional(), + credProps: z + .object({ + rk: z.boolean().optional(), + }) + .optional(), + hmacCreateSecret: z.boolean().optional(), +}); + +export const ZAuthenticationResponseJSONSchema = z.object({ + id: z.string(), + rawId: z.string(), + response: z.object({ + clientDataJSON: z.string(), + authenticatorData: z.string(), + signature: z.string(), + userHandle: z.string().optional(), + }), + authenticatorAttachment: z.union([z.literal('cross-platform'), z.literal('platform')]).optional(), + clientExtensionResults: ZClientExtensionResults, + type: z.literal('public-key'), +}); + +export const ZRegistrationResponseJSONSchema = z.object({ + id: z.string(), + rawId: z.string(), + response: z.object({ + clientDataJSON: z.string(), + attestationObject: z.string(), + authenticatorData: z.string().optional(), + transports: z.array(z.string()).optional(), + publicKeyAlgorithm: z.number().optional(), + publicKey: z.string().optional(), + }), + authenticatorAttachment: z.string().optional(), + clientExtensionResults: ZClientExtensionResults.optional(), + type: z.string(), +}); + +export type TAuthenticationResponseJSONSchema = z.infer; +export type TRegistrationResponseJSONSchema = z.infer; diff --git a/packages/lib/utils/authenticator.ts b/packages/lib/utils/authenticator.ts new file mode 100644 index 000000000..b5563a4ed --- /dev/null +++ b/packages/lib/utils/authenticator.ts @@ -0,0 +1,17 @@ +import { WEBAPP_BASE_URL } from '../constants/app'; +import { PASSKEY_TIMEOUT } from '../constants/auth'; + +/** + * Extracts common fields to identify the RP (relying party) + */ +export const getAuthenticatorRegistrationOptions = () => { + const webAppBaseUrl = new URL(WEBAPP_BASE_URL); + const rpId = webAppBaseUrl.hostname; + + return { + rpName: 'Documenso', + rpId, + origin: WEBAPP_BASE_URL, + timeout: PASSKEY_TIMEOUT, + }; +}; diff --git a/packages/prisma/migrations/20240306060259_add_passkeys/migration.sql b/packages/prisma/migrations/20240306060259_add_passkeys/migration.sql new file mode 100644 index 000000000..e1bd80e69 --- /dev/null +++ b/packages/prisma/migrations/20240306060259_add_passkeys/migration.sql @@ -0,0 +1,49 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_CREATED'; +ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_DELETED'; +ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_UPDATED'; +ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'SIGN_IN_PASSKEY_FAIL'; + +-- CreateTable +CREATE TABLE "Passkey" ( + "id" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + "credentialId" BYTEA NOT NULL, + "credentialPublicKey" BYTEA NOT NULL, + "counter" BIGINT NOT NULL, + "credentialDeviceType" TEXT NOT NULL, + "credentialBackedUp" BOOLEAN NOT NULL, + "transports" TEXT[], + + CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AnonymousVerificationToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AnonymousVerificationToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AnonymousVerificationToken_id_key" ON "AnonymousVerificationToken"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "AnonymousVerificationToken_token_key" ON "AnonymousVerificationToken"("token"); + +-- AddForeignKey +ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index b1bf9f985..aa161fa1f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -52,6 +52,7 @@ model User { securityAuditLogs UserSecurityAuditLog[] Webhooks Webhook[] siteSettings SiteSettings[] + passkeys Passkey[] @@index([email]) } @@ -68,12 +69,16 @@ enum UserSecurityAuditLogType { ACCOUNT_SSO_LINK AUTH_2FA_DISABLE AUTH_2FA_ENABLE + PASSKEY_CREATED + PASSKEY_DELETED + PASSKEY_UPDATED PASSWORD_RESET PASSWORD_UPDATE SIGN_OUT SIGN_IN SIGN_IN_FAIL SIGN_IN_2FA_FAIL + SIGN_IN_PASSKEY_FAIL } model UserSecurityAuditLog { @@ -96,6 +101,30 @@ model PasswordResetToken { User User @relation(fields: [userId], references: [id]) } +model Passkey { + id String @id @default(cuid()) + userId Int + name String + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + lastUsedAt DateTime? + credentialId Bytes + credentialPublicKey Bytes + counter BigInt + credentialDeviceType String + credentialBackedUp Boolean + transports String[] + + User User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model AnonymousVerificationToken { + id String @id @unique @default(cuid()) + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) +} + model VerificationToken { id Int @id @default(autoincrement()) identifier String diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 16b370b3e..7ee86b17b 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -1,15 +1,30 @@ +import type { RegistrationResponseJSON } from '@simplewebauthn/types'; import { TRPCError } from '@trpc/server'; import { env } from 'next-runtime-env'; 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 { 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'; +import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys'; import { compareSync } from '@documenso/lib/server-only/auth/hash'; +import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; import { createUser } from '@documenso/lib/server-only/user/create-user'; import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, procedure, router } from '../trpc'; -import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema'; +import { + ZCreatePasskeyMutationSchema, + ZDeletePasskeyMutationSchema, + ZFindPasskeysQuerySchema, + ZSignUpMutationSchema, + ZUpdatePasskeyMutationSchema, + ZVerifyPasswordMutationSchema, +} from './schema'; const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP'); @@ -78,4 +93,131 @@ export const authRouter = router({ return valid; }), + + createPasskey: authenticatedProcedure + .input(ZCreatePasskeyMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const verificationResponse = input.verificationResponse as RegistrationResponseJSON; + + return await createPasskey({ + userId: ctx.user.id, + verificationResponse, + passkeyName: input.passkeyName, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this passkey. Please try again later.', + }); + } + }), + + createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => { + try { + return await createPasskeyRegistrationOptions({ + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to create the registration options for the passkey. Please try again later.', + }); + } + }), + + createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => { + const cookie = ctx.req.headers.cookie ?? ''; + + const sessionIdToken = cookie?.split(';').find((c) => c.includes('next-auth.csrf-token')); + + if (!sessionIdToken) { + throw new Error('Missing CSRF token'); + } + + const sessionId = decodeURI(sessionIdToken.split('=')[1]).split('|')[0]; + + try { + return await createPasskeySigninOptions({ sessionId }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create the options for passkey signin. Please try again later.', + }); + } + }), + + deletePasskey: authenticatedProcedure + .input(ZDeletePasskeyMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + const { passkeyId } = input; + + await deletePasskey({ + userId: ctx.user.id, + passkeyId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete this passkey. Please try again later.', + }); + } + }), + + findPasskeys: authenticatedProcedure + .input(ZFindPasskeysQuerySchema) + .query(async ({ input, ctx }) => { + try { + const { page, perPage, orderBy } = input; + + return await findPasskeys({ + page, + perPage, + orderBy, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find passkeys. Please try again later.', + }); + } + }), + + updatePasskey: authenticatedProcedure + .input(ZUpdatePasskeyMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + const { passkeyId, name } = input; + + await updatePasskey({ + userId: ctx.user.id, + passkeyId, + name, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to update this passkey. Please try again later.', + }); + } + }), }); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index 9a52f7fc2..d78b429fc 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -1,5 +1,8 @@ import { z } from 'zod'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn'; + export const ZCurrentPasswordSchema = z .string() .min(6, { message: 'Must be at least 6 characters in length' }) @@ -32,6 +35,29 @@ export const ZSignUpMutationSchema = z.object({ .optional(), }); +export const ZCreatePasskeyMutationSchema = z.object({ + passkeyName: z.string().trim().min(1), + verificationResponse: ZRegistrationResponseJSONSchema, +}); + +export const ZDeletePasskeyMutationSchema = z.object({ + passkeyId: z.string().trim().min(1), +}); + +export const ZUpdatePasskeyMutationSchema = z.object({ + passkeyId: z.string().trim().min(1), + name: z.string().trim().min(1), +}); + +export const ZFindPasskeysQuerySchema = ZBaseTableSearchParamsSchema.extend({ + orderBy: z + .object({ + column: z.enum(['createdAt', 'updatedAt', 'name']), + direction: z.enum(['asc', 'desc']), + }) + .optional(), +}); + export type TSignUpMutationSchema = z.infer; export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });