Compare commits

..

8 Commits

Author SHA1 Message Date
Ephraim Duncan 8ca8ad907e Merge branch 'main' into feat/external-2fa-codes 2026-05-27 13:45:46 +00:00
ephraimduncan f7b3554b2a Merge remote-tracking branch 'origin/main' into pr-2468
# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
2026-05-14 15:44:01 +00:00
ephraimduncan 6ff8cd7cb2 chore: merge main, resolve biome formatting conflicts 2026-05-12 12:20:22 +00:00
ephraimduncan 138d663c25 chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
2026-05-12 11:46:11 +00:00
ephraimduncan 9194884fbe test: remove flaky external 2fa auth test 2026-02-11 00:10:10 +00:00
ephraimduncan 9de87ca906 fix: move 2FA reason codes to shared constants to fix client bundle
Importing SIGNING_2FA_VERIFY_REASON_CODES from a server-only module
pulled prisma into the browser bundle, causing "process is not defined"
and breaking all client-side JS hydration.
2026-02-10 13:57:51 +00:00
ephraimduncan 7163800d36 chore: remove .sisyphus planning artifacts 2026-02-10 12:48:54 +00:00
ephraimduncan bd56929db1 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
2026-02-10 12:39:13 +00:00
54 changed files with 1813 additions and 873 deletions
@@ -1,199 +0,0 @@
---
date: 2026-03-02
title: View Pdf As Recipient Online After Completion Via Qr Share Url Outside Team
---
## Goal
Allow a recipient (including users outside the owning team) to open the final completed PDF online from the post-completion experience.
## Non-Goals
- No support for historical completed documents that predate this change.
- No recipient access to draft versions, intermediate revisions, or team-internal metadata.
- No rollout flag/canary; ship enabled by default.
## Current State
Only sender/team-member paths reliably reach the online PDF viewer. Recipient-side access after signing can fail when authorization assumes team membership.
## Product Decisions Captured
- Authorization primitive: signed recipient token (recipient + document scoped).
- URL availability: generate and persist synchronously before showing completion CTA.
- Revocation: inherit existing document share/QR toggle behavior.
- Security controls in v1: access logs plus per-token + IP rate limiting.
- Token binding strictness: recipient + document binding only (no device/IP hard binding).
- Artifact visibility: final completed PDF only.
- Backward compatibility: only new completions are supported.
- Multi-recipient policy: recipient can view only after whole document is fully completed.
- Failure UX: inline retry with backoff and support code.
- Rollout: enabled globally by default.
## Detailed Plan
1. Trace and reuse the existing QR/share URL generation source for completed documents.
2. Ensure share URL/token material is created transactionally in the completion finalization flow, before completion CTA rendering.
3. Wire recipient post-completion CTA to this shared URL source (not team-member viewer route assumptions).
4. Add a dedicated authorization path for recipient online-view requests:
- Validate signed token.
- Confirm token recipientId matches a recipient on the target document.
- Confirm token documentId matches request document.
- Confirm document status is fully completed.
- Confirm share/QR access is currently enabled.
5. Keep sender behavior unchanged; sender paths continue through existing sender/team rules.
6. Add fallback UX for missing/failed share URL generation with bounded retry and support code.
7. Add observability and abuse controls (logs, rate limits) on recipient view endpoint.
8. Add and update automated tests for happy path and denial path coverage.
## Authorization and Security Model
### Access Contract
- Recipient view endpoint accepts a signed recipient token (bearer).
- Token claims should include at minimum:
- `documentId`
- `recipientId`
- `completedAt` (or equivalent anti-stale marker)
- `exp` (bounded expiry)
- Team membership is not required for this path.
### Access Denial Conditions
- Invalid signature, expired token, or malformed claims.
- Token/document mismatch or token/recipient mismatch.
- Document not fully completed.
- Share/QR feature disabled/revoked for document.
- Rate limit exceeded.
### Rate Limiting
- Apply sliding window limits keyed by token fingerprint + source IP.
- Return `429` with `Retry-After` on throttle.
- Log throttle events with reason and correlation id.
### Audit Logging
- Log recipient view attempts (allow + deny) with:
- document id
- recipient id (if resolvable)
- result (allow/deny)
- deny reason code
- IP and user-agent
- request correlation id
## UX and Behavior
### Post-Completion CTA
- Completion screen includes `View completed PDF` CTA for recipients.
- CTA is rendered only after synchronous URL generation succeeds.
### Failure Handling
- If synchronous generation fails, keep user on completion success context and show:
- clear inline error
- retry action with exponential backoff (bounded attempts)
- support code/correlation id for escalation
- Do not expose internal stack details.
### Multi-Recipient Behavior
- Recipient access is blocked until all required recipients are completed and document is in final completed state.
## Data and Lifecycle
- Reuse existing share URL persistence model.
- Generate token/share material during completion finalization for new completions only.
- No retroactive migration for previously completed documents.
## API / Endpoint Expectations
- Recipient viewer endpoint should return:
- `200` with final PDF viewer payload on success
- `401/403` for token/authz failures (reason mapped to safe frontend message)
- `404` if document is not visible via token context
- `409` if document not yet fully completed
- `429` when rate-limited
- Error responses should expose stable error codes consumable by frontend copy mapping.
## Testing Strategy
### Unit / Integration
- Token validation and claim mismatch rejection.
- Denial when document incomplete.
- Denial when share toggle disabled.
- Rate-limit enforcement behavior.
### End-to-End
- Sender path unaffected (regression).
- Recipient outside team can open final PDF after full completion.
- Recipient cannot open before full completion.
- Unauthorized/random user without valid token is denied.
- Failure fallback UI shows retry + support code on forced generation failure.
## Validation Criteria
- Recipient can open completed PDF online from completion context without team membership.
- Final-PDF-only visibility is enforced.
- Sender behavior remains unchanged.
- Unauthorized users still cannot access the document.
- Share-toggle revocation immediately removes recipient access.
- Access events and rate-limit events are observable in logs.
## Risks and Mitigations
- Risk: broader access than intended.
- Mitigation: strict recipient+document token checks, completed-state check, share-toggle gate.
- Risk: completion-time URL generation increases latency.
- Mitigation: keep generation in bounded transaction path, add retry fallback UX, log latency.
- Risk: support confusion for pre-existing completed documents.
- Mitigation: document "new completions only" behavior in release notes/internal support docs.
## Implementation Checklist (Repo-Mapped)
1. Completion UX entry point (recipient side)
- File: `apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx`
- Replace/augment current post-completion action so recipients get a direct `View completed PDF` action that points to `/share/${document.qrToken}` when available.
- Gate visibility on final completion state (`signingStatus === 'COMPLETED'`) and `document.qrToken` presence.
- Keep download and existing sender/home actions unchanged.
2. Ensure QR token availability at completion time
- File: `packages/lib/jobs/definitions/internal/seal-document.handler.ts`
- Preserve existing `qrToken` creation in sealing flow and confirm it runs before the recipient completion page can surface the final view action.
- If race conditions appear (status completed but `qrToken` missing), add an explicit short polling/fallback state in UI rather than exposing a broken link.
3. Recipient-readable completed document route
- File: `apps/remix/app/routes/_share+/share.$slug.tsx`
- Keep `qr_` branch as the recipient-safe online view path and ensure it stays independent from team membership checks.
- Keep non-`qr_` slug behavior (social share redirects/meta) unchanged.
4. Access/read model for QR token
- File: `packages/lib/server-only/document/get-document-by-access-token.ts`
- Maintain strict completed-document-only lookup (`status: COMPLETED`) and minimal selected payload.
- Verify returned payload remains final-artifact-only (no draft/intermediate data leakage).
5. Existing share-link path boundary (non-goal guardrail)
- Files: `packages/trpc/server/document-router/share-document.ts`, `packages/lib/server-only/share/create-or-get-share-link.ts`
- Do not repurpose social `DocumentShareLink` as authorization source for final PDF access in this change.
- Keep this as a separate concern from QR token completed-document viewing.
6. Logging and throttling hooks
- Files: `apps/remix/server/router.ts`, `packages/lib/server-only/rate-limit/rate-limit.ts`, `packages/lib/server-only/rate-limit/rate-limit-middleware.ts`
- Add or reuse per-route limits for `/share/qr_*` access attempts.
- Log allow/deny/throttle events with correlation id to support abuse triage.
7. Test coverage targets
- Add route/component coverage for recipient completion page behavior in `apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx` flows.
- Add integration coverage for QR route access in `apps/remix/app/routes/_share+/share.$slug.tsx` + `packages/lib/server-only/document/get-document-by-access-token.ts`.
- Add E2E scenario under `packages/app-tests/e2e/document-auth/` for:
- recipient outside team sees and uses `View completed PDF` after full completion,
- recipient does not see final-view action before full completion,
- invalid/random QR token is denied.
8. Verification commands
- Typecheck changed TS packages: `npx tsc --noEmit`
- Run affected tests (targeted): `npm run test:dev -w @documenso/app-tests`
- Optional broader confidence (if needed): `npm run lint`
@@ -0,0 +1,289 @@
---
date: 2026-02-02
title: Support For External 2fa Codes
---
## Objective
Enable organizations to enforce a second factor for document signing while keeping delivery fully external (for example customer-owned SMS), with strong recipient/session binding and auditable controls.
## Problem Context
- Many legacy organizations still rely on SMS for second-factor delivery.
- Their users cannot realistically migrate to authenticator apps or passkeys yet.
- Operating first-party SMS infrastructure in Documenso is costly, risky, and outside core scope.
- Customers need an API-first integration path that fits existing notification infrastructure and compliance controls.
## Proposed Solution
Introduce external 2FA codes for signing:
1. A trusted backend service requests a one-time signing token via API.
2. The customer delivers that token to the signer through their own existing channel (for example SMS).
3. The signer enters the token in the signing flow.
4. Documenso validates the submitted token, then issues a short-lived session-bound verification proof.
5. Signature completion is allowed only when the proof is present and valid for that recipient signing session.
## Decisions Captured In Interview
- Enforcement scope: template-level default with per-recipient override.
- Issuer trust boundary: scoped machine API keys with explicit permission.
- Token lifecycle: newest token immediately revokes prior active token for same recipient/document.
- Brute-force control: token-scoped hard attempt cap.
- Security defaults: TTL 10 minutes, max 5 attempts.
- Verification unlock: session-bound proof (not global recipient unlock).
- Issuance contract: idempotent-ish reissue behavior with explicit structured denial reasons.
- Audit privacy: never log token/code material; log identifiers and reason codes only.
- Missing token at signing time: block with actionable state.
- Rollback behavior: feature-flag off for new sessions only.
- Resend/recovery in v1: support-owned reissue guidance only (no signer self-serve trigger).
- Workspace policy controls in v1: no per-workspace TTL/attempt overrides.
- Session proof TTL in v1: 10 minutes.
## Scope
### In Scope
- API endpoint to issue short-lived signing 2FA tokens for eligible recipients.
- Secure storage/verification mechanism (hashed token + expiry + attempt tracking).
- Signing UI step to collect token before signature submission.
- Standard operating flow: token is generated via API and entered by the recipient in the UI.
- Verification endpoint/path integrated into signing completion checks.
- Audit logging for token issuance and verification attempts.
- Template policy defaults with per-recipient override support.
- Session-bound verification proof issuance after successful code validation.
- Feature-flagged rollout controls at workspace/organization scope.
### Out of Scope
- Native SMS sending/providers inside Documenso.
- New authenticator/passkey implementation.
- Cross-channel delivery guarantees (owned by customer infrastructure).
- UI-only token generation as the primary flow in this phase.
- Fully configurable TTL/attempt policy per workspace in v1.
- Customer callback/webhook resend orchestration in v1.
- Signer-triggered self-serve reissue controls in v1.
## Functional Requirements
- Token is recipient-bound and document/session-bound.
- Token cannot be shared across recipients or recipient roles.
- A recipient token only authorizes signature actions for that same recipient identity.
- If the same human is represented by multiple recipient records, each recipient record still requires its own token.
- Token has strict TTL of 10 minutes and single-use semantics.
- Token verification fails on expiry, mismatch, too many attempts, or reuse.
- Endpoint access is restricted to scoped API clients with explicit issuance permission.
- Clear, localized user errors for invalid/expired tokens.
- Max 5 verification attempts per token; on cap reached, token becomes unusable and signer must use a newly issued token.
- Issuing a new token revokes any existing active token for the same recipient/document pair.
- Successful verification creates a short-lived session-bound proof; only that session can complete signature.
- If 2FA is required but no valid token has been issued yet, signing must be blocked with actionable guidance.
## Non-Functional Requirements
- Verification and consumption path must be atomic and race-safe under concurrent requests.
- Error responses must use stable machine-readable reason codes for customer integrations.
- p95 verification latency should remain within existing signing guardrail budget (target: <= 300 ms server-side).
- Security controls and audit logging must not expose token/code values in logs, traces, or analytics payloads.
## Policy Model
- Default requirement is configured at template/workflow level.
- Sender can override requirement per recipient before send.
- Effective policy is materialized on recipient/document at send time to avoid template drift during in-flight signing.
- Feature flag gates enforcement by workspace/organization for rollout and rollback.
## API Contract
### Token Issuance Endpoint
- Auth: scoped API key with dedicated permission (for example `signing_2fa:issue`).
- Input: recipient/document context and optional idempotency metadata.
- Behavior:
- Eligible recipient: always issues a fresh token and revokes prior active token.
- Ineligible/forbidden state: returns structured 4xx with explicit reason code.
- Never returns previously generated plaintext token; token is visible exactly once at issuance.
- Output:
- Plaintext token (single response only).
- Metadata for integration handling (expiresAt, ttlSeconds, attemptLimit, issuedAt).
### Verification Endpoint
- Input: token submission from signing UI bound to current signing session context.
- Behavior:
- Valid token: atomically consumes token and issues session-bound verification proof.
- Invalid token: increments attempts and returns reason code.
- Expired/revoked/consumed/capped: returns denial reason without revealing sensitive internals.
- Output:
- Success: verification state for current session.
- Failure: localized user-safe message + machine reason code.
### Resend/Reissue Behavior (v1)
- No signer-triggered callback/webhook or self-serve reissue endpoint in v1.
- If token is missing/expired/revoked/capped, signer sees actionable guidance to contact sender/support.
- Reissue remains an API-key-initiated operation from trusted customer backend only.
### Suggested Reason Codes
- `TWO_FA_NOT_REQUIRED`
- `TWO_FA_NOT_ISSUED`
- `TWO_FA_TOKEN_INVALID`
- `TWO_FA_TOKEN_EXPIRED`
- `TWO_FA_TOKEN_REVOKED`
- `TWO_FA_TOKEN_CONSUMED`
- `TWO_FA_ATTEMPT_LIMIT_REACHED`
- `TWO_FA_ISSUER_FORBIDDEN`
- `TWO_FA_RECIPIENT_INELIGIBLE`
## Data Model
Create `signing_two_factor_tokens` (name indicative):
- `id`
- `recipientId`
- `documentId`
- `tokenHash`
- `tokenSalt` (or use KDF settings sufficient to avoid raw-secret recovery)
- `expiresAt`
- `consumedAt` nullable
- `revokedAt` nullable
- `attempts` default 0
- `attemptLimit` default 5
- `issuedByApiKeyId` (or actor reference)
- `createdAt`
Optional companion table/entity for session proof:
- `signing_session_2fa_proofs`
- `sessionId`
- `recipientId`
- `documentId`
- `verifiedAt`
- `expiresAt`
Constraints and indexes:
- Index on (`recipientId`, `documentId`, `expiresAt`).
- At most one active token per (`recipientId`, `documentId`) enforced by transactional revoke-on-issue.
- Guard against lost-update on attempts and consume via row lock or atomic update conditions.
## Signing UX
- Insert 2FA code step before signature commit when effective policy requires it.
- UX states:
- Waiting for code input.
- Invalid code (remaining attempts shown where safe).
- Expired/revoked/attempt cap reached with clear next-step copy.
- Not issued yet state with actionable guidance.
- Recovery copy in v1 must direct signer to sender/support (no in-product resend action).
- Localization required for all user-facing errors.
- Accessibility: input labeling, error announcement, keyboard submission, mobile-friendly numeric entry.
- Session-bound proof behavior must be transparent to user (no global unlock across devices/tabs).
## Security Requirements
- Never persist plaintext token; store salted hash only.
- Rate-limit issuance and verification attempts.
- Invalidate previous active token immediately when a new token is issued.
- Emit security/audit events with actor, recipient, document, timestamp, and reason codes.
- Prevent token leakage in logs, telemetry, and error payloads.
- Use constant-time comparison and hardened random token generation.
- Enforce short proof lifetime for verified session to reduce replay window.
- Set proof TTL to 10 minutes in v1.
## Observability And Audit
Emit events for:
- `2fa_token_issued`
- `2fa_token_issue_denied`
- `2fa_token_verify_succeeded`
- `2fa_token_verify_failed`
- `2fa_token_consumed`
- `2fa_token_revoked`
Event fields:
- `workspaceId`, `documentId`, `recipientId`
- `actorType` (api_key, signer_session, system)
- `actorId` (where applicable)
- `reasonCode`
- `ipHash`, `userAgentHash` (if available)
- `timestamp`
Metrics and alerts:
- Issuance success/failure rates.
- Verification success/failure rate split by reason code.
- Attempt-limit-hit rate.
- p95 verification latency.
- Alert on unusual spikes in invalid attempts per recipient/document/workspace.
## Implementation Plan
1. Domain model
- Add signing 2FA token entity/table and session-proof persistence.
2. Token issuance API
- Add authenticated route for scoped API keys; issue fresh token, revoke prior active.
3. Verification logic
- Validate token state, increment attempts atomically, consume on success, mint session proof.
4. Signing flow integration
- Add UI token prompt and backend guard requiring valid session proof.
5. Observability
- Add reason-coded events and dashboards/alerts.
6. Controls
- Add rate limits, attempt cap (5), revoke-on-reissue, and feature flag checks.
7. Testing
- Unit tests for generation/verification edge cases.
- Integration tests for API and signing flow.
- Concurrency tests for double-submit and parallel verification.
## Testing Matrix
- Token issuance for eligible/ineligible recipients.
- Reissue revokes previous token immediately.
- Verification success path creates session-bound proof.
- Verification fails on mismatch, expiry, revoked, consumed, cap reached.
- Attempt counter increments correctly under concurrent requests.
- Signature blocked when proof absent or expired.
- Recipient A token rejected for recipient B (including same human/multiple recipient records).
- Feature flag off: new sessions bypass external 2FA requirement.
- Audit events emitted with expected reason codes and no token material.
## Acceptance Criteria
- External system can request a token for an eligible signer through API.
- Signer cannot complete signing without valid token when policy requires 2FA.
- A token issued for recipient A is always rejected for recipient B, including when both recipients map to the same underlying person.
- Valid token allows signing exactly once within TTL.
- Expired/reused/invalid tokens are rejected with clear errors.
- No Documenso-owned SMS infrastructure is introduced.
- Audit trail captures issuance and verification outcomes.
- Default policy can be set at template level with per-recipient override at send time.
- New token issuance revokes prior active token for same recipient/document.
- Max 5 failed attempts per token is enforced.
- Successful verification unlocks only the active signing session.
- If no token has been issued yet, signer is blocked with actionable guidance.
## Rollout Strategy
- Ship behind feature flag (workspace-level or organization-level).
- Enable first for pilot customers in regulated domains.
- Monitor verification failure rates and support feedback.
- Gradually expand availability once stable.
- Rollback path: disable flag for new sessions only; preserve already verified in-flight sessions.
## Risks and Mitigations
- Brute-force attempts -> enforce attempt caps, lockouts, and rate limits.
- Delivery delays in customer SMS systems -> allow controlled token re-issue.
- Support burden from expiry confusion -> clear UX copy and resend guidance.
- Concurrency race on consume/attempt updates -> use transactional atomic updates and dedicated tests.
- Misconfigured API clients -> explicit permission scopes and structured denial reasons.
- Forensic gaps vs privacy over-collection -> reason-coded audits with hashed network metadata only.
## Open Questions
- None for v1 scope.
- v1.1 exploration candidate: customer-controlled signer-triggered callback/reissue flow with abuse protections.
@@ -103,7 +103,6 @@ export const EnvelopeDownloadDialog = ({
);
const envelopeItems = envelopeItemsPayload?.data || [];
const isQrToken = Boolean(token?.startsWith('qr_'));
const onDownload = async (envelopeItem: EnvelopeItemToDownload, version: 'original' | 'signed' | 'pending') => {
const { id: envelopeItemId } = envelopeItem;
@@ -161,41 +160,41 @@ export const EnvelopeDownloadDialog = ({
</DialogHeader>
<div className="flex w-full flex-col gap-4 overflow-hidden">
{isLoadingEnvelopeItems ? (
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
{isLoadingEnvelopeItems
? Array.from({ length: 1 }).map((_, index) => (
<div key={index} className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
<div className="flex w-full flex-col gap-2">
<Skeleton className="h-4 w-28 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
</div>
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
</div>
) : (
envelopeItems.map((item) => (
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
>
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
<div className="flex w-full flex-col gap-2">
<Skeleton className="h-4 w-28 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
</div>
</div>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="truncate font-medium text-foreground text-sm" title={item.title}>
{item.title}
</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>PDF Document</Trans>
</p>
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
</div>
))
: envelopeItems.map((item) => (
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
>
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{!isQrToken && (
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="truncate font-medium text-foreground text-sm" title={item.title}>
{item.title}
</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>PDF Document</Trans>
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -208,26 +207,24 @@ export const EnvelopeDownloadDialog = ({
)}
<Trans context="Original document (adjective)">Original</Trans>
</Button>
)}
{secondaryDownload && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, secondaryDownload.version)}
loading={isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]}
>
{!isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
{secondaryDownload.label}
</Button>
)}
{secondaryDownload && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, secondaryDownload.version)}
loading={isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]}
>
{!isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
{secondaryDownload.label}
</Button>
)}
</div>
</div>
</div>
))
)}
))}
</div>
</DialogContent>
</Dialog>
@@ -58,7 +58,6 @@ export type TDocumentPreferencesFormSchema = {
documentDateFormat: TDocumentMetaDateFormat | null;
includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null;
allowPublicCompletedDocumentAccess: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
@@ -76,7 +75,6 @@ type SettingsSubset = Pick<
| 'documentDateFormat'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'allowPublicCompletedDocumentAccess'
| 'includeAuditLog'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
@@ -118,7 +116,6 @@ export const DocumentPreferencesForm = ({
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
allowPublicCompletedDocumentAccess: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
@@ -139,7 +136,6 @@ export const DocumentPreferencesForm = ({
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
allowPublicCompletedDocumentAccess: settings.allowPublicCompletedDocumentAccess,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
@@ -475,58 +471,6 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="allowPublicCompletedDocumentAccess"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Allow Public Access to Completed Documents via QR/Share Link</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="allow-public-completed-document-access-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether recipients can open completed documents online through QR/share links, including
recipients outside your team.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
@@ -13,6 +13,7 @@ import { match, P } from 'ts-pattern';
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
import { DocumentSigningAuthExternal2FA } from './document-signing-auth-external-2fa';
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
@@ -58,15 +59,8 @@ export const DocumentSigningAuthDialog = ({
return;
}
// Reset selected auth type when dialog closes
if (!value) {
setSelectedAuthType(() => {
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
return null;
});
setSelectedAuthType(validAuthTypes.length === 1 ? validAuthTypes[0] : null);
}
onOpenChange(value);
@@ -123,6 +117,7 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => <Trans>Verification code</Trans>)
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
.exhaustive()}
</div>
@@ -132,6 +127,9 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>Enter your 2FA code</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
<Trans>Enter the verification code provided to you</Trans>
))
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
.exhaustive()}
</div>
@@ -169,6 +167,13 @@ export const DocumentSigningAuthDialog = ({
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuthExternal2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent>
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { SIGNING_2FA_VERIFY_REASON_CODES } from '@documenso/lib/constants/document-auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthExternal2FAProps = {
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZExternal2FAFormSchema = z.object({
code: z
.string()
.length(6, { message: 'Code must be exactly 6 digits' })
.regex(/^\d{6}$/, { message: 'Code must contain only digits' }),
});
type TExternal2FAFormSchema = z.infer<typeof ZExternal2FAFormSchema>;
export const DocumentSigningAuthExternal2FA = ({
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentSigningAuthExternal2FAProps) => {
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const [formError, setFormError] = useState<string | null>(null);
const statusQuery = trpc.envelope.signing2fa.getStatus.useQuery(
{ token: recipient.token },
{ enabled: open },
);
const verifyMutation = trpc.envelope.signing2fa.verify.useMutation();
const form = useForm<TExternal2FAFormSchema>({
resolver: zodResolver(ZExternal2FAFormSchema),
defaultValues: {
code: '',
},
});
const onFormSubmit = async ({ code }: TExternal2FAFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
setFormError(null);
await verifyMutation.mutateAsync({
token: recipient.token,
code,
});
await onReauthFormSubmit({
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED) {
setFormError('Too many failed attempts. Please request a new code.');
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED) {
setFormError('The code has expired. Please request a new code.');
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED) {
setFormError('No code has been issued yet. Please contact the document sender.');
} else {
setFormError('Invalid code. Please try again.');
}
await statusQuery.refetch();
form.reset({ code: '' });
} finally {
setIsCurrentlyAuthenticating(false);
}
};
useEffect(() => {
form.reset({ code: '' });
setFormError(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const attemptsRemaining = statusQuery.data?.attemptsRemaining ?? null;
const hasActiveToken = statusQuery.data?.hasActiveToken ?? false;
const hasValidProof = statusQuery.data?.hasValidProof ?? false;
if (hasValidProof) {
return (
<div className="space-y-4">
<Alert>
<AlertDescription>
<Trans>Your identity has already been verified. You can proceed to sign.</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button
type="button"
onClick={async () => {
await onReauthFormSubmit({
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
});
onOpenChange(false);
}}
>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</div>
);
}
if (!hasActiveToken && !statusQuery.isLoading) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertTitle>
<Trans>Verification code required</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
A verification code is required to sign this document. Please contact the document
sender to request your code.
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>Enter the 6-digit verification code that was provided to you.</Trans>
</p>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Verification code</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{attemptsRemaining !== null && attemptsRemaining > 0 && (
<p className="text-xs text-muted-foreground">
<Trans>{attemptsRemaining} attempts remaining</Trans>
</p>
)}
{formError && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Verification failed</Trans>
</AlertTitle>
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Verify</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -61,13 +61,13 @@ export const useRequiredDocumentSigningAuthContext = () => {
return context;
};
export interface DocumentSigningAuthProviderProps {
export type DocumentSigningAuthProviderProps = {
documentAuthOptions: Envelope['authOptions'];
recipient: SigningAuthRecipient;
isDirectTemplate?: boolean;
user?: SessionUser | null;
children: React.ReactNode;
}
};
export const DocumentSigningAuthProvider = ({
documentAuthOptions: initialDocumentAuthOptions,
@@ -169,12 +169,12 @@ export const DocumentSigningAuthProvider = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [passkeyData.passkeys]);
// Assume that a user must be logged in for any auth requirements.
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
(method) => method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
const isAuthRedirectRequired = Boolean(
derivedRecipientActionAuth &&
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
user?.email !== recipient.email,
authMethodsRequiringLogin && authMethodsRequiringLogin.length > 0 && user?.email !== recipient.email,
);
const refetchPasskeys = async () => {
@@ -106,8 +106,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
}))
.with(undefined, () => undefined)
.with(
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
P.union(
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
),
() => 'NOT_SUPPORTED' as const,
)
.exhaustive();
@@ -2,7 +2,6 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@@ -50,9 +49,9 @@ export const DocumentCertificateQRView = ({
completedDate,
token,
}: DocumentCertificateQRViewProps) => {
const { sessionData } = useOptionalSession();
const { data: documentViaUser } = trpc.document.get.useQuery({ documentId }, { enabled: !!sessionData?.user });
const { data: documentViaUser } = trpc.document.get.useQuery({
documentId,
});
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser);
@@ -66,10 +66,6 @@ export const EnvelopeRendererFileSelector = ({
}: EnvelopeRendererFileSelectorProps) => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
if (envelopeItems.length <= 1) {
return null;
}
return (
<div className={cn('scrollbar-hidden flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
{envelopeItems.map((doc, i) => (
@@ -53,7 +53,6 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
signatureTypes,
defaultRecipients,
@@ -69,7 +68,6 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null ||
allowPublicCompletedDocumentAccess === null ||
includeAuditLog === null ||
aiFeaturesEnabled === null
) {
@@ -85,7 +83,6 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
defaultRecipients,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
@@ -48,7 +48,6 @@ export default function TeamsSettingsPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
signatureTypes,
defaultRecipients,
@@ -67,7 +66,6 @@ export default function TeamsSettingsPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
defaultRecipients,
aiFeaturesEnabled,
@@ -150,6 +150,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
.with('EXTERNAL_TWO_FACTOR_AUTH', () => _(msg`External Two-Factor Re-Authentication`))
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => _(msg`Email`))
@@ -16,11 +16,11 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { CheckCircle2, Clock8, DownloadIcon, EyeIcon, Loader2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Link, useRevalidator } from 'react-router';
import { CheckCircle2, Clock8, DownloadIcon, Loader2 } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
@@ -31,8 +31,6 @@ import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/complete';
const MAX_QR_RETRY_COUNT = 4;
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
@@ -105,26 +103,32 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}
export default function CompletedSigningPage({ loaderData }: Route.ComponentProps) {
const revalidator = useRevalidator();
const { _ } = useLingui();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const cspNonce = useCspNonce();
const { isDocumentAccessValid, recipientEmail, branding } = loaderData;
const signingStatusToken = isDocumentAccessValid ? loaderData.recipient.token : '';
const initialSigningStatus = isDocumentAccessValid ? loaderData.document.status : DocumentStatus.PENDING;
const {
isDocumentAccessValid,
canSignUp,
recipientName,
signatures,
document,
recipient,
recipientEmail,
returnToHomePath,
branding,
} = loaderData;
// Poll signing status every few seconds
const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery(
{
token: signingStatusToken,
token: recipient?.token || '',
},
{
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'COMPLETED' || status === 'REJECTED' ? false : 3000;
},
initialData: match(initialSigningStatus)
refetchInterval: 3000,
initialData: match(document?.status)
.with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const)
.with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const)
.with(DocumentStatus.PENDING, () => ({ status: 'PENDING' }) as const)
@@ -132,56 +136,9 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
},
);
// Use signing status from query if available, otherwise fall back to document status
const signingStatus = signingStatusData?.status ?? 'PENDING';
const [qrRetryCount, setQrRetryCount] = useState(0);
const [isRetryingQrLink, setIsRetryingQrLink] = useState(false);
const onRetryQrLink = useCallback(async () => {
if (!isDocumentAccessValid) {
return;
}
if (qrRetryCount >= MAX_QR_RETRY_COUNT) {
return;
}
setIsRetryingQrLink(true);
const nextRetryCount = qrRetryCount + 1;
const retryDelay = Math.min(250 * 2 ** nextRetryCount, 3000);
await new Promise<void>((resolve) => {
setTimeout(resolve, retryDelay);
});
setQrRetryCount(nextRetryCount);
try {
await revalidator.revalidate();
} finally {
setIsRetryingQrLink(false);
}
}, [isDocumentAccessValid, qrRetryCount, revalidator]);
const isFullyCompleted = isDocumentAccessValid && signingStatus === 'COMPLETED';
const hasQrToken = isDocumentAccessValid && Boolean(loaderData.document.qrToken);
const supportCode = isDocumentAccessValid ? `QR-${loaderData.document.id}-${loaderData.recipient.id}` : '';
useEffect(() => {
if (
!isFullyCompleted ||
hasQrToken ||
isRetryingQrLink ||
revalidator.state !== 'idle' ||
qrRetryCount >= MAX_QR_RETRY_COUNT
) {
return;
}
void onRetryQrLink();
}, [hasQrToken, isFullyCompleted, isRetryingQrLink, onRetryQrLink, qrRetryCount, revalidator]);
if (!isDocumentAccessValid) {
return (
<>
@@ -191,8 +148,6 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
);
}
const { canSignUp, recipientName, signatures, document, recipient, returnToHomePath } = loaderData;
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
@@ -294,40 +249,6 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
))}
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
{isFullyCompleted && hasQrToken && (
<Button asChild variant="secondary" className="w-full">
<Link to={`/share/${document.qrToken}`} target="_blank" rel="noopener noreferrer">
<EyeIcon className="mr-2 h-5 w-5" />
<Trans>View completed PDF</Trans>
</Link>
</Button>
)}
{isFullyCompleted && !hasQrToken && (
<div className="w-full rounded-md border border-orange-200 bg-orange-50 p-3 text-left text-orange-900 text-sm md:max-w-sm">
<p>
<Trans>We are preparing your online PDF view. If it does not appear, retry below.</Trans>
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="font-medium text-orange-700 text-xs uppercase tracking-wide">
<Trans>Support code</Trans>: {supportCode}
</span>
<Button
type="button"
variant="outline"
size="sm"
loading={isRetryingQrLink || revalidator.state === 'loading'}
disabled={qrRetryCount >= MAX_QR_RETRY_COUNT}
onClick={() => void onRetryQrLink()}
>
<Trans>Retry</Trans>
</Button>
</div>
</div>
)}
<DocumentShareButton
documentId={document.id}
token={recipient.token}
+11 -195
View File
@@ -1,27 +1,14 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentByAccessToken } from '@documenso/lib/server-only/document/get-document-by-access-token';
import { qrShareViewRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { tokenFingerprint } from '@documenso/lib/universal/crypto';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { logger } from '@documenso/lib/utils/logger';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { AlertCircle } from 'lucide-react';
import { nanoid } from 'nanoid';
import { isRouteErrorResponse, Link, redirect, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
import { redirect, useLoaderData } from 'react-router';
import { DocumentCertificateQRView } from '~/components/general/document/document-certificate-qr-view';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/share.$slug';
export function meta({ params: { slug }, loaderData }: Route.MetaArgs) {
export function meta({ params: { slug } }: Route.MetaArgs) {
if (slug.startsWith('qr_')) {
const documentTitle = loaderData?.document?.title ?? 'Shared Document';
return [...appMetaTags(documentTitle), { name: 'robots', content: 'noindex, nofollow' }];
return undefined;
}
return [
@@ -62,149 +49,18 @@ export function meta({ params: { slug }, loaderData }: Route.MetaArgs) {
];
}
type TQrShareErrorPayload = {
code: string;
correlationId: string;
};
const createQrShareErrorResponse = ({
status,
code,
correlationId,
headers,
}: {
status: number;
code: string;
correlationId: string;
headers?: HeadersInit;
}) => {
return new Response(
JSON.stringify({
code,
correlationId,
} satisfies TQrShareErrorPayload),
{
status,
headers: {
'Content-Type': 'application/json',
'X-Documenso-Error-Code': code,
...headers,
},
},
);
};
const parseQrShareErrorPayload = (value: unknown): TQrShareErrorPayload | null => {
if (!value || typeof value !== 'string') {
return null;
}
try {
const parsed = JSON.parse(value);
if (typeof parsed.code === 'string' && typeof parsed.correlationId === 'string') {
return parsed;
}
return null;
} catch {
return null;
}
};
export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) => {
if (slug.startsWith('qr_')) {
const correlationId = request.headers.get('x-request-id') ?? nanoid(12);
const requestMetadata = extractRequestMetadata(request);
const rateLimitResult = await qrShareViewRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: tokenFingerprint(slug),
});
const document = await getDocumentByAccessToken({ token: slug });
if (rateLimitResult.isLimited) {
const retryAfter = String(Math.max(1, Math.ceil((rateLimitResult.reset.getTime() - Date.now()) / 1000)));
logger.warn({
msg: 'QR share access throttled',
documentId: null,
recipientId: null,
result: 'deny',
denyReasonCode: 'QR_VIEW_RATE_LIMITED',
correlationId,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
});
throw createQrShareErrorResponse({
status: 429,
code: 'QR_VIEW_RATE_LIMITED',
correlationId,
headers: {
'Retry-After': retryAfter,
'X-RateLimit-Limit': String(rateLimitResult.limit),
'X-RateLimit-Remaining': String(rateLimitResult.remaining),
'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset.getTime() / 1000)),
},
});
if (!document) {
throw redirect('/');
}
try {
const document = await getDocumentByAccessToken({ token: slug });
logger.info({
msg: 'QR share access allowed',
documentId: document.id,
recipientId: null,
result: 'allow',
denyReasonCode: null,
correlationId,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
});
return {
document,
token: slug,
};
} catch (error) {
const appError = AppError.parseError(error);
const { status, code } = match(appError)
.when(
(e) => e.code === AppErrorCode.NOT_FOUND,
() => ({ status: 404, code: 'QR_VIEW_NOT_FOUND' }),
)
.when(
(e) => e.code === AppErrorCode.INVALID_REQUEST,
() => ({ status: 409, code: 'QR_VIEW_NOT_COMPLETED' }),
)
.when(
(e) => e.code === AppErrorCode.UNAUTHORIZED && e.statusCode === 403,
() => ({ status: 403, code: 'QR_VIEW_DISABLED' }),
)
.when(
(e) => e.code === AppErrorCode.UNAUTHORIZED,
() => ({ status: 401, code: 'QR_VIEW_UNAUTHORIZED' }),
)
.otherwise(() => ({ status: 500, code: 'QR_VIEW_INTERNAL_ERROR' }));
logger.warn({
msg: 'QR share access denied',
documentId: null,
recipientId: null,
result: 'deny',
denyReasonCode: code,
correlationId,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
});
throw createQrShareErrorResponse({
status,
code,
correlationId,
});
}
return {
document,
token: slug,
};
}
const userAgent = request.headers.get('User-Agent') ?? '';
@@ -235,45 +91,5 @@ export default function SharePage() {
);
}
return null;
}
const qrShareErrorMessage = (code: string | undefined) =>
match(code)
.with('QR_VIEW_NOT_FOUND', () => <Trans>The shared document could not be found.</Trans>)
.with('QR_VIEW_NOT_COMPLETED', () => <Trans>This document is not fully completed yet.</Trans>)
.with('QR_VIEW_DISABLED', () => <Trans>Public completed-document access is currently disabled.</Trans>)
.with('QR_VIEW_UNAUTHORIZED', () => <Trans>You are not authorized to view this document.</Trans>)
.with('QR_VIEW_RATE_LIMITED', () => <Trans>Too many requests. Please try again shortly.</Trans>)
.otherwise(() => <Trans>Something went wrong while opening this shared view.</Trans>);
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const payload = isRouteErrorResponse(error) ? parseQrShareErrorPayload(error.data) : null;
return (
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<AlertCircle className="size-10 self-start text-destructive" />
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Unable to Open Document</Trans>
</h2>
<p className="text-muted-foreground text-sm">{qrShareErrorMessage(payload?.code)}</p>
{payload?.correlationId && (
<p className="mt-4 font-medium text-muted-foreground text-xs uppercase tracking-wide">
<Trans>Support code: {payload.correlationId}</Trans>
</p>
)}
<Button className="mt-6 w-fit" asChild>
<Link to="/">
<Trans>Return Home</Trans>
</Link>
</Button>
</div>
</div>
</div>
);
return <div></div>;
}
+2 -4
View File
@@ -1,15 +1,13 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { i18n, type MessageDescriptor } from '@lingui/core';
export const appMetaTags = (title?: MessageDescriptor | string) => {
export const appMetaTags = (title?: MessageDescriptor) => {
const description =
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.';
const resolvedTitle = typeof title === 'string' ? title : title ? i18n._(title) : undefined;
return [
{
title: resolvedTitle ? `${resolvedTitle} - Documenso` : 'Documenso',
title: title ? `${i18n._(title)} - Documenso` : 'Documenso',
},
{
name: 'description',
+70 -120
View File
@@ -2,17 +2,12 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { qrShareViewRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { tokenFingerprint } from '@documenso/lib/universal/crypto';
import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { DocumentStatus, type Prisma } from '@prisma/client';
import { type Context, Hono } from 'hono';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
@@ -29,101 +24,6 @@ import {
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
import getEnvelopeItemPdfByTokenRoute from './routes/get-envelope-item-pdf-by-token';
const envelopeItemTokenInclude = {
envelope: {
include: {
team: {
include: {
teamGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
organisation: {
include: {
organisationGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
},
},
},
},
},
},
documentData: true,
} as const;
const maybeApplyQrRateLimit = async (c: Context<HonoEnv>, token: string) => {
let ip: string;
try {
ip = getIpAddress(c.req.raw);
} catch {
ip = 'unknown';
}
const result = await qrShareViewRateLimit.check({
ip,
identifier: tokenFingerprint(token),
});
return rateLimitResponse(c, result);
};
const getEnvelopeItemByToken = async (c: Context<HonoEnv>, token: string, envelopeItemId: string) => {
const isQrToken = token.startsWith('qr_');
if (isQrToken) {
const limited = await maybeApplyQrRateLimit(c, token);
if (limited) {
return { limited } as const;
}
}
const envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = isQrToken
? {
id: envelopeItemId,
envelope: {
qrToken: token,
status: DocumentStatus.COMPLETED,
},
}
: {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: envelopeItemTokenInclude,
});
if (!envelopeItem) {
return { error: c.json({ error: 'Envelope item not found' }, 404) } as const;
}
if (isQrToken && !isPublicDocumentAccessEnabled(envelopeItem.envelope.team)) {
return {
error: c.json({ error: 'Public completed-document access is disabled for this document' }, 403),
} as const;
}
if (!envelopeItem.documentData) {
return { error: c.json({ error: 'Document data not found' }, 404) } as const;
}
return { envelopeItem, isQrToken } as const;
};
export const filesRoute = new Hono<HonoEnv>()
/**
* Uploads a document file to the appropriate storage location and creates
@@ -349,20 +249,46 @@ export const filesRoute = new Hono<HonoEnv>()
async (c) => {
const { token, envelopeItemId } = c.req.valid('param');
const result = await getEnvelopeItemByToken(c, token, envelopeItemId);
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
if ('limited' in result) {
return result.limited;
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
}
if ('error' in result) {
return result.error;
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: {
envelope: true,
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: result.envelopeItem.title,
status: result.envelopeItem.envelope.status,
documentData: result.envelopeItem.documentData!,
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version: 'signed',
isDownload: false,
context: c,
@@ -375,23 +301,47 @@ export const filesRoute = new Hono<HonoEnv>()
async (c) => {
const { token, envelopeItemId, version } = c.req.valid('param');
const result = await getEnvelopeItemByToken(c, token, envelopeItemId);
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
if ('limited' in result) {
return result.limited;
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
}
if ('error' in result) {
return result.error;
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: {
envelope: true,
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const effectiveVersion = result.isQrToken ? 'signed' : version;
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: result.envelopeItem.title,
status: result.envelopeItem.envelope.status,
documentData: result.envelopeItem.documentData!,
version: effectiveVersion,
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version,
isDownload: true,
context: c,
});
+13
View File
@@ -26,8 +26,21 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
key: DocumentAuth.PASSWORD,
value: msg`Require password`,
},
[DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH]: {
key: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
value: msg`Require external 2FA`,
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: msg`None (Overrides global settings)`,
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
export const SIGNING_2FA_VERIFY_REASON_CODES = {
TWO_FA_TOKEN_INVALID: 'TWO_FA_TOKEN_INVALID',
TWO_FA_TOKEN_EXPIRED: 'TWO_FA_TOKEN_EXPIRED',
TWO_FA_TOKEN_REVOKED: 'TWO_FA_TOKEN_REVOKED',
TWO_FA_TOKEN_CONSUMED: 'TWO_FA_TOKEN_CONSUMED',
TWO_FA_ATTEMPT_LIMIT_REACHED: 'TWO_FA_ATTEMPT_LIMIT_REACHED',
TWO_FA_NOT_ISSUED: 'TWO_FA_NOT_ISSUED',
} as const;
@@ -115,11 +115,28 @@ export const completeDocumentWithToken = async ({
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
if (derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH)) {
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId: token,
envelopeId: envelope.id,
expiresAt: { gt: new Date() },
},
});
if (!validProof) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'External 2FA verification required before completing document',
statusCode: 403,
});
}
}
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
@@ -1,5 +1,3 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
@@ -11,41 +9,25 @@ export type GetDocumentByAccessTokenOptions = {
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Missing QR access token',
statusCode: 401,
});
throw new Error('Missing token');
}
const result = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
status: DocumentStatus.COMPLETED,
qrToken: token,
},
// Do not provide extra information that is not needed.
select: {
id: true,
secondaryId: true,
status: true,
internalVersion: true,
title: true,
completedAt: true,
team: {
select: {
url: true,
organisation: {
select: {
organisationGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
},
},
teamGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
},
},
envelopeItems: {
@@ -74,33 +56,17 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
});
if (!result) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'QR token not found',
statusCode: 404,
});
return null;
}
if (result.status !== DocumentStatus.COMPLETED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not fully completed',
statusCode: 409,
});
if (result.envelopeItems.length === 0) {
throw new Error('Completed envelope has no items');
}
if (!isPublicDocumentAccessEnabled(result.team)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Public completed-document access is disabled for this document',
statusCode: 403,
});
}
const firstDocumentData = result.envelopeItems[0].documentData;
const firstEnvelopeItem = result.envelopeItems[0];
if (!firstEnvelopeItem?.documentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Missing document data for QR token',
statusCode: 404,
});
if (!firstDocumentData) {
throw new Error('Missing document data');
}
return {
@@ -31,6 +31,7 @@ type IsRecipientAuthorizedOptions = {
* using the user ID.
*/
authOptions?: TDocumentAuthMethods;
recipientToken?: string;
};
const getUserByEmail = async (email: string) => {
@@ -56,6 +57,7 @@ export const isRecipientAuthorized = async ({
recipient,
userId,
authOptions,
recipientToken,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
@@ -163,6 +165,21 @@ export const isRecipientAuthorized = async ({
password,
});
})
.with({ type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, async () => {
if (!recipientToken) {
return false;
}
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId: recipientToken,
envelopeId: recipient.envelopeId,
expiresAt: { gt: new Date() },
},
});
return !!validProof;
})
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
return true;
})
@@ -11,6 +11,7 @@ export type ValidateFieldAuthOptions = {
field: Field;
userId?: number;
authOptions?: TRecipientActionAuth;
recipientToken?: string;
};
/**
@@ -24,6 +25,7 @@ export const validateFieldAuth = async ({
field,
userId,
authOptions,
recipientToken,
}: ValidateFieldAuthOptions) => {
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
@@ -36,6 +38,7 @@ export const validateFieldAuth = async ({
recipient,
userId,
authOptions,
recipientToken,
});
if (!isValid) {
@@ -177,6 +177,7 @@ export const signFieldWithToken = async ({
field,
userId,
authOptions,
recipientToken: token,
});
const documentMeta = await prisma.documentMeta.findFirst({
@@ -101,6 +101,7 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Re-Authentication`))
.with('EXTERNAL_TWO_FACTOR_AUTH', () => i18n._(msg`External Two-Factor Re-Authentication`))
.with('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
@@ -97,10 +97,3 @@ export const fileUploadRateLimit = createRateLimit({
max: 20,
window: '1m',
});
export const qrShareViewRateLimit = createRateLimit({
action: 'app.qr-share-view',
max: 20,
globalMax: 120,
window: '1m',
});
@@ -0,0 +1,105 @@
import { prisma } from '@documenso/prisma';
import { DocumentAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
export type GetSigningTwoFactorStatusOptions = {
recipientId: number;
envelopeId: string;
sessionId: string;
};
export type SigningTwoFactorStatus = {
required: boolean;
hasActiveToken: boolean;
hasValidProof: boolean;
tokenExpiresAt: Date | null;
proofExpiresAt: Date | null;
attemptsRemaining: number | null;
};
const NOT_REQUIRED_STATUS: SigningTwoFactorStatus = {
required: false,
hasActiveToken: false,
hasValidProof: false,
tokenExpiresAt: null,
proofExpiresAt: null,
attemptsRemaining: null,
};
export const getSigningTwoFactorStatus = async ({
recipientId,
envelopeId,
sessionId,
}: GetSigningTwoFactorStatusOptions): Promise<SigningTwoFactorStatus> => {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: {
authOptions: true,
recipients: {
where: { id: recipientId },
select: {
authOptions: true,
},
},
},
});
if (!envelope || envelope.recipients.length === 0) {
return NOT_REQUIRED_STATUS;
}
const [recipient] = envelope.recipients;
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const required = derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH);
if (!required) {
return NOT_REQUIRED_STATUS;
}
const now = new Date();
const [activeToken, validProof] = await Promise.all([
prisma.signingTwoFactorToken.findFirst({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
select: {
expiresAt: true,
attempts: true,
attemptLimit: true,
},
}),
prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId,
recipientId,
envelopeId,
expiresAt: { gt: now },
},
select: {
expiresAt: true,
},
}),
]);
return {
required: true,
hasActiveToken: !!activeToken,
hasValidProof: !!validProof,
tokenExpiresAt: activeToken?.expiresAt ?? null,
proofExpiresAt: validProof?.expiresAt ?? null,
attemptsRemaining: activeToken
? Math.max(0, activeToken.attemptLimit - activeToken.attempts)
: null,
};
};
@@ -0,0 +1,176 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { DocumentAuth } from '../../types/document-auth';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { generateSigningTwoFactorToken, generateTokenSalt, hashToken } from './token-utils';
const TOKEN_TTL_MINUTES = 10;
const DEFAULT_ATTEMPT_LIMIT = 5;
export const SIGNING_2FA_REASON_CODES = {
TWO_FA_NOT_REQUIRED: 'TWO_FA_NOT_REQUIRED',
TWO_FA_RECIPIENT_INELIGIBLE: 'TWO_FA_RECIPIENT_INELIGIBLE',
TWO_FA_ISSUER_FORBIDDEN: 'TWO_FA_ISSUER_FORBIDDEN',
} as const;
export type IssueSigningTwoFactorTokenOptions = {
recipientId: number;
envelopeId: string;
apiTokenId: number;
};
export const issueSigningTwoFactorToken = async ({
recipientId,
envelopeId,
apiTokenId,
}: IssueSigningTwoFactorTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
},
include: {
recipients: {
where: {
id: recipientId,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
statusCode: 404,
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document must be in PENDING status`,
statusCode: 400,
});
}
if (envelope.recipients.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for this document',
statusCode: 404,
});
}
const [recipient] = envelope.recipients;
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const requiresExternal2FA = derivedRecipientActionAuth.includes(
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
if (!requiresExternal2FA) {
await throwIssuanceDenied({
envelopeId,
recipient,
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_NOT_REQUIRED,
});
}
if (recipient.signingStatus === 'SIGNED') {
await throwIssuanceDenied({
envelopeId,
recipient,
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_RECIPIENT_INELIGIBLE,
});
}
const plaintextToken = generateSigningTwoFactorToken();
const salt = generateTokenSalt();
const tokenHash = hashToken(plaintextToken, salt);
const expiresAt = new Date(Date.now() + TOKEN_TTL_MINUTES * 60 * 1000);
const result = await prisma.$transaction(async (tx) => {
await tx.signingTwoFactorToken.updateMany({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
},
data: {
status: 'REVOKED',
revokedAt: new Date(),
},
});
const newToken = await tx.signingTwoFactorToken.create({
data: {
recipientId,
envelopeId,
tokenHash,
tokenSalt: salt,
expiresAt,
attemptLimit: DEFAULT_ATTEMPT_LIMIT,
issuedByApiTokenId: apiTokenId,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: newToken.id,
},
}),
});
return newToken;
});
return {
token: plaintextToken,
tokenId: result.id,
expiresAt: result.expiresAt,
ttlSeconds: TOKEN_TTL_MINUTES * 60,
attemptLimit: result.attemptLimit,
issuedAt: result.createdAt,
};
};
const throwIssuanceDenied = async ({
envelopeId,
recipient,
reasonCode,
}: {
envelopeId: string;
recipient: { id: number; email: string; name: string | null };
reasonCode: string;
}) => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name ?? '',
reasonCode,
},
}),
});
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: reasonCode,
statusCode: 400,
});
};
@@ -0,0 +1,30 @@
import crypto from 'crypto';
const TOKEN_LENGTH = 6;
const SALT_LENGTH = 32;
const HASH_ITERATIONS = 100000;
const HASH_KEY_LENGTH = 64;
const HASH_DIGEST = 'sha512';
export const generateSigningTwoFactorToken = (): string => {
const bytes = crypto.randomBytes(4);
const num = bytes.readUInt32BE(0) % 10 ** TOKEN_LENGTH;
return num.toString().padStart(TOKEN_LENGTH, '0');
};
export const generateTokenSalt = (): string => {
return crypto.randomBytes(SALT_LENGTH).toString('hex');
};
export const hashToken = (token: string, salt: string): string => {
return crypto
.pbkdf2Sync(token, salt, HASH_ITERATIONS, HASH_KEY_LENGTH, HASH_DIGEST)
.toString('hex');
};
export const verifyTokenHash = (token: string, salt: string, expectedHash: string): boolean => {
const hash = hashToken(token, salt);
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expectedHash, 'hex'));
};
@@ -0,0 +1,245 @@
import { prisma } from '@documenso/prisma';
import { SIGNING_2FA_VERIFY_REASON_CODES } from '../../constants/document-auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { verifyTokenHash } from './token-utils';
export { SIGNING_2FA_VERIFY_REASON_CODES };
const PROOF_TTL_MINUTES = 10;
export type VerifySigningTwoFactorTokenOptions = {
recipientId: number;
envelopeId: string;
token: string;
sessionId: string;
};
export const verifySigningTwoFactorToken = async ({
recipientId,
envelopeId,
token: plaintextToken,
sessionId,
}: VerifySigningTwoFactorTokenOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelopeId,
},
select: {
id: true,
email: true,
name: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
const activeToken = await prisma.signingTwoFactorToken.findFirst({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
},
orderBy: {
createdAt: 'desc',
},
});
if (!activeToken) {
await throwVerificationError({
envelopeId,
recipient,
tokenId: 'none',
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED,
attemptsUsed: 0,
attemptLimit: 0,
errorCode: AppErrorCode.INVALID_REQUEST,
statusCode: 400,
});
return;
}
if (activeToken.expiresAt < new Date()) {
await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'EXPIRED',
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED,
attemptsUsed: activeToken.attempts,
attemptLimit: activeToken.attemptLimit,
errorCode: AppErrorCode.EXPIRED_CODE,
statusCode: 400,
});
return;
}
if (activeToken.attempts >= activeToken.attemptLimit) {
await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'REVOKED',
revokedAt: new Date(),
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED,
attemptsUsed: activeToken.attempts,
attemptLimit: activeToken.attemptLimit,
errorCode: AppErrorCode.TOO_MANY_REQUESTS,
statusCode: 429,
});
return;
}
const isValid = verifyTokenHash(plaintextToken, activeToken.tokenSalt, activeToken.tokenHash);
if (!isValid) {
const updatedToken = await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
attempts: { increment: 1 },
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_INVALID,
attemptsUsed: updatedToken.attempts,
attemptLimit: updatedToken.attemptLimit,
errorCode: AppErrorCode.INVALID_REQUEST,
statusCode: 400,
});
return;
}
const proofExpiresAt = new Date(Date.now() + PROOF_TTL_MINUTES * 60 * 1000);
const result = await prisma.$transaction(async (tx) => {
await tx.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'CONSUMED',
consumedAt: new Date(),
attempts: { increment: 1 },
},
});
const proof = await tx.signingSessionTwoFactorProof.upsert({
where: {
sessionId_recipientId_envelopeId: {
sessionId,
recipientId,
envelopeId,
},
},
create: {
sessionId,
recipientId,
envelopeId,
expiresAt: proofExpiresAt,
},
update: {
verifiedAt: new Date(),
expiresAt: proofExpiresAt,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: activeToken.id,
},
}),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: activeToken.id,
},
}),
});
return proof;
});
return {
verified: true,
proofId: result.id,
expiresAt: result.expiresAt,
};
};
type ThrowVerificationErrorOptions = {
envelopeId: string;
recipient: { id: number; email: string; name: string };
tokenId: string;
reasonCode: string;
attemptsUsed: number;
attemptLimit: number;
errorCode: AppErrorCode;
statusCode: number;
};
const throwVerificationError = async ({
envelopeId,
recipient,
tokenId,
reasonCode,
attemptsUsed,
attemptLimit,
errorCode,
statusCode,
}: ThrowVerificationErrorOptions): Promise<never> => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId,
reasonCode,
attemptsUsed,
attemptLimit,
},
}),
});
throw new AppError(errorCode, {
message: reasonCode,
statusCode,
});
};
+67
View File
@@ -54,6 +54,14 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
// External signing 2FA events.
'EXTERNAL_2FA_TOKEN_ISSUED',
'EXTERNAL_2FA_TOKEN_ISSUE_DENIED',
'EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED',
'EXTERNAL_2FA_TOKEN_VERIFY_FAILED',
'EXTERNAL_2FA_TOKEN_CONSUMED',
'EXTERNAL_2FA_TOKEN_REVOKED',
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -710,6 +718,59 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
}),
});
const ZExternal2FARecipientDataSchema = z.object({
recipientId: z.number(),
recipientEmail: z.string(),
recipientName: z.string(),
});
export const ZDocumentAuditLogEventExternal2FATokenIssuedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string().optional(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED),
data: ZExternal2FARecipientDataSchema.extend({
reasonCode: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string(),
attemptsUsed: z.number(),
attemptLimit: z.number(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenConsumedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenRevokedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string(),
}),
});
/**
* Event: Recipient's signing window expired.
*/
@@ -769,6 +830,12 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventExternal2FATokenIssuedSchema,
ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema,
ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema,
ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema,
ZDocumentAuditLogEventExternal2FATokenConsumedSchema,
ZDocumentAuditLogEventExternal2FATokenRevokedSchema,
ZDocumentAuditLogEventRecipientExpiredSchema,
]),
);
+23 -2
View File
@@ -5,7 +5,14 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
/**
* All the available types of document authentication options for both access and action.
*/
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', 'PASSWORD', 'EXPLICIT_NONE']);
export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'EXTERNAL_TWO_FACTOR_AUTH',
'PASSWORD',
'EXPLICIT_NONE',
]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
@@ -34,6 +41,10 @@ const ZDocumentAuth2FASchema = z.object({
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
});
const ZDocumentAuthExternal2FASchema = z.object({
type: z.literal(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH),
});
/**
* All the document auth methods for both accessing and actioning.
*/
@@ -42,6 +53,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
]);
@@ -67,10 +79,17 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
]);
export const ZDocumentActionAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD])
.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
])
.describe(
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
);
@@ -97,6 +116,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
ZDocumentAuthExplicitNoneSchema,
]);
@@ -105,6 +125,7 @@ export const ZRecipientActionAuthTypesSchema = z
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
DocumentAuth.EXPLICIT_NONE,
])
+7
View File
@@ -34,6 +34,8 @@ export const ZClaimFlagsSchema = z.object({
allowLegacyEnvelopes: z.boolean().optional(),
externalSigning2fa: z.boolean().optional(),
signingReminders: z.boolean().optional(),
});
@@ -102,6 +104,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'allowLegacyEnvelopes',
label: 'Allow Legacy Envelopes',
},
externalSigning2fa: {
key: 'externalSigning2fa',
label: 'External signing 2FA',
isEnterprise: true,
},
signingReminders: {
key: 'signingReminders',
label: 'Signing reminders',
-4
View File
@@ -31,8 +31,4 @@ export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
return chacha.decrypt(dataAsBytes);
};
export const tokenFingerprint = (token: string): string => {
return bytesToHex(sha256(token)).slice(0, 16);
};
export { sha256 };
-21
View File
@@ -1,21 +0,0 @@
type PublicAccessTeam = {
teamGlobalSettings?: {
allowPublicCompletedDocumentAccess: boolean | null;
} | null;
organisation: {
organisationGlobalSettings: {
allowPublicCompletedDocumentAccess: boolean;
};
};
} | null;
export const isPublicDocumentAccessEnabled = (team: PublicAccessTeam): boolean => {
if (!team) {
return true;
}
return (
team.teamGlobalSettings?.allowPublicCompletedDocumentAccess ??
team.organisation.organisationGlobalSettings.allowPublicCompletedDocumentAccess
);
};
+42
View File
@@ -597,6 +597,48 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
user: message,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED }, ({ data }) => {
const message = msg({
message: `External 2FA token issued for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED }, ({ data }) => {
const message = msg({
message: `External 2FA token issuance denied for recipient ${data.recipientEmail}: ${data.reasonCode}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED }, ({ data }) => {
const message = msg({
message: `External 2FA verification succeeded for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED }, ({ data }) => {
const message = msg({
message: `External 2FA verification failed for recipient ${data.recipientEmail}: ${data.reasonCode} (attempt ${data.attemptsUsed}/${data.attemptLimit})`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED }, ({ data }) => {
const message = msg({
message: `External 2FA token consumed for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED }, ({ data }) => {
const message = msg({
message: `External 2FA token revoked for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.exhaustive();
let selectedDescription = description.anonymous;
-1
View File
@@ -114,7 +114,6 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
includeSenderDetails: true,
includeSigningCertificate: true,
allowPublicCompletedDocumentAccess: true,
includeAuditLog: false,
typedSignatureEnabled: true,
-1
View File
@@ -181,7 +181,6 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
includeSenderDetails: null,
includeSigningCertificate: null,
allowPublicCompletedDocumentAccess: null,
includeAuditLog: null,
typedSignatureEnabled: null,
@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "SigningTwoFactorTokenStatus" AS ENUM ('ACTIVE', 'CONSUMED', 'REVOKED', 'EXPIRED');
-- CreateTable
CREATE TABLE "SigningTwoFactorToken" (
"id" TEXT NOT NULL,
"recipientId" INTEGER NOT NULL,
"envelopeId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenSalt" TEXT NOT NULL,
"status" "SigningTwoFactorTokenStatus" NOT NULL DEFAULT 'ACTIVE',
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"attempts" INTEGER NOT NULL DEFAULT 0,
"attemptLimit" INTEGER NOT NULL DEFAULT 5,
"issuedByApiTokenId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SigningTwoFactorToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SigningSessionTwoFactorProof" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"recipientId" INTEGER NOT NULL,
"envelopeId" TEXT NOT NULL,
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SigningSessionTwoFactorProof_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SigningTwoFactorToken_recipientId_envelopeId_status_idx" ON "SigningTwoFactorToken"("recipientId", "envelopeId", "status");
-- CreateIndex
CREATE INDEX "SigningTwoFactorToken_envelopeId_idx" ON "SigningTwoFactorToken"("envelopeId");
-- CreateIndex
CREATE INDEX "SigningSessionTwoFactorProof_recipientId_envelopeId_idx" ON "SigningSessionTwoFactorProof"("recipientId", "envelopeId");
-- CreateIndex
CREATE INDEX "SigningSessionTwoFactorProof_expiresAt_idx" ON "SigningSessionTwoFactorProof"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "SigningSessionTwoFactorProof_sessionId_recipientId_envelope_key" ON "SigningSessionTwoFactorProof"("sessionId", "recipientId", "envelopeId");
-- AddForeignKey
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,5 +0,0 @@
ALTER TABLE "OrganisationGlobalSettings"
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "TeamGlobalSettings"
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN;
@@ -1 +0,0 @@
CREATE INDEX "Envelope_qrToken_idx" ON "Envelope"("qrToken");
+58 -3
View File
@@ -437,13 +437,15 @@ model Envelope {
envelopeAttachments EnvelopeAttachment[]
signingTwoFactorTokens SigningTwoFactorToken[]
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
@@index([type])
@@index([status])
@@index([userId])
@@index([teamId])
@@index([folderId])
@@index([createdAt])
@@index([qrToken])
}
model EnvelopeItem {
@@ -605,6 +607,9 @@ model Recipient {
fields Field[]
signatures Signature[]
signingTwoFactorTokens SigningTwoFactorToken[]
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
@@index([token])
@@index([email])
@@index([envelopeId])
@@ -841,7 +846,6 @@ model OrganisationGlobalSettings {
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
allowPublicCompletedDocumentAccess Boolean @default(true)
includeAuditLog Boolean @default(false)
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
@@ -888,7 +892,6 @@ model TeamGlobalSettings {
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
allowPublicCompletedDocumentAccess Boolean?
includeAuditLog Boolean?
typedSignatureEnabled Boolean?
@@ -1118,6 +1121,58 @@ model Counter {
value Int
}
enum SigningTwoFactorTokenStatus {
ACTIVE
CONSUMED
REVOKED
EXPIRED
}
model SigningTwoFactorToken {
id String @id @default(cuid())
recipientId Int
envelopeId String
tokenHash String
tokenSalt String
status SigningTwoFactorTokenStatus @default(ACTIVE)
expiresAt DateTime
consumedAt DateTime?
revokedAt DateTime?
attempts Int @default(0)
attemptLimit Int @default(5)
issuedByApiTokenId Int?
createdAt DateTime @default(now())
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@index([recipientId, envelopeId, status])
@@index([envelopeId])
}
model SigningSessionTwoFactorProof {
id String @id @default(cuid())
sessionId String
recipientId Int
envelopeId String
verifiedAt DateTime @default(now())
expiresAt DateTime
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@unique([sessionId, recipientId, envelopeId])
@@index([recipientId, envelopeId])
@@index([expiresAt])
}
model RateLimit {
key String
action String
@@ -1,9 +1,8 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getOrganisationTemplateWhereInput } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
@@ -57,13 +56,15 @@ export const getEnvelopeItemsByTokenRoute = maybeAuthenticatedProcedure
});
const handleGetEnvelopeItemsByToken = async ({ envelopeId, token }: { envelopeId: string; token: string }) => {
const isQrToken = token.startsWith('qr_');
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
...(isQrToken ? { qrToken: token, status: DocumentStatus.COMPLETED } : { recipients: { some: { token } } }),
type: EnvelopeType.DOCUMENT, // You cannot get template envelope items by token.
recipients: {
some: {
token,
},
},
},
include: {
envelopeItems: {
@@ -71,20 +72,6 @@ const handleGetEnvelopeItemsByToken = async ({ envelopeId, token }: { envelopeId
documentData: true,
},
},
team: {
include: {
teamGlobalSettings: {
select: { allowPublicCompletedDocumentAccess: true },
},
organisation: {
include: {
organisationGlobalSettings: {
select: { allowPublicCompletedDocumentAccess: true },
},
},
},
},
},
},
});
@@ -94,12 +81,6 @@ const handleGetEnvelopeItemsByToken = async ({ envelopeId, token }: { envelopeId
});
}
if (isQrToken && !isPublicDocumentAccessEnabled(envelope.team)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Public completed-document access is disabled',
});
}
return {
envelopeItems: envelope.envelopeItems,
};
@@ -34,6 +34,9 @@ import { saveAsTemplateRoute } from './save-as-template';
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';
@@ -97,5 +100,10 @@ export const envelopeRouter = router({
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
signing2fa: {
issue: issueSigningTwoFactorTokenRoute,
verify: verifySigningTwoFactorTokenRoute,
getStatus: getSigningTwoFactorStatusRoute,
},
signingStatus: signingStatusEnvelopeRoute,
});
@@ -176,6 +176,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
>;
@@ -33,7 +33,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -164,7 +163,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -21,7 +21,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
documentDateFormat: ZDocumentMetaDateFormatSchema.optional(),
includeSenderDetails: z.boolean().optional(),
includeSigningCertificate: z.boolean().optional(),
allowPublicCompletedDocumentAccess: z.boolean().optional(),
includeAuditLog: z.boolean().optional(),
typedSignatureEnabled: z.boolean().optional(),
uploadSignatureEnabled: z.boolean().optional(),
@@ -32,7 +32,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -167,7 +166,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -25,7 +25,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
documentDateFormat: ZDocumentMetaDateFormatSchema.nullish(),
includeSenderDetails: z.boolean().nullish(),
includeSigningCertificate: z.boolean().nullish(),
allowPublicCompletedDocumentAccess: z.boolean().nullish(),
includeAuditLog: z.boolean().nullish(),
typedSignatureEnabled: z.boolean().nullish(),
uploadSignatureEnabled: z.boolean().nullish(),