+
+
+ Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
+ this {actionTarget.toLowerCase()}.
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ );
+};
diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx
index 986cfc12e..ef771f19e 100644
--- a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx
@@ -1,9 +1,10 @@
'use client';
-import { createContext, useContext, useMemo, useState } from 'react';
+import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { match } from 'ts-pattern';
+import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import type {
TDocumentAuthOptions,
@@ -13,11 +14,19 @@ import type {
} from '@documenso/lib/types/document-auth';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
-import type { Document, Recipient, User } from '@documenso/prisma/client';
+import type { Document, Passkey, Recipient, User } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog';
import { DocumentActionAuthDialog } from './document-action-auth-dialog';
+type PasskeyData = {
+ passkeys: Omit[];
+ isLoading: boolean;
+ isInitialLoading: boolean;
+ isLoadingError: boolean;
+};
+
export type DocumentAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise;
document: Document;
@@ -29,6 +38,11 @@ export type DocumentAuthContextValue = {
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
isAuthRedirectRequired: boolean;
+ isCurrentlyAuthenticating: boolean;
+ setIsCurrentlyAuthenticating: (_value: boolean) => void;
+ passkeyData: PasskeyData;
+ preferredPasskeyId: string | null;
+ setPreferredPasskeyId: (_value: string | null) => void;
user?: User | null;
};
@@ -64,6 +78,26 @@ export const DocumentAuthProvider = ({
const [document, setDocument] = useState(initialDocument);
const [recipient, setRecipient] = useState(initialRecipient);
+ const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
+ const [preferredPasskeyId, setPreferredPasskeyId] = useState(null);
+
+ const passkeyQuery = trpc.auth.findPasskeys.useQuery(
+ {
+ perPage: MAXIMUM_PASSKEYS,
+ },
+ {
+ keepPreviousData: true,
+ enabled: false,
+ },
+ );
+
+ const passkeyData: PasskeyData = {
+ passkeys: passkeyQuery.data?.data || [],
+ isLoading: passkeyQuery.isLoading,
+ isInitialLoading: passkeyQuery.isInitialLoading,
+ isLoadingError: passkeyQuery.isLoadingError,
+ };
+
const {
documentAuthOption,
recipientAuthOption,
@@ -78,6 +112,24 @@ export const DocumentAuthProvider = ({
[document, recipient],
);
+ /**
+ * By default, select the first passkey since it's pre sorted by most recently used.
+ */
+ useEffect(() => {
+ if (!preferredPasskeyId && passkeyQuery.data && passkeyQuery.data.data.length > 0) {
+ setPreferredPasskeyId(passkeyQuery.data.data[0].id);
+ }
+ }, [passkeyQuery.data, preferredPasskeyId]);
+
+ /**
+ * Only fetch passkeys if required.
+ */
+ useEffect(() => {
+ if (derivedRecipientActionAuth === DocumentAuth.PASSKEY) {
+ void passkeyQuery.refetch();
+ }
+ }, [derivedRecipientActionAuth, passkeyQuery]);
+
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
useState(null);
@@ -101,7 +153,7 @@ export const DocumentAuthProvider = ({
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
- .with(null, () => null)
+ .with(DocumentAuth.PASSKEY, null, () => null)
.exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
@@ -111,7 +163,7 @@ export const DocumentAuthProvider = ({
return;
}
- // Run callback with precalculated auth options if avaliable.
+ // Run callback with precalculated auth options if available.
if (preCalculatedActionAuthOptions) {
setDocumentAuthDialogPayload(null);
await options.onReauthFormSubmit(preCalculatedActionAuthOptions);
@@ -143,6 +195,11 @@ export const DocumentAuthProvider = ({
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
isAuthRedirectRequired,
+ isCurrentlyAuthenticating,
+ setIsCurrentlyAuthenticating,
+ passkeyData,
+ preferredPasskeyId,
+ setPreferredPasskeyId,
}}
>
{children}
diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
index 78a591505..f5e598dab 100644
--- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
@@ -30,7 +30,7 @@ export type SignatureFieldProps = {
/**
* The function required to be executed to insert the field.
*
- * The auth values will be passed in if avaliable.
+ * The auth values will be passed in if available.
*/
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise | void;
onRemove?: () => Promise | void;
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index 6fa5492ac..8d4dd7cd0 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -124,7 +124,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
};
const onSignInWithPasskey = async () => {
- if (!browserSupportsWebAuthn) {
+ if (!browserSupportsWebAuthn()) {
toast({
title: 'Not supported',
description: 'Passkeys are not supported on this browser',
diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts
index 81f22236e..af40a45df 100644
--- a/packages/lib/constants/document-auth.ts
+++ b/packages/lib/constants/document-auth.ts
@@ -20,10 +20,10 @@ export const DOCUMENT_AUTH_TYPES: Record = {
value: 'Require account',
isAuthRedirectRequired: true,
},
- // [DocumentAuthType.PASSKEY]: {
- // key: DocumentAuthType.PASSKEY,
- // value: 'Require passkey',
- // },
+ [DocumentAuth.PASSKEY]: {
+ key: DocumentAuth.PASSKEY,
+ value: 'Require passkey',
+ },
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',
diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts
index a88797910..6b475932e 100644
--- a/packages/lib/next-auth/auth-options.ts
+++ b/packages/lib/next-auth/auth-options.ts
@@ -22,7 +22,7 @@ import { sendConfirmationToken } from '../server-only/user/send-confirmation-tok
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
-import { getAuthenticatorRegistrationOptions } from '../utils/authenticator';
+import { getAuthenticatorOptions } from '../utils/authenticator';
import { ErrorCode } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = {
@@ -196,7 +196,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const user = passkey.User;
- const { rpId, origin } = getAuthenticatorRegistrationOptions();
+ const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
diff --git a/packages/lib/server-only/auth/create-passkey-authentication-options.ts b/packages/lib/server-only/auth/create-passkey-authentication-options.ts
new file mode 100644
index 000000000..e7c4178d6
--- /dev/null
+++ b/packages/lib/server-only/auth/create-passkey-authentication-options.ts
@@ -0,0 +1,76 @@
+import { generateAuthenticationOptions } from '@simplewebauthn/server';
+import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
+import { DateTime } from 'luxon';
+
+import { prisma } from '@documenso/prisma';
+import type { Passkey } from '@documenso/prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { getAuthenticatorOptions } from '../../utils/authenticator';
+
+type CreatePasskeyAuthenticationOptions = {
+ userId: number;
+
+ /**
+ * The ID of the passkey to request authentication for.
+ *
+ * If not set, we allow the browser client to handle choosing.
+ */
+ preferredPasskeyId?: string;
+};
+
+export const createPasskeyAuthenticationOptions = async ({
+ userId,
+ preferredPasskeyId,
+}: CreatePasskeyAuthenticationOptions) => {
+ const { rpId, timeout } = getAuthenticatorOptions();
+
+ let preferredPasskey: Pick | null = null;
+
+ if (preferredPasskeyId) {
+ preferredPasskey = await prisma.passkey.findFirst({
+ where: {
+ userId,
+ id: preferredPasskeyId,
+ },
+ select: {
+ credentialId: true,
+ transports: true,
+ },
+ });
+
+ if (!preferredPasskey) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found');
+ }
+ }
+
+ const options = await generateAuthenticationOptions({
+ rpID: rpId,
+ userVerification: 'preferred',
+ timeout,
+ allowCredentials: preferredPasskey
+ ? [
+ {
+ id: preferredPasskey.credentialId,
+ type: 'public-key',
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ transports: preferredPasskey.transports as AuthenticatorTransportFuture[],
+ },
+ ]
+ : undefined,
+ });
+
+ const { secondaryId } = await prisma.verificationToken.create({
+ data: {
+ userId,
+ token: options.challenge,
+ expires: DateTime.now().plus({ minutes: 2 }).toJSDate(),
+ identifier: 'PASSKEY_CHALLENGE',
+ },
+ });
+
+ return {
+ tokenReference: secondaryId,
+ options,
+ };
+};
diff --git a/packages/lib/server-only/auth/create-passkey-registration-options.ts b/packages/lib/server-only/auth/create-passkey-registration-options.ts
index 5c9d73b8a..8f2b3d53a 100644
--- a/packages/lib/server-only/auth/create-passkey-registration-options.ts
+++ b/packages/lib/server-only/auth/create-passkey-registration-options.ts
@@ -5,7 +5,7 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { PASSKEY_TIMEOUT } from '../../constants/auth';
-import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
+import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyRegistrationOptions = {
userId: number;
@@ -27,7 +27,7 @@ export const createPasskeyRegistrationOptions = async ({
const { passkeys } = user;
- const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions();
+ const { rpName, rpId: rpID } = getAuthenticatorOptions();
const options = await generateRegistrationOptions({
rpName,
diff --git a/packages/lib/server-only/auth/create-passkey-signin-options.ts b/packages/lib/server-only/auth/create-passkey-signin-options.ts
index 03241edd0..e6f9a7152 100644
--- a/packages/lib/server-only/auth/create-passkey-signin-options.ts
+++ b/packages/lib/server-only/auth/create-passkey-signin-options.ts
@@ -3,14 +3,14 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
-import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
+import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeySigninOptions = {
sessionId: string;
};
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
- const { rpId, timeout } = getAuthenticatorRegistrationOptions();
+ const { rpId, timeout } = getAuthenticatorOptions();
const options = await generateAuthenticationOptions({
rpID: rpId,
diff --git a/packages/lib/server-only/auth/create-passkey.ts b/packages/lib/server-only/auth/create-passkey.ts
index c493d8205..0ec86845d 100644
--- a/packages/lib/server-only/auth/create-passkey.ts
+++ b/packages/lib/server-only/auth/create-passkey.ts
@@ -7,7 +7,7 @@ import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { MAXIMUM_PASSKEYS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
-import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
+import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyOptions = {
userId: number;
@@ -64,7 +64,7 @@ export const createPasskey = async ({
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
}
- const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions();
+ const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
const verification = await verifyRegistrationResponse({
response: verificationResponse,
diff --git a/packages/lib/server-only/auth/find-passkeys.ts b/packages/lib/server-only/auth/find-passkeys.ts
index 26eac95c3..8f21c8aa6 100644
--- a/packages/lib/server-only/auth/find-passkeys.ts
+++ b/packages/lib/server-only/auth/find-passkeys.ts
@@ -11,6 +11,7 @@ export interface FindPasskeysOptions {
orderBy?: {
column: keyof Passkey;
direction: 'asc' | 'desc';
+ nulls?: Prisma.NullsOrder;
};
}
@@ -21,8 +22,9 @@ export const findPasskeys = async ({
perPage = 10,
orderBy,
}: FindPasskeysOptions) => {
- const orderByColumn = orderBy?.column ?? 'name';
+ const orderByColumn = orderBy?.column ?? 'lastUsedAt';
const orderByDirection = orderBy?.direction ?? 'desc';
+ const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last';
const whereClause: Prisma.PasskeyWhereInput = {
userId,
@@ -41,7 +43,10 @@ export const findPasskeys = async ({
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
- [orderByColumn]: orderByDirection,
+ [orderByColumn]: {
+ sort: orderByDirection,
+ nulls: orderByNulls,
+ },
},
select: {
id: true,
diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts
index bf595fa9b..3a92d3103 100644
--- a/packages/lib/server-only/document/is-recipient-authorized.ts
+++ b/packages/lib/server-only/document/is-recipient-authorized.ts
@@ -1,10 +1,14 @@
+import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Recipient } from '@documenso/prisma/client';
+import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
+import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn';
+import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
@@ -64,12 +68,12 @@ export const isRecipientAuthorized = async ({
}
// Authentication required does not match provided method.
- if (authOptions && authOptions.type !== authMethod) {
+ if (!authOptions || authOptions.type !== authMethod) {
return false;
}
- return await match(authMethod)
- .with(DocumentAuth.ACCOUNT, async () => {
+ return await match(authOptions)
+ .with({ type: DocumentAuth.ACCOUNT }, async () => {
if (userId === undefined) {
return false;
}
@@ -82,5 +86,117 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId;
})
+ .with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
+ if (!userId) {
+ return false;
+ }
+
+ return await isPasskeyAuthValid({
+ userId,
+ authenticationResponse,
+ tokenReference,
+ });
+ })
.exhaustive();
};
+
+type VerifyPasskeyOptions = {
+ /**
+ * The ID of the user who initiated the request.
+ */
+ userId: number;
+
+ /**
+ * The secondary ID of the verification token.
+ */
+ tokenReference: string;
+
+ /**
+ * The response from the passkey authenticator.
+ */
+ authenticationResponse: TAuthenticationResponseJSONSchema;
+
+ /**
+ * Whether to throw errors when the user fails verification instead of returning
+ * false.
+ */
+ throwError?: boolean;
+};
+
+/**
+ * Whether the provided passkey authenticator response is valid and the user is
+ * authenticated.
+ */
+const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise => {
+ return verifyPasskey(options)
+ .then(() => true)
+ .catch(() => false);
+};
+
+/**
+ * Verifies whether the provided passkey authenticator is valid and the user is
+ * authenticated.
+ *
+ * Will throw an error if the user should not be authenticated.
+ */
+const verifyPasskey = async ({
+ userId,
+ tokenReference,
+ authenticationResponse,
+}: VerifyPasskeyOptions): Promise => {
+ const passkey = await prisma.passkey.findFirst({
+ where: {
+ credentialId: Buffer.from(authenticationResponse.id, 'base64'),
+ userId,
+ },
+ });
+
+ const verificationToken = await prisma.verificationToken
+ .delete({
+ where: {
+ userId,
+ secondaryId: tokenReference,
+ },
+ })
+ .catch(() => null);
+
+ if (!passkey) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
+ }
+
+ if (!verificationToken) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
+ }
+
+ if (verificationToken.expires < new Date()) {
+ throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
+ }
+
+ const { rpId, origin } = getAuthenticatorOptions();
+
+ const verification = await verifyAuthenticationResponse({
+ response: authenticationResponse,
+ expectedChallenge: verificationToken.token,
+ expectedOrigin: origin,
+ expectedRPID: rpId,
+ authenticator: {
+ credentialID: new Uint8Array(Array.from(passkey.credentialId)),
+ credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
+ counter: Number(passkey.counter),
+ },
+ }).catch(() => null); // May want to log this for insights.
+
+ if (verification?.verified !== true) {
+ throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
+ }
+
+ await prisma.passkey.update({
+ where: {
+ id: passkey.id,
+ },
+ data: {
+ lastUsedAt: new Date(),
+ counter: verification.authenticationInfo.newCounter,
+ },
+ });
+};
diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts
index 730806d0c..d44a17bb0 100644
--- a/packages/lib/types/document-auth.ts
+++ b/packages/lib/types/document-auth.ts
@@ -1,9 +1,11 @@
import { z } from 'zod';
+import { ZAuthenticationResponseJSONSchema } from './webauthn';
+
/**
* All the available types of document authentication options for both access and action.
*/
-export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']);
+export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'EXPLICIT_NONE']);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({
@@ -14,12 +16,19 @@ const ZDocumentAuthExplicitNoneSchema = z.object({
type: z.literal(DocumentAuth.EXPLICIT_NONE),
});
+const ZDocumentAuthPasskeySchema = z.object({
+ type: z.literal(DocumentAuth.PASSKEY),
+ authenticationResponse: ZAuthenticationResponseJSONSchema,
+ tokenReference: z.string().min(1),
+});
+
/**
* All the document auth methods for both accessing and actioning.
*/
export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthExplicitNoneSchema,
+ ZDocumentAuthPasskeySchema,
]);
/**
@@ -35,8 +44,11 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
*
* Must keep these two in sync.
*/
-export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here.
-export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
+export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
+ ZDocumentAuthAccountSchema,
+ ZDocumentAuthPasskeySchema,
+]);
+export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY]);
/**
* The recipient access auth methods.
@@ -54,11 +66,13 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
* Must keep these two in sync.
*/
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
- ZDocumentAuthAccountSchema, // Todo: Add passkeys here.
+ ZDocumentAuthAccountSchema,
+ ZDocumentAuthPasskeySchema,
ZDocumentAuthExplicitNoneSchema,
]);
export const ZRecipientActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
+ DocumentAuth.PASSKEY,
DocumentAuth.EXPLICIT_NONE,
]);
diff --git a/packages/lib/utils/authenticator.ts b/packages/lib/utils/authenticator.ts
index b5563a4ed..b689d82e9 100644
--- a/packages/lib/utils/authenticator.ts
+++ b/packages/lib/utils/authenticator.ts
@@ -4,7 +4,7 @@ import { PASSKEY_TIMEOUT } from '../constants/auth';
/**
* Extracts common fields to identify the RP (relying party)
*/
-export const getAuthenticatorRegistrationOptions = () => {
+export const getAuthenticatorOptions = () => {
const webAppBaseUrl = new URL(WEBAPP_BASE_URL);
const rpId = webAppBaseUrl.hostname;
diff --git a/packages/prisma/migrations/20240317092548_add_verification_secondary_id/migration.sql b/packages/prisma/migrations/20240317092548_add_verification_secondary_id/migration.sql
new file mode 100644
index 000000000..4e6d6e227
--- /dev/null
+++ b/packages/prisma/migrations/20240317092548_add_verification_secondary_id/migration.sql
@@ -0,0 +1,12 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[secondaryId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail.
+ - The required column `secondaryId` was added to the `VerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
+
+*/
+-- AlterTable
+ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT NOT NULL;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId");
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index d632ae60e..868b8d8e1 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -126,13 +126,14 @@ model AnonymousVerificationToken {
}
model VerificationToken {
- id Int @id @default(autoincrement())
- identifier String
- token String @unique
- expires DateTime
- createdAt DateTime @default(now())
- userId Int
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ id Int @id @default(autoincrement())
+ secondaryId String @unique @default(cuid())
+ identifier String
+ token String @unique
+ expires DateTime
+ createdAt DateTime @default(now())
+ userId Int
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum WebhookTriggerEvents {
diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts
index 86d0e3491..02b12424d 100644
--- a/packages/trpc/server/auth-router/router.ts
+++ b/packages/trpc/server/auth-router/router.ts
@@ -6,6 +6,7 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
+import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options';
import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
@@ -18,6 +19,7 @@ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
+ ZCreatePasskeyAuthenticationOptionsMutationSchema,
ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema,
@@ -114,6 +116,25 @@ export const authRouter = router({
}
}),
+ createPasskeyAuthenticationOptions: authenticatedProcedure
+ .input(ZCreatePasskeyAuthenticationOptionsMutationSchema)
+ .mutation(async ({ ctx, input }) => {
+ try {
+ return await createPasskeyAuthenticationOptions({
+ userId: ctx.user.id,
+ preferredPasskeyId: input?.preferredPasskeyId,
+ });
+ } catch (err) {
+ console.error(err);
+
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message:
+ 'We were unable to create the authentication options for the passkey. Please try again later.',
+ });
+ }
+ }),
+
createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => {
try {
return await createPasskeyRegistrationOptions({
diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts
index d78b429fc..b84c5e1c9 100644
--- a/packages/trpc/server/auth-router/schema.ts
+++ b/packages/trpc/server/auth-router/schema.ts
@@ -40,6 +40,12 @@ export const ZCreatePasskeyMutationSchema = z.object({
verificationResponse: ZRegistrationResponseJSONSchema,
});
+export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z
+ .object({
+ preferredPasskeyId: z.string().optional(),
+ })
+ .optional();
+
export const ZDeletePasskeyMutationSchema = z.object({
passkeyId: z.string().trim().min(1),
});
diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx
index 6e3cd190f..8f09749ba 100644
--- a/packages/ui/primitives/document-flow/add-settings.tsx
+++ b/packages/ui/primitives/document-flow/add-settings.tsx
@@ -214,6 +214,10 @@ export const AddSettingsFormPartial = ({
Require account - The recipient must be signed in
+
+ Require passkey - The recipient must have an account
+ and passkey configured via their settings
+
None - No authentication required
diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx
index 8e174e578..2e50f1a90 100644
--- a/packages/ui/primitives/document-flow/add-signers.tsx
+++ b/packages/ui/primitives/document-flow/add-signers.tsx
@@ -280,6 +280,10 @@ export const AddSignersFormPartial = ({
Require account - The recipient must be
signed in
+
+ Require passkey - The recipient must have
+ an account and passkey configured via their settings
+