mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: add passkey and 2FA document action auth options (#1065)
## Description Add the following document action auth options: - 2FA - Passkey If the user does not have the required auth setup, we onboard them directly. ## Changes made Note: Added secondaryId to the VerificationToken schema ## Testing Performed Tested locally, pending preview tests ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have added/updated tests that prove the effectiveness of these changes. - [X] I have followed the project's coding style guidelines. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced components for 2FA, account, and passkey authentication during document signing. - Added "Require passkey" option to document settings and signer authentication settings. - Enhanced form submission and loading states for improved user experience. - **Refactor** - Optimized authentication components to efficiently support multiple authentication methods. - **Chores** - Updated and renamed functions and components for clarity and consistency across the authentication system. - Refined sorting options and database schema to support new authentication features. - **Bug Fixes** - Adjusted SignInForm to verify browser support for WebAuthn before proceeding. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@ -4,26 +4,21 @@ import { DocumentAuth } from '../types/document-auth';
|
||||
type DocumentAuthTypeData = {
|
||||
key: TDocumentAuth;
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* Whether this authentication event will require the user to halt and
|
||||
* redirect.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
isAuthRedirectRequired?: boolean;
|
||||
};
|
||||
|
||||
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
|
||||
[DocumentAuth.ACCOUNT]: {
|
||||
key: DocumentAuth.ACCOUNT,
|
||||
value: 'Require account',
|
||||
isAuthRedirectRequired: true,
|
||||
},
|
||||
// [DocumentAuthType.PASSKEY]: {
|
||||
// key: DocumentAuthType.PASSKEY,
|
||||
// value: 'Require passkey',
|
||||
// },
|
||||
[DocumentAuth.PASSKEY]: {
|
||||
key: DocumentAuth.PASSKEY,
|
||||
value: 'Require passkey',
|
||||
},
|
||||
[DocumentAuth.TWO_FACTOR_AUTH]: {
|
||||
key: DocumentAuth.TWO_FACTOR_AUTH,
|
||||
value: 'Require 2FA',
|
||||
},
|
||||
[DocumentAuth.EXPLICIT_NONE]: {
|
||||
key: DocumentAuth.EXPLICIT_NONE,
|
||||
value: 'None (Overrides global settings)',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Passkey, 'credentialId' | 'transports'> | 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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
|
||||
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
|
||||
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 = {
|
||||
@ -63,17 +68,20 @@ export const isRecipientAuthorized = async ({
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create auth options when none are passed for account.
|
||||
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
|
||||
authOptions = {
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
};
|
||||
}
|
||||
|
||||
// Authentication required does not match provided method.
|
||||
if (authOptions && authOptions.type !== authMethod) {
|
||||
if (!authOptions || authOptions.type !== authMethod || !userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await match(authMethod)
|
||||
.with(DocumentAuth.ACCOUNT, async () => {
|
||||
if (userId === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await match(authOptions)
|
||||
.with({ type: DocumentAuth.ACCOUNT }, async () => {
|
||||
const recipientUser = await getUserByEmail(recipient.email);
|
||||
|
||||
if (!recipientUser) {
|
||||
@ -82,5 +90,124 @@ export const isRecipientAuthorized = async ({
|
||||
|
||||
return recipientUser.id === userId;
|
||||
})
|
||||
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
|
||||
return await isPasskeyAuthValid({
|
||||
userId,
|
||||
authenticationResponse,
|
||||
tokenReference,
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not be possible.
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
|
||||
}
|
||||
|
||||
return await verifyTwoFactorAuthenticationToken({
|
||||
user,
|
||||
totpCode: token,
|
||||
});
|
||||
})
|
||||
.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 the provided passkey authenticator response is valid and the user is
|
||||
* authenticated.
|
||||
*/
|
||||
const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise<boolean> => {
|
||||
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<void> => {
|
||||
const passkey = await prisma.passkey.findFirst({
|
||||
where: {
|
||||
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken
|
||||
.delete({
|
||||
where: {
|
||||
userId,
|
||||
secondaryId: tokenReference,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
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',
|
||||
'TWO_FACTOR_AUTH',
|
||||
'EXPLICIT_NONE',
|
||||
]);
|
||||
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
||||
|
||||
const ZDocumentAuthAccountSchema = z.object({
|
||||
@ -14,12 +21,25 @@ 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),
|
||||
});
|
||||
|
||||
const ZDocumentAuth2FASchema = z.object({
|
||||
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
|
||||
token: z.string().min(4).max(10),
|
||||
});
|
||||
|
||||
/**
|
||||
* All the document auth methods for both accessing and actioning.
|
||||
*/
|
||||
export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
@ -35,8 +55,16 @@ 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,
|
||||
ZDocumentAuth2FASchema,
|
||||
]);
|
||||
export const ZDocumentActionAuthTypesSchema = z.enum([
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The recipient access auth methods.
|
||||
@ -54,11 +82,15 @@ 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,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
]);
|
||||
export const ZRecipientActionAuthTypesSchema = z.enum([
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXPLICIT_NONE,
|
||||
]);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user