mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: add passkeys (#989)
## Description Add support to login with passkeys. Passkeys can be added via the user security settings page. Note: Currently left out adding the type of authentication method for the 'user security audit logs' because we're using the `signIn` next-auth event which doesn't appear to provide the context. Will look into it at another time. ## Changes Made - Add passkeys to login - Add passkeys feature flag - Add page to manage passkeys - Add audit logs relating to passkeys - Updated prisma schema to support passkeys & anonymous verification tokens ## Testing Performed To be done. MacOS: - Safari ✅ - Chrome ✅ - Firefox ✅ Windows: - Chrome [Untested] - Firefox [Untested] Linux: - Chrome [Untested] - Firefox [Untested] iOS: - Safari ✅ ## Checklist <!--- Please check the boxes that apply to this pull request. --> <!--- You can add or remove items as needed. --> - [X] I have tested these changes locally and they work as expected. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced Passkey authentication, including creation, sign-in, and management of passkeys. - Added a Passkeys section in Security Settings for managing user passkeys. - Implemented UI updates for Passkey authentication, including a new dialog for creating passkeys and a data table for managing them. - Enhanced security settings with server-side feature flags to conditionally display new security features. - **Bug Fixes** - Improved UI consistency in the Settings Security Activity Page. - Updated button styling in the 2FA Recovery Codes component for better visibility. - **Refactor** - Streamlined authentication options to include WebAuthn credentials provider. - **Chores** - Updated database schema to support passkeys and related functionality. - Added new audit log types for passkey-related activities. - Enhanced server-only authentication utilities for passkey registration and management. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@ -1,15 +1,31 @@
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { parse } from 'cookie-es';
|
||||
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 +94,126 @@ 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 AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
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 sessionIdToken = parse(ctx.req.headers.cookie ?? '')['next-auth.csrf-token'];
|
||||
|
||||
if (!sessionIdToken) {
|
||||
throw new Error('Missing CSRF token');
|
||||
}
|
||||
|
||||
const [sessionId] = decodeURI(sessionIdToken).split('|');
|
||||
|
||||
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 { 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 });
|
||||
|
||||
Reference in New Issue
Block a user