mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 21:12:48 +10:00
feat: add passkeys
This commit is contained in:
@ -23,6 +23,8 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"@tanstack/react-query": "^4.29.5",
|
"@tanstack/react-query": "^4.29.5",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
@ -52,6 +54,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
|
|||||||
@ -15,15 +15,14 @@ export default function SettingsSecurityActivityPage() {
|
|||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Security activity"
|
title="Security activity"
|
||||||
subtitle="View all recent security activity related to your account."
|
subtitle="View all recent security activity related to your account."
|
||||||
|
hideDivider={true}
|
||||||
>
|
>
|
||||||
<div>
|
<ActivityPageBackButton />
|
||||||
<ActivityPageBackButton />
|
|
||||||
</div>
|
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<div className="mt-4">
|
||||||
|
<UserSecurityActivityDataTable />
|
||||||
<UserSecurityActivityDataTable />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -18,6 +19,8 @@ export const metadata: Metadata = {
|
|||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
@ -47,6 +50,25 @@ export default async function SecuritySettingsPage() {
|
|||||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{isPasskeyEnabled && (
|
||||||
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Passkeys</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-4">
|
||||||
|
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="outline" className="bg-background">
|
||||||
|
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{user.twoFactorEnabled && (
|
{user.twoFactorEnabled && (
|
||||||
<Alert
|
<Alert
|
||||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
@ -91,7 +113,7 @@ export default async function SecuritySettingsPage() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild variant="outline" className="bg-background">
|
||||||
<Link href="/settings/security/activity">View activity</Link>
|
<Link href="/settings/security/activity">View activity</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@ -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<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZCreatePasskeyFormSchema = z.object({
|
||||||
|
passkeyName: z.string().min(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||||
|
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TCreatePasskeyFormSchema>({
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary" loading={isLoading}>
|
||||||
|
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||||
|
Add passkey
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="passkeyName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Passkey name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" placeholder="eg. Mac" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
{match(formError)
|
||||||
|
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
|
||||||
|
<AlertDescription>This passkey has already been registered.</AlertDescription>
|
||||||
|
))
|
||||||
|
.with('InvalidStateError', () => (
|
||||||
|
<>
|
||||||
|
<AlertTitle className="text-sm">
|
||||||
|
Passkey creation cancelled due to one of the following reasons:
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="mt-1 list-inside list-disc">
|
||||||
|
<li>Cancelled by user</li>
|
||||||
|
<li>Passkey already exists for the provided authenticator</li>
|
||||||
|
<li>Exceeded timeout</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<AlertDescription>
|
||||||
|
Something went wrong. Please try again or contact support.
|
||||||
|
</AlertDescription>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
|
||||||
|
<CreatePasskeyDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<UserPasskeysDataTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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<typeof ZUpdatePasskeySchema>;
|
||||||
|
|
||||||
|
export const UserPasskeysDataTableActions = ({
|
||||||
|
className,
|
||||||
|
passkeyId,
|
||||||
|
passkeyName,
|
||||||
|
}: UserPasskeysDataTableActionsProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TUpdatePasskeySchema>({
|
||||||
|
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 (
|
||||||
|
<div className={cn('flex justify-end space-x-2', className)}>
|
||||||
|
<Dialog
|
||||||
|
open={isUpdateDialogOpen}
|
||||||
|
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
<Button variant="outline">Edit</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are currently updating the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(async ({ name }) =>
|
||||||
|
updatePasskey({
|
||||||
|
passkeyId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={isUpdatingPasskey}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isUpdatingPasskey}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
void deletePasskey({
|
||||||
|
passkeyId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset className="flex h-full flex-col space-y-4" disabled={isDeletingPasskey}>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" variant="destructive" loading={isDeletingPasskey}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Created',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => 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 }) => (
|
||||||
|
<UserPasskeysDataTableActions
|
||||||
|
className="justify-end"
|
||||||
|
passkeyId={row.original.id}
|
||||||
|
passkeyName={row.original.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
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: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row space-x-2">
|
||||||
|
<Skeleton className="h-8 w-16 rounded" />
|
||||||
|
<Skeleton className="h-8 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,11 +5,18 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
export type SettingsHeaderProps = {
|
export type SettingsHeaderProps = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
hideDivider?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
|
export const SettingsHeader = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
className,
|
||||||
|
hideDivider,
|
||||||
|
}: SettingsHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
<div className={cn('flex flex-row items-center justify-between', className)}>
|
||||||
@ -22,7 +29,7 @@ export const SettingsHeader = ({ children, title, subtitle, className }: Setting
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
{!hideDivider && <hr className="my-4" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -16,7 +16,8 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
className="flex-shrink-0"
|
variant="outline"
|
||||||
|
className="bg-background flex-shrink-0"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
disabled={!isTwoFactorEnabled}
|
disabled={!isTwoFactorEnabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -6,12 +6,18 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -66,14 +72,22 @@ export type SignInFormProps = {
|
|||||||
|
|
||||||
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
>('totp');
|
>('totp');
|
||||||
|
|
||||||
|
const isPasskeyEnabled = getFlag('app_passkey');
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeySigninOptions } =
|
||||||
|
trpc.auth.createPasskeySigninOptions.useMutation();
|
||||||
|
|
||||||
const form = useForm<TSignInFormSchema>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
@ -107,6 +121,59 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
setTwoFactorAuthenticationMethod(method);
|
setTwoFactorAuthenticationMethod(method);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignInWithPasskey = async () => {
|
||||||
|
if (!browserSupportsWebAuthn) {
|
||||||
|
toast({
|
||||||
|
title: 'Not supported',
|
||||||
|
description: 'Passkeys are not supported on this browser',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = await createPasskeySigninOptions();
|
||||||
|
|
||||||
|
const credential = await startAuthentication(options);
|
||||||
|
|
||||||
|
const result = await signIn('webauthn', {
|
||||||
|
credential: JSON.stringify(credential),
|
||||||
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.url || result.error) {
|
||||||
|
throw new AppError(result?.error ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = result.url;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const errorMessage = match(error.code)
|
||||||
|
.with(
|
||||||
|
AppErrorCode.NOT_SETUP,
|
||||||
|
() =>
|
||||||
|
'This passkey is not configured for this application. Please login and add one in the user settings.',
|
||||||
|
)
|
||||||
|
.with(AppErrorCode.EXPIRED_CODE, () => 'This session has expired. Please try again.')
|
||||||
|
.otherwise(() => 'Please try again later or login using your normal details');
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: errorMessage,
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const credentials: Record<string, string> = {
|
const credentials: Record<string, string> = {
|
||||||
@ -240,26 +307,40 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
|
||||||
<>
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="bg-border h-px flex-1" />
|
||||||
<div className="bg-border h-px flex-1" />
|
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
||||||
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
<div className="bg-border h-px flex-1" />
|
||||||
<div className="bg-border h-px flex-1" />
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<Button
|
{isGoogleSSOEnabled && (
|
||||||
type="button"
|
<Button
|
||||||
size="lg"
|
type="button"
|
||||||
variant="outline"
|
size="lg"
|
||||||
className="bg-background text-muted-foreground border"
|
variant="outline"
|
||||||
disabled={isSubmitting}
|
className="bg-background text-muted-foreground border"
|
||||||
onClick={onSignInWithGoogleClick}
|
disabled={isSubmitting}
|
||||||
>
|
onClick={onSignInWithGoogleClick}
|
||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
>
|
||||||
Google
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
</Button>
|
Google
|
||||||
</>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPasskeyEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
loading={isSubmitting}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
onClick={onSignInWithPasskey}
|
||||||
|
>
|
||||||
|
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||||
|
Passkey
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
163
package-lock.json
generated
163
package-lock.json
generated
@ -139,6 +139,8 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"@tanstack/react-query": "^4.29.5",
|
"@tanstack/react-query": "^4.29.5",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
@ -168,6 +170,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
@ -2221,6 +2224,11 @@
|
|||||||
"@hapi/hoek": "^9.0.0"
|
"@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": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
|
||||||
@ -2833,6 +2841,11 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@manypkg/find-root": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.1.tgz",
|
||||||
@ -3939,6 +3952,60 @@
|
|||||||
"pako": "^1.0.10"
|
"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": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
|
||||||
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
|
"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": {
|
"node_modules/@sindresorhus/slugify": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
|
"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": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"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==",
|
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||||
"devOptional": true
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||||
@ -12441,6 +12580,14 @@
|
|||||||
"loose-envify": "^1.0.0"
|
"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": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@ -16543,6 +16690,22 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.11.2",
|
"version": "6.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
|
||||||
|
|||||||
@ -16,10 +16,19 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s
|
|||||||
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
||||||
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
||||||
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
[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_RESET]: 'Password reset',
|
||||||
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
||||||
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
||||||
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
||||||
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
[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',
|
[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;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { env } from 'next-runtime-env';
|
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_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
|
||||||
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
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<string, boolean> = {
|
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||||
app_document_page_view_history_sheet: false,
|
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_header_single_player_mode: false,
|
||||||
marketing_profiles_announcement_bar: true,
|
marketing_profiles_announcement_bar: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
/// <reference types="../types/next-auth.d.ts" />
|
/// <reference types="../types/next-auth.d.ts" />
|
||||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||||
|
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||||
import { compare } from 'bcrypt';
|
import { compare } from 'bcrypt';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { AuthOptions, Session, User } from 'next-auth';
|
import type { AuthOptions, Session, User } from 'next-auth';
|
||||||
@ -12,12 +13,16 @@ import { env } from 'next-runtime-env';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||||
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
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 { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
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 { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||||
|
import { getAuthenticatorRegistrationOptions } from '../utils/authenticator';
|
||||||
import { ErrorCode } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
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: {
|
callbacks: {
|
||||||
async jwt({ token, user, trigger, account }) {
|
async jwt({ token, user, trigger, account }) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
};
|
||||||
94
packages/lib/server-only/auth/create-passkey.ts
Normal file
94
packages/lib/server-only/auth/create-passkey.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
41
packages/lib/server-only/auth/delete-passkey.ts
Normal file
41
packages/lib/server-only/auth/delete-passkey.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
71
packages/lib/server-only/auth/find-passkeys.ts
Normal file
71
packages/lib/server-only/auth/find-passkeys.ts
Normal file
@ -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<typeof data>;
|
||||||
|
};
|
||||||
51
packages/lib/server-only/auth/update-passkey.ts
Normal file
51
packages/lib/server-only/auth/update-passkey.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
44
packages/lib/types/webauthn.ts
Normal file
44
packages/lib/types/webauthn.ts
Normal file
@ -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<typeof ZAuthenticationResponseJSONSchema>;
|
||||||
|
export type TRegistrationResponseJSONSchema = z.infer<typeof ZRegistrationResponseJSONSchema>;
|
||||||
17
packages/lib/utils/authenticator.ts
Normal file
17
packages/lib/utils/authenticator.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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;
|
||||||
@ -52,6 +52,7 @@ model User {
|
|||||||
securityAuditLogs UserSecurityAuditLog[]
|
securityAuditLogs UserSecurityAuditLog[]
|
||||||
Webhooks Webhook[]
|
Webhooks Webhook[]
|
||||||
siteSettings SiteSettings[]
|
siteSettings SiteSettings[]
|
||||||
|
passkeys Passkey[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@ -68,12 +69,16 @@ enum UserSecurityAuditLogType {
|
|||||||
ACCOUNT_SSO_LINK
|
ACCOUNT_SSO_LINK
|
||||||
AUTH_2FA_DISABLE
|
AUTH_2FA_DISABLE
|
||||||
AUTH_2FA_ENABLE
|
AUTH_2FA_ENABLE
|
||||||
|
PASSKEY_CREATED
|
||||||
|
PASSKEY_DELETED
|
||||||
|
PASSKEY_UPDATED
|
||||||
PASSWORD_RESET
|
PASSWORD_RESET
|
||||||
PASSWORD_UPDATE
|
PASSWORD_UPDATE
|
||||||
SIGN_OUT
|
SIGN_OUT
|
||||||
SIGN_IN
|
SIGN_IN
|
||||||
SIGN_IN_FAIL
|
SIGN_IN_FAIL
|
||||||
SIGN_IN_2FA_FAIL
|
SIGN_IN_2FA_FAIL
|
||||||
|
SIGN_IN_PASSKEY_FAIL
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserSecurityAuditLog {
|
model UserSecurityAuditLog {
|
||||||
@ -96,6 +101,30 @@ model PasswordResetToken {
|
|||||||
User User @relation(fields: [userId], references: [id])
|
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 {
|
model VerificationToken {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
identifier String
|
identifier String
|
||||||
|
|||||||
@ -1,15 +1,30 @@
|
|||||||
|
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { env } from 'next-runtime-env';
|
import { env } from 'next-runtime-env';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
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 { 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 { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
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 { 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');
|
const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
@ -78,4 +93,131 @@ export const authRouter = router({
|
|||||||
|
|
||||||
return valid;
|
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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
||||||
|
|
||||||
export const ZCurrentPasswordSchema = z
|
export const ZCurrentPasswordSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(6, { message: 'Must be at least 6 characters in length' })
|
.min(6, { message: 'Must be at least 6 characters in length' })
|
||||||
@ -32,6 +35,29 @@ export const ZSignUpMutationSchema = z.object({
|
|||||||
.optional(),
|
.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<typeof ZSignUpMutationSchema>;
|
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||||
|
|
||||||
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });
|
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user