mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 01:32:06 +10:00
Merge branch 'main' of https://github.com/documenso/documenso into document-super-delete#1020
This commit is contained in:
@ -16,10 +16,24 @@ 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;
|
||||
|
||||
/**
|
||||
* The maximum number of passkeys are user can have.
|
||||
*/
|
||||
export const MAXIMUM_PASSKEYS = 50;
|
||||
|
||||
@ -13,7 +13,7 @@ export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'YYYYMMDD',
|
||||
label: 'YYYY-MM-DD',
|
||||
value: 'YYYY-MM-DD',
|
||||
value: 'yyyy-MM-dd',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYY',
|
||||
|
||||
@ -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;
|
||||
|
||||
25
packages/lib/constants/trpc.ts
Normal file
25
packages/lib/constants/trpc.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* For TRPC useQueries that should not be batched with other queries.
|
||||
*/
|
||||
export const SKIP_QUERY_BATCH_META = {
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* For TRPC useQueries and useMutations to adjust the logic on when query invalidation
|
||||
* should occur.
|
||||
*
|
||||
* When used in:
|
||||
* - useQuery: Will not invalidate the given query when a mutation occurs.
|
||||
* - useMutation: Will not trigger invalidation on all queries when mutation succeeds.
|
||||
*
|
||||
*/
|
||||
export const DO_NOT_INVALIDATE_QUERY_ON_MUTATION = {
|
||||
meta: {
|
||||
doNotInvalidateQueryOnMutation: true,
|
||||
},
|
||||
};
|
||||
@ -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,113 @@ 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);
|
||||
|
||||
if (!verification?.verified) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, account }) {
|
||||
|
||||
@ -1,40 +1,30 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
token: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
token,
|
||||
user,
|
||||
password,
|
||||
requestMetadata,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
|
||||
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
@ -17,25 +17,38 @@ export const enableTwoFactorAuthentication = async ({
|
||||
code,
|
||||
requestMetadata,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
|
||||
throw new AppError('TWO_FACTOR_ALREADY_ENABLED');
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
throw new AppError('TWO_FACTOR_SETUP_REQUIRED');
|
||||
}
|
||||
|
||||
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
|
||||
|
||||
if (!isValidToken) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.$transaction(async (tx) => {
|
||||
let recoveryCodes: string[] = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updatedUser = await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
recoveryCodes = getBackupCodes({ user: updatedUser }) ?? [];
|
||||
|
||||
if (recoveryCodes.length === 0) {
|
||||
throw new AppError('MISSING_BACKUP_CODE');
|
||||
}
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@ -44,18 +57,7 @@ export const enableTwoFactorAuthentication = async ({
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
return { recoveryCodes };
|
||||
};
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { base32 } from '@scure/base';
|
||||
import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
@ -12,14 +11,12 @@ import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
type SetupTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const ISSUER = 'Documenso';
|
||||
|
||||
export const setupTwoFactorAuthentication = async ({
|
||||
user,
|
||||
password,
|
||||
}: SetupTwoFactorAuthenticationOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
@ -27,20 +24,6 @@ export const setupTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(10);
|
||||
|
||||
const backupCodes = Array.from({ length: 10 })
|
||||
|
||||
30
packages/lib/server-only/2fa/view-backup-codes.ts
Normal file
30
packages/lib/server-only/2fa/view-backup-codes.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type ViewBackupCodesOptions = {
|
||||
user: User;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => {
|
||||
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
|
||||
|
||||
if (!isValid) {
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
const backupCodes = getBackupCodes({ user });
|
||||
|
||||
if (!backupCodes) {
|
||||
throw new AppError('MISSING_BACKUP_CODE');
|
||||
}
|
||||
|
||||
return backupCodes;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
106
packages/lib/server-only/auth/create-passkey.ts
Normal file
106
packages/lib/server-only/auth/create-passkey.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server';
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
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';
|
||||
|
||||
type CreatePasskeyOptions = {
|
||||
userId: number;
|
||||
passkeyName: string;
|
||||
verificationResponse: RegistrationResponseJSON;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const createPasskey = async ({
|
||||
userId,
|
||||
passkeyName,
|
||||
verificationResponse,
|
||||
requestMetadata,
|
||||
}: CreatePasskeyOptions) => {
|
||||
const { _count } = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
passkeys: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (_count.passkeys >= MAXIMUM_PASSKEYS) {
|
||||
throw new AppError('TOO_MANY_PASSKEYS');
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -1,13 +1,30 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
export type GetDocumentByTokenOptions = {
|
||||
token: string;
|
||||
}
|
||||
};
|
||||
|
||||
export interface GetDocumentAndRecipientByTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
export type GetDocumentAndSenderByTokenOptions = GetDocumentByTokenOptions;
|
||||
export type GetDocumentAndRecipientByTokenOptions = GetDocumentByTokenOptions;
|
||||
|
||||
export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getDocumentAndSenderByToken = async ({
|
||||
token,
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type GetDocumentWithDetailsByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const getDocumentWithDetailsById = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetDocumentWithDetailsByIdOptions): Promise<DocumentWithDetails> => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
documentId: id,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
Field: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -24,6 +24,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
include: {
|
||||
documentData: true,
|
||||
Recipient: true,
|
||||
User: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -35,61 +42,116 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const buffer = await getFile(document.documentData);
|
||||
const { User: owner } = document;
|
||||
|
||||
const completedDocument = await getFile(document.documentData);
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/documents/${document.id}`;
|
||||
|
||||
if (document.team?.url) {
|
||||
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${document.team.url}/documents/${
|
||||
document.id
|
||||
}`;
|
||||
}
|
||||
|
||||
// If the document owner is not a recipient then send the email to them separately
|
||||
if (!document.Recipient.find((recipient) => recipient.email === owner.email)) {
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: documentOwnerDownloadLink,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: [
|
||||
{
|
||||
name: owner.name || '',
|
||||
address: owner.email,
|
||||
},
|
||||
],
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
content: Buffer.from(completedDocument),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: 'DOCUMENT_COMPLETED',
|
||||
recipientEmail: owner.email,
|
||||
recipientName: owner.name,
|
||||
recipientId: owner.id,
|
||||
recipientRole: 'OWNER',
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name, token } = recipient;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
|
||||
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
|
||||
downloadLink: recipient.email === owner.email ? documentOwnerDownloadLink : downloadLink,
|
||||
});
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
content: Buffer.from(buffer),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: 'DOCUMENT_COMPLETED',
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
await mailer.sendMail({
|
||||
to: [
|
||||
{
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
],
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
content: Buffer.from(completedDocument),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: 'DOCUMENT_COMPLETED',
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
diffFieldChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType } from '@documenso/prisma/client';
|
||||
import type { Field, FieldType } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
@ -29,7 +29,7 @@ export const setFieldsForDocument = async ({
|
||||
documentId,
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: SetFieldsForDocumentOptions) => {
|
||||
}: SetFieldsForDocumentOptions): Promise<Field[]> => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
@ -99,7 +99,7 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(async (tx) => {
|
||||
await Promise.all(
|
||||
return await Promise.all(
|
||||
linkedFields.map(async (field) => {
|
||||
const fieldSignerEmail = field.signerEmail.toLowerCase();
|
||||
|
||||
@ -218,5 +218,13 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return persistedFields;
|
||||
// Filter out fields that have been removed or have been updated.
|
||||
const filteredFields = existingFields.filter((field) => {
|
||||
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
|
||||
return [...filteredFields, ...persistedFields];
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
diffRecipientChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
@ -28,7 +29,7 @@ export const setRecipientsForDocument = async ({
|
||||
documentId,
|
||||
recipients,
|
||||
requestMetadata,
|
||||
}: SetRecipientsForDocumentOptions) => {
|
||||
}: SetRecipientsForDocumentOptions): Promise<Recipient[]> => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
@ -226,5 +227,17 @@ export const setRecipientsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return persistedRecipients;
|
||||
// Filter out recipients that have been removed or have been updated.
|
||||
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
|
||||
const isRemoved = removedRecipients.find(
|
||||
(removedRecipient) => removedRecipient.id === recipient.id,
|
||||
);
|
||||
const isUpdated = persistedRecipients.find(
|
||||
(persistedRecipient) => persistedRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
|
||||
return [...filteredRecipients, ...persistedRecipients];
|
||||
};
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetCompletedDocumentsMonthlyResult = Array<{
|
||||
month: string;
|
||||
count: number;
|
||||
cume_count: number;
|
||||
}>;
|
||||
|
||||
type GetCompletedDocumentsMonthlyQueryResult = Array<{
|
||||
month: Date;
|
||||
count: bigint;
|
||||
cume_count: bigint;
|
||||
}>;
|
||||
|
||||
export const getCompletedDocumentsMonthly = async () => {
|
||||
const result = await prisma.$queryRaw<GetCompletedDocumentsMonthlyQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "updatedAt") AS "month",
|
||||
COUNT("id") as "count",
|
||||
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "updatedAt")) as "cume_count"
|
||||
FROM "Document"
|
||||
WHERE "status" = 'COMPLETED'
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
cume_count: Number(row.cume_count),
|
||||
}));
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user