refactor(signing-2fa): simplify server-side and UI code for external 2FA

- Extract throwVerificationError helper in verify-signing-two-factor-token.ts
- Extract throwIssuanceDenied helper in issue-signing-two-factor-token.ts
- Eliminate duplicated attemptsRemaining state in UI component
- Use imported SIGNING_2FA_VERIFY_REASON_CODES constants
- Add statusQuery.refetch() after failed verify for single source of truth
- Fix TypeScript control flow with explicit returns after throws
This commit is contained in:
ephraimduncan
2026-02-10 12:39:13 +00:00
parent f1c91c4951
commit bd56929db1
34 changed files with 2406 additions and 21 deletions
@@ -30,6 +30,9 @@ import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
import { getSigningTwoFactorStatusRoute } from './signing-2fa/get-signing-two-factor-status';
import { issueSigningTwoFactorTokenRoute } from './signing-2fa/issue-signing-two-factor-token';
import { verifySigningTwoFactorTokenRoute } from './signing-2fa/verify-signing-two-factor-token';
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
import { updateEnvelopeRoute } from './update-envelope';
import { updateEnvelopeItemsRoute } from './update-envelope-items';
@@ -87,5 +90,10 @@ export const envelopeRouter = router({
duplicate: duplicateEnvelopeRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
signing2fa: {
issue: issueSigningTwoFactorTokenRoute,
verify: verifySigningTwoFactorTokenRoute,
getStatus: getSigningTwoFactorStatusRoute,
},
signingStatus: signingStatusEnvelopeRoute,
});
@@ -182,6 +182,7 @@ export const signEnvelopeFieldRoute = procedure
field,
userId: user?.id,
authOptions,
recipientToken: token,
});
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
@@ -0,0 +1,45 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getSigningTwoFactorStatus } from '@documenso/lib/server-only/signing-2fa/get-signing-two-factor-status';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import {
ZGetSigningTwoFactorStatusRequestSchema,
ZGetSigningTwoFactorStatusResponseSchema,
} from './get-signing-two-factor-status.types';
export const getSigningTwoFactorStatusRoute = procedure
.input(ZGetSigningTwoFactorStatusRequestSchema)
.output(ZGetSigningTwoFactorStatusResponseSchema)
.query(async ({ input, ctx }) => {
const { token } = input;
ctx.logger.info({
input: {
token: '***',
},
});
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
select: {
id: true,
envelopeId: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
return await getSigningTwoFactorStatus({
recipientId: recipient.id,
envelopeId: recipient.envelopeId,
sessionId: token,
});
});
@@ -0,0 +1,24 @@
import { z } from 'zod';
export const ZGetSigningTwoFactorStatusRequestSchema = z.object({
token: z.string().describe('The recipient signing token from the signing URL.'),
});
export const ZGetSigningTwoFactorStatusResponseSchema = z.object({
required: z.boolean().describe('Whether external 2FA is required for this recipient.'),
hasActiveToken: z.boolean().describe('Whether an active (unexpired) token exists.'),
hasValidProof: z.boolean().describe('Whether a valid session proof exists.'),
tokenExpiresAt: z.date().nullable().describe('When the active token expires, if any.'),
proofExpiresAt: z.date().nullable().describe('When the session proof expires, if any.'),
attemptsRemaining: z
.number()
.nullable()
.describe('Remaining verification attempts for the active token.'),
});
export type TGetSigningTwoFactorStatusRequest = z.infer<
typeof ZGetSigningTwoFactorStatusRequestSchema
>;
export type TGetSigningTwoFactorStatusResponse = z.infer<
typeof ZGetSigningTwoFactorStatusResponseSchema
>;
@@ -0,0 +1,53 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { issueSigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/issue-signing-two-factor-token';
import { authenticatedProcedure } from '../../trpc';
import {
ZIssueSigningTwoFactorTokenRequestSchema,
ZIssueSigningTwoFactorTokenResponseSchema,
issueSigningTwoFactorTokenMeta,
} from './issue-signing-two-factor-token.types';
export const issueSigningTwoFactorTokenRoute = authenticatedProcedure
.meta(issueSigningTwoFactorTokenMeta)
.input(ZIssueSigningTwoFactorTokenRequestSchema)
.output(ZIssueSigningTwoFactorTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { envelopeId, recipientId } = input;
ctx.logger.info({
input: {
envelopeId,
recipientId,
},
});
const authorizationHeader = ctx.req.headers.get('authorization');
if (!authorizationHeader) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token required to issue signing 2FA tokens',
statusCode: 401,
});
}
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token required to issue signing 2FA tokens',
statusCode: 401,
});
}
const apiToken = await getApiTokenByToken({ token });
const result = await issueSigningTwoFactorToken({
recipientId,
envelopeId,
apiTokenId: apiToken.id,
});
return result;
});
@@ -0,0 +1,35 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../../trpc';
export const issueSigningTwoFactorTokenMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/signing-2fa/issue',
summary: 'Issue a signing 2FA token',
description:
'Issue a one-time signing two-factor authentication token for a recipient. The caller is responsible for delivering the token to the signer through their own channel (e.g., SMS).',
tags: ['Envelope'],
},
};
export const ZIssueSigningTwoFactorTokenRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope.'),
recipientId: z.number().describe('The ID of the recipient to issue the token for.'),
});
export const ZIssueSigningTwoFactorTokenResponseSchema = z.object({
token: z.string().describe('The plaintext one-time token. Visible exactly once.'),
tokenId: z.string().describe('The ID of the created token record.'),
expiresAt: z.date().describe('When the token expires.'),
ttlSeconds: z.number().describe('Token time-to-live in seconds.'),
attemptLimit: z.number().describe('Maximum verification attempts allowed.'),
issuedAt: z.date().describe('When the token was issued.'),
});
export type TIssueSigningTwoFactorTokenRequest = z.infer<
typeof ZIssueSigningTwoFactorTokenRequestSchema
>;
export type TIssueSigningTwoFactorTokenResponse = z.infer<
typeof ZIssueSigningTwoFactorTokenResponseSchema
>;
@@ -0,0 +1,51 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifySigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/verify-signing-two-factor-token';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import {
ZVerifySigningTwoFactorTokenRequestSchema,
ZVerifySigningTwoFactorTokenResponseSchema,
} from './verify-signing-two-factor-token.types';
export const verifySigningTwoFactorTokenRoute = procedure
.input(ZVerifySigningTwoFactorTokenRequestSchema)
.output(ZVerifySigningTwoFactorTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token, code } = input;
ctx.logger.info({
input: {
token: '***',
},
});
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
select: {
id: true,
envelopeId: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
const result = await verifySigningTwoFactorToken({
recipientId: recipient.id,
envelopeId: recipient.envelopeId,
token: code,
sessionId: token,
});
return {
verified: result!.verified,
expiresAt: result!.expiresAt,
};
});
@@ -0,0 +1,23 @@
import { z } from 'zod';
export const ZVerifySigningTwoFactorTokenRequestSchema = z.object({
token: z.string().describe('The recipient signing token from the signing URL.'),
code: z
.string()
.min(6)
.max(6)
.regex(/^\d{6}$/)
.describe('The 6-digit one-time code to verify.'),
});
export const ZVerifySigningTwoFactorTokenResponseSchema = z.object({
verified: z.boolean().describe('Whether the code was successfully verified.'),
expiresAt: z.date().describe('When the session proof expires.'),
});
export type TVerifySigningTwoFactorTokenRequest = z.infer<
typeof ZVerifySigningTwoFactorTokenRequestSchema
>;
export type TVerifySigningTwoFactorTokenResponse = z.infer<
typeof ZVerifySigningTwoFactorTokenResponseSchema
>;