fix: squish passkeys

This commit is contained in:
David Nguyen
2024-03-17 14:20:20 +08:00
parent 5033799724
commit cb020cc7d0
26 changed files with 1686 additions and 29 deletions

View File

@ -16,10 +16,19 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
[UserSecurityAuditLogType.PASSKEY_CREATED]: 'Passkey created',
[UserSecurityAuditLogType.PASSKEY_DELETED]: 'Passkey deleted',
[UserSecurityAuditLogType.PASSKEY_UPDATED]: 'Passkey updated',
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
[UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL]: 'Passkey sign in failed',
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
};
/**
* The duration to wait for a passkey to be verified in MS.
*/
export const PASSKEY_TIMEOUT = 60000;

View File

@ -1,6 +1,6 @@
import { env } from 'next-runtime-env';
import { APP_BASE_URL } from './app';
import { APP_BASE_URL, WEBAPP_BASE_URL } from './app';
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
@ -23,6 +23,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
marketing_header_single_player_mode: false,
marketing_profiles_announcement_bar: true,
} as const;

View File

@ -1,6 +1,7 @@
/// <reference types="../types/next-auth.d.ts" />
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { compare } from '@node-rs/bcrypt';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { DateTime } from 'luxon';
import type { AuthOptions, Session, User } from 'next-auth';
import type { JWT } from 'next-auth/jwt';
@ -12,12 +13,16 @@ import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../errors/app-error';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { getAuthenticatorRegistrationOptions } from '../utils/authenticator';
import { ErrorCode } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = {
@ -131,6 +136,114 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
};
},
}),
CredentialsProvider({
id: 'webauthn',
name: 'Keypass',
credentials: {
csrfToken: { label: 'csrfToken', type: 'csrfToken' },
},
async authorize(credentials, req) {
const csrfToken = credentials?.csrfToken;
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
try {
const parsedBodyCredential = JSON.parse(req.body?.credential);
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
const challengeToken = await prisma.anonymousVerificationToken
.delete({
where: {
id: csrfToken,
},
})
.catch(() => null);
if (!challengeToken) {
return null;
}
if (challengeToken.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
},
include: {
User: {
select: {
id: true,
email: true,
name: true,
emailVerified: true,
},
},
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const user = passkey.User;
const { rpId, origin } = getAuthenticatorRegistrationOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
const requestMetadata = extractNextAuthRequestMetadata(req);
// Explicit success state to reduce chances of bugs.
if (verification?.verified === true) {
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
return {
id: Number(user.id),
email: user.email,
name: user.name,
emailVerified: user.emailVerified?.toISOString() ?? null,
} satisfies User;
}
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
},
});
return null;
},
}),
],
callbacks: {
async jwt({ token, user, trigger, account }) {

View File

@ -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;
};

View File

@ -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;
};

View 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,
},
});
});
};

View 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,
},
});
});
};

View 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>;
};

View 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,
},
});
});
};

View 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>;

View 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,
};
};

View File

@ -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;

View File

@ -52,6 +52,7 @@ model User {
securityAuditLogs UserSecurityAuditLog[]
Webhooks Webhook[]
siteSettings SiteSettings[]
passkeys Passkey[]
@@index([email])
}
@ -68,12 +69,16 @@ enum UserSecurityAuditLogType {
ACCOUNT_SSO_LINK
AUTH_2FA_DISABLE
AUTH_2FA_ENABLE
PASSKEY_CREATED
PASSKEY_DELETED
PASSKEY_UPDATED
PASSWORD_RESET
PASSWORD_UPDATE
SIGN_OUT
SIGN_IN
SIGN_IN_FAIL
SIGN_IN_2FA_FAIL
SIGN_IN_PASSKEY_FAIL
}
model UserSecurityAuditLog {
@ -96,6 +101,30 @@ model PasswordResetToken {
User User @relation(fields: [userId], references: [id])
}
model Passkey {
id String @id @default(cuid())
userId Int
name String
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
lastUsedAt DateTime?
credentialId Bytes
credentialPublicKey Bytes
counter BigInt
credentialDeviceType String
credentialBackedUp Boolean
transports String[]
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model AnonymousVerificationToken {
id String @id @unique @default(cuid())
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}
model VerificationToken {
id Int @id @default(autoincrement())
identifier String

View File

@ -1,15 +1,30 @@
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import { TRPCError } from '@trpc/server';
import { env } from 'next-runtime-env';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options';
import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc';
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
import {
ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema,
ZSignUpMutationSchema,
ZUpdatePasskeyMutationSchema,
ZVerifyPasswordMutationSchema,
} from './schema';
const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP');
@ -78,4 +93,131 @@ export const authRouter = router({
return valid;
}),
createPasskey: authenticatedProcedure
.input(ZCreatePasskeyMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const verificationResponse = input.verificationResponse as RegistrationResponseJSON;
return await createPasskey({
userId: ctx.user.id,
verificationResponse,
passkeyName: input.passkeyName,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this passkey. Please try again later.',
});
}
}),
createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => {
try {
return await createPasskeyRegistrationOptions({
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to create the registration options for the passkey. Please try again later.',
});
}
}),
createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => {
const cookie = ctx.req.headers.cookie ?? '';
const sessionIdToken = cookie?.split(';').find((c) => c.includes('next-auth.csrf-token'));
if (!sessionIdToken) {
throw new Error('Missing CSRF token');
}
const sessionId = decodeURI(sessionIdToken.split('=')[1]).split('|')[0];
try {
return await createPasskeySigninOptions({ sessionId });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create the options for passkey signin. Please try again later.',
});
}
}),
deletePasskey: authenticatedProcedure
.input(ZDeletePasskeyMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const { passkeyId } = input;
await deletePasskey({
userId: ctx.user.id,
passkeyId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to delete this passkey. Please try again later.',
});
}
}),
findPasskeys: authenticatedProcedure
.input(ZFindPasskeysQuerySchema)
.query(async ({ input, ctx }) => {
try {
const { page, perPage, orderBy } = input;
return await findPasskeys({
page,
perPage,
orderBy,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find passkeys. Please try again later.',
});
}
}),
updatePasskey: authenticatedProcedure
.input(ZUpdatePasskeyMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const { passkeyId, name } = input;
await updatePasskey({
userId: ctx.user.id,
passkeyId,
name,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to update this passkey. Please try again later.',
});
}
}),
});

View File

@ -1,5 +1,8 @@
import { z } from 'zod';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
@ -32,6 +35,29 @@ export const ZSignUpMutationSchema = z.object({
.optional(),
});
export const ZCreatePasskeyMutationSchema = z.object({
passkeyName: z.string().trim().min(1),
verificationResponse: ZRegistrationResponseJSONSchema,
});
export const ZDeletePasskeyMutationSchema = z.object({
passkeyId: z.string().trim().min(1),
});
export const ZUpdatePasskeyMutationSchema = z.object({
passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1),
});
export const ZFindPasskeysQuerySchema = ZBaseTableSearchParamsSchema.extend({
orderBy: z
.object({
column: z.enum(['createdAt', 'updatedAt', 'name']),
direction: z.enum(['asc', 'desc']),
})
.optional(),
});
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });