From df815e5b44b5d21876159191eb616348703ed10c Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 8 Sep 2026 14:12:25 +1000 Subject: [PATCH] feat: add instance and org 2fa enrolment and enforcement --- .../docs/self-hosting/configuration/meta.json | 1 + .../configuration/two-factor-enforcement.mdx | 54 ++ .../admin-user-reset-two-factor-dialog.tsx | 8 +- .../2fa/disable-authenticator-app-dialog.tsx | 31 +- .../2fa/enable-authenticator-app-dialog.tsx | 14 +- ...ganisation-two-factor-enforcement-form.tsx | 282 +++++++ .../admin-two-factor-enforcement-section.tsx | 332 ++++++++ .../general/two-factor-grace-banner.tsx | 144 ++++ .../tables/organisation-members-table.tsx | 18 + apps/remix/app/root.tsx | 15 +- .../app/routes/_authenticated+/_layout.tsx | 146 +++- .../_authenticated+/admin+/site-settings.tsx | 3 + .../_authenticated+/admin+/users.$id.tsx | 8 +- .../o.$orgUrl.settings.general.tsx | 14 + .../_unauthenticated+/2fa-challenge.tsx | 244 ++++++ .../organisation.invite.$token.tsx | 36 + .../organisation.sso.confirmation.$token.tsx | 30 + .../routes/embed+/v2+/authoring+/_layout.tsx | 3 + apps/remix/app/routes/onboarding+/2fa.tsx | 385 +++++++++ apps/remix/app/routes/onboarding+/_layout.tsx | 32 + apps/remix/server/api/ai/detect-fields.ts | 9 + apps/remix/server/api/ai/detect-recipients.ts | 9 + apps/remix/server/api/files/files.ts | 62 ++ .../api/files/routes/get-envelope-item-pdf.ts | 31 + .../app-tests/e2e/fixtures/authentication.ts | 8 + packages/app-tests/e2e/fixtures/two-factor.ts | 111 +++ ...rganisation-two-factor-enforcement.spec.ts | 272 ++++++ .../two-factor-backup-code-recovery.spec.ts | 64 ++ packages/auth/client/index.ts | 77 +- .../auth/server/lib/errors/error-codes.ts | 3 + .../server/lib/session/session-cookies.ts | 2 +- packages/auth/server/lib/session/session.ts | 35 +- packages/auth/server/lib/utils/authorizer.ts | 25 +- packages/auth/server/lib/utils/get-session.ts | 2 + .../lib/utils/handle-oauth-callback-url.ts | 48 +- .../handle-oauth-organisation-callback-url.ts | 25 +- .../server/lib/utils/two-factor-challenge.ts | 377 +++++++++ packages/auth/server/routes/email-password.ts | 210 ++--- packages/auth/server/routes/passkey.ts | 19 +- packages/auth/server/routes/session.ts | 19 +- packages/auth/server/routes/two-factor.ts | 266 +++++- .../auth/server/routes/two-factor.types.ts | 14 + packages/auth/server/types/passkey.ts | 6 + .../lib/link-organisation-account.ts | 170 +++- ...isation-account-link-confirmation-email.ts | 5 +- .../lib/client-only/providers/session.tsx | 9 + packages/lib/constants/organisations.ts | 6 + packages/lib/errors/app-error.ts | 10 +- packages/lib/server-only/2fa/disable-2fa.ts | 35 +- packages/lib/server-only/2fa/enable-2fa.ts | 49 +- ...-instance-two-factor-enforcement-config.ts | 68 ++ ...instance-two-factor-enforcement-setting.ts | 52 ++ .../2fa/get-two-factor-enforcement-status.ts | 53 ++ .../lib/server-only/2fa/org-enforcement.ts | 191 +++++ .../2fa/reset-2fa-after-backup-code-use.ts | 82 ++ packages/lib/server-only/2fa/setup-2fa.ts | 16 +- .../lib/server-only/2fa/validate-2fa.test.ts | 108 +++ packages/lib/server-only/2fa/validate-2fa.ts | 20 +- .../lib/server-only/2fa/view-backup-codes.ts | 4 +- .../auth/create-passkey-signin-options.ts | 11 +- .../public-api/get-api-token-by-token.ts | 9 + .../lib/server-only/rate-limit/rate-limits.ts | 22 + .../lib/server-only/site-settings/schema.ts | 2 + .../schemas/two-factor-enforcement.ts | 25 + packages/lib/types/license.ts | 1 + packages/lib/types/organisation.ts | 18 +- packages/lib/types/session-auth-method.ts | 12 + packages/lib/types/two-factor-challenge.ts | 76 ++ packages/lib/utils/organisations.ts | 4 + packages/lib/utils/teams.ts | 23 + .../lib/utils/two-factor-challenge.test.ts | 141 ++++ packages/lib/utils/two-factor-challenge.ts | 54 ++ packages/lib/utils/two-factor.test.ts | 777 ++++++++++++++++++ packages/lib/utils/two-factor.ts | 449 ++++++++++ .../migration.sql | 15 + .../migration.sql | 2 + .../migration.sql | 2 + packages/prisma/schema.prisma | 31 +- packages/trpc/package.json | 2 + .../get-two-factor-enforcement.ts | 23 + .../get-two-factor-enforcement.types.ts | 14 + .../reset-two-factor-authentication.ts | 69 +- packages/trpc/server/admin-router/router.ts | 4 + .../admin-router/update-site-setting.types.ts | 17 +- .../update-two-factor-enforcement.ts | 101 +++ .../update-two-factor-enforcement.types.ts | 16 + .../api-token-router/create-api-token.ts | 2 + .../api-token-router/delete-api-token.ts | 2 + .../server/api-token-router/get-api-tokens.ts | 2 + .../create-passkey-authentication-options.ts | 3 + .../create-passkey-registration-options.ts | 3 + .../trpc/server/auth-router/create-passkey.ts | 3 + .../trpc/server/auth-router/delete-passkey.ts | 3 + .../trpc/server/auth-router/find-passkeys.ts | 3 + .../trpc/server/auth-router/update-passkey.ts | 3 + .../attachment/create-attachment.ts | 2 + .../attachment/delete-attachment.ts | 2 + .../attachment/find-attachments.ts | 2 + .../attachment/update-attachment.ts | 2 + .../create-document-temporary.ts | 2 + .../server/document-router/create-document.ts | 2 + .../server/document-router/delete-document.ts | 2 + .../document-router/distribute-document.ts | 2 + .../download-document-audit-logs.ts | 2 + .../document-router/download-document-beta.ts | 2 + .../download-document-certificate.ts | 2 + .../document-router/download-document.ts | 2 + .../document-router/duplicate-document.ts | 2 + .../find-document-audit-logs.ts | 2 + .../find-documents-internal.ts | 2 + .../server/document-router/find-documents.ts | 2 + .../trpc/server/document-router/find-inbox.ts | 3 + .../document-router/get-document-by-token.ts | 7 + .../server/document-router/get-document.ts | 2 + .../document-router/get-documents-by-ids.ts | 2 + .../server/document-router/get-inbox-count.ts | 3 + .../document-router/redistribute-document.ts | 2 + .../server/document-router/search-document.ts | 2 + .../server/document-router/update-document.ts | 2 + .../create-organisation-email-domain.ts | 2 + .../create-organisation-email.ts | 2 + .../enterprise-router/create-subscription.ts | 2 + .../delete-organisation-email-domain.ts | 2 + .../delete-organisation-email.ts | 2 + .../find-organisation-email-domain.ts | 2 + .../find-organisation-emails.ts | 2 + .../server/enterprise-router/get-invoices.ts | 2 + .../get-organisation-authentication-portal.ts | 2 + .../get-organisation-email-domain.ts | 2 + .../server/enterprise-router/get-plans.ts | 42 +- .../enterprise-router/get-subscription.ts | 2 + .../enterprise-router/manage-subscription.ts | 2 + .../enterprise-router/sync-subscription.ts | 2 + ...date-organisation-authentication-portal.ts | 2 + .../update-organisation-email.ts | 2 + .../verify-organisation-email-domain.ts | 2 + .../attachment/create-attachment.ts | 2 + .../attachment/delete-attachment.ts | 2 + .../attachment/find-attachments.ts | 5 + .../attachment/update-attachment.ts | 2 + .../envelope-router/bulk-cancel-envelopes.ts | 2 + .../envelope-router/bulk-delete-envelopes.ts | 2 + .../envelope-router/bulk-move-envelopes.ts | 2 + .../server/envelope-router/cancel-envelope.ts | 2 + .../envelope-router/create-envelope-items.ts | 2 + .../server/envelope-router/create-envelope.ts | 2 + .../envelope-router/delete-envelope-item.ts | 2 + .../server/envelope-router/delete-envelope.ts | 2 + .../envelope-router/distribute-envelope.ts | 2 + .../download-envelope-audit-log-pdf.ts | 2 + .../download-envelope-certificate-pdf.ts | 2 + .../envelope-router/download-envelope-item.ts | 2 + .../envelope-router/duplicate-envelope.ts | 2 + .../envelope-fields/create-envelope-fields.ts | 2 + .../envelope-fields/delete-envelope-field.ts | 2 + .../get-envelope-field-signatures.ts | 2 + .../envelope-fields/get-envelope-field.ts | 2 + .../envelope-fields/update-envelope-fields.ts | 2 + .../create-envelope-recipients.ts | 2 + .../delete-envelope-recipient.ts | 2 + .../get-envelope-recipient.ts | 2 + .../reject-envelope-recipient-on-behalf-of.ts | 2 + .../update-envelope-recipients.ts | 2 + .../find-envelope-audit-logs.ts | 2 + .../server/envelope-router/find-envelopes.ts | 2 + .../envelope-router/get-editor-envelope.ts | 2 + .../get-envelope-items-by-token.ts | 7 + .../envelope-router/get-envelope-items.ts | 2 + .../server/envelope-router/get-envelope.ts | 2 + .../envelope-router/get-envelopes-by-ids.ts | 13 + .../envelope-router/redistribute-envelope.ts | 2 + .../replace-envelope-item-pdf.ts | 2 + .../envelope-router/save-as-template.ts | 2 + .../envelope-router/set-envelope-fields.ts | 2 + .../set-envelope-recipients.ts | 2 + .../signing-status-envelope.ts | 4 + .../envelope-router/update-envelope-items.ts | 2 + .../server/envelope-router/update-envelope.ts | 2 + .../server/envelope-router/use-envelope.ts | 2 + packages/trpc/server/field-router/router.ts | 15 + packages/trpc/server/folder-router/router.ts | 7 + .../accept-organisation-member-invite.ts | 3 + .../create-organisation-group.ts | 2 + .../create-organisation-member-invites.ts | 2 + .../create-organisation.ts | 3 + .../decline-organisation-member-invite.ts | 3 + .../delete-organisation-group.ts | 2 + .../delete-organisation-member-invites.ts | 2 + .../delete-organisation-member.ts | 2 + .../delete-organisation-members.ts | 2 + .../delete-organisation.ts | 2 + .../find-organisation-groups.ts | 2 + .../find-organisation-member-invites.ts | 2 + .../find-organisation-members.ts | 4 + .../find-organisation-members.types.ts | 7 + .../get-organisation-member-invites.ts | 3 + .../get-organisation-quota-flags.ts | 2 + .../get-organisation-session.ts | 68 +- .../get-organisation-session.types.ts | 25 + .../organisation-router/get-organisation.ts | 2 + .../organisation-router/get-organisations.ts | 3 + .../organisation-router/leave-organisation.ts | 3 + .../resend-organisation-member-invite.ts | 2 + .../update-organisation-branding-logo.ts | 4 + .../update-organisation-group.ts | 2 + .../update-organisation-members.ts | 2 + .../update-organisation-settings.ts | 101 ++- .../update-organisation-settings.types.ts | 12 + .../update-organisation.ts | 2 + packages/trpc/server/profile-router/router.ts | 127 +-- .../find-recipient-suggestions.ts | 2 + .../trpc/server/recipient-router/router.ts | 15 + .../server/team-router/create-team-groups.ts | 2 + .../server/team-router/create-team-members.ts | 2 + .../trpc/server/team-router/create-team.ts | 2 + .../server/team-router/delete-team-group.ts | 2 + .../server/team-router/delete-team-member.ts | 2 + .../trpc/server/team-router/delete-team.ts | 2 + .../server/team-router/find-team-groups.ts | 2 + .../server/team-router/find-team-members.ts | 2 + .../trpc/server/team-router/find-teams.ts | 2 + .../server/team-router/get-team-members.ts | 2 + packages/trpc/server/team-router/get-team.ts | 2 + packages/trpc/server/team-router/router.ts | 93 ++- .../team-router/update-team-branding-logo.ts | 4 + .../server/team-router/update-team-group.ts | 2 + .../server/team-router/update-team-member.ts | 2 + .../team-router/update-team-settings.ts | 2 + .../trpc/server/team-router/update-team.ts | 2 + .../template-router/get-templates-by-ids.ts | 2 + .../trpc/server/template-router/router.ts | 110 ++- .../server/template-router/search-template.ts | 2 + packages/trpc/server/trpc.ts | 48 +- .../drift-guard.test.ts | 207 +++++ .../server/two-factor-enforcement/enforce.ts | 338 ++++++++ .../two-factor-enforcement/resolution.ts | 497 +++++++++++ .../two-factor-enforcement/scope.test.ts | 137 +++ .../server/two-factor-enforcement/scope.ts | 293 +++++++ .../webhook-router/find-webhook-calls.ts | 2 + .../webhook-router/resend-webhook-call.ts | 2 + packages/trpc/server/webhook-router/router.ts | 186 +++-- packages/trpc/vitest.config.ts | 13 + 242 files changed, 8423 insertions(+), 528 deletions(-) create mode 100644 apps/docs/content/docs/self-hosting/configuration/two-factor-enforcement.mdx create mode 100644 apps/remix/app/components/forms/organisation-two-factor-enforcement-form.tsx create mode 100644 apps/remix/app/components/general/admin-two-factor-enforcement-section.tsx create mode 100644 apps/remix/app/components/general/two-factor-grace-banner.tsx create mode 100644 apps/remix/app/routes/_unauthenticated+/2fa-challenge.tsx create mode 100644 apps/remix/app/routes/onboarding+/2fa.tsx create mode 100644 apps/remix/app/routes/onboarding+/_layout.tsx create mode 100644 packages/app-tests/e2e/fixtures/two-factor.ts create mode 100644 packages/app-tests/e2e/organisations/organisation-two-factor-enforcement.spec.ts create mode 100644 packages/app-tests/e2e/user/two-factor-backup-code-recovery.spec.ts create mode 100644 packages/auth/server/lib/utils/two-factor-challenge.ts create mode 100644 packages/lib/server-only/2fa/get-instance-two-factor-enforcement-config.ts create mode 100644 packages/lib/server-only/2fa/get-instance-two-factor-enforcement-setting.ts create mode 100644 packages/lib/server-only/2fa/get-two-factor-enforcement-status.ts create mode 100644 packages/lib/server-only/2fa/org-enforcement.ts create mode 100644 packages/lib/server-only/2fa/reset-2fa-after-backup-code-use.ts create mode 100644 packages/lib/server-only/2fa/validate-2fa.test.ts create mode 100644 packages/lib/server-only/site-settings/schemas/two-factor-enforcement.ts create mode 100644 packages/lib/types/session-auth-method.ts create mode 100644 packages/lib/types/two-factor-challenge.ts create mode 100644 packages/lib/utils/two-factor-challenge.test.ts create mode 100644 packages/lib/utils/two-factor-challenge.ts create mode 100644 packages/lib/utils/two-factor.test.ts create mode 100644 packages/lib/utils/two-factor.ts create mode 100644 packages/prisma/migrations/20260828000000_add_two_factor_enforcement/migration.sql create mode 100644 packages/prisma/migrations/20260828000001_add_verification_token_attempts/migration.sql create mode 100644 packages/prisma/migrations/20260828000002_add_two_factor_admin_reset_audit_log/migration.sql create mode 100644 packages/trpc/server/admin-router/get-two-factor-enforcement.ts create mode 100644 packages/trpc/server/admin-router/get-two-factor-enforcement.types.ts create mode 100644 packages/trpc/server/admin-router/update-two-factor-enforcement.ts create mode 100644 packages/trpc/server/admin-router/update-two-factor-enforcement.types.ts create mode 100644 packages/trpc/server/two-factor-enforcement/drift-guard.test.ts create mode 100644 packages/trpc/server/two-factor-enforcement/enforce.ts create mode 100644 packages/trpc/server/two-factor-enforcement/resolution.ts create mode 100644 packages/trpc/server/two-factor-enforcement/scope.test.ts create mode 100644 packages/trpc/server/two-factor-enforcement/scope.ts create mode 100644 packages/trpc/vitest.config.ts diff --git a/apps/docs/content/docs/self-hosting/configuration/meta.json b/apps/docs/content/docs/self-hosting/configuration/meta.json index 6cc250f64..5fb37ebdd 100644 --- a/apps/docs/content/docs/self-hosting/configuration/meta.json +++ b/apps/docs/content/docs/self-hosting/configuration/meta.json @@ -9,6 +9,7 @@ "background-jobs", "signing-certificate", "telemetry", + "two-factor-enforcement", "organisation-limits", "advanced" ] diff --git a/apps/docs/content/docs/self-hosting/configuration/two-factor-enforcement.mdx b/apps/docs/content/docs/self-hosting/configuration/two-factor-enforcement.mdx new file mode 100644 index 000000000..d807b2950 --- /dev/null +++ b/apps/docs/content/docs/self-hosting/configuration/two-factor-enforcement.mdx @@ -0,0 +1,54 @@ +--- +title: Two-Factor Enforcement +description: Require two-factor authentication instance-wide or per organisation, with grace periods and important limitations. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +## Overview + +Documenso can require users to enable two-factor authentication (2FA) at two levels: + +- **Instance-wide enforcement** — configured by an instance administrator under **Admin → Site Settings**. Requires a Documenso license that includes the feature. Once a user's grace period expires, they are redirected to a forced enrolment page before they can continue using the app. +- **Per-organisation enforcement** — available to everyone, configured by organisation admins in the organisation settings. Once a member's grace period expires, only access to that organisation (and its teams) is blocked; the rest of the app stays usable. + +A user satisfies enforcement when they have 2FA enabled **and** their current session has passed a second factor (a TOTP/backup-code challenge, a user-verified passkey sign-in, or enabling 2FA during the session). Sessions created before the user enabled 2FA must sign out and back in to verify. + +## Grace Periods + +Both levels support a grace period of 0–365 days: + +- **Instance**: the window starts at the later of the user's grace start (typically account creation, restarted by an admin 2FA reset) and the moment enforcement was enabled. +- **Organisation**: the window starts at the latest of joining the organisation, the moment the organisation enabled enforcement, and the user's grace start. + +A grace period of **0 days** enforces immediately: for the instance policy, users are forced to enrol right after signing up or signing in; for the organisation policy, members are blocked from the organisation until they enrol. + +**Joining is never blocked; access is.** Invitations and SSO sign-ins always succeed — the grace window starts at join. Members who never comply still occupy a seat and count towards member limits; organisation admins can see per-member 2FA compliance in the members list. + +Reducing an active grace period requires an explicit acknowledgement in the settings UI, since it can immediately block users who have not yet enrolled. + +Enabling enforcement requires the acting administrator to already satisfy the policy themselves (2FA enabled and verified on their current session). This prevents administrators from locking themselves out with a 0-day grace period. + +## Known Limitation: API Tokens Are Exempt + + + Enforcement applies to interactive (session-based) access only. **API tokens minted before a + user's deadline keep working after it.** A blocked user cannot mint new tokens, but existing + tokens are not revoked by enforcement. If you need to cut off a non-compliant user's API access, + revoke their tokens explicitly. + + +## Licensing + +Instance-wide enforcement is license-gated: + +- Without the license, the instance-wide section in Admin → Site Settings is visible but disabled. +- If enforcement was configured while licensed and the license later lapses, the stored configuration becomes **inactive** (nothing is enforced) and the only permitted change is disabling it. + +Per-organisation enforcement does not require a license. When instance-wide enforcement is active, it takes precedence over organisation policies. + +--- + +## See Also + +- [License](/docs/self-hosting/configuration/license) - Configuring your Documenso license diff --git a/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx index 3a63ede2a..9c4886994 100644 --- a/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx @@ -56,7 +56,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) .with( AppErrorCode.UNAUTHORIZED, - () => msg`You are not authorized to reset two factor authentcation for this user.`, + () => msg`You are not authorized to reset two factor authentication for this user.`, ) .otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`); @@ -85,7 +85,8 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese Reset the users two factor authentication. This action is irreversible and will disable two factor - authentication for the user. + authentication for the user. Their two-factor enforcement grace period will restart from the moment of the + reset. @@ -108,7 +109,8 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese - This action is irreversible. Please ensure you have informed the user before proceeding. + This action is irreversible. Please ensure you have informed the user before proceeding. Any + two-factor enforcement grace period for this user will restart from the moment of the reset. diff --git a/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx b/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx index 8e6b7b202..ca5cc55f0 100644 --- a/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx +++ b/apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx @@ -1,5 +1,6 @@ import { authClient } from '@documenso/auth/client'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { AppError } from '@documenso/lib/errors/app-error'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -68,15 +69,23 @@ export const DisableAuthenticatorAppDialog = () => { const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState; + // Todo: (2FA enforcement, step 5) Once org enforcement state is available + // client-side, warn BEFORE disabling that org/team access will block at the + // org 2FA deadline. Until then the warning is shown after the fact based on + // the server response. const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => { try { - await authClient.twoFactor.disable({ totpCode, backupCode }); + const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode }); toast({ title: _(msg`Two-factor authentication disabled`), - description: _( - msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`, - ), + description: orgEnforcementApplies + ? _( + msg`Two-factor authentication has been disabled for your account. One of your organisations requires two-factor authentication: access to it will be blocked at its deadline until you re-enable 2FA.`, + ) + : _( + msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`, + ), }); flushSync(() => { @@ -84,7 +93,19 @@ export const DisableAuthenticatorAppDialog = () => { }); await refreshSession(); - } catch (_err) { + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === 'TWO_FACTOR_DISABLE_FORBIDDEN') { + toast({ + title: _(msg`Unable to disable two-factor authentication`), + description: _(msg`Two-factor authentication is required by this instance and cannot be disabled.`), + variant: 'destructive', + }); + + return; + } + toast({ title: _(msg`Unable to disable two-factor authentication`), description: _( diff --git a/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx index b1934b3da..9a3541a46 100644 --- a/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -1,6 +1,7 @@ import { authClient } from '@documenso/auth/client'; import { downloadFile } from '@documenso/lib/client-only/download-file'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { AppError } from '@documenso/lib/errors/app-error'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -69,11 +70,18 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA setSetup2FAData(data); } catch (err) { + const error = AppError.parseError(err); + toast({ title: _(msg`Unable to setup two-factor authentication`), - description: _( - msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`, - ), + description: + error.code === 'TWO_FACTOR_ALREADY_ENABLED' + ? _( + msg`Two-factor authentication is already enabled for your account. Disable it before setting it up again.`, + ) + : _( + msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`, + ), variant: 'destructive', }); } diff --git a/apps/remix/app/components/forms/organisation-two-factor-enforcement-form.tsx b/apps/remix/app/components/forms/organisation-two-factor-enforcement-form.tsx new file mode 100644 index 000000000..100967506 --- /dev/null +++ b/apps/remix/app/components/forms/organisation-two-factor-enforcement-form.tsx @@ -0,0 +1,282 @@ +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const ZTwoFactorEnforcementFormSchema = z.object({ + twoFactorRequired: z.boolean(), + twoFactorGracePeriodDays: z.coerce.number().int().min(0).max(365), + acknowledgeGracePeriodReduction: z.boolean(), +}); + +type TTwoFactorEnforcementFormSchema = z.infer; + +/** + * Organisation 2FA enforcement settings (require toggle + grace period). + * + * Rendered only for MANAGE_ORGANISATION_SECURITY holders (ADMIN). When + * instance-wide enforcement is active the fields are shown disabled — not + * hidden — with a banner explaining that the instance policy takes + * precedence, so a configured organisation policy stays visible instead of + * resurfacing already-expired later. + */ +export const OrganisationTwoFactorEnforcementForm = () => { + const { _, i18n } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + const { twoFactorEnforcement: instanceTwoFactorEnforcement } = useSession(); + + const isInstanceEnforcementActive = instanceTwoFactorEnforcement.required; + + const { data: organisationWithSettings, isLoading } = trpc.organisation.get.useQuery({ + organisationReference: organisation.url, + }); + + const utils = trpc.useUtils(); + + const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation(); + + const settings = organisationWithSettings?.organisationGlobalSettings; + + const form = useForm({ + values: { + twoFactorRequired: settings?.twoFactorRequired ?? false, + twoFactorGracePeriodDays: settings?.twoFactorGracePeriodDays ?? 7, + acknowledgeGracePeriodReduction: false, + }, + resolver: zodResolver(ZTwoFactorEnforcementFormSchema), + }); + + const watchedValues = form.watch(); + + // Client-side mirror of the server's grace-reduction detection so we can + // surface the acknowledgement checkbox before submitting. + const isGraceReduction = + settings !== undefined && + isTwoFactorGracePeriodReduction({ + previous: settings.twoFactorRequired + ? { + anchors: [settings.twoFactorEnforcedFrom], + gracePeriodDays: settings.twoFactorGracePeriodDays, + } + : null, + next: watchedValues.twoFactorRequired + ? { + anchors: [settings.twoFactorRequired ? settings.twoFactorEnforcedFrom : new Date()], + gracePeriodDays: watchedValues.twoFactorGracePeriodDays, + } + : null, + now: new Date(), + }); + + const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => { + try { + await updateOrganisationSettings({ + organisationId: organisation.id, + acknowledgeGracePeriodReduction: data.acknowledgeGracePeriodReduction, + data: { + twoFactorRequired: data.twoFactorRequired, + twoFactorGracePeriodDays: data.twoFactorGracePeriodDays, + }, + }); + + await utils.organisation.get.invalidate(); + + toast({ + title: _(msg`Two-factor enforcement settings updated`), + }); + + form.setValue('acknowledgeGracePeriodReduction', false); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) { + toast({ + title: _(msg`Two-factor authentication required`), + description: _( + msg`You must have two-factor authentication enabled and verified on this session before requiring it for the organisation.`, + ), + variant: 'destructive', + }); + + return; + } + + toast({ + title: _(msg`Something went wrong`), + description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`), + variant: 'destructive', + }); + } + }; + + if (isLoading || !settings) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ {isInstanceEnforcementActive && ( + + + Instance policy takes precedence + + + + Two-factor authentication is enforced instance-wide by your administrator, so the organisation policy + below is not editable while the instance policy is active. + + + + )} + + ( + +
+ + Require two-factor authentication + + + + Members must enable two-factor authentication to access this organisation. Joining is never + blocked — the grace period starts when a member joins. + + +
+ + + +
+ )} + /> + + ( + + + Grace period (days) + + + + + + + Number of days a member has to enable two-factor authentication after joining. 0 blocks organisation + access immediately until they enrol. + + + + + )} + /> + + {settings.twoFactorRequired && settings.twoFactorEnforcedFrom && ( +

+ + Enforcement has been active since {i18n.date(settings.twoFactorEnforcedFrom, { dateStyle: 'long' })}. + +

+ )} + + + +
    +
  • + + API tokens are exempt: tokens minted before a member's deadline keep working after it. Blocked + members cannot mint new tokens. + +
  • +
  • + + Members who have not yet complied still occupy a seat and count towards your member limit. + +
  • +
+
+
+ + {isGraceReduction && ( + + + This change reduces an active grace period + + + + Members who have not yet enabled two-factor authentication will have less time to comply — possibly + none, which blocks their organisation access immediately. + + + ( + + + field.onChange(checked === true)} + /> + + + I understand that this reduces the remaining grace period for members + + + )} + /> + + + )} + +
+ +
+
+
+ + ); +}; diff --git a/apps/remix/app/components/general/admin-two-factor-enforcement-section.tsx b/apps/remix/app/components/general/admin-two-factor-enforcement-section.tsx new file mode 100644 index 000000000..bc9087810 --- /dev/null +++ b/apps/remix/app/components/general/admin-two-factor-enforcement-section.tsx @@ -0,0 +1,332 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { LoaderIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const ZTwoFactorEnforcementFormSchema = z.object({ + enabled: z.boolean(), + gracePeriodDays: z.coerce.number().int().min(0).max(365), + acknowledgeGracePeriodReduction: z.boolean(), +}); + +type TTwoFactorEnforcementFormSchema = z.infer; + +/** + * Instance-wide 2FA enforcement settings for the admin site-settings page. + * + * License-gated states: + * + * - Licensed: full form. + * - Unlicensed + unconfigured: section visible but disabled with a "requires + * license" note. + * - Unlicensed + configured ("configured but inactive", e.g. license lapsed): + * stored values shown read-only with a disable-only affordance — the only + * permitted unlicensed update is turning the stored policy off. + */ +export const AdminTwoFactorEnforcementSection = () => { + const { _, i18n } = useLingui(); + const { toast } = useToast(); + + const { data: enforcementConfig, isLoading } = trpc.admin.getTwoFactorEnforcement.useQuery(); + + const utils = trpc.useUtils(); + + const { mutateAsync: updateTwoFactorEnforcement, isPending: isUpdatePending } = + trpc.admin.updateTwoFactorEnforcement.useMutation(); + + const form = useForm({ + values: { + enabled: enforcementConfig?.enabled ?? false, + gracePeriodDays: enforcementConfig?.gracePeriodDays ?? 7, + acknowledgeGracePeriodReduction: false, + }, + resolver: zodResolver(ZTwoFactorEnforcementFormSchema), + }); + + const watchedValues = form.watch(); + + const isLicensed = enforcementConfig?.isLicensed ?? false; + const isConfiguredButInactive = !isLicensed && (enforcementConfig?.enabled ?? false); + + // Client-side mirror of the server's grace-reduction detection so we can + // surface the acknowledgement checkbox before submitting. The server resets + // `enforcedFrom` to now on an off→on transition, hence the `new Date()` + // anchor when the stored policy is currently disabled. + const isGraceReduction = + enforcementConfig !== undefined && + isTwoFactorGracePeriodReduction({ + previous: enforcementConfig.enabled + ? { + anchors: [enforcementConfig.enforcedFrom ? new Date(enforcementConfig.enforcedFrom) : null], + gracePeriodDays: enforcementConfig.gracePeriodDays, + } + : null, + next: watchedValues.enabled + ? { + anchors: [ + enforcementConfig.enabled && enforcementConfig.enforcedFrom + ? new Date(enforcementConfig.enforcedFrom) + : new Date(), + ], + gracePeriodDays: watchedValues.gracePeriodDays, + } + : null, + now: new Date(), + }); + + const onUpdate = async (data: { + enabled: boolean; + gracePeriodDays: number; + acknowledgeGracePeriodReduction?: boolean; + }) => { + try { + await updateTwoFactorEnforcement(data); + + await utils.admin.getTwoFactorEnforcement.invalidate(); + + toast({ + title: _(msg`Two-factor enforcement settings updated`), + }); + + form.setValue('acknowledgeGracePeriodReduction', false); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) { + toast({ + title: _(msg`Two-factor authentication required`), + description: _( + msg`Enable two-factor authentication on your own account and verify it on this session before requiring it for the instance.`, + ), + variant: 'destructive', + }); + + return; + } + + if (error.code === AppErrorCode.FORBIDDEN) { + toast({ + title: _(msg`License required`), + description: _( + msg`Your license does not include instance-wide two-factor enforcement. Only disabling the stored configuration is permitted.`, + ), + variant: 'destructive', + }); + + return; + } + + toast({ + title: _(msg`Something went wrong`), + description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`), + variant: 'destructive', + }); + } + }; + + const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => { + await onUpdate(data); + }; + + // Disable-only affordance for the "configured but inactive" state: submits + // an enabled→disabled transition with the stored values unchanged, which is + // the only unlicensed update the server accepts. + const onDisableOnly = async () => { + if (!enforcementConfig) { + return; + } + + await onUpdate({ + enabled: false, + gracePeriodDays: enforcementConfig.gracePeriodDays, + }); + }; + + return ( +
+

+ Instance Two-Factor Enforcement +

+

+ + Require every user on this instance, including administrators, to enable two-factor authentication within a + grace period. + +

+ + {isLoading || !enforcementConfig ? ( +
+ +
+ ) : ( +
+ +
+ {!isLicensed && !isConfiguredButInactive && ( + + + Requires a license + + + + Instance-wide two-factor enforcement requires a Documenso license that includes this feature. + + + + )} + + {isConfiguredButInactive && ( + + + Configured but inactive + + + + Two-factor enforcement is configured but your current license does not include this feature, so it + is not being enforced. You can disable the stored configuration below; changing it requires a + license. + + + + )} + + ( + +
+ + Require two-factor authentication + + + + Users who have not enabled two-factor authentication by their deadline are redirected to a + forced enrolment page before they can continue. + + +
+ + + +
+ )} + /> + + ( + + + Grace period (days) + + + + + + + Number of days a user has to enable two-factor authentication. 0 forces enrolment immediately + after signing up or signing in. + + + + + )} + /> + + {enforcementConfig.isActive && enforcementConfig.enforcedFrom && ( +

+ + Enforcement has been active since{' '} + {i18n.date(new Date(enforcementConfig.enforcedFrom), { dateStyle: 'long' })}. + +

+ )} + + + + + API tokens are exempt: tokens minted before a user's deadline keep working after it. Blocked users + cannot mint new tokens. + + + + + {isGraceReduction && ( + + + This change reduces an active grace period + + + + Users who have not yet enabled two-factor authentication will have less time to comply — possibly + none, which blocks their access immediately. + + + ( + + + field.onChange(checked === true)} + /> + + + I understand that this reduces the remaining grace period for users + + + )} + /> + + + )} + +
+ +
+
+ + {isConfiguredButInactive && ( +
+ +
+ )} +
+ + )} +
+ ); +}; diff --git a/apps/remix/app/components/general/two-factor-grace-banner.tsx b/apps/remix/app/components/general/two-factor-grace-banner.tsx new file mode 100644 index 000000000..d11ec65a5 --- /dev/null +++ b/apps/remix/app/components/general/two-factor-grace-banner.tsx @@ -0,0 +1,144 @@ +import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; +import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon, XIcon } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { Link, useLocation } from 'react-router'; + +type GraceBannerCandidate = { + /** + * Dismissal scope: `instance` or `org:`. + */ + scope: string; + deadline: Date; + isSessionUnverified: boolean; +}; + +const buildDismissalKey = (userId: number, candidate: GraceBannerCandidate) => + `2fa-grace-banner:${userId}:${candidate.scope}:${candidate.deadline.getTime()}`; + +const toCandidate = ( + status: TTwoFactorEnforcementStatus, + scope: string, + isSessionUnverified: boolean, +): GraceBannerCandidate | null => { + // Banner territory is the grace window only: required, not yet satisfied, + // not yet expired. Expiry is handled by the org 403 screen (and, for + // instance enforcement, the onboarding redirect). + if (!status.required || status.isSatisfied || status.isDeadlineExpired) { + return null; + } + + return { + scope, + deadline: status.deadline, + isSessionUnverified, + }; +}; + +/** + * Shared grace-period banner for 2FA enforcement. + * + * Shows the NEAREST applicable deadline between instance enforcement and the + * current organisation's enforcement. Dismissal is stored in `sessionStorage` + * keyed by userId + scope + deadline, so a changed deadline re-shows the + * banner. + */ +export const TwoFactorGraceBanner = () => { + const { i18n } = useLingui(); + + const { sessionData } = useOptionalSession(); + const currentOrganisation = useOptionalCurrentOrganisation(); + + const location = useLocation(); + + const [dismissedKeys, setDismissedKeys] = useState([]); + + const candidate = useMemo(() => { + if (!sessionData) { + return null; + } + + const isSessionUnverified = sessionData.user.twoFactorEnabled && !sessionData.session.twoFactorVerified; + + const candidates = [ + toCandidate(sessionData.twoFactorEnforcement, 'instance', isSessionUnverified), + currentOrganisation + ? toCandidate(currentOrganisation.twoFactorEnforcement, `org:${currentOrganisation.id}`, isSessionUnverified) + : null, + ].filter((value): value is GraceBannerCandidate => value !== null); + + if (candidates.length === 0) { + return null; + } + + return candidates.reduce((nearest, current) => + current.deadline.getTime() < nearest.deadline.getTime() ? current : nearest, + ); + }, [sessionData, currentOrganisation]); + + if (!sessionData || !candidate) { + return null; + } + + const dismissalKey = buildDismissalKey(sessionData.user.id, candidate); + + const isDismissed = + dismissedKeys.includes(dismissalKey) || + (typeof window !== 'undefined' && window.sessionStorage.getItem(dismissalKey) === 'true'); + + if (isDismissed) { + return null; + } + + const onDismiss = () => { + try { + window.sessionStorage.setItem(dismissalKey, 'true'); + } catch { + // Storage may be unavailable (private browsing); fall back to state. + } + + setDismissedKeys((keys) => [...keys, dismissalKey]); + }; + + const returnTo = encodeURIComponent(`${location.pathname}${location.search}`); + + return ( +
+
+
+ + + + {candidate.isSessionUnverified ? ( + + Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}. + Two-factor authentication is enabled for your account, but this session has not been verified with a + second factor — sign out and log back in to verify this session. + + ) : ( + + Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.{' '} + + Enable it now + {' '} + to keep access. + + )} + +
+ + +
+
+ ); +}; diff --git a/apps/remix/app/components/tables/organisation-members-table.tsx b/apps/remix/app/components/tables/organisation-members-table.tsx index f98fc1889..d79f0baf9 100644 --- a/apps/remix/app/components/tables/organisation-members-table.tsx +++ b/apps/remix/app/components/tables/organisation-members-table.tsx @@ -6,6 +6,7 @@ import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/orga import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { trpc } from '@documenso/trpc/react'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; @@ -100,6 +101,23 @@ export const OrganisationMembersDataTable = () => { header: _(msg`Groups`), cell: ({ row }) => row.original.groups.filter((group) => group.type === OrganisationGroupType.CUSTOM).length, }, + { + // Per-member 2FA compliance indicator: enrolment is the durable half + // of the satisfaction rule, so org admins can see who has and hasn't + // enrolled (non-compliant members still consume seats). + header: _(msg`2FA`), + accessorKey: 'twoFactorEnabled', + cell: ({ row }) => + row.original.twoFactorEnabled ? ( + + Enrolled + + ) : ( + + Not enrolled + + ), + }, { header: _(msg`Actions`), cell: ({ row }) => ( diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index baffaa6ec..d32745c54 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -3,8 +3,10 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { SessionProvider } from '@documenso/lib/client-only/providers/session'; import { getBasePath } from '@documenso/lib/constants/app'; import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; +import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status'; import { createPublicEnv } from '@documenso/lib/utils/env'; import { extractLocaleData } from '@documenso/lib/utils/i18n'; +import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor'; import { TrpcProvider } from '@documenso/trpc/react'; import { getOrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session'; import { Toaster } from '@documenso/ui/primitives/toaster'; @@ -60,9 +62,19 @@ export async function loader({ context, request }: Route.LoaderArgs) { const disableAnimations = cookieHeader.includes('__disable_animations=true'); let organisations = null; + let twoFactorEnforcement: TTwoFactorEnforcementStatus = { required: false }; if (session.isAuthenticated) { - organisations = await getOrganisationSession({ userId: session.user.id }); + [organisations, twoFactorEnforcement] = await Promise.all([ + getOrganisationSession({ + userId: session.user.id, + user: session.user, + session: session.session, + }), + // Instance 2FA enforcement status is part of the session payload so + // layouts can derive banners/redirects client-side without new queries. + getTwoFactorEnforcementStatus({ user: session.user, session: session.session }), + ]); } return data( @@ -80,6 +92,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { user: session.user, session: session.session, organisations: organisations || [], + twoFactorEnforcement, } : null, publicEnv: createPublicEnv(), diff --git a/apps/remix/app/routes/_authenticated+/_layout.tsx b/apps/remix/app/routes/_authenticated+/_layout.tsx index 9c1054b23..c85311fc6 100644 --- a/apps/remix/app/routes/_authenticated+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/_layout.tsx @@ -1,31 +1,44 @@ +import { authClient } from '@documenso/auth/client'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags'; import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status'; import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings'; import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner'; +import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to'; +import { calculateTwoFactorDeadlineTimerDelay } from '@documenso/lib/utils/two-factor'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { Link, Outlet, redirect } from 'react-router'; +import { useEffect } from 'react'; +import { Link, Outlet, redirect, useLocation, useNavigate } from 'react-router'; import { AppBanner } from '~/components/general/app-banner'; import { Header } from '~/components/general/app-header'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { OrganisationBillingBanner } from '~/components/general/organisations/organisation-billing-banner'; import { OrganisationQuotaBanner } from '~/components/general/organisations/organisation-quota-banner'; +import { TwoFactorGraceBanner } from '~/components/general/two-factor-grace-banner'; import { VerifyEmailBanner } from '~/components/general/verify-email-banner'; import { TeamProvider } from '~/providers/team'; import type { Route } from './+types/_layout'; /** - * Don't revalidate (run the loader on sequential navigations) - * - * Update values via providers. + * Builds the enrolment redirect for an instance-blocked user, carrying the + * current path so they land back where they were after enrolling. */ -export const shouldRevalidate = () => false; +const buildTwoFactorOnboardingPath = (currentPath: string) => { + const returnTo = (isValidReturnTo(currentPath) && normalizeReturnTo(currentPath)) || '/'; + + return `/onboarding/2fa?returnTo=${encodeURIComponent(returnTo)}`; +}; + +// Note: no `shouldRevalidate` suppression on this layout (the root layout +// keeps its own) — the loader must rerun on navigations so the instance +// enforcement redirect below is re-evaluated server-side. export async function loader({ request }: Route.LoaderArgs) { const [session, banner] = await Promise.all([ @@ -37,6 +50,22 @@ export async function loader({ request }: Route.LoaderArgs) { throw redirect('/signin'); } + // Instance-wide 2FA enforcement (UX chokepoint — the security boundary is + // the tRPC/Hono asserts): a blocked user is redirected into forced + // enrolment. `/onboarding/2fa` lives outside this layout, so the redirect + // cannot loop. Never blocks login itself — signin/onboarding are outside + // this layout too. + const twoFactorEnforcement = await getTwoFactorEnforcementStatus({ + user: session.user, + session: session.session, + }); + + if (twoFactorEnforcement.required && twoFactorEnforcement.isBlocked) { + const url = new URL(request.url); + + throw redirect(buildTwoFactorOnboardingPath(`${url.pathname}${url.search}`)); + } + return { banner, }; @@ -45,7 +74,51 @@ export async function loader({ request }: Route.LoaderArgs) { export default function Layout({ loaderData, params, matches }: Route.ComponentProps) { const { banner } = loaderData; - const { user, organisations } = useSession(); + const { user, session, organisations, twoFactorEnforcement } = useSession(); + + const location = useLocation(); + const navigate = useNavigate(); + + // Client-side counterpart of the loader's instance enforcement redirect: + // parent-layout loaders don't rerun on every child navigation, and a grace + // deadline can pass while the app is open. The session provider refreshes + // the enforcement status on navigation/focus; the timer covers a deadline + // crossing while the tab sits idle. + const isInstanceTwoFactorBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked; + + useEffect(() => { + if (!twoFactorEnforcement.required || twoFactorEnforcement.isSatisfied) { + return; + } + + const redirectToOnboarding = () => { + void navigate(buildTwoFactorOnboardingPath(`${location.pathname}${location.search}`)); + }; + + if (twoFactorEnforcement.isBlocked) { + redirectToOnboarding(); + + return; + } + + // Within grace: fire at the deadline instant. A `null` delay means the + // deadline is beyond `setTimeout` range (~24.8 days) — no timer needed, + // the status is re-evaluated long before then. + const timerDelay = calculateTwoFactorDeadlineTimerDelay({ + deadline: twoFactorEnforcement.deadline, + now: new Date(), + }); + + if (timerDelay === null) { + return; + } + + const timeout = window.setTimeout(redirectToOnboarding, timerDelay); + + return () => { + window.clearTimeout(timeout); + }; + }, [twoFactorEnforcement, location.pathname, location.search, navigate]); const { layoutMode } = useChildRouteFlags(); @@ -80,6 +153,65 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP match?.id === 'routes/_authenticated+/t.$teamUrl+/templates.$id.edit', ); + // Per-organisation 2FA enforcement: when the current org/team context's + // organisation blocks the user, render a 403 screen (NOT a redirect — the + // rest of the app stays usable) linking to the enrolment page. Derived + // client-side from the session provider's bootstrap payload, which stays + // readable while blocked. This is UX only — the security boundary is the + // tRPC/Hono asserts. + const isCurrentOrganisationTwoFactorBlocked = + Boolean(orgUrl || teamUrl) && + Boolean(currentOrganisation?.twoFactorEnforcement.required && currentOrganisation.twoFactorEnforcement.isBlocked); + + // State (b): enrolled, but this session never passed a second factor — + // enrolment would rightly refuse, so the remediation is a fresh sign-in. + const requiresRelogin = user.twoFactorEnabled && !session.twoFactorVerified; + + // Instance enforcement takes precedence over the org 403 below: render + // nothing while the effect above navigates to forced enrolment. + if (isInstanceTwoFactorBlocked) { + return null; + } + + if (isCurrentOrganisationTwoFactorBlocked) { + const returnTo = encodeURIComponent(`${location.pathname}${location.search}`); + + return ( + void authClient.signOut()}> + Sign out + + ) : ( + + ) + } + secondaryButton={ + + } + /> + ); + } + if (orgNotFound || teamNotFound) { return (
+ + diff --git a/apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx b/apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx index ebb454fa2..43046e437 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx @@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react'; import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section'; import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section'; +import { AdminTwoFactorEnforcementSection } from '~/components/general/admin-two-factor-enforcement-section'; import { SettingsHeader } from '~/components/general/settings-header'; import type { Route } from './+types/site-settings'; @@ -31,6 +32,8 @@ export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentPro + +
); diff --git a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx index 6f4e13d33..70bbf8009 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx @@ -1,3 +1,4 @@ +import { useSession } from '@documenso/lib/client-only/providers/session'; import { trpc } from '@documenso/trpc/react'; import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types'; @@ -75,6 +76,11 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => { const { toast } = useToast(); const { revalidate } = useRevalidator(); + const { user: currentUser } = useSession(); + + // Self-reset is forbidden server-side; hide the affordance entirely. + const canResetTwoFactor = user.twoFactorEnabled && user.id !== currentUser.id; + const roles = user.roles ?? []; const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation(); @@ -228,7 +234,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
- {user && user.twoFactorEnabled && } + {canResetTwoFactor && } {user && user.disabled && } {user && !user.disabled && } {user && } diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx index 245daba14..4fcd63eb4 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx @@ -7,6 +7,7 @@ import { Trans } from '@lingui/react/macro'; import { OrganisationDeleteDialog } from '~/components/dialogs/organisation-delete-dialog'; import { AvatarImageForm } from '~/components/forms/avatar-image'; +import { OrganisationTwoFactorEnforcementForm } from '~/components/forms/organisation-two-factor-enforcement-form'; import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form'; import { SettingsHeader } from '~/components/general/settings-header'; import { appMetaTags } from '~/utils/meta'; @@ -29,6 +30,19 @@ export default function OrganisationSettingsGeneral() {
+ {canExecuteOrganisationAction('MANAGE_ORGANISATION_SECURITY', organisation.currentOrganisationRole) && ( + <> +
+ + + + + + )} + {canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
diff --git a/apps/remix/app/routes/_unauthenticated+/2fa-challenge.tsx b/apps/remix/app/routes/_unauthenticated+/2fa-challenge.tsx new file mode 100644 index 000000000..73818ed4d --- /dev/null +++ b/apps/remix/app/routes/_unauthenticated+/2fa-challenge.tsx @@ -0,0 +1,244 @@ +import { authClient } from '@documenso/auth/client'; +import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes'; +import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { Button } from '@documenso/ui/primitives/button'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { Loader2Icon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { Link, redirect, useNavigate } from 'react-router'; +import { z } from 'zod'; + +import { appMetaTags } from '~/utils/meta'; + +import type { Route } from './+types/2fa-challenge'; + +export function meta() { + return appMetaTags(msg`Two-Factor Authentication`); +} + +export async function loader({ request }: Route.LoaderArgs) { + const { isAuthenticated } = await getOptionalSession(request); + + // A signed-in user has no pending challenge to complete (any successful + // sign-in clears it server-side). + if (isAuthenticated) { + throw redirect('/'); + } + + return null; +} + +const ZTwoFactorChallengeFormSchema = z.object({ + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), +}); + +type TTwoFactorChallengeFormSchema = z.infer; + +export default function TwoFactorChallenge() { + const { _ } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigate(); + + const [isValidatingChallenge, setIsValidatingChallenge] = useState(true); + const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<'totp' | 'backup'>('totp'); + + const form = useForm({ + values: { + totpCode: '', + backupCode: '', + }, + resolver: zodResolver(ZTwoFactorChallengeFormSchema), + }); + + const isSubmitting = form.formState.isSubmitting; + + const onRedirectToSignIn = async () => { + toast({ + title: _(msg`Sign in required`), + description: _(msg`Your sign-in attempt has expired. Please sign in again.`), + variant: 'destructive', + }); + + await navigate('/signin'); + }; + + useEffect(() => { + void authClient.twoFactor + .getChallenge() + .then(async ({ valid }) => { + if (!valid) { + await onRedirectToSignIn(); + + return; + } + + setIsValidatingChallenge(false); + }) + .catch(async () => onRedirectToSignIn()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onToggleTwoFactorAuthenticationMethodClick = () => { + const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp'; + + if (method === 'totp') { + form.setValue('backupCode', ''); + } + + if (method === 'backup') { + form.setValue('totpCode', ''); + } + + setTwoFactorAuthenticationMethod(method); + }; + + const onFormSubmit = async ({ totpCode, backupCode }: TTwoFactorChallengeFormSchema) => { + try { + // On success this navigates to the server-provided redirect path. + await authClient.twoFactor.verifyChallenge( + twoFactorAuthenticationMethod === 'totp' ? { totpCode } : { backupCode }, + ); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AuthenticationErrorCode.TwoFactorChallengeExpired) { + await onRedirectToSignIn(); + + return; + } + + if (error.code === AuthenticationErrorCode.InvalidTwoFactorCode) { + toast({ + title: _(msg`Unable to sign in`), + description: _(msg`The two-factor authentication code provided is incorrect.`), + variant: 'destructive', + }); + + return; + } + + toast({ + title: _(msg`Something went wrong`), + description: _(msg`We were unable to verify your code. Please try again.`), + variant: 'destructive', + }); + } + }; + + if (isValidatingChallenge) { + return ( +
+
+ +

+ Checking your sign-in... +

+
+
+ ); + } + + return ( +
+
+

+ Two-Factor Authentication +

+ +

+ {twoFactorAuthenticationMethod === 'totp' ? ( + Enter the code from your authenticator app to finish signing in. + ) : ( + Enter one of your backup codes to finish signing in. + )} +

+ +
+ +
+ +
+ {twoFactorAuthenticationMethod === 'totp' && ( + ( + + + Token + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + )} + + {twoFactorAuthenticationMethod === 'backup' && ( + ( + + + Backup Code + + + + + + + )} + /> + )} + +
+ + + +
+
+
+ + +

+ + Not you?{' '} + + Back to sign in + + +

+
+
+ ); +} diff --git a/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx b/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx index 5f6609d85..336b668bd 100644 --- a/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx @@ -32,6 +32,12 @@ export async function loader({ params, request }: Route.LoaderArgs) { organisation: { select: { name: true, + organisationGlobalSettings: { + select: { + twoFactorRequired: true, + twoFactorGracePeriodDays: true, + }, + }, }, }, }, @@ -71,6 +77,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { }, }); + // Non-blocking notice data: joining always succeeds, but the member's 2FA + // grace window starts at join when the organisation requires 2FA. + const twoFactorSettings = organisationMemberInvite.organisation.organisationGlobalSettings; + return { state: 'Pending', token: organisationMemberInvite.token, @@ -78,6 +88,8 @@ export async function loader({ params, request }: Route.LoaderArgs) { organisationName, userExists: user !== null, isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id, + organisationTwoFactorRequired: twoFactorSettings.twoFactorRequired, + organisationTwoFactorGracePeriodDays: twoFactorSettings.twoFactorGracePeriodDays, } as const; } @@ -141,6 +153,8 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp organisationName={data.organisationName} userExists={data.userExists} isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser} + organisationTwoFactorRequired={data.organisationTwoFactorRequired} + organisationTwoFactorGracePeriodDays={data.organisationTwoFactorGracePeriodDays} /> ); } @@ -151,6 +165,8 @@ type PendingInvitationProps = { organisationName: string; userExists: boolean; isSessionUserTheInvitedUser: boolean; + organisationTwoFactorRequired: boolean; + organisationTwoFactorGracePeriodDays: number; }; type InvitationResult = 'idle' | 'accepted' | 'declined'; @@ -163,6 +179,8 @@ const PendingInvitation = ({ organisationName, userExists, isSessionUserTheInvitedUser, + organisationTwoFactorRequired, + organisationTwoFactorGracePeriodDays, }: PendingInvitationProps) => { const { t } = useLingui(); const { toast } = useToast(); @@ -289,6 +307,24 @@ const PendingInvitation = ({

+ {/* Non-blocking notice: accepting always succeeds; access to the + organisation blocks only after the grace period expires. */} + {organisationTwoFactorRequired && !actionIsDecline && ( +

+ {organisationTwoFactorGracePeriodDays > 0 ? ( + + This organisation requires two-factor authentication. You will need to enable it within{' '} + {organisationTwoFactorGracePeriodDays} days of joining to keep access to the organisation. + + ) : ( + + This organisation requires two-factor authentication. You will need to enable it immediately after + joining to access the organisation. + + )} +

+ )} + {acceptFailureReason && (

{match(acceptFailureReason) diff --git a/apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx b/apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx index a93f096c5..e9335a968 100644 --- a/apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx @@ -86,6 +86,12 @@ export async function loader({ params }: Route.LoaderArgs) { name: true, url: true, avatarImageId: true, + organisationGlobalSettings: { + select: { + twoFactorRequired: true, + twoFactorGracePeriodDays: true, + }, + }, }, }); @@ -107,6 +113,10 @@ export async function loader({ params }: Route.LoaderArgs) { name: organisation.name, url: organisation.url, avatar: organisation.avatarImageId, + // Non-blocking notice data: SSO membership creation always succeeds; + // the 2FA grace window starts at join. + twoFactorRequired: organisation.organisationGlobalSettings.twoFactorRequired, + twoFactorGracePeriodDays: organisation.organisationGlobalSettings.twoFactorGracePeriodDays, }, } as const; } @@ -266,6 +276,26 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou

+ {/* Non-blocking notice: confirming always succeeds; organisation + access blocks only after the grace period expires. */} + {organisation.twoFactorRequired && ( + + + {organisation.twoFactorGracePeriodDays > 0 ? ( + + This organisation requires two-factor authentication. You will need to enable it within{' '} + {organisation.twoFactorGracePeriodDays} days of joining to keep access to the organisation. + + ) : ( + + This organisation requires two-factor authentication. You will need to enable it immediately after + joining to access the organisation. + + )} + + + )} +
; + +export default function OnboardingTwoFactorPage() { + const { returnTo, isTwoFactorEnabled, isSessionTwoFactorVerified, twoFactorEnforcement } = + useSuperLoaderData(); + + const { _, i18n } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigate(); + const { revalidate } = useRevalidator(); + + // The whole page renders from the loader snapshot + local state, never from + // the live session context. The session provider refreshes in the + // background (and `twoFactorEnabled` flips the moment 2FA is enabled), but + // navigation is controlled exclusively by this page's state machine — + // recovery codes are shown exactly once and must stay on screen until the + // user explicitly acknowledges saving them. + const [setupData, setSetupData] = useState<{ uri: string; secret: string } | null>(null); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [hasSetupFailed, setHasSetupFailed] = useState(false); + + const hasRequestedSetupRef = useRef(false); + + const isBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked; + const canSkip = !isBlocked; + + // State (b): enrolled, but this session was created before 2FA was enabled + // so it never passed a second factor. Setup would rightly refuse + // (already enabled), so the only remediation is a fresh sign-in. + const requiresRelogin = isTwoFactorEnabled && !isSessionTwoFactorVerified; + + const form = useForm({ + defaultValues: { + token: '', + }, + resolver: zodResolver(ZEnableTwoFactorFormSchema), + }); + + const { isSubmitting: isEnabling } = form.formState; + + // Enrolment goes through the auth routes (`authClient.twoFactor.*`), NOT + // tRPC: while the user is blocked by instance enforcement, session tRPC + // procedures respond 403 — the remediation page must not depend on them. + const setupTwoFactor = async () => { + setHasSetupFailed(false); + + try { + const data = await authClient.twoFactor.setup(); + + setSetupData(data); + } catch (err) { + const error = AppError.parseError(err); + + // The user enrolled concurrently (e.g. in another tab). Re-run the + // loader instead of dead-ending on a retry that would refuse forever: + // it auto-redirects when this session became verified by the + // concurrent enable, or renders the sign-out-and-re-login prompt. + if (error.code === 'TWO_FACTOR_ALREADY_ENABLED') { + await revalidate(); + + return; + } + + setHasSetupFailed(true); + + toast({ + title: _(msg`Unable to setup two-factor authentication`), + description: _(msg`We were unable to setup two-factor authentication for your account. Please try again.`), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (isTwoFactorEnabled || hasRequestedSetupRef.current) { + return; + } + + hasRequestedSetupRef.current = true; + + void setupTwoFactor(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onEnableSubmit = async ({ token }: TEnableTwoFactorFormSchema) => { + try { + const data = await authClient.twoFactor.enable({ code: token }); + + // Phase one complete. Do NOT navigate — show the recovery codes and + // wait for the explicit acknowledgement below. + setRecoveryCodes(data.recoveryCodes); + } catch (err) { + const error = AppError.parseError(err); + + // Enabled concurrently (e.g. another tab) between setup and enable — + // the recovery codes were shown there. Re-run the loader to land on + // the correct state instead of claiming the code was wrong. + if (error.code === 'TWO_FACTOR_ALREADY_ENABLED') { + toast({ + title: _(msg`Two-factor authentication is already enabled`), + description: _(msg`Two-factor authentication was already enabled for your account.`), + }); + + await revalidate(); + + return; + } + + toast({ + title: _(msg`Unable to setup two-factor authentication`), + description: _( + msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`, + ), + variant: 'destructive', + }); + } + }; + + const onDownloadRecoveryCodes = () => { + if (!recoveryCodes) { + return; + } + + const blob = new Blob([recoveryCodes.join('\n')], { + type: 'text/plain', + }); + + downloadFile({ + filename: 'documenso-2FA-recovery-codes.txt', + data: blob, + }); + }; + + // Phase two: only the explicit acknowledgement navigates away. + const onRecoveryCodesAcknowledged = async () => { + await navigate(returnTo); + }; + + const onSignOut = async () => { + await authClient.signOut(); + }; + + return ( +
+
+

+ Two-factor authentication +

+ + {twoFactorEnforcement.required && isBlocked && ( +

+ Two-factor authentication is required to continue using your account. +

+ )} + + {twoFactorEnforcement.required && !isBlocked && ( +

+ + Two-factor authentication is required for your account from{' '} + {i18n.date(twoFactorEnforcement.deadline, { dateStyle: 'long' })}. + +

+ )} + +
+ + {requiresRelogin ? ( +
+

+ + Two-factor authentication is enabled for your account, but this session has not been verified with a + second factor. Sign out and log back in to verify this session. + +

+ + +
+ ) : recoveryCodes ? ( +
+
+

+ Save your recovery codes +

+ +

+ + Your recovery codes are listed below. Please store them in a safe place — they will not be shown + again. + +

+
+ + + +
+ + + +
+
+ ) : !setupData ? ( +
+ {hasSetupFailed ? ( + <> +

+ We were unable to prepare two-factor authentication. +

+ + + + ) : ( + <> + + +

+ Preparing two-factor authentication... +

+ + )} +
+ ) : ( +
+ +
+

+ + To enable two-factor authentication, scan the following QR code using your authenticator app. + +

+ +
+ +

+ + If your authenticator app does not support QR codes, you can use the following code instead: + +

+ +

+ {setupData.secret} +

+ + ( + + + Token + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + + +
+
+ + )} + + {(canSkip || !requiresRelogin) && ( +

+ {canSkip && !recoveryCodes && ( + + Skip for now + + )} + + {!requiresRelogin && ( + + )} +

+ )} +
+
+ ); +} diff --git a/apps/remix/app/routes/onboarding+/_layout.tsx b/apps/remix/app/routes/onboarding+/_layout.tsx new file mode 100644 index 000000000..874035517 --- /dev/null +++ b/apps/remix/app/routes/onboarding+/_layout.tsx @@ -0,0 +1,32 @@ +import backgroundPattern from '@documenso/assets/images/background-pattern.png'; +import { Outlet } from 'react-router'; + +/** + * Onboarding routes require a session (each route's own loader asserts it) + * but deliberately live OUTSIDE the `_authenticated+` layout: that layout is + * the UX chokepoint for 2FA enforcement redirects, and remediation pages such + * as `/onboarding/2fa` must be exempt from it or the redirect would loop. + */ +export default function Layout() { + return ( +
+
+
+ background pattern +
+ +
+ +
+
+
+ ); +} diff --git a/apps/remix/server/api/ai/detect-fields.ts b/apps/remix/server/api/ai/detect-fields.ts index cae5f513f..c10f2281d 100644 --- a/apps/remix/server/api/ai/detect-fields.ts +++ b/apps/remix/server/api/ai/detect-fields.ts @@ -1,6 +1,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session'; import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement'; import { detectFieldsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-fields'; import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { sValidator } from '@hono/standard-validator'; @@ -41,6 +42,14 @@ export const detectFieldsRoute = new Hono().post( }); } + // 2FA enforcement: session-authenticated endpoint — instance assert + + // the owning organisation's policy for the envelope's team. + await assertTwoFactorEnforcementForSession({ + user: session.user, + session: session.session, + organisationIds: [team.organisationId], + }); + // Check if AI features are enabled for the team const { aiFeaturesEnabled } = team.derivedSettings; diff --git a/apps/remix/server/api/ai/detect-recipients.ts b/apps/remix/server/api/ai/detect-recipients.ts index 94d511c88..0f7d7be3a 100644 --- a/apps/remix/server/api/ai/detect-recipients.ts +++ b/apps/remix/server/api/ai/detect-recipients.ts @@ -1,6 +1,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session'; import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement'; import { detectRecipientsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-recipients'; import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { sValidator } from '@hono/standard-validator'; @@ -41,6 +42,14 @@ export const detectRecipientsRoute = new Hono().post( }); } + // 2FA enforcement: session-authenticated endpoint — instance assert + + // the owning organisation's policy for the envelope's team. + await assertTwoFactorEnforcementForSession({ + user: session.user, + session: session.session, + organisationIds: [team.organisationId], + }); + // Check if AI features are enabled for the team const { aiFeaturesEnabled } = team.derivedSettings; diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index bbca38885..2226f54f6 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -1,6 +1,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { AppError } from '@documenso/lib/errors/app-error'; +import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { prisma } from '@documenso/prisma'; @@ -28,6 +29,17 @@ export const filesRoute = new Hono() */ .post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => { try { + // 2FA enforcement applies only when the request is session + // authenticated — presign-token access (embedding) is machine access + // and stays exempt. `resolveFileUploadUserId` prefers the session, so + // asserting on the session here cannot be bypassed by a session user. + const { user: sessionUser, session } = await getOptionalSession(c); + + if (sessionUser && session) { + // No organisation scope: the upload creates unattached document data. + await assertTwoFactorEnforcementForSession({ user: sessionUser, session }); + } + const userId = await resolveFileUploadUserId(c); if (!userId) { @@ -54,6 +66,13 @@ export const filesRoute = new Hono() return c.json(result); } catch (error) { console.error('Upload failed:', error); + + if (error instanceof AppError) { + const { status, body } = AppError.toRestAPIError(error); + + return c.json({ error: body.message, code: error.code }, status); + } + return c.json({ error: 'Upload failed' }, 500); } }) @@ -69,6 +88,10 @@ export const filesRoute = new Hono() let userId = session.user?.id; + // Presign-token access (embedding) is machine access and exempt from + // 2FA enforcement; the assert below only applies to session auth. + const isPresignTokenAccess = Boolean(token); + if (token) { const presignToken = await verifyEmbeddingPresignToken({ token, @@ -86,6 +109,11 @@ export const filesRoute = new Hono() id: envelopeId, }, include: { + team: { + select: { + organisationId: true, + }, + }, envelopeItems: { where: { id: envelopeItemId, @@ -101,6 +129,26 @@ export const filesRoute = new Hono() return c.json({ error: 'Envelope not found' }, 404); } + // 2FA enforcement (session auth only): instance assert + the owning + // organisation's policy for the envelope being accessed. + if (!isPresignTokenAccess && session.user && session.session) { + try { + await assertTwoFactorEnforcementForSession({ + user: session.user, + session: session.session, + organisationIds: [envelope.team.organisationId], + }); + } catch (error) { + if (error instanceof AppError) { + const { status, body } = AppError.toRestAPIError(error); + + return c.json({ error: body.message, code: error.code }, status); + } + + throw error; + } + } + const [envelopeItem] = envelope.envelopeItems; if (!envelopeItem) { @@ -152,6 +200,11 @@ export const filesRoute = new Hono() id: envelopeId, }, include: { + team: { + select: { + organisationId: true, + }, + }, envelopeItems: { where: { id: envelopeItemId, @@ -173,6 +226,15 @@ export const filesRoute = new Hono() return c.json({ error: 'Envelope not found' }, 404); } + // 2FA enforcement: this route is session-only, so both the instance + // assert and the owning organisation's policy apply. The thrown + // AppError is mapped to a 403 by the catch below. + await assertTwoFactorEnforcementForSession({ + user: session.user, + session: session.session, + organisationIds: [envelope.team.organisationId], + }); + const [envelopeItem] = envelope.envelopeItems; if (!envelopeItem) { diff --git a/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts b/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts index 425717121..cca4f9d4f 100644 --- a/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts +++ b/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts @@ -1,4 +1,6 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import type { DocumentDataVersion } from '@documenso/lib/types/document'; import { sha256 } from '@documenso/lib/universal/crypto'; @@ -41,6 +43,10 @@ route.get( let userId = session.user?.id; + // Presign-token access (embedding) is machine access and exempt from 2FA + // enforcement; the assert below only applies to session auth. + const isPresignTokenAccess = Boolean(presignToken); + // Check presignToken if provided if (presignToken) { const verifiedToken = await verifyEmbeddingPresignToken({ @@ -69,6 +75,11 @@ route.get( type: true, teamId: true, templateType: true, + team: { + select: { + organisationId: true, + }, + }, }, }, }, @@ -78,6 +89,26 @@ route.get( return c.json({ error: 'Not found' }, 404); } + // 2FA enforcement (session auth only): instance assert + the owning + // organisation's policy for the envelope being accessed. + if (!isPresignTokenAccess && session.user && session.session) { + try { + await assertTwoFactorEnforcementForSession({ + user: session.user, + session: session.session, + organisationIds: [envelopeItem.envelope.team.organisationId], + }); + } catch (error) { + if (error instanceof AppError) { + const { status, body } = AppError.toRestAPIError(error); + + return c.json({ error: body.message, code: error.code }, status); + } + + throw error; + } + } + // Check whether the user has access to the document. const hasAccess = await checkEnvelopeFileAccess({ userId, diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts index 6fb465afb..ff33c4c01 100644 --- a/packages/app-tests/e2e/fixtures/authentication.ts +++ b/packages/app-tests/e2e/fixtures/authentication.ts @@ -6,6 +6,12 @@ type LoginOptions = { email?: string; password?: string; + /** + * TOTP code for accounts with 2FA enabled. Sign-ins that pass a valid code + * create a session with `twoFactorVerified: true`. + */ + totpCode?: string; + /** * Where to navigate after login. */ @@ -16,6 +22,7 @@ export const apiSignin = async ({ page, email = 'example@documenso.com', password = 'password', + totpCode, redirectPath = '/', }: LoginOptions) => { const { request } = page.context(); @@ -26,6 +33,7 @@ export const apiSignin = async ({ data: { email, password, + totpCode, csrfToken, }, }); diff --git a/packages/app-tests/e2e/fixtures/two-factor.ts b/packages/app-tests/e2e/fixtures/two-factor.ts new file mode 100644 index 000000000..f82c53e39 --- /dev/null +++ b/packages/app-tests/e2e/fixtures/two-factor.ts @@ -0,0 +1,111 @@ +import crypto from 'node:crypto'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; +import { prisma } from '@documenso/prisma'; +import { base32 } from '@scure/base'; +import { generateHOTP } from 'oslo/otp'; + +/** + * Must match the period used by `verifyTwoFactorAuthenticationToken` in + * `packages/lib/server-only/2fa/verify-2fa-token.ts`. + */ +const TOTP_PERIOD_MS = 30_000; + +/** + * Enables 2FA for an existing user using the exact storage format the server + * writes in `setup-2fa.ts`/`enable-2fa.ts` (base32 TOTP secret + JSON backup + * codes, both symmetrically encrypted with the instance encryption key). + * + * No mocks: the server validates codes generated from this secret with the + * same OTP library used here. + */ +export const seedUserTwoFactorAuthentication = async ({ userId }: { userId: number }) => { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('NEXT_PRIVATE_ENCRYPTION_KEY must be set to seed 2FA users'); + } + + const secret = crypto.randomBytes(10); + + const backupCodes = Array.from({ length: 10 }) + .fill(null) + .map(() => crypto.randomBytes(5).toString('hex')) + .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase()); + + await prisma.user.update({ + where: { + id: userId, + }, + data: { + twoFactorEnabled: true, + twoFactorSecret: symmetricEncrypt({ + key, + data: base32.encode(new Uint8Array(secret)), + }), + twoFactorBackupCodes: symmetricEncrypt({ + key, + data: JSON.stringify(backupCodes), + }), + }, + }); + + return { + secret, + backupCodes, + }; +}; + +/** + * Computes the TOTP code for the current time window, mirroring the server's + * verification (`generateHOTP` with a 30 second period). + */ +export const generateTotpCode = async ({ secret }: { secret: Buffer }) => { + return await generateHOTP(new Uint8Array(secret), Math.floor(Date.now() / TOTP_PERIOD_MS)); +}; + +/** + * The server only accepts the code for the current 30s window (window = 1). + * If we are close to a window boundary, wait for the next window so the code + * cannot expire between generation and verification. + */ +export const waitForStableTotpWindow = async () => { + const remainingMs = TOTP_PERIOD_MS - (Date.now() % TOTP_PERIOD_MS); + + if (remainingMs < 5_000) { + await new Promise((resolve) => { + setTimeout(resolve, remainingMs + 250); + }); + } +}; + +type SeedOrganisationTwoFactorEnforcementOptions = { + organisationId: string; + twoFactorRequired?: boolean; + twoFactorGracePeriodDays?: number; +}; + +/** + * Directly seeds the organisation-level 2FA enforcement settings, bypassing + * the tRPC settings write path (which is covered by its own tests). + */ +export const seedOrganisationTwoFactorEnforcement = async ({ + organisationId, + twoFactorRequired = true, + twoFactorGracePeriodDays = 0, +}: SeedOrganisationTwoFactorEnforcementOptions) => { + await prisma.organisation.update({ + where: { + id: organisationId, + }, + data: { + organisationGlobalSettings: { + update: { + twoFactorRequired, + twoFactorGracePeriodDays, + twoFactorEnforcedFrom: twoFactorRequired ? new Date() : null, + }, + }, + }, + }); +}; diff --git a/packages/app-tests/e2e/organisations/organisation-two-factor-enforcement.spec.ts b/packages/app-tests/e2e/organisations/organisation-two-factor-enforcement.spec.ts new file mode 100644 index 000000000..e549c9bbf --- /dev/null +++ b/packages/app-tests/e2e/organisations/organisation-two-factor-enforcement.spec.ts @@ -0,0 +1,272 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { + generateTotpCode, + seedOrganisationTwoFactorEnforcement, + seedUserTwoFactorAuthentication, + waitForStableTotpWindow, +} from '../fixtures/two-factor'; + +test('[ORGANISATIONS]: joining succeeds then org context is blocked at 0-day grace', async ({ page }) => { + const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false }); + + // The owner must satisfy the policy themselves to use the org settings + // pages while enforcement is active with a 0-day grace period. + const { secret: ownerSecret } = await seedUserTwoFactorAuthentication({ userId: owner.id }); + + await seedOrganisationTwoFactorEnforcement({ + organisationId: organisation.id, + twoFactorGracePeriodDays: 0, + }); + + const { user: member } = await seedUser({ isPersonalOrganisation: false }); + + await waitForStableTotpWindow(); + + await apiSignin({ + page, + email: owner.email, + totpCode: await generateTotpCode({ secret: ownerSecret }), + redirectPath: `/o/${organisation.url}/settings/members`, + }); + + // Invite the member while the 0-day enforcement policy is already active. + await page.getByRole('button', { name: 'Invite member' }).click(); + await page.getByRole('textbox', { name: 'Email address *' }).fill(member.email); + await page.getByRole('button', { name: 'Invite' }).click(); + + await page.getByRole('tab', { name: 'Pending' }).click(); + await expect(page.getByText(member.email)).toBeVisible(); + + // Joining is never blocked: the member accepts the invite without 2FA. + await apiSignout({ page }); + await apiSignin({ page, email: member.email, redirectPath: `/settings/organisations` }); + + await page.getByRole('button', { name: 'View invites' }).click(); + await page.getByRole('button', { name: 'Accept' }).click(); + await expect(page.getByText('Invitation accepted').first()).toBeVisible(); + + const membership = await prisma.organisationMember.findFirst({ + where: { + userId: member.id, + organisationId: organisation.id, + }, + }); + + expect(membership).not.toBeNull(); + + // Access, however, is blocked immediately (0-day grace): the org context + // renders the 403 screen linking to forced enrolment. + await page.goto(`/o/${organisation.url}`); + await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible(); + await expect(page.getByText('403 Forbidden')).toBeVisible(); + + const enrolmentLink = page.getByRole('link', { name: 'Set up two-factor authentication' }); + await expect(enrolmentLink).toBeVisible(); + await expect(enrolmentLink).toHaveAttribute('href', /\/onboarding\/2fa\?returnTo=/); + + // Team contexts resolve to the owning organisation and are blocked too. + await page.goto(`/t/${team.url}/documents`); + await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible(); + + // The security boundary: a blocked org-scoped tRPC call returns 403. + const blockedResponse = await page + .context() + .request.get( + `${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.get?input=${encodeURIComponent( + JSON.stringify({ json: { organisationReference: organisation.url } }), + )}`, + ); + + expect(blockedResponse.status()).toBe(403); + + // The rest of the app stays usable: the member's own organisation is + // unaffected. + await page.goto(`/settings/organisations`); + await expect(page.getByText('Two-factor authentication required')).not.toBeVisible(); +}); + +test('[ORGANISATIONS]: member with satisfied 2FA is unaffected by enforcement', async ({ page }) => { + const { organisation, team } = await seedUser({ isPersonalOrganisation: false }); + + const memberEmail = `member-${nanoid()}@test.documenso.com`; + + const [member] = await seedOrganisationMembers({ + members: [ + { + email: memberEmail, + name: 'Member 2FA', + organisationRole: 'MEMBER', + }, + ], + organisationId: organisation.id, + }); + + const { secret } = await seedUserTwoFactorAuthentication({ userId: member.id }); + + await seedOrganisationTwoFactorEnforcement({ + organisationId: organisation.id, + twoFactorGracePeriodDays: 0, + }); + + await waitForStableTotpWindow(); + + // Signing in with a valid TOTP code marks the session as second-factor + // verified, which satisfies enforcement. + await apiSignin({ + page, + email: memberEmail, + totpCode: await generateTotpCode({ secret }), + redirectPath: `/o/${organisation.url}`, + }); + + await expect(page.getByText(team.name).first()).toBeVisible(); + await expect(page.getByText('Two-factor authentication required')).not.toBeVisible(); + + await page.goto(`/t/${team.url}/documents`); + await expect(page.getByText('Two-factor authentication required')).not.toBeVisible(); + + const allowedResponse = await page + .context() + .request.get( + `${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.get?input=${encodeURIComponent( + JSON.stringify({ json: { organisationReference: organisation.url } }), + )}`, + ); + + expect(allowedResponse.status()).toBe(200); +}); + +test('[ORGANISATIONS]: blocked member can leave the organisation', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const memberEmail = `member-${nanoid()}@test.documenso.com`; + + const [member] = await seedOrganisationMembers({ + members: [ + { + email: memberEmail, + name: 'Blocked Member', + organisationRole: 'MEMBER', + }, + ], + organisationId: organisation.id, + }); + + await seedOrganisationTwoFactorEnforcement({ + organisationId: organisation.id, + twoFactorGracePeriodDays: 0, + }); + + await apiSignin({ + page, + email: memberEmail, + redirectPath: `/o/${organisation.url}`, + }); + + // Confirm the member is blocked from the organisation context. + await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible(); + + // A member must always be able to walk away: `organisation.leave` is on + // the remediation allow-list, so leaving works while blocked. + await page.goto('/settings/organisations'); + await page.getByRole('button', { name: 'Leave' }).click(); + await page.getByRole('button', { name: 'Leave' }).click(); + + await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible(); + await expect(page.getByText('No results found').first()).toBeVisible(); + + const membership = await prisma.organisationMember.findFirst({ + where: { + userId: member.id, + organisationId: organisation.id, + }, + }); + + expect(membership).toBeNull(); +}); + +test('[ORGANISATIONS]: admin with satisfied 2FA can enable and persist enforcement settings', async ({ page }) => { + const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false }); + + const { secret } = await seedUserTwoFactorAuthentication({ userId: owner.id }); + + await waitForStableTotpWindow(); + + await apiSignin({ + page, + email: owner.email, + totpCode: await generateTotpCode({ secret }), + redirectPath: `/o/${organisation.url}/settings/general`, + }); + + const requireSwitch = page.getByRole('switch', { name: 'Require two-factor authentication' }); + + await expect(requireSwitch).toBeVisible(); + await expect(requireSwitch).not.toBeChecked(); + + await requireSwitch.click(); + await page.getByLabel('Grace period (days)').fill('30'); + await page.getByRole('button', { name: 'Update' }).click(); + + await expect(page.getByText('Two-factor enforcement settings updated').first()).toBeVisible(); + + // Roundtrip: values persist across a reload. + await page.reload(); + + await expect(page.getByRole('switch', { name: 'Require two-factor authentication' })).toBeChecked(); + await expect(page.getByLabel('Grace period (days)')).toHaveValue('30'); + + const settings = await prisma.organisation.findFirstOrThrow({ + where: { + id: organisation.id, + }, + include: { + organisationGlobalSettings: true, + }, + }); + + expect(settings.organisationGlobalSettings.twoFactorRequired).toBe(true); + expect(settings.organisationGlobalSettings.twoFactorGracePeriodDays).toBe(30); + expect(settings.organisationGlobalSettings.twoFactorEnforcedFrom).not.toBeNull(); +}); + +test('[ORGANISATIONS]: admin without 2FA cannot enable enforcement', async ({ page }) => { + const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/o/${organisation.url}/settings/general`, + }); + + const requireSwitch = page.getByRole('switch', { name: 'Require two-factor authentication' }); + + await expect(requireSwitch).toBeVisible(); + + await requireSwitch.click(); + await page.getByRole('button', { name: 'Update' }).click(); + + // Enable-time guard: the acting admin must already satisfy the policy + // being enabled. + await expect( + page.getByText('You must have two-factor authentication enabled and verified on this session').first(), + ).toBeVisible(); + + const settings = await prisma.organisation.findFirstOrThrow({ + where: { + id: organisation.id, + }, + include: { + organisationGlobalSettings: true, + }, + }); + + expect(settings.organisationGlobalSettings.twoFactorRequired).toBe(false); +}); diff --git a/packages/app-tests/e2e/user/two-factor-backup-code-recovery.spec.ts b/packages/app-tests/e2e/user/two-factor-backup-code-recovery.spec.ts new file mode 100644 index 000000000..262687f5d --- /dev/null +++ b/packages/app-tests/e2e/user/two-factor-backup-code-recovery.spec.ts @@ -0,0 +1,64 @@ +import { prisma } from '@documenso/prisma'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { UserSecurityAuditLogType } from '@prisma/client'; + +import { checkSessionValid } from '../fixtures/authentication'; +import { seedUserTwoFactorAuthentication } from '../fixtures/two-factor'; + +test('[USER] backup code sign-in resets 2FA and lands on the re-enrolment page', async ({ page }) => { + const { user } = await seedUser(); + + const { backupCodes } = await seedUserTwoFactorAuthentication({ userId: user.id }); + + await page.goto('/signin'); + await page.getByLabel('Email').fill(user.email); + await page.getByLabel('Password', { exact: true }).fill('password'); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // The missing second factor opens the 2FA dialog. + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('Two-Factor Authentication')).toBeVisible(); + + await dialog.getByRole('button', { name: 'Use Backup Code' }).click(); + await dialog.getByLabel('Backup Code').fill(backupCodes[0]); + await dialog.getByRole('button', { name: 'Sign In' }).click(); + + // Backup codes are recovery, not sign-in: the server resets 2FA and the + // client is redirected to the forced re-enrolment page. + await page.waitForURL(/\/onboarding\/2fa/); + + await expect(page.getByRole('heading', { name: 'Two-factor authentication' })).toBeVisible(); + + // Enforcement is not active for this user, so the page offers an explicit + // skip link instead of auto-redirecting away. + await expect(page.getByRole('link', { name: 'Skip for now' })).toBeVisible(); + + // The recovery sign-in still authorizes a session. + expect(await checkSessionValid(page)).toBe(true); + + // The reset is atomic: 2FA disabled, secret and backup codes cleared. + const updatedUser = await prisma.user.findFirstOrThrow({ + where: { + id: user.id, + }, + }); + + expect(updatedUser.twoFactorEnabled).toBe(false); + expect(updatedUser.twoFactorSecret).toBeNull(); + expect(updatedUser.twoFactorBackupCodes).toBeNull(); + + // The recovery reset is audit-logged. + const auditLog = await prisma.userSecurityAuditLog.findFirst({ + where: { + userId: user.id, + type: UserSecurityAuditLogType.AUTH_2FA_DISABLE, + }, + }); + + expect(auditLog).not.toBeNull(); + + // A consumed backup code cannot be used to sign in again: 2FA is no longer + // enabled, so a plain password sign-in succeeds and re-enrolment starts + // from scratch. +}); diff --git a/packages/auth/client/index.ts b/packages/auth/client/index.ts index a7ac7977f..7eb0095b2 100644 --- a/packages/auth/client/index.ts +++ b/packages/auth/client/index.ts @@ -5,13 +5,14 @@ import { hc } from 'hono/client'; import superjson from 'superjson'; import type { AuthAppType } from '../server'; -import type { SessionValidationResult } from '../server/lib/session/session'; import type { PartialAccount } from '../server/lib/utils/get-accounts'; import type { ActiveSession } from '../server/lib/utils/get-session'; import { handleSignInRedirect } from '../server/lib/utils/redirect'; +import type { TSessionJsonResponse } from '../server/routes/session'; import type { TDisableTwoFactorRequestSchema, TEnableTwoFactorRequestSchema, + TVerifyTwoFactorChallengeRequestSchema, TViewTwoFactorRecoveryCodesRequestSchema, } from '../server/routes/two-factor.types'; import type { @@ -29,9 +30,8 @@ type TEmailPasswordSignin = InferRequestType['json'] & { - redirectPath?: string; -}; +// `redirectPath` is part of the request schema and validated server-side. +type TPasskeySignin = InferRequestType['json']; export class AuthClient { public client: AuthClientType; @@ -71,7 +71,7 @@ export class AuthClient { const result = await response.json(); - return superjson.deserialize(result); + return superjson.deserialize(result); } public async getSessions() { @@ -146,6 +146,20 @@ export class AuthClient { throw AppError.parseError(error); } + const result = await response.json(); + + // The server overrides the redirect when the sign-in requires a + // follow-up page, e.g. a backup-code sign-in resets 2FA and lands on + // the re-enrolment page. The caller's redirect path is preserved as + // `returnTo` (validated by the target page before use). + if (result.redirectPath) { + const returnTo = data.redirectPath ? `?returnTo=${encodeURIComponent(data.redirectPath)}` : ''; + + handleSignInRedirect(`${result.redirectPath}${returnTo}`); + + return; + } + handleSignInRedirect(data.redirectPath); }, @@ -245,6 +259,8 @@ export class AuthClient { throw AppError.parseError(error); } + + return response.json(); }, viewRecoveryCodes: async (data: TViewTwoFactorRecoveryCodesRequestSchema) => { const response = await this.client['two-factor']['view-recovery-codes'].$post({ json: data }); @@ -257,6 +273,51 @@ export class AuthClient { return response.json(); }, + + /** + * Check whether a pending 2FA challenge exists for this browser, so the + * /2fa-challenge page can bounce back to sign-in when there is none. + */ + getChallenge: async () => { + const response = await this.client['two-factor'].challenge.$get(); + + if (!response.ok) { + const error = await response.json(); + + throw AppError.parseError(error); + } + + return response.json(); + }, + + /** + * Verify the second factor for a pending 2FA challenge. Fetches a fresh + * CSRF token first since the challenge page is reached via a 302 + * redirect, not the sign-in form. + */ + verifyChallenge: async (data: Omit) => { + const { csrfToken } = await this.client.csrf.$get().then(async (res) => res.json()); + + const response = await this.client['two-factor'].challenge.$post({ + json: { + ...data, + csrfToken, + }, + }); + + if (!response.ok) { + const error = await response.json(); + + throw AppError.parseError(error); + } + + const result = await response.json(); + + // The server returns the validated redirect path stored when the + // challenge was created (or the re-enrolment page after a backup-code + // recovery). Navigation goes through the same-origin redirect helper. + handleSignInRedirect(result.redirectPath); + }, }; public passkey = { @@ -269,7 +330,11 @@ export class AuthClient { throw AppError.parseError(error); } - handleSignInRedirect(data.redirectPath); + const result = await response.json(); + + // The server validates the requested redirect path and echoes back a + // safe same-origin path (falling back to `/`). + handleSignInRedirect(result.url); }, }; diff --git a/packages/auth/server/lib/errors/error-codes.ts b/packages/auth/server/lib/errors/error-codes.ts index dbce82f09..d316c4351 100644 --- a/packages/auth/server/lib/errors/error-codes.ts +++ b/packages/auth/server/lib/errors/error-codes.ts @@ -17,6 +17,9 @@ export const AuthenticationErrorCode = { // TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET', // TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS', InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE', + // A pending 2FA challenge is missing, expired, or exhausted — the client + // must restart the sign-in flow. + TwoFactorChallengeExpired: 'TWO_FACTOR_CHALLENGE_EXPIRED', SigninDisabled: 'SIGNIN_DISABLED', SignupDisabled: 'SIGNUP_DISABLED', SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL', diff --git a/packages/auth/server/lib/session/session-cookies.ts b/packages/auth/server/lib/session/session-cookies.ts index 39ed9f45c..6dfff56ce 100644 --- a/packages/auth/server/lib/session/session-cookies.ts +++ b/packages/auth/server/lib/session/session-cookies.ts @@ -11,7 +11,7 @@ import { generateSessionToken } from './session'; export const sessionCookieName = formatSecureCookieName('sessionId'); export const csrfCookieName = formatSecureCookieName('csrfToken'); -const getAuthSecret = () => { +export const getAuthSecret = () => { const authSecret = env('NEXTAUTH_SECRET'); if (!authSecret) { diff --git a/packages/auth/server/lib/session/session.ts b/packages/auth/server/lib/session/session.ts index 75d3830d7..ddb60d788 100644 --- a/packages/auth/server/lib/session/session.ts +++ b/packages/auth/server/lib/session/session.ts @@ -1,4 +1,5 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TSessionAuthMethod } from '@documenso/lib/types/session-auth-method'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; import { sha256 } from '@oslojs/crypto/sha2'; @@ -14,7 +15,16 @@ import { AUTH_SESSION_LIFETIME } from '../../config'; */ export type SessionUser = Pick< User, - 'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled' + | 'id' + | 'name' + | 'email' + | 'emailVerified' + | 'avatarImageId' + | 'twoFactorEnabled' + | 'twoFactorGraceStartedAt' + | 'roles' + | 'signature' + | 'disabled' >; export type SessionValidationResult = @@ -35,7 +45,25 @@ export const generateSessionToken = (): string => { return token; }; -export const createSession = async (token: string, userId: number, metadata: RequestMetadata): Promise => { +export type CreateSessionOptions = { + /** + * The method used to authenticate this session. + */ + authMethod: TSessionAuthMethod; + + /** + * Whether a second factor was passed during sign-in (TOTP/backup challenge + * or UV passkey). + */ + twoFactorVerified: boolean; +}; + +export const createSession = async ( + token: string, + userId: number, + metadata: RequestMetadata, + options: CreateSessionOptions, +): Promise => { const hashedSessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const session: Session = { @@ -47,6 +75,8 @@ export const createSession = async (token: string, userId: number, metadata: Req expiresAt: new Date(Date.now() + AUTH_SESSION_LIFETIME), ipAddress: metadata.ipAddress ?? null, userAgent: metadata.userAgent ?? null, + authMethod: options.authMethod, + twoFactorVerified: options.twoFactorVerified, }; await prisma.session.create({ @@ -84,6 +114,7 @@ export const validateSessionToken = async (token: string): Promise) => { await assertUserNotDisabledById({ userId: user.userId }); + // Any successful sign-in clears a pending 2FA challenge — an abandoned + // challenge must not outlive a login via another route. The challenge + // endpoint consumes its own token before calling `onAuthorize`, so this + // sweep finds nothing there (no recursion, no double-consumption). + await sweepPendingTwoFactorChallenge(c); + const metadata = c.get('requestMetadata'); const sessionToken = generateSessionToken(); - await createSession(sessionToken, user.userId, metadata); + await createSession(sessionToken, user.userId, metadata, { + authMethod: user.authMethod, + twoFactorVerified: user.twoFactorVerified, + }); await setSessionCookie(c, sessionToken); }; diff --git a/packages/auth/server/lib/utils/get-session.ts b/packages/auth/server/lib/utils/get-session.ts index 07049d3ff..92d707833 100644 --- a/packages/auth/server/lib/utils/get-session.ts +++ b/packages/auth/server/lib/utils/get-session.ts @@ -59,6 +59,8 @@ export const getActiveSessions = async (c: Context | Request): Promise { await tx.account.create({ data: { @@ -114,7 +156,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti } }); - await onAuthorize({ userId: userWithSameEmail.id }, c); + await onAuthorize({ userId: userWithSameEmail.id, authMethod: 'oauth', twoFactorVerified: false }, c); return c.redirect(redirectPath, 302); } @@ -179,7 +221,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti console.error(err); }); - await onAuthorize({ userId: createdUser.id }, c); + await onAuthorize({ userId: createdUser.id, authMethod: 'oauth', twoFactorVerified: false }, c); return c.redirect(redirectPath, 302); }; diff --git a/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts index 51f35a766..5d4caa1ce 100644 --- a/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts @@ -12,6 +12,7 @@ import { AuthenticationErrorCode } from '../errors/error-codes'; import { onAuthorize } from './authorizer'; import { validateOauth } from './handle-oauth-callback-url'; import { getOrganisationAuthenticationPortalOptions } from './organisation-portal'; +import { createTwoFactorChallenge } from './two-factor-challenge'; type HandleOAuthOrganisationCallbackUrlOptions = { c: Context; @@ -26,7 +27,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg organisationUrl: orgUrl, }); - const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({ + const { email, name, sub } = await validateOauth({ c, clientOptions: { ...clientOptions, @@ -55,7 +56,21 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg // Directly log in user if account already exists. if (existingAccount) { - await onAuthorize({ userId: existingAccount.user.id }, c); + // A 2FA-enabled user must pass a TOTP/backup challenge before any session + // exists — primary (org OIDC) auth alone only earns a pending challenge. + if (existingAccount.user.twoFactorEnabled) { + await createTwoFactorChallenge(c, { + userId: existingAccount.user.id, + metadata: { + redirectPath: `/o/${orgUrl}`, + authMethod: 'oauth', + }, + }); + + return c.redirect('/2fa-challenge', 302); + } + + await onAuthorize({ userId: existingAccount.user.id, authMethod: 'oauth', twoFactorVerified: false }, c); return c.redirect(formatPath(`/o/${orgUrl}`), 302); } @@ -103,16 +118,16 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg }); } + // Note: only the provider subject crosses into the verification token + // metadata — access/ID tokens are deliberately not persisted (see + // ZOrganisationAccountLinkMetadataSchema). await sendOrganisationAccountLinkConfirmationEmail({ type: userToLink.emailVerified ? 'link' : 'create', userId: userToLink.id, organisationId: organisation.id, organisationName: organisation.name, oauthConfig: { - accessToken, - idToken, providerAccountId: sub, - expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000), }, }); diff --git a/packages/auth/server/lib/utils/two-factor-challenge.ts b/packages/auth/server/lib/utils/two-factor-challenge.ts new file mode 100644 index 000000000..d3d748c19 --- /dev/null +++ b/packages/auth/server/lib/utils/two-factor-challenge.ts @@ -0,0 +1,377 @@ +import { formatSecureCookieName } from '@documenso/lib/constants/auth'; +import { AppError } from '@documenso/lib/errors/app-error'; +import type { TTwoFactorChallengeAction, TTwoFactorChallengeMetadata } from '@documenso/lib/types/two-factor-challenge'; +import { ZTwoFactorChallengeMetadataSchema } from '@documenso/lib/types/two-factor-challenge'; +import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { + isChallengeExpired, + shouldConsumeChallengeAfterFailure, + TWO_FACTOR_CHALLENGE_LIFETIME_MS, + TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS, + TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER, +} from '@documenso/lib/utils/two-factor-challenge'; +import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@prisma/client'; +import { UserSecurityAuditLogType } from '@prisma/client'; +import crypto from 'crypto'; +import type { Context } from 'hono'; +import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie'; + +import { AuthenticationErrorCode } from '../errors/error-codes'; +import { getAuthSecret, sessionCookieOptions } from '../session/session-cookies'; + +/** + * Pending 2FA challenge plumbing for sign-in flows where the second factor + * arrives in a later request than primary authentication (OAuth/OIDC + * callbacks). + * + * The challenge is a random token stored in `VerificationToken` plus a signed + * HttpOnly cookie carrying that token. The token is NOT a second factor — it + * only carries "primary auth passed for user X" across the redirect, since no + * session may exist before the TOTP/backup code is verified. Code + * verification itself is the same `validateTwoFactorAuthentication` used by + * the email/password flow. + */ + +const twoFactorChallengeCookieName = formatSecureCookieName('twoFactorChallenge'); + +export type PendingTwoFactorChallenge = { + id: number; + userId: number; + attempts: number; + metadata: TTwoFactorChallengeMetadata; +}; + +export type CreateTwoFactorChallengeOptions = { + userId: number; + metadata: TTwoFactorChallengeMetadata; +}; + +/** + * Issue a pending 2FA challenge for a user whose primary authentication has + * succeeded, and attach the signed challenge cookie to the response. + * + * The metadata is round-tripped through the strict schema so an invalid + * redirect path or a payload carrying unexpected keys (e.g. provider tokens) + * can never be persisted. + */ +export const createTwoFactorChallenge = async (c: Context, options: CreateTwoFactorChallengeOptions): Promise => { + const metadata = ZTwoFactorChallengeMetadataSchema.parse(options.metadata); + + const token = crypto.randomBytes(32).toString('hex'); + + await prisma.verificationToken.create({ + data: { + identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER, + token, + expires: new Date(Date.now() + TWO_FACTOR_CHALLENGE_LIFETIME_MS), + metadata, + userId: options.userId, + }, + }); + + await setSignedCookie(c, twoFactorChallengeCookieName, token, getAuthSecret(), { + ...sessionCookieOptions, + maxAge: Math.floor(TWO_FACTOR_CHALLENGE_LIFETIME_MS / 1000), + }); +}; + +export const clearTwoFactorChallengeCookie = (c: Context): void => { + deleteCookie(c, twoFactorChallengeCookieName, sessionCookieOptions); +}; + +/** + * Resolve the pending challenge for the current request, if any. + * + * Expired, exhausted, or malformed challenges are consumed and the cookie is + * cleared — callers uniformly receive `null` and should tell the client to + * restart sign-in. + */ +export const getPendingTwoFactorChallenge = async (c: Context): Promise => { + const token = await getSignedCookie(c, getAuthSecret(), twoFactorChallengeCookieName); + + if (!token) { + return null; + } + + const row = await prisma.verificationToken.findFirst({ + where: { + token, + identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER, + }, + }); + + if (!row) { + clearTwoFactorChallengeCookie(c); + + return null; + } + + const metadataResult = ZTwoFactorChallengeMetadataSchema.safeParse(row.metadata); + + const isUsable = + metadataResult.success && + !isChallengeExpired({ expiresAt: row.expires, now: new Date() }) && + !shouldConsumeChallengeAfterFailure(row.attempts); + + if (!isUsable) { + // `deleteMany` so a concurrent consumption is a no-op instead of a P2025. + await prisma.verificationToken.deleteMany({ + where: { + id: row.id, + }, + }); + + clearTwoFactorChallengeCookie(c); + + return null; + } + + return { + id: row.id, + userId: row.userId, + attempts: row.attempts, + metadata: metadataResult.data, + }; +}; + +export type RecordTwoFactorChallengeFailureOptions = { + challenge: PendingTwoFactorChallenge; + requestMetadata: RequestMetadata; +}; + +/** + * Record a failed code attempt against a pending challenge. + * + * Failed codes do not consume the token but atomically increment its attempt + * counter. The rate limiter fails open on DB errors, so this counter is the + * hard bound on guesses per challenge — once it reaches the cap the challenge + * is consumed and sign-in must restart. + */ +export const recordTwoFactorChallengeFailure = async ( + c: Context, + { challenge, requestMetadata }: RecordTwoFactorChallengeFailureOptions, +): Promise<{ isExhausted: boolean }> => { + // Conditional increment: only counts while under the cap so the counter + // cannot be raced past it. + const { count } = await prisma.verificationToken.updateMany({ + where: { + id: challenge.id, + attempts: { + lt: TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS, + }, + }, + data: { + attempts: { + increment: 1, + }, + }, + }); + + await prisma.userSecurityAuditLog.create({ + data: { + userId: challenge.userId, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL, + }, + }); + + if (count === 0) { + // Already at the cap (or deleted) via concurrent requests. + await prisma.verificationToken.deleteMany({ + where: { + id: challenge.id, + }, + }); + + clearTwoFactorChallengeCookie(c); + + return { isExhausted: true }; + } + + const updated = await prisma.verificationToken.findFirst({ + where: { + id: challenge.id, + }, + select: { + attempts: true, + }, + }); + + const isExhausted = shouldConsumeChallengeAfterFailure(updated?.attempts ?? Number.MAX_SAFE_INTEGER); + + if (isExhausted) { + await prisma.verificationToken.deleteMany({ + where: { + id: challenge.id, + }, + }); + + clearTwoFactorChallengeCookie(c); + } + + return { isExhausted }; +}; + +/** + * Consume a pending challenge on success. + * + * Uses `deleteMany` + count check so a concurrent replay of the same + * challenge errors cleanly instead of surfacing a P2025 as a 500. + */ +export const consumeTwoFactorChallenge = async (tx: Prisma.TransactionClient, challengeId: number): Promise => { + const { count } = await tx.verificationToken.deleteMany({ + where: { + id: challengeId, + }, + }); + + if (count === 0) { + throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, { + message: 'The two factor challenge has already been consumed.', + statusCode: 401, + }); + } +}; + +export type ExecuteTwoFactorChallengeActionOptions = { + action: TTwoFactorChallengeAction; + userId: number; + requestMetadata: RequestMetadata; +}; + +/** + * Execute the deferred OAuth account-link action after the code verified. + * + * The invariant this preserves: NO account mutation happens before the second + * factor passes. The entire link transaction the OAuth callback would have + * run inline (account row, email-verification/password clearing, link audit + * log) is recreated here, in the same transaction as token consumption, + * re-validating that the world has not changed since the callback. + */ +export const executeTwoFactorChallengeAction = async ( + tx: Prisma.TransactionClient, + { action, userId, requestMetadata }: ExecuteTwoFactorChallengeActionOptions, +): Promise => { + const user = await tx.user.findFirst({ + where: { + id: userId, + }, + select: { + id: true, + email: true, + emailVerified: true, + disabled: true, + }, + }); + + // Re-validate: the target user must still exist and must not be disabled. + if (!user || user.disabled) { + throw new AppError(AuthenticationErrorCode.AccountDisabled, { + message: 'Account is not eligible for linking.', + statusCode: 403, + }); + } + + // Re-validate: the email must be unchanged since the OAuth callback — a + // changed email means the provider account may no longer belong to this + // user. + if (user.email !== action.email) { + throw new AppError(AuthenticationErrorCode.InvalidRequest, { + message: 'Account email has changed since the sign-in was initiated.', + statusCode: 400, + }); + } + + const existingAccount = await tx.account.findFirst({ + where: { + provider: action.provider, + providerAccountId: action.providerAccountId, + }, + select: { + userId: true, + }, + }); + + // Re-validate: the provider account must not already be linked. Linked to + // the same user is an idempotent no-op; linked to another user is a + // conflict. + if (existingAccount) { + if (existingAccount.userId !== user.id) { + throw new AppError(AuthenticationErrorCode.InvalidRequest, { + message: 'This provider account is already linked to another user.', + statusCode: 400, + }); + } + + return; + } + + // The deferred account row is created WITHOUT access/ID tokens — they are + // intentionally never stored in challenge metadata, so they are not + // available here. This is deliberate: challenge metadata sits in the + // database on the strength of primary auth alone. + await tx.account.create({ + data: { + type: 'oauth', + provider: action.provider, + providerAccountId: action.providerAccountId, + userId: user.id, + }, + }); + + await tx.userSecurityAuditLog.create({ + data: { + userId: user.id, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK, + }, + }); + + // Mirrors the inline OAuth link path: if the user was unverified, the OAuth + // provider has now verified the email, and the password is removed since we + // cannot confirm it was set by the real owner of the email. + if (!user.emailVerified) { + await tx.user.update({ + where: { + id: user.id, + }, + data: { + emailVerified: new Date(), + password: null, + }, + }); + } +}; + +/** + * Best-effort cleanup used by `onAuthorize`: any successful sign-in clears a + * pending challenge cookie and deletes its token — an abandoned challenge + * must not outlive a login via another route. + * + * The challenge endpoint itself consumes its own token BEFORE calling + * `onAuthorize`, so this sweep finds nothing to delete there and only clears + * the (already superseded) cookie. + */ +export const sweepPendingTwoFactorChallenge = async (c: Context): Promise => { + try { + const token = await getSignedCookie(c, getAuthSecret(), twoFactorChallengeCookieName); + + if (!token) { + return; + } + + await prisma.verificationToken.deleteMany({ + where: { + token, + identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER, + }, + }); + + clearTwoFactorChallengeCookie(c); + } catch { + // A failed sweep must never block a successful sign-in. + } +}; diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 8d890b81a..966f974f0 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -7,12 +7,9 @@ import { import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email'; import { AppError } from '@documenso/lib/errors/app-error'; import { jobsClient } from '@documenso/lib/jobs/client'; -import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa'; -import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa'; import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa/is-2fa-availble'; -import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; +import { resetTwoFactorAfterBackupCodeUse } from '@documenso/lib/server-only/2fa/reset-2fa-after-backup-code-use'; import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa'; -import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes'; import { verifyCaptchaToken } from '@documenso/lib/server-only/captcha/verify-captcha'; import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware'; import { @@ -24,6 +21,7 @@ import { verifyEmailRateLimit, } from '@documenso/lib/server-only/rate-limit/rate-limits'; import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains'; +import { assertUserNotDisabled } from '@documenso/lib/server-only/user/assert-user-not-disabled'; import { createUser } from '@documenso/lib/server-only/user/create-user'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token'; @@ -40,7 +38,6 @@ import { UserSecurityAuditLogType } from '@prisma/client'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { DateTime } from 'luxon'; -import { z } from 'zod'; import { AuthenticationErrorCode } from '../lib/errors/error-codes'; import { invalidateSessions } from '../lib/session/session'; @@ -135,14 +132,16 @@ export const emailPasswordRoute = new Hono() const is2faEnabled = isTwoFactorAuthenticationEnabled({ user }); + let isBackupCodeRecovery = false; + if (is2faEnabled) { - const isValid = await validateTwoFactorAuthentication({ + const validationResult = await validateTwoFactorAuthentication({ backupCode, totpCode, user, }); - if (!isValid) { + if (!validationResult.isValid) { await prisma.userSecurityAuditLog.create({ data: { userId: user.id, @@ -154,6 +153,8 @@ export const emailPasswordRoute = new Hono() throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode); } + + isBackupCodeRecovery = validationResult.method === 'backup'; } if (!user.emailVerified) { @@ -179,11 +180,44 @@ export const emailPasswordRoute = new Hono() }); } + // A rejected sign-in must never strip 2FA, so every sign-in guard runs + // before the recovery reset below: the disabled check here (onAuthorize + // re-checks it as defence in depth) and the unverified-email guard above. + assertUserNotDisabled(user); + + // Backup codes are recovery, not sign-in: a successful backup-code + // sign-in atomically resets the user's 2FA configuration (conditional + // update — concurrent uses cannot double-spend) and the client is told to + // land on the re-enrolment page. + if (isBackupCodeRecovery) { + await resetTwoFactorAfterBackupCodeUse({ user, requestMetadata }); + } + // The disabled check now lives inside `onAuthorize` so every sign-in path // (password, passkey, OAuth, OIDC) shares the same enforcement. - await onAuthorize({ userId: user.id }, c); + // + // If 2FA is enabled we only reach this point after the inline TOTP/backup + // validation above has passed, so the session counts as second-factor + // verified. A backup code IS a second factor, so recovery sign-ins are + // verified too. + await onAuthorize( + { + userId: user.id, + authMethod: 'email-password', + twoFactorVerified: is2faEnabled, + }, + c, + ); - return c.text('', 201); + return c.json( + { + // Non-null when the server needs the client to land somewhere + // specific instead of its own redirect path. Currently only the + // post-recovery re-enrolment page. + redirectPath: isBackupCodeRecovery ? '/onboarding/2fa' : null, + }, + 201, + ); }) /** * Signup endpoint. @@ -316,7 +350,7 @@ export const emailPasswordRoute = new Hono() // If email is verified, automatically authenticate user. if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) { - await onAuthorize({ userId }, c); + await onAuthorize({ userId, authMethod: 'email-password', twoFactorVerified: false }, c); } return c.json({ @@ -454,156 +488,8 @@ export const emailPasswordRoute = new Hono() } return c.text('OK', 201); - }) - /** - * Setup two factor authentication. - */ - .post('/2fa/setup', async (c) => { - const { user } = await getSession(c); + }); - const result = await setupTwoFactorAuthentication({ - user, - }); - - return c.json({ - success: true, - secret: result.secret, - uri: result.uri, - }); - }) - /** - * Enable two factor authentication. - */ - .post( - '/2fa/enable', - sValidator( - 'json', - z.object({ - code: z.string(), - }), - ), - async (c) => { - const requestMetadata = c.get('requestMetadata'); - - const { user: sessionUser } = await getSession(c); - - const user = await prisma.user.findFirst({ - where: { - id: sessionUser.id, - }, - select: { - id: true, - email: true, - twoFactorEnabled: true, - twoFactorSecret: true, - }, - }); - - if (!user) { - throw new AppError(AuthenticationErrorCode.InvalidRequest); - } - - const { code } = c.req.valid('json'); - - const result = await enableTwoFactorAuthentication({ - user, - code, - requestMetadata, - }); - - return c.json({ - success: true, - recoveryCodes: result.recoveryCodes, - }); - }, - ) - /** - * Disable two factor authentication. - */ - .post( - '/2fa/disable', - sValidator( - 'json', - z.object({ - totpCode: z.string().trim().optional(), - backupCode: z.string().trim().optional(), - }), - ), - async (c) => { - const requestMetadata = c.get('requestMetadata'); - - const { user: sessionUser } = await getSession(c); - - const user = await prisma.user.findFirst({ - where: { - id: sessionUser.id, - }, - select: { - id: true, - email: true, - twoFactorEnabled: true, - twoFactorSecret: true, - twoFactorBackupCodes: true, - }, - }); - - if (!user) { - throw new AppError(AuthenticationErrorCode.InvalidRequest); - } - - const { totpCode, backupCode } = c.req.valid('json'); - - await disableTwoFactorAuthentication({ - user, - totpCode, - backupCode, - requestMetadata, - }); - - return c.text('OK', 201); - }, - ) - /** - * View backup codes. - */ - .post( - '/2fa/view-recovery-codes', - sValidator( - 'json', - z.object({ - token: z.string(), - }), - ), - async (c) => { - const { user: sessionUser } = await getSession(c); - - const user = await prisma.user.findFirst({ - where: { - id: sessionUser.id, - }, - select: { - id: true, - email: true, - twoFactorEnabled: true, - twoFactorSecret: true, - twoFactorBackupCodes: true, - }, - }); - - if (!user) { - throw new AppError(AuthenticationErrorCode.InvalidRequest); - } - - const { token } = c.req.valid('json'); - - const backupCodes = await viewBackupCodes({ - user, - token, - }); - - return c.json({ - success: true, - backupCodes, - }); - }, - ); +// Note: The duplicated `/2fa/*` endpoints previously mounted here have been +// consolidated into the `/two-factor` route (`./two-factor.ts`), which is the +// only set of 2FA endpoints the auth client calls. diff --git a/packages/auth/server/routes/passkey.ts b/packages/auth/server/routes/passkey.ts index 34f3b241b..5956dbee0 100644 --- a/packages/auth/server/routes/passkey.ts +++ b/packages/auth/server/routes/passkey.ts @@ -6,6 +6,7 @@ import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/servi import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn'; import { ZAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn'; import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator'; +import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to'; import { prisma } from '@documenso/prisma'; import { sValidator } from '@hono/standard-validator'; import { UserSecurityAuditLogType } from '@prisma/client'; @@ -37,7 +38,7 @@ export const passkeyRoute = new Hono() }); } - const { csrfToken, credential } = c.req.valid('json'); + const { csrfToken, credential, redirectPath } = c.req.valid('json'); if (typeof csrfToken !== 'string' || csrfToken.length === 0) { throw new AppError(AppErrorCode.INVALID_REQUEST); @@ -104,6 +105,12 @@ export const passkeyRoute = new Hono() expectedChallenge: challengeToken.token, expectedOrigin: origin, expectedRPID: rpId, + // The library defaults this to true — stated explicitly because the + // resulting session counts as second-factor verified, which is only + // sound if the authenticator performed user verification (PIN or + // biometric). See the matching `userVerification: 'required'` in + // `create-passkey-signin-options.ts`. + requireUserVerification: true, credential: { id: isoBase64URL.fromBuffer(passkey.credentialId), publicKey: new Uint8Array(passkey.credentialPublicKey), @@ -134,11 +141,17 @@ export const passkeyRoute = new Hono() }, }); - await onAuthorize({ userId: user.id }, c); + // A passkey is a trusted second factor (user verification enforced + // above), so the session counts as second-factor verified. + await onAuthorize({ userId: user.id, authMethod: 'passkey', twoFactorVerified: true }, c); + + // Honor the client's redirect path only when it is a valid same-origin + // path. + const url = isValidReturnTo(redirectPath) ? (normalizeReturnTo(redirectPath) ?? '/') : '/'; return c.json( { - url: '/', + url, }, 200, ); diff --git a/packages/auth/server/routes/session.ts b/packages/auth/server/routes/session.ts index fc0ebec88..4b1cbb326 100644 --- a/packages/auth/server/routes/session.ts +++ b/packages/auth/server/routes/session.ts @@ -1,9 +1,20 @@ +import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status'; +import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor'; import { Hono } from 'hono'; import superjson from 'superjson'; import type { SessionValidationResult } from '../lib/session/session'; import { getActiveSessions, getOptionalSession } from '../lib/utils/get-session'; +/** + * The payload consumed by the client session provider. The instance 2FA + * enforcement status rides along with the session so the client can expose it + * without any extra queries. + */ +export type TSessionJsonResponse = SessionValidationResult & { + twoFactorEnforcement: TTwoFactorEnforcementStatus; +}; + export const sessionRoute = new Hono() .get('/session', async (c) => { const session: SessionValidationResult = await getOptionalSession(c); @@ -18,5 +29,11 @@ export const sessionRoute = new Hono() .get('/session-json', async (c) => { const session: SessionValidationResult = await getOptionalSession(c); - return c.json(superjson.serialize(session)); + const twoFactorEnforcement: TTwoFactorEnforcementStatus = session.isAuthenticated + ? await getTwoFactorEnforcementStatus({ user: session.user, session: session.session }) + : { required: false }; + + const response: TSessionJsonResponse = { ...session, twoFactorEnforcement }; + + return c.json(superjson.serialize(response)); }); diff --git a/packages/auth/server/routes/two-factor.ts b/packages/auth/server/routes/two-factor.ts index 516a9e79d..ad371bb8c 100644 --- a/packages/auth/server/routes/two-factor.ts +++ b/packages/auth/server/routes/two-factor.ts @@ -1,18 +1,36 @@ import { AppError } from '@documenso/lib/errors/app-error'; import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa'; import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa'; +import { resetTwoFactorAfterBackupCodeUse } from '@documenso/lib/server-only/2fa/reset-2fa-after-backup-code-use'; import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; +import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa'; import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes'; +import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware'; +import { + twoFactorChallengeIpRateLimit, + twoFactorChallengeUserRateLimit, +} from '@documenso/lib/server-only/rate-limit/rate-limits'; import { prisma } from '@documenso/prisma'; import { sValidator } from '@hono/standard-validator'; import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; import { AuthenticationErrorCode } from '../lib/errors/error-codes'; +import { getCsrfCookie } from '../lib/session/session-cookies'; +import { onAuthorize } from '../lib/utils/authorizer'; import { getSession } from '../lib/utils/get-session'; +import { + clearTwoFactorChallengeCookie, + consumeTwoFactorChallenge, + executeTwoFactorChallengeAction, + getPendingTwoFactorChallenge, + recordTwoFactorChallengeFailure, +} from '../lib/utils/two-factor-challenge'; import type { HonoAuthContext } from '../types/context'; import { ZDisableTwoFactorRequestSchema, ZEnableTwoFactorRequestSchema, + ZVerifyTwoFactorChallengeRequestSchema, ZViewTwoFactorRecoveryCodesRequestSchema, } from './two-factor.types'; @@ -40,7 +58,7 @@ export const twoFactorRoute = new Hono() .post('/enable', sValidator('json', ZEnableTwoFactorRequestSchema), async (c) => { const requestMetadata = c.get('requestMetadata'); - const { user: sessionUser } = await getSession(c); + const { user: sessionUser, session } = await getSession(c); const user = await prisma.user.findFirst({ where: { @@ -63,6 +81,7 @@ export const twoFactorRoute = new Hono() const result = await enableTwoFactorAuthentication({ user, code, + sessionId: session.id, requestMetadata, }); @@ -99,14 +118,24 @@ export const twoFactorRoute = new Hono() const { totpCode, backupCode } = c.req.valid('json'); - await disableTwoFactorAuthentication({ + const { orgEnforcementApplies } = await disableTwoFactorAuthentication({ user, totpCode, backupCode, requestMetadata, }); - return c.text('OK', 201); + // `orgEnforcementApplies` lets the client warn that org/team context + // access will block at the org 2FA deadline (immediately if already + // past). The disable itself is allowed — only instance enforcement + // refuses it server-side. + return c.json( + { + success: true, + orgEnforcementApplies, + }, + 201, + ); }) /** @@ -143,4 +172,235 @@ export const twoFactorRoute = new Hono() success: true, backupCodes, }); + }) + + /** + * Lightweight pending-challenge validity check for the /2fa-challenge page. + * + * Unauthenticated by design — the signed challenge cookie is the proof that + * primary authentication passed. Resolving the challenge also garbage + * collects expired/exhausted tokens, so an invalid state reports `false` + * and the page sends the user back to sign-in. + */ + .get('/challenge', async (c) => { + const requestMetadata = c.get('requestMetadata'); + + // IP-based limit before any challenge resolution. + const ipLimitResult = await twoFactorChallengeIpRateLimit.check({ + ip: requestMetadata.ipAddress ?? 'unknown', + }); + + const ipLimited = rateLimitResponse(c, ipLimitResult); + + if (ipLimited) { + throw new HTTPException(429, { + res: ipLimited, + }); + } + + const challenge = await getPendingTwoFactorChallenge(c); + + return c.json({ + valid: challenge !== null, + }); + }) + + /** + * Verify the second factor for a pending 2FA challenge (OAuth/OIDC + * sign-ins where the code arrives in a later request than primary auth). + * + * Unauthenticated by design: no session may exist before the code is + * verified, so the signed HttpOnly challenge cookie is the proof of primary + * authentication. The pending token is NOT a second factor itself — it only + * carries "primary auth passed for user X" across the OAuth redirect; the + * code is verified against the user's stored TOTP secret / backup codes via + * `validateTwoFactorAuthentication`, identical to email/password login. + * + * No captcha here: this is the continuation of a sign-in that already + * passed the provider's and our own abuse controls at the primary auth + * step. + */ + .post('/challenge', sValidator('json', ZVerifyTwoFactorChallengeRequestSchema), async (c) => { + const requestMetadata = c.get('requestMetadata'); + + const { totpCode, backupCode, csrfToken } = c.req.valid('json'); + + // IP-based limit BEFORE challenge resolution so hammering without a valid + // cookie never reaches the database token lookup. + const ipLimitResult = await twoFactorChallengeIpRateLimit.check({ + ip: requestMetadata.ipAddress ?? 'unknown', + }); + + const ipLimited = rateLimitResponse(c, ipLimitResult); + + if (ipLimited) { + throw new HTTPException(429, { + res: ipLimited, + }); + } + + const csrfCookieToken = await getCsrfCookie(c); + + if (!csrfCookieToken || csrfToken !== csrfCookieToken) { + throw new AppError(AuthenticationErrorCode.InvalidRequest, { + message: 'Invalid CSRF token', + statusCode: 400, + }); + } + + const challenge = await getPendingTwoFactorChallenge(c); + + if (!challenge) { + throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, { + message: 'No pending two factor challenge. Restart the sign-in flow.', + statusCode: 401, + }); + } + + // Per-user limit only after the cookie resolved to a user. + const userLimitResult = await twoFactorChallengeUserRateLimit.check({ + ip: requestMetadata.ipAddress ?? 'unknown', + identifier: `user:${challenge.userId}`, + }); + + const userLimited = rateLimitResponse(c, userLimitResult); + + if (userLimited) { + throw new HTTPException(429, { + res: userLimited, + }); + } + + const user = await prisma.user.findFirst({ + where: { + id: challenge.userId, + }, + select: { + id: true, + email: true, + disabled: true, + twoFactorEnabled: true, + twoFactorSecret: true, + twoFactorBackupCodes: true, + }, + }); + + // The challenge is moot if the user disappeared or no longer has 2FA + // enabled — consume it and force a fresh sign-in. + if (!user || !user.twoFactorEnabled) { + await prisma.verificationToken.deleteMany({ + where: { + id: challenge.id, + }, + }); + + clearTwoFactorChallengeCookie(c); + + throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, { + message: 'The two factor challenge is no longer valid. Restart the sign-in flow.', + statusCode: 401, + }); + } + + // A rejected sign-in must never mutate the account, so a disabled user is + // refused BEFORE code validation — otherwise a valid backup code would + // run the recovery reset (stripping 2FA) even though `onAuthorize` would + // refuse the session afterwards. The challenge is consumed so it cannot + // be retried. + if (user.disabled) { + await prisma.verificationToken.deleteMany({ + where: { + id: challenge.id, + }, + }); + + clearTwoFactorChallengeCookie(c); + + throw new AppError(AuthenticationErrorCode.AccountDisabled, { + message: 'Account disabled', + statusCode: 403, + }); + } + + const validationResult = await validateTwoFactorAuthentication({ + totpCode, + backupCode, + user, + }); + + if (!validationResult.isValid) { + // Failed codes do not consume the token but atomically increment its + // attempt counter (audit-logged). The rate limiter fails open on DB + // errors, so the counter is the hard bound — exhaustion consumes the + // challenge. + const { isExhausted } = await recordTwoFactorChallengeFailure(c, { + challenge, + requestMetadata, + }); + + if (isExhausted) { + throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, { + message: 'Too many failed attempts. Restart the sign-in flow.', + statusCode: 401, + }); + } + + throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode, { + message: 'Invalid two factor code', + statusCode: 400, + }); + } + + const isBackupCodeRecovery = validationResult.method === 'backup'; + + // Token consumption, the deferred link action (if any) and the + // backup-code recovery reset all commit atomically — a concurrent replay + // of the same challenge fails the consumption count check cleanly. + await prisma.$transaction(async (tx) => { + await consumeTwoFactorChallenge(tx, challenge.id); + + if (challenge.metadata.action) { + await executeTwoFactorChallengeAction(tx, { + action: challenge.metadata.action, + userId: challenge.userId, + requestMetadata, + }); + } + + // Backup codes are recovery, not sign-in: a successful backup-code + // challenge atomically resets 2FA so the user re-enrols. + if (isBackupCodeRecovery) { + await resetTwoFactorAfterBackupCodeUse({ + user, + requestMetadata, + tx, + }); + } + }); + + // The session is created with the primary auth method stored at challenge + // creation, and counts as second-factor verified. + await onAuthorize( + { + userId: challenge.userId, + authMethod: challenge.metadata.authMethod, + twoFactorVerified: true, + }, + c, + ); + + clearTwoFactorChallengeCookie(c); + + // A backup-code recovery lands on the re-enrolment page, carrying the + // original destination as its returnTo. + const redirectPath = isBackupCodeRecovery + ? `/onboarding/2fa?returnTo=${encodeURIComponent(challenge.metadata.redirectPath)}` + : challenge.metadata.redirectPath; + + return c.json( + { + redirectPath, + }, + 201, + ); }); diff --git a/packages/auth/server/routes/two-factor.types.ts b/packages/auth/server/routes/two-factor.types.ts index a8f1c7dda..02226be8c 100644 --- a/packages/auth/server/routes/two-factor.types.ts +++ b/packages/auth/server/routes/two-factor.types.ts @@ -18,3 +18,17 @@ export const ZViewTwoFactorRecoveryCodesRequestSchema = z.object({ }); export type TViewTwoFactorRecoveryCodesRequestSchema = z.infer; + +/** + * Mirrors the email/password sign-in shape (`ZSignInSchema`) for the second + * factor: one of `totpCode` or `backupCode`, plus the CSRF token the client + * fetched before submitting (the challenge page is reached via a 302, not the + * sign-in form, so it fetches its own). + */ +export const ZVerifyTwoFactorChallengeRequestSchema = z.object({ + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), + csrfToken: z.string().trim(), +}); + +export type TVerifyTwoFactorChallengeRequestSchema = z.infer; diff --git a/packages/auth/server/types/passkey.ts b/packages/auth/server/types/passkey.ts index d009eb33b..54f5b0b33 100644 --- a/packages/auth/server/types/passkey.ts +++ b/packages/auth/server/types/passkey.ts @@ -3,6 +3,12 @@ import { z } from 'zod'; export const ZPasskeyAuthorizeSchema = z.object({ csrfToken: z.string().min(1), credential: z.string().min(1), + + /** + * Optional client redirect path, validated server-side with + * `isValidReturnTo`/`normalizeReturnTo` before being echoed back. + */ + redirectPath: z.string().optional(), }); export type TPasskeyAuthorizeSchema = z.infer; diff --git a/packages/ee/server-only/lib/link-organisation-account.ts b/packages/ee/server-only/lib/link-organisation-account.ts index 71f7f168b..a443a9c0e 100644 --- a/packages/ee/server-only/lib/link-organisation-account.ts +++ b/packages/ee/server-only/lib/link-organisation-account.ts @@ -5,11 +5,12 @@ import { ORGANISATION_USER_ACCOUNT_TYPE, } from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation'; +import { jobs } from '@documenso/lib/jobs/client'; import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { generateDatabaseId } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; -import { UserSecurityAuditLogType } from '@prisma/client'; +import { OrganisationGroupType, UserSecurityAuditLogType } from '@prisma/client'; export interface LinkOrganisationAccountOptions { token: string; @@ -23,8 +24,10 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani }); } - // Delete the token since it contains unnecessary sensitive data. - const verificationToken = await prisma.verificationToken.delete({ + // Read WITHOUT consuming: the token must stay retryable until membership + // creation succeeds. Consumption happens atomically with the membership + // creation below. + const verificationToken = await prisma.verificationToken.findFirst({ where: { token, identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER, @@ -33,13 +36,9 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani user: { select: { id: true, + email: true, emailVerified: true, - accounts: { - select: { - provider: true, - providerAccountId: true, - }, - }, + disabled: true, }, }, }, @@ -73,11 +72,47 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani const user = verificationToken.user; + // Never link into (or activate membership for) a disabled account. + if (user.disabled) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Account is disabled', + }); + } + + // The confirmation is only valid for the email address it was sent to. An + // email change between token issuance and confirmation invalidates it. + if (user.email.toLowerCase() !== tokenMetadata.data.email.toLowerCase()) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Verification token not found, used or expired', + }); + } + const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({ type: 'id', organisationId: tokenMetadata.data.organisationId, }); + const { providerAccountId } = tokenMetadata.data.oauthConfig; + + // Ownership conflict check: the provider subject must not already belong to + // a different user. `createMany skipDuplicates` below would silently skip + // in that case, which would confirm a link that never happened. + const existingProviderAccount = await prisma.account.findFirst({ + where: { + provider: clientOptions.id, + providerAccountId, + }, + select: { + userId: true, + }, + }); + + if (existingProviderAccount && existingProviderAccount.userId !== user.id) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, { + message: 'This identity provider account is already linked to another user', + }); + } + const organisationMember = await prisma.organisationMember.findFirst({ where: { userId: user.id, @@ -85,32 +120,34 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani }, }); - const oauthConfig = tokenMetadata.data.oauthConfig; + const isProviderAccountLinked = existingProviderAccount !== null; - const userAlreadyLinked = user.accounts.find( - (account) => account.provider === clientOptions.id && account.providerAccountId === oauthConfig.providerAccountId, - ); - - if (organisationMember && userAlreadyLinked) { - return; - } - - // Link the user if not linked yet. - if (!userAlreadyLinked) { + // Link the provider account idempotently. `createMany` + `skipDuplicates` + // (unique on provider + providerAccountId) can never clobber an existing + // row and is safe under concurrent confirmation attempts. The account row + // is intentionally created WITHOUT access/ID tokens — they are never stored + // in the token metadata, and the portal re-authenticates against the IdP on + // every sign-in. + if (!isProviderAccountLinked) { await prisma.$transaction(async (tx) => { - await tx.account.create({ - data: { - type: ORGANISATION_USER_ACCOUNT_TYPE, - provider: clientOptions.id, - providerAccountId: oauthConfig.providerAccountId, - access_token: oauthConfig.accessToken, - expires_at: oauthConfig.expiresAt, - token_type: 'Bearer', - id_token: oauthConfig.idToken, - userId: user.id, - }, + const createdAccounts = await tx.account.createMany({ + data: [ + { + type: ORGANISATION_USER_ACCOUNT_TYPE, + provider: clientOptions.id, + providerAccountId, + userId: user.id, + }, + ], + skipDuplicates: true, }); + // A concurrent confirmation created the row first — nothing to do, and + // the audit/verification side effects below belong to that request. + if (createdAccounts.count === 0) { + return; + } + // Log link event. await tx.userSecurityAuditLog.create({ data: { @@ -139,15 +176,66 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani }); } - // Only add the user to the organisation if they are not already a member. - // Done outside the above transaction to avoid nested transactions and - // holding connections during the job trigger network I/O. - if (!organisationMember) { - await addUserToOrganisation({ - userId: user.id, - organisationId: tokenMetadata.data.organisationId, - organisationGroups: organisation.groups, - organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole, + // Create the membership and consume the verification token ATOMICALLY. The + // `deleteMany` count check means a concurrent confirmation loses cleanly + // (no double membership, no P2025 500), while any failure before commit + // leaves the token intact and retryable. + await prisma.$transaction(async (tx) => { + const deletedTokens = await tx.verificationToken.deleteMany({ + where: { + id: verificationToken.id, + }, }); + + if (deletedTokens.count !== 1) { + throw new AppError('ALREADY_USED'); + } + + if (organisationMember) { + return; + } + + const organisationGroupToUse = organisation.groups.find( + (group) => + group.type === OrganisationGroupType.INTERNAL_ORGANISATION && + group.organisationRole === organisation.organisationAuthenticationPortal.defaultOrganisationRole, + ); + + if (!organisationGroupToUse) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Organisation group not found', + }); + } + + await tx.organisationMember.create({ + data: { + id: generateDatabaseId('member'), + userId: user.id, + organisationId: tokenMetadata.data.organisationId, + organisationGroupMembers: { + create: { + id: generateDatabaseId('group_member'), + groupId: organisationGroupToUse.id, + }, + }, + }, + }); + }); + + // Best-effort notification AFTER the confirmation is committed — a failing + // email job must not fail (or roll back) the confirmation itself. + if (!organisationMember) { + try { + await jobs.triggerJob({ + name: 'send.organisation-member-joined.email', + payload: { + organisationId: tokenMetadata.data.organisationId, + memberUserId: user.id, + }, + }); + } catch (error) { + // Todo: (RR7) Add logging. + console.error('Failed to trigger organisation member joined email', error); + } } }; diff --git a/packages/ee/server-only/lib/send-organisation-account-link-confirmation-email.ts b/packages/ee/server-only/lib/send-organisation-account-link-confirmation-email.ts index 99f25bb4b..b9494e8c7 100644 --- a/packages/ee/server-only/lib/send-organisation-account-link-confirmation-email.ts +++ b/packages/ee/server-only/lib/send-organisation-account-link-confirmation-email.ts @@ -14,7 +14,7 @@ import crypto from 'crypto'; import { DateTime } from 'luxon'; import { createElement } from 'react'; -export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & { +export type SendOrganisationAccountLinkConfirmationEmailProps = Omit & { organisationName: string; }; @@ -69,6 +69,9 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({ type, userId, organisationId, + // The address this confirmation is being sent to — asserted unchanged + // at confirmation time. + email: user.email, oauthConfig, } satisfies TOrganisationAccountLinkMetadata, userId, diff --git a/packages/lib/client-only/providers/session.tsx b/packages/lib/client-only/providers/session.tsx index 131b9f504..b919cac8f 100644 --- a/packages/lib/client-only/providers/session.tsx +++ b/packages/lib/client-only/providers/session.tsx @@ -8,11 +8,19 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea import { useLocation } from 'react-router'; import { SKIP_QUERY_BATCH_META } from '../../constants/trpc'; +import type { TTwoFactorEnforcementStatus } from '../../utils/two-factor'; export type AppSession = { session: Session; user: SessionUser; organisations: TGetOrganisationSessionResponse; + + /** + * Instance-wide 2FA enforcement status for the current user + session, + * computed server-side. Layout components read this to derive banners and + * the enforcement redirect client-side without extra queries. + */ + twoFactorEnforcement: TTwoFactorEnforcementStatus; }; interface SessionProviderProps { @@ -94,6 +102,7 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro session: newSession.session, user: newSession.user, organisations, + twoFactorEnforcement: newSession.twoFactorEnforcement, }); }, []); diff --git a/packages/lib/constants/organisations.ts b/packages/lib/constants/organisations.ts index de4a071a5..0cf02eebd 100644 --- a/packages/lib/constants/organisations.ts +++ b/packages/lib/constants/organisations.ts @@ -31,6 +31,12 @@ export const ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP = { MANAGE_BILLING: [OrganisationMemberRole.ADMIN], DELETE_ORGANISATION_TRANSFER_REQUEST: [OrganisationMemberRole.ADMIN], MANAGE_ORGANISATION: [OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER], + /** + * Security-sensitive organisation settings (2FA enforcement). ADMIN only — + * managers can manage day-to-day settings but must not be able to lock + * members out (or unlock them) via enforcement policy. + */ + MANAGE_ORGANISATION_SECURITY: [OrganisationMemberRole.ADMIN], } satisfies Record; /** diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 79d5235b2..2e59e5fd4 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -23,6 +23,11 @@ export enum AppErrorCode { SCHEMA_FAILED = 'SCHEMA_FAILED', TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', TWO_FACTOR_AUTH_FAILED = 'TWO_FACTOR_AUTH_FAILED', + /** + * The user is blocked by 2FA enforcement (instance-wide or per-organisation) + * and must enrol/verify a second factor before accessing the resource. + */ + TWO_FACTOR_REQUIRED = 'TWO_FACTOR_REQUIRED', WEBHOOK_INVALID_REQUEST = 'WEBHOOK_INVALID_REQUEST', ENVELOPE_DRAFT = 'ENVELOPE_DRAFT', ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED', @@ -106,6 +111,9 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record 400 as const, ) .with(AppErrorCode.UNAUTHORIZED, () => 401 as const) - .with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const) + .with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, AppErrorCode.TWO_FACTOR_REQUIRED, () => 403 as const) .with(AppErrorCode.NOT_FOUND, () => 404 as const) .with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const) .otherwise(() => 500 as const); diff --git a/packages/lib/server-only/2fa/disable-2fa.ts b/packages/lib/server-only/2fa/disable-2fa.ts index ebbeab5a7..9f0e7eefd 100644 --- a/packages/lib/server-only/2fa/disable-2fa.ts +++ b/packages/lib/server-only/2fa/disable-2fa.ts @@ -4,6 +4,7 @@ import { UserSecurityAuditLogType } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import { getInstanceTwoFactorEnforcementSetting } from './get-instance-two-factor-enforcement-setting'; import { validateTwoFactorAuthentication } from './validate-2fa'; type DisableTwoFactorAuthenticationOptions = { @@ -19,22 +20,42 @@ export const disableTwoFactorAuthentication = async ({ user, requestMetadata, }: DisableTwoFactorAuthenticationOptions) => { - let isValid = false; - if (!totpCode && !backupCode) { throw new AppError(AppErrorCode.INVALID_REQUEST); } - if (totpCode) { - isValid = await validateTwoFactorAuthentication({ totpCode, user }); - } else if (backupCode) { - isValid = await validateTwoFactorAuthentication({ backupCode, user }); + // Disabling always breaks enforcement satisfaction (a passkey sign-in never + // substitutes for enrolment), so while instance-wide enforcement is active + // disabling is a straight path to being blocked — refuse it outright. + const instanceEnforcementSetting = await getInstanceTwoFactorEnforcementSetting(); + + if (instanceEnforcementSetting !== null) { + throw new AppError('TWO_FACTOR_DISABLE_FORBIDDEN', { + message: 'Two-factor authentication cannot be disabled while it is required by this instance.', + statusCode: 403, + }); } + const { isValid } = await validateTwoFactorAuthentication({ totpCode, backupCode, user }); + if (!isValid) { throw new AppError('INCORRECT_TWO_FACTOR_CODE'); } + // Org-only enforcement allows the disable (the rest of the app stays + // usable) but the caller should warn that org/team context access blocks at + // the org deadline — immediately if it is already past. + const orgEnforcementCount = await prisma.organisationMember.count({ + where: { + userId: user.id, + organisation: { + organisationGlobalSettings: { + twoFactorRequired: true, + }, + }, + }, + }); + await prisma.$transaction(async (tx) => { await tx.user.update({ where: { @@ -57,5 +78,5 @@ export const disableTwoFactorAuthentication = async ({ }); }); - return true; + return { orgEnforcementApplies: orgEnforcementCount > 0 }; }; diff --git a/packages/lib/server-only/2fa/enable-2fa.ts b/packages/lib/server-only/2fa/enable-2fa.ts index 4d6a86f1f..6d9fe3149 100644 --- a/packages/lib/server-only/2fa/enable-2fa.ts +++ b/packages/lib/server-only/2fa/enable-2fa.ts @@ -9,12 +9,22 @@ import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; type EnableTwoFactorAuthenticationOptions = { user: Pick; code: string; + + /** + * The session the enable request arrived on. A valid enable code proves + * possession of the second factor, so this session is marked + * `twoFactorVerified`. Other sessions of the same user intentionally stay + * unverified — they must re-login to verify. + */ + sessionId: string; + requestMetadata?: RequestMetadata; }; export const enableTwoFactorAuthentication = async ({ user, code, + sessionId, requestMetadata, }: EnableTwoFactorAuthenticationOptions) => { if (user.twoFactorEnabled) { @@ -34,21 +44,58 @@ export const enableTwoFactorAuthentication = async ({ let recoveryCodes: string[] = []; await prisma.$transaction(async (tx) => { - const updatedUser = await tx.user.update({ + // Conditional update: the write itself asserts 2FA is still disabled AND + // the stored secret is the one the code was just verified against, so a + // concurrent enable or setup-time secret rotation loses the race cleanly + // instead of enabling 2FA with an unverified secret. + const { count } = await tx.user.updateMany({ where: { id: user.id, + twoFactorEnabled: false, + twoFactorSecret: user.twoFactorSecret, }, data: { twoFactorEnabled: true, }, }); + if (count === 0) { + throw new AppError('TWO_FACTOR_ALREADY_ENABLED', { + message: + 'Two-factor authentication is already enabled, or the setup was restarted after this code was issued. Restart setup and try again.', + statusCode: 400, + }); + } + + const updatedUser = await tx.user.findFirst({ + where: { + id: user.id, + }, + }); + + if (!updatedUser) { + throw new AppError('MISSING_BACKUP_CODE'); + } + recoveryCodes = getBackupCodes({ user: updatedUser }) ?? []; if (recoveryCodes.length === 0) { throw new AppError('MISSING_BACKUP_CODE'); } + // `updateMany` so a session that expired mid-request is a noop rather + // than a thrown P2025. The user filter guards against marking a session + // that does not belong to this user. + await tx.session.updateMany({ + where: { + id: sessionId, + userId: user.id, + }, + data: { + twoFactorVerified: true, + }, + }); + await tx.userSecurityAuditLog.create({ data: { userId: user.id, diff --git a/packages/lib/server-only/2fa/get-instance-two-factor-enforcement-config.ts b/packages/lib/server-only/2fa/get-instance-two-factor-enforcement-config.ts new file mode 100644 index 000000000..c78257078 --- /dev/null +++ b/packages/lib/server-only/2fa/get-instance-two-factor-enforcement-config.ts @@ -0,0 +1,68 @@ +import { prisma } from '@documenso/prisma'; + +import type { InstanceTwoFactorEnforcementStoredConfig } from '../../utils/two-factor'; +import { isInstanceTwoFactorEnforcementActive } from '../../utils/two-factor'; +import { assertLicensedFor } from '../license/assert-licensed-for'; +import { + SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID, + ZSiteSettingsTwoFactorEnforcementSchema, +} from '../site-settings/schemas/two-factor-enforcement'; + +export type TInstanceTwoFactorEnforcementConfig = InstanceTwoFactorEnforcementStoredConfig & { + isLicensed: boolean; + + /** + * Whether enforcement is currently active (enabled AND licensed) — the same + * activation decision the policy getter applies. + */ + isActive: boolean; +}; + +/** + * Returns the raw stored instance 2FA enforcement configuration (schema + * defaults when the row is absent or malformed) together with the license and + * activation state. + * + * FOR ADMIN SETTINGS ONLY — never use this from enforcement code. Enforcement + * reads `getInstanceTwoFactorEnforcementSetting()`, which returns `null` + * unless the policy is actually active. This getter deliberately exposes the + * stored values that the policy getter hides so the admin UI can render the + * "configured but inactive" (e.g. license lapsed) state with a disable-only + * affordance. + */ +export const getInstanceTwoFactorEnforcementConfig = async (): Promise => { + const settingRow = await prisma.siteSettings.findFirst({ + where: { + id: SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID, + }, + }); + + // A missing or malformed row is presented as the unconfigured defaults, + // mirroring how the policy getter treats it as unconfigured. + const parsedSetting = settingRow ? ZSiteSettingsTwoFactorEnforcementSchema.safeParse(settingRow) : null; + + const storedConfig: InstanceTwoFactorEnforcementStoredConfig = parsedSetting?.success + ? { + enabled: parsedSetting.data.enabled, + gracePeriodDays: parsedSetting.data.data.gracePeriodDays, + enforcedFrom: parsedSetting.data.data.enforcedFrom, + } + : { + enabled: false, + gracePeriodDays: 7, + enforcedFrom: null, + }; + + const isLicensed = await assertLicensedFor('instanceTwoFactorEnforcement') + .then(() => true) + .catch(() => false); + + return { + ...storedConfig, + isLicensed, + isActive: isInstanceTwoFactorEnforcementActive({ + setting: { enabled: storedConfig.enabled }, + isLicensed, + }), + }; +}; diff --git a/packages/lib/server-only/2fa/get-instance-two-factor-enforcement-setting.ts b/packages/lib/server-only/2fa/get-instance-two-factor-enforcement-setting.ts new file mode 100644 index 000000000..ab8e55f1c --- /dev/null +++ b/packages/lib/server-only/2fa/get-instance-two-factor-enforcement-setting.ts @@ -0,0 +1,52 @@ +import { prisma } from '@documenso/prisma'; + +import { isInstanceTwoFactorEnforcementActive } from '../../utils/two-factor'; +import { assertLicensedFor } from '../license/assert-licensed-for'; +import type { TSiteSettingsTwoFactorEnforcementSchema } from '../site-settings/schemas/two-factor-enforcement'; +import { + SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID, + ZSiteSettingsTwoFactorEnforcementSchema, +} from '../site-settings/schemas/two-factor-enforcement'; + +/** + * Returns the instance-wide 2FA enforcement setting, or `null` when + * enforcement is not active. + * + * Enforcement is only active when the `site.two-factor-enforcement` row + * exists, parses, is enabled, AND the license grants + * `instanceTwoFactorEnforcement` — a manually inserted row on an unlicensed + * instance is a silent noop. + * + * Enforcement code must read only this getter. Admin settings UI which needs + * the raw "configured but inactive" values uses its own config getter. + */ +export const getInstanceTwoFactorEnforcementSetting = + async (): Promise => { + const settingRow = await prisma.siteSettings.findFirst({ + where: { + id: SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID, + }, + }); + + if (!settingRow) { + return null; + } + + // A malformed row is treated as unconfigured rather than throwing, so a + // bad manual insert cannot break every enforcement check. + const parsedSetting = ZSiteSettingsTwoFactorEnforcementSchema.safeParse(settingRow); + + if (!parsedSetting.success) { + return null; + } + + const isLicensed = await assertLicensedFor('instanceTwoFactorEnforcement') + .then(() => true) + .catch(() => false); + + if (!isInstanceTwoFactorEnforcementActive({ setting: parsedSetting.data, isLicensed })) { + return null; + } + + return parsedSetting.data; + }; diff --git a/packages/lib/server-only/2fa/get-two-factor-enforcement-status.ts b/packages/lib/server-only/2fa/get-two-factor-enforcement-status.ts new file mode 100644 index 000000000..e9281762c --- /dev/null +++ b/packages/lib/server-only/2fa/get-two-factor-enforcement-status.ts @@ -0,0 +1,53 @@ +import type { Session, User } from '@prisma/client'; + +import type { TTwoFactorEnforcementStatus } from '../../utils/two-factor'; +import { computeTwoFactorEnforcementStatus } from '../../utils/two-factor'; +import { getInstanceTwoFactorEnforcementSetting } from './get-instance-two-factor-enforcement-setting'; + +export type GetTwoFactorEnforcementStatusOptions = { + /** + * The user to evaluate. Callers that already hold the user row pass the + * fields in directly — no redundant query is made here. + */ + user: Pick; + + /** + * The current session, or null for contexts that carry no session (e.g. + * API access), which never count as verified. + */ + session: Pick | null; +}; + +/** + * Instance-wide 2FA enforcement status for a user + session. + * + * Thin I/O wrapper around the pure `computeTwoFactorEnforcementStatus`: + * loads the instance setting (null unless configured + licensed) and derives + * the deadline from `max(user.twoFactorGraceStartedAt, setting.enforcedFrom) + * + setting.gracePeriodDays`. + * + * `isBlocked` is computed here once — consumers branch only on it; deadline + * fields are for banners. + */ +export const getTwoFactorEnforcementStatus = async ( + options: GetTwoFactorEnforcementStatusOptions, +): Promise => { + const { user, session } = options; + + const setting = await getInstanceTwoFactorEnforcementSetting(); + + return computeTwoFactorEnforcementStatus({ + graceWindow: setting + ? { + anchors: [ + user.twoFactorGraceStartedAt, + setting.data.enforcedFrom ? new Date(setting.data.enforcedFrom) : null, + ], + gracePeriodDays: setting.data.gracePeriodDays, + } + : null, + userTwoFactorEnabled: user.twoFactorEnabled, + sessionTwoFactorVerified: session?.twoFactorVerified ?? false, + now: new Date(), + }); +}; diff --git a/packages/lib/server-only/2fa/org-enforcement.ts b/packages/lib/server-only/2fa/org-enforcement.ts new file mode 100644 index 000000000..5f419ff04 --- /dev/null +++ b/packages/lib/server-only/2fa/org-enforcement.ts @@ -0,0 +1,191 @@ +import { prisma } from '@documenso/prisma'; +import type { Session, User } from '@prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { OrganisationTwoFactorEnforcementSettings } from '../../utils/two-factor'; +import { computeOrganisationTwoFactorEnforcementStatus, isTwoFactorSatisfied } from '../../utils/two-factor'; +import { getTwoFactorEnforcementStatus } from './get-two-factor-enforcement-status'; + +export type EnforcementUser = Pick; + +export type EnforcementSession = Pick | null; + +/** + * The minimal data required to evaluate a user's 2FA enforcement status for a + * single organisation. + */ +export type OrganisationTwoFactorScope = { + organisationId: string; + memberCreatedAt: Date; + settings: OrganisationTwoFactorEnforcementSettings; +}; + +export type LoadOrganisationTwoFactorScopesOptions = { + userId: number; + + /** + * The organisations to load. `undefined` loads every organisation the user + * is a member of (used for cross-organisation queries where the data scope + * is "all my organisations"). + */ + organisationIds?: string[]; +}; + +/** + * Membership-qualified lookup of the per-organisation 2FA enforcement inputs. + * + * Organisations the user is not a member of are silently dropped — the + * underlying handler's own authorization will reject those anyway, and + * enforcement only applies to organisations the user can actually access. + */ +export const loadOrganisationTwoFactorScopes = async ( + options: LoadOrganisationTwoFactorScopesOptions, +): Promise => { + const { userId, organisationIds } = options; + + if (organisationIds && organisationIds.length === 0) { + return []; + } + + const organisations = await prisma.organisation.findMany({ + where: { + ...(organisationIds ? { id: { in: organisationIds } } : {}), + members: { + some: { + userId, + }, + }, + }, + select: { + id: true, + organisationGlobalSettings: { + select: { + twoFactorRequired: true, + twoFactorGracePeriodDays: true, + twoFactorEnforcedFrom: true, + }, + }, + members: { + where: { + userId, + }, + take: 1, + select: { + createdAt: true, + }, + }, + }, + }); + + return organisations.flatMap((organisation) => { + const [member] = organisation.members; + + // Defensive: the membership filter above guarantees a member row exists. + if (!member) { + return []; + } + + return [ + { + organisationId: organisation.id, + memberCreatedAt: member.createdAt, + settings: organisation.organisationGlobalSettings, + }, + ]; + }); +}; + +const throwTwoFactorRequiredError = (): never => { + throw new AppError(AppErrorCode.TWO_FACTOR_REQUIRED, { + message: 'Two-factor authentication is required to access this resource.', + userMessage: 'Two-factor authentication is required. Please enable it to continue.', + statusCode: 403, + }); +}; + +export type AssertOrganisationScopesTwoFactorEnforcementOptions = { + user: EnforcementUser; + session: EnforcementSession; + scopes: OrganisationTwoFactorScope[]; + now?: Date; +}; + +/** + * Throws `AppError(TWO_FACTOR_REQUIRED)` when the user is blocked by ANY of + * the provided organisation scopes. + */ +export const assertOrganisationScopesTwoFactorEnforcement = ( + options: AssertOrganisationScopesTwoFactorEnforcementOptions, +): void => { + const { user, session, scopes, now = new Date() } = options; + + for (const scope of scopes) { + const status = computeOrganisationTwoFactorEnforcementStatus({ + organisationSettings: scope.settings, + memberCreatedAt: scope.memberCreatedAt, + userTwoFactorEnabled: user.twoFactorEnabled, + userTwoFactorGraceStartedAt: user.twoFactorGraceStartedAt, + sessionTwoFactorVerified: session?.twoFactorVerified ?? false, + now, + }); + + if (status.required && status.isBlocked) { + throwTwoFactorRequiredError(); + } + } +}; + +export type AssertTwoFactorEnforcementForSessionOptions = { + user: EnforcementUser; + session: EnforcementSession; + + /** + * Organisations whose enforcement policy applies to this request. + * `undefined` skips the organisation assert entirely (instance assert only). + */ + organisationIds?: string[]; +}; + +/** + * Shared 2FA enforcement assert for session-authenticated endpoints outside + * of tRPC (e.g. the Hono file/AI routes). + * + * - Satisfied users (enrolled + verified session) can never be blocked at + * either level, so they early-out without any queries. + * - Instance enforcement blocks everything for the user. + * - Organisation enforcement blocks when any of the provided organisations' + * policies block the user. + */ +export const assertTwoFactorEnforcementForSession = async ( + options: AssertTwoFactorEnforcementForSessionOptions, +): Promise => { + const { user, session, organisationIds } = options; + + // Cheap early-out: a satisfied user cannot be blocked by instance or + // organisation enforcement (isBlocked requires !isSatisfied). + if ( + isTwoFactorSatisfied({ + userTwoFactorEnabled: user.twoFactorEnabled, + sessionTwoFactorVerified: session?.twoFactorVerified ?? false, + }) + ) { + return; + } + + const instanceStatus = await getTwoFactorEnforcementStatus({ user, session }); + + if (instanceStatus.required && instanceStatus.isBlocked) { + throwTwoFactorRequiredError(); + } + + if (!organisationIds || organisationIds.length === 0) { + return; + } + + const scopes = await loadOrganisationTwoFactorScopes({ + userId: user.id, + organisationIds, + }); + + assertOrganisationScopesTwoFactorEnforcement({ user, session, scopes }); +}; diff --git a/packages/lib/server-only/2fa/reset-2fa-after-backup-code-use.ts b/packages/lib/server-only/2fa/reset-2fa-after-backup-code-use.ts new file mode 100644 index 000000000..da130aa56 --- /dev/null +++ b/packages/lib/server-only/2fa/reset-2fa-after-backup-code-use.ts @@ -0,0 +1,82 @@ +import { prisma } from '@documenso/prisma'; +import type { Prisma, User } from '@prisma/client'; +import { UserSecurityAuditLogType } from '@prisma/client'; + +import { AppError } from '../../errors/app-error'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; + +type ResetTwoFactorAfterBackupCodeUseOptions = { + /** + * The user whose backup code was just validated. `twoFactorBackupCodes` + * must be the exact stored value the code was validated against. + */ + user: Pick; + requestMetadata?: RequestMetadata; + + /** + * Optional transaction client so callers (e.g. the 2FA challenge endpoint) + * can run the recovery reset atomically with their own writes, such as + * challenge token consumption. When omitted a dedicated transaction is + * used. + */ + tx?: Prisma.TransactionClient; +}; + +/** + * Backup codes are recovery, not sign-in: a successful backup-code sign-in + * proves the authenticator is unavailable, so the user's 2FA configuration is + * atomically reset and they are sent to re-enrol. + * + * The conditional update requires 2FA to still be enabled with the exact + * backup-code state the code was validated against, so concurrent uses cannot + * double-spend — the loser of the race fails the count check and the sign-in + * is rejected. + * + * Audit trail: reuses `AUTH_2FA_DISABLE` — the effect on the account is + * identical to a disable. (A dedicated enum value would require a migration; + * the plan only mandates a new value for the admin reset in a later step.) + */ +export const resetTwoFactorAfterBackupCodeUse = async ({ + user, + requestMetadata, + tx, +}: ResetTwoFactorAfterBackupCodeUseOptions) => { + const run = async (client: Prisma.TransactionClient) => { + const { count } = await client.user.updateMany({ + where: { + id: user.id, + twoFactorEnabled: true, + twoFactorBackupCodes: user.twoFactorBackupCodes, + }, + data: { + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: null, + }, + }); + + if (count === 0) { + throw new AppError('INCORRECT_TWO_FACTOR_CODE', { + message: 'The backup code has already been consumed.', + statusCode: 400, + }); + } + + await client.userSecurityAuditLog.create({ + data: { + userId: user.id, + type: UserSecurityAuditLogType.AUTH_2FA_DISABLE, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); + }; + + if (tx) { + await run(tx); + + return; + } + + await prisma.$transaction(run); +}; diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts index 515d01779..194985ce9 100644 --- a/packages/lib/server-only/2fa/setup-2fa.ts +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -5,6 +5,7 @@ import crypto from 'crypto'; import { createTOTPKeyURI } from 'oslo/otp'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; +import { AppError } from '../../errors/app-error'; import { symmetricEncrypt } from '../../universal/crypto'; type SetupTwoFactorAuthenticationOptions = { @@ -31,9 +32,15 @@ export const setupTwoFactorAuthentication = async ({ user }: SetupTwoFactorAuthe const uri = createTOTPKeyURI(ISSUER, accountName, secret); const encodedSecret = base32.encode(new Uint8Array(secret)); - await prisma.user.update({ + // Conditional update rather than a read-then-write pre-check: the write + // itself asserts 2FA is not enabled, so a concurrent enable can never be + // silently clobbered by a secret/backup-code rotation. Users with 2FA + // enabled must disable it (which requires a valid code) before re-running + // setup. + const { count } = await prisma.user.updateMany({ where: { id: user.id, + twoFactorEnabled: false, }, data: { twoFactorEnabled: false, @@ -48,6 +55,13 @@ export const setupTwoFactorAuthentication = async ({ user }: SetupTwoFactorAuthe }, }); + if (count === 0) { + throw new AppError('TWO_FACTOR_ALREADY_ENABLED', { + message: 'Two-factor authentication is already enabled. Disable it before running setup again.', + statusCode: 400, + }); + } + return { secret: encodedSecret, uri, diff --git a/packages/lib/server-only/2fa/validate-2fa.test.ts b/packages/lib/server-only/2fa/validate-2fa.test.ts new file mode 100644 index 000000000..2c484c40f --- /dev/null +++ b/packages/lib/server-only/2fa/validate-2fa.test.ts @@ -0,0 +1,108 @@ +import { base32 } from '@scure/base'; +import crypto from 'crypto'; +import { generateHOTP } from 'oslo/otp'; +import { describe, expect, it } from 'vitest'; + +import { AppError } from '../../errors/app-error'; +import { symmetricEncrypt } from '../../universal/crypto'; + +// `DOCUMENSO_ENCRYPTION_KEY` is captured at module load, so the env var must +// be set before `validate-2fa` (via `verify-2fa-token`/`verify-backup-code`) +// is imported. This is real environment configuration, not a mock — the code +// under test performs no I/O. +process.env.NEXT_PRIVATE_ENCRYPTION_KEY ??= 'test-encryption-key'; + +const ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY ?? 'test-encryption-key'; + +const { validateTwoFactorAuthentication } = await import('./validate-2fa'); + +const BACKUP_CODES = ['AAAAA-AAAAA', 'BBBBB-BBBBB']; + +const createUser = (overrides: Partial[0]['user']> = {}) => { + const secret = crypto.randomBytes(10); + const encodedSecret = base32.encode(new Uint8Array(secret)); + + return { + user: { + id: 1, + email: 'user@example.com', + twoFactorEnabled: true, + twoFactorSecret: symmetricEncrypt({ data: encodedSecret, key: ENCRYPTION_KEY }), + twoFactorBackupCodes: symmetricEncrypt({ data: JSON.stringify(BACKUP_CODES), key: ENCRYPTION_KEY }), + ...overrides, + }, + secret, + }; +}; + +const generateCurrentTotpCode = async (secret: Buffer) => { + const counter = Math.floor(Date.now() / 30_000); + + return await generateHOTP(new Uint8Array(secret), counter); +}; + +describe('validateTwoFactorAuthentication', () => { + it('returns method totp for a valid TOTP code', async () => { + const { user, secret } = createUser(); + + const totpCode = await generateCurrentTotpCode(secret); + + await expect(validateTwoFactorAuthentication({ totpCode, user })).resolves.toEqual({ + isValid: true, + method: 'totp', + }); + }); + + it('returns invalid for an incorrect TOTP code', async () => { + const { user, secret } = createUser(); + + const validCode = await generateCurrentTotpCode(secret); + const invalidCode = validCode === '000000' ? '000001' : '000000'; + + await expect(validateTwoFactorAuthentication({ totpCode: invalidCode, user })).resolves.toEqual({ + isValid: false, + method: null, + }); + }); + + it('returns method backup for a valid backup code', async () => { + const { user } = createUser(); + + await expect(validateTwoFactorAuthentication({ backupCode: BACKUP_CODES[0], user })).resolves.toEqual({ + isValid: true, + method: 'backup', + }); + }); + + it('returns invalid for an unknown backup code', async () => { + const { user } = createUser(); + + await expect(validateTwoFactorAuthentication({ backupCode: 'ZZZZZ-ZZZZZ', user })).resolves.toEqual({ + isValid: false, + method: null, + }); + }); + + it('prefers the TOTP code when both credentials are provided', async () => { + const { user, secret } = createUser(); + + const totpCode = await generateCurrentTotpCode(secret); + + await expect(validateTwoFactorAuthentication({ totpCode, backupCode: BACKUP_CODES[0], user })).resolves.toEqual({ + isValid: true, + method: 'totp', + }); + }); + + it('throws when 2FA is not enabled', async () => { + const { user } = createUser({ twoFactorEnabled: false }); + + await expect(validateTwoFactorAuthentication({ totpCode: '000000', user })).rejects.toThrowError(AppError); + }); + + it('throws when no credentials are provided', async () => { + const { user } = createUser(); + + await expect(validateTwoFactorAuthentication({ user })).rejects.toThrowError(AppError); + }); +}); diff --git a/packages/lib/server-only/2fa/validate-2fa.ts b/packages/lib/server-only/2fa/validate-2fa.ts index 64a99adcc..fd49466a2 100644 --- a/packages/lib/server-only/2fa/validate-2fa.ts +++ b/packages/lib/server-only/2fa/validate-2fa.ts @@ -10,11 +10,21 @@ type ValidateTwoFactorAuthenticationOptions = { user: Pick; }; +export type TTwoFactorAuthenticationMethod = 'totp' | 'backup'; + +/** + * Discriminates which second factor matched so callers can treat backup codes + * as recovery (which resets 2FA) rather than a regular sign-in factor. + */ +export type TValidateTwoFactorAuthenticationResult = + | { isValid: true; method: TTwoFactorAuthenticationMethod } + | { isValid: false; method: null }; + export const validateTwoFactorAuthentication = async ({ backupCode, totpCode, user, -}: ValidateTwoFactorAuthenticationOptions) => { +}: ValidateTwoFactorAuthenticationOptions): Promise => { if (!user.twoFactorEnabled) { throw new AppError('TWO_FACTOR_SETUP_REQUIRED'); } @@ -24,11 +34,15 @@ export const validateTwoFactorAuthentication = async ({ } if (totpCode) { - return await verifyTwoFactorAuthenticationToken({ user, totpCode }); + const isValid = await verifyTwoFactorAuthenticationToken({ user, totpCode }); + + return isValid ? { isValid: true, method: 'totp' } : { isValid: false, method: null }; } if (backupCode) { - return verifyBackupCode({ user, backupCode }); + const isValid = verifyBackupCode({ user, backupCode }); + + return isValid ? { isValid: true, method: 'backup' } : { isValid: false, method: null }; } throw new AppError('TWO_FACTOR_MISSING_CREDENTIALS'); diff --git a/packages/lib/server-only/2fa/view-backup-codes.ts b/packages/lib/server-only/2fa/view-backup-codes.ts index 9ed75bde4..916e1dad3 100644 --- a/packages/lib/server-only/2fa/view-backup-codes.ts +++ b/packages/lib/server-only/2fa/view-backup-codes.ts @@ -10,10 +10,10 @@ type ViewBackupCodesOptions = { }; export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => { - let isValid = await validateTwoFactorAuthentication({ totpCode: token, user }); + let { isValid } = await validateTwoFactorAuthentication({ totpCode: token, user }); if (!isValid) { - isValid = await validateTwoFactorAuthentication({ backupCode: token, user }); + ({ isValid } = await validateTwoFactorAuthentication({ backupCode: token, user })); } if (!isValid) { diff --git a/packages/lib/server-only/auth/create-passkey-signin-options.ts b/packages/lib/server-only/auth/create-passkey-signin-options.ts index 2b7f5aec2..b5264c82b 100644 --- a/packages/lib/server-only/auth/create-passkey-signin-options.ts +++ b/packages/lib/server-only/auth/create-passkey-signin-options.ts @@ -13,7 +13,16 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig const options = await generateAuthenticationOptions({ rpID: rpId, - userVerification: 'preferred', + // A passkey sign-in counts as a trusted second factor (the session is + // created `twoFactorVerified: true`), so user verification (PIN or + // biometric) is mandatory — possession of the authenticator alone is a + // single factor. + // + // Release note: authenticators that cannot perform user verification + // (e.g. some older U2F-style security keys) can no longer be used to sign + // in; affected users should sign in via another method and register a + // UV-capable passkey. + userVerification: 'required', timeout, }); diff --git a/packages/lib/server-only/public-api/get-api-token-by-token.ts b/packages/lib/server-only/public-api/get-api-token-by-token.ts index abfab5ec3..0c1146009 100644 --- a/packages/lib/server-only/public-api/get-api-token-by-token.ts +++ b/packages/lib/server-only/public-api/get-api-token-by-token.ts @@ -37,6 +37,10 @@ export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: Get name: true, email: true, disabled: true, + // Kept in sync with the `user` select below — team tokens + // substitute the organisation owner as the acting user. + twoFactorEnabled: true, + twoFactorGraceStartedAt: true, }, }, }, @@ -49,6 +53,11 @@ export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: Get name: true, email: true, disabled: true, + // Selected so `ctx.user` keeps a consistent shape between the + // session and API-token auth branches. API-token access itself is + // exempt from 2FA enforcement (machine access). + twoFactorEnabled: true, + twoFactorGraceStartedAt: true, }, }, }, diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index 5dfa47450..7e43a6303 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -59,6 +59,28 @@ export const passkeyRateLimit = createRateLimit({ window: '15m', }); +/** + * IP-scoped limit for the unauthenticated 2FA challenge endpoint, checked + * BEFORE the challenge cookie is resolved so hammering without a valid cookie + * is bounded without any DB token lookups. + */ +export const twoFactorChallengeIpRateLimit = createRateLimit({ + action: 'auth.2fa-challenge.ip', + max: 30, + window: '15m', +}); + +/** + * Per-user limit for the 2FA challenge endpoint, checked AFTER the challenge + * cookie resolves to a user. No `globalMax` — the IP bound is enforced by + * `twoFactorChallengeIpRateLimit` above. + */ +export const twoFactorChallengeUserRateLimit = createRateLimit({ + action: 'auth.2fa-challenge.user', + max: 10, + window: '15m', +}); + export const linkOrgAccountRateLimit = createRateLimit({ action: 'auth.link-org-account', max: 5, diff --git a/packages/lib/server-only/site-settings/schema.ts b/packages/lib/server-only/site-settings/schema.ts index 85bf75ff8..b3991b2fa 100644 --- a/packages/lib/server-only/site-settings/schema.ts +++ b/packages/lib/server-only/site-settings/schema.ts @@ -3,11 +3,13 @@ import { z } from 'zod'; import { ZSiteSettingsBannerSchema } from './schemas/banner'; import { ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist'; import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry'; +import { ZSiteSettingsTwoFactorEnforcementSchema } from './schemas/two-factor-enforcement'; export const ZSiteSettingSchema = z.union([ ZSiteSettingsBannerSchema, ZSiteSettingsEmailBlocklistSchema, ZSiteSettingsTelemetrySchema, + ZSiteSettingsTwoFactorEnforcementSchema, ]); export type TSiteSettingSchema = z.infer; diff --git a/packages/lib/server-only/site-settings/schemas/two-factor-enforcement.ts b/packages/lib/server-only/site-settings/schemas/two-factor-enforcement.ts new file mode 100644 index 000000000..f6e50411a --- /dev/null +++ b/packages/lib/server-only/site-settings/schemas/two-factor-enforcement.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { ZSiteSettingsBaseSchema } from './_base'; + +export const SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID = 'site.two-factor-enforcement'; + +export const ZSiteSettingsTwoFactorEnforcementSchema = ZSiteSettingsBaseSchema.extend({ + id: z.literal(SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID), + data: z + .object({ + gracePeriodDays: z.number().int().min(0).max(365), + /** + * Set server-side on each off→on transition of `enabled`, preserved + * otherwise. ISO datetime string, or null when never enabled. + */ + enforcedFrom: z.string().datetime().nullable(), + }) + .optional() + .default({ + gracePeriodDays: 7, + enforcedFrom: null, + }), +}); + +export type TSiteSettingsTwoFactorEnforcementSchema = z.infer; diff --git a/packages/lib/types/license.ts b/packages/lib/types/license.ts index a456e188c..55f19ac3e 100644 --- a/packages/lib/types/license.ts +++ b/packages/lib/types/license.ts @@ -13,6 +13,7 @@ export const ZLicenseClaimSchema = z.object({ billing: z.boolean().optional(), instanceCscSigning: z.boolean().optional(), cscQesSigning: z.boolean().optional(), + instanceTwoFactorEnforcement: z.boolean().optional(), }); /** diff --git a/packages/lib/types/organisation.ts b/packages/lib/types/organisation.ts index c2bcaabf6..f2ea14a17 100644 --- a/packages/lib/types/organisation.ts +++ b/packages/lib/types/organisation.ts @@ -44,15 +44,27 @@ export const ZOrganisationLiteSchema = OrganisationSchema.pick({ */ export const ZOrganisationManySchema = ZOrganisationLiteSchema; +/** + * Metadata stored on the SSO account-link verification token. + * + * Intentionally stores ONLY the provider subject and the email address the + * confirmation was sent to — never access/ID tokens. The linked account row + * is created without provider tokens; the organisation portal re-authenticates + * against the IdP on every sign-in, so persisting tokens in a verification + * token row would only widen the blast radius of a database leak. + */ export const ZOrganisationAccountLinkMetadataSchema = z.object({ type: z.enum(['link', 'create']), userId: z.number(), organisationId: z.string(), + + /** + * The email address the confirmation email was sent to. Confirmation + * asserts the user's email still matches this value. + */ + email: z.string().email(), oauthConfig: z.object({ providerAccountId: z.string(), - accessToken: z.string(), - expiresAt: z.number(), - idToken: z.string(), }), }); diff --git a/packages/lib/types/session-auth-method.ts b/packages/lib/types/session-auth-method.ts new file mode 100644 index 000000000..23b706f98 --- /dev/null +++ b/packages/lib/types/session-auth-method.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +/** + * The method used to authenticate a session. + * + * Stored as a plain string on `Session.authMethod` (deliberately not a PG + * enum). `unknown` is the column default and covers sessions created before + * the column existed. + */ +export const ZSessionAuthMethodSchema = z.enum(['email-password', 'oauth', 'passkey', 'unknown']); + +export type TSessionAuthMethod = z.infer; diff --git a/packages/lib/types/two-factor-challenge.ts b/packages/lib/types/two-factor-challenge.ts new file mode 100644 index 000000000..799eb097b --- /dev/null +++ b/packages/lib/types/two-factor-challenge.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +import { isValidReturnTo, normalizeReturnTo } from '../utils/is-valid-return-to'; +import { ZSessionAuthMethodSchema } from './session-auth-method'; + +/** + * Deferred OAuth account-link action stored in pending 2FA challenge metadata. + * + * When an OAuth callback would link a new provider account to an existing + * 2FA-enabled user, NO account mutation may happen before the second factor + * verifies. The entire link transaction is deferred: this payload carries just + * enough to recreate it after the code passes. + * + * Access/ID tokens are deliberately NOT stored here — the deferred account row + * is created without them. Challenge metadata lives in the database for up to + * 10 minutes on the strength of primary auth alone, and provider tokens must + * never be obtainable from a stolen challenge row. + */ +export const ZTwoFactorChallengeActionSchema = z + .object({ + type: z.literal('link-oauth-account'), + + /** + * The OAuth provider id (e.g. 'google', 'microsoft', 'oidc'). + */ + provider: z.string().min(1), + + /** + * The provider subject (`sub` claim) identifying the external account. + */ + providerAccountId: z.string().min(1), + + /** + * The user's email at the time of the OAuth callback. Execution re-checks + * that the user's email is unchanged before linking. + */ + email: z.string().email(), + }) + .strict(); + +export type TTwoFactorChallengeAction = z.infer; + +/** + * Metadata stored on a pending 2FA challenge verification token. + * + * Strict: unknown keys are rejected so nothing can smuggle extra state (such + * as provider tokens) into challenge metadata. + */ +export const ZTwoFactorChallengeMetadataSchema = z + .object({ + /** + * Where to send the user after the challenge passes. Validated as a + * same-origin path both when written and when read back. + */ + redirectPath: z + .string() + .refine((value) => isValidReturnTo(value), { + message: 'redirectPath must be a same-origin path', + }) + .transform((value) => normalizeReturnTo(value) || '/'), + + /** + * The primary authentication method that initiated this challenge. The + * session created after the code verifies is stamped with this value. + */ + authMethod: ZSessionAuthMethodSchema, + + /** + * Optional deferred OAuth account-link action, executed in the same + * transaction as token consumption after the code verifies. + */ + action: ZTwoFactorChallengeActionSchema.optional(), + }) + .strict(); + +export type TTwoFactorChallengeMetadata = z.infer; diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts index b643ba6aa..6cb3dd46a 100644 --- a/packages/lib/utils/organisations.ts +++ b/packages/lib/utils/organisations.ts @@ -139,5 +139,9 @@ export const generateDefaultOrganisationSettings = (): Omit + ORGANISATION_ONLY_SETTINGS_KEYS.some((organisationOnlyKey) => organisationOnlyKey === key); + /** * Derive the final settings for a team. * @@ -225,6 +244,10 @@ export const extractDerivedTeamSettings = ( // eslint-disable-next-line @typescript-eslint/consistent-type-assertions for (const key of Object.keys(derivedSettings) as (keyof typeof derivedSettings)[]) { + if (isOrganisationOnlySettingsKey(key)) { + continue; + } + const teamValue = teamSettings[key]; if (teamValue !== null) { diff --git a/packages/lib/utils/two-factor-challenge.test.ts b/packages/lib/utils/two-factor-challenge.test.ts new file mode 100644 index 000000000..730fc448b --- /dev/null +++ b/packages/lib/utils/two-factor-challenge.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { ZTwoFactorChallengeMetadataSchema } from '../types/two-factor-challenge'; +import { + isChallengeExpired, + shouldConsumeChallengeAfterFailure, + TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS, +} from './two-factor-challenge'; + +describe('ZTwoFactorChallengeMetadataSchema', () => { + it('parses minimal valid metadata and normalizes the redirect path', () => { + const result = ZTwoFactorChallengeMetadataSchema.parse({ + redirectPath: '/documents?page=2', + authMethod: 'oauth', + }); + + expect(result).toEqual({ + redirectPath: '/documents?page=2', + authMethod: 'oauth', + }); + }); + + it('normalizes a same-origin absolute URL down to a path', () => { + const result = ZTwoFactorChallengeMetadataSchema.parse({ + redirectPath: 'http://localhost:3000/settings/security', + authMethod: 'oauth', + }); + + expect(result.redirectPath).toBe('/settings/security'); + }); + + it('rejects cross-origin redirect paths', () => { + const result = ZTwoFactorChallengeMetadataSchema.safeParse({ + redirectPath: 'https://evil.example.com/phish', + authMethod: 'oauth', + }); + + expect(result.success).toBe(false); + }); + + it('rejects unknown authentication methods', () => { + const result = ZTwoFactorChallengeMetadataSchema.safeParse({ + redirectPath: '/', + authMethod: 'carrier-pigeon', + }); + + expect(result.success).toBe(false); + }); + + it('accepts a deferred link action', () => { + const result = ZTwoFactorChallengeMetadataSchema.parse({ + redirectPath: '/', + authMethod: 'oauth', + action: { + type: 'link-oauth-account', + provider: 'google', + providerAccountId: 'subject-123', + email: 'user@example.com', + }, + }); + + expect(result.action?.provider).toBe('google'); + }); + + it('rejects unknown keys on the metadata (strict)', () => { + const result = ZTwoFactorChallengeMetadataSchema.safeParse({ + redirectPath: '/', + authMethod: 'oauth', + accessToken: 'must-never-be-stored', + }); + + expect(result.success).toBe(false); + }); + + it('rejects unknown keys on the action (strict)', () => { + const result = ZTwoFactorChallengeMetadataSchema.safeParse({ + redirectPath: '/', + authMethod: 'oauth', + action: { + type: 'link-oauth-account', + provider: 'google', + providerAccountId: 'subject-123', + email: 'user@example.com', + idToken: 'must-never-be-stored', + }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects an action with an invalid email', () => { + const result = ZTwoFactorChallengeMetadataSchema.safeParse({ + redirectPath: '/', + authMethod: 'oauth', + action: { + type: 'link-oauth-account', + provider: 'google', + providerAccountId: 'subject-123', + email: 'not-an-email', + }, + }); + + expect(result.success).toBe(false); + }); +}); + +describe('shouldConsumeChallengeAfterFailure', () => { + it('does not consume below the maximum attempts', () => { + expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS - 1)).toBe(false); + expect(shouldConsumeChallengeAfterFailure(1)).toBe(false); + }); + + it('consumes at exactly the maximum attempts', () => { + expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS)).toBe(true); + }); + + it('consumes beyond the maximum attempts', () => { + expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS + 5)).toBe(true); + }); + + it('honors a custom maximum', () => { + expect(shouldConsumeChallengeAfterFailure(2, 3)).toBe(false); + expect(shouldConsumeChallengeAfterFailure(3, 3)).toBe(true); + }); +}); + +describe('isChallengeExpired', () => { + const expiresAt = new Date('2026-01-01T00:10:00.000Z'); + + it('is not expired before the expiry instant', () => { + expect(isChallengeExpired({ expiresAt, now: new Date('2026-01-01T00:09:59.999Z') })).toBe(false); + }); + + it('is expired at exactly the expiry instant', () => { + expect(isChallengeExpired({ expiresAt, now: expiresAt })).toBe(true); + }); + + it('is expired after the expiry instant', () => { + expect(isChallengeExpired({ expiresAt, now: new Date('2026-01-01T00:10:00.001Z') })).toBe(true); + }); +}); diff --git a/packages/lib/utils/two-factor-challenge.ts b/packages/lib/utils/two-factor-challenge.ts new file mode 100644 index 000000000..55c6d803b --- /dev/null +++ b/packages/lib/utils/two-factor-challenge.ts @@ -0,0 +1,54 @@ +/** + * Pure helpers for pending 2FA challenge tokens. + * + * Everything in this file must remain free of I/O so the challenge + * consumption rules can be unit tested directly. The server-side + * create/consume logic lives in `@documenso/auth`. + */ + +/** + * `VerificationToken.identifier` for pending 2FA challenge tokens. + */ +export const TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER = 'two-factor-challenge'; + +/** + * How long a pending challenge remains valid after primary auth passes. + */ +export const TWO_FACTOR_CHALLENGE_LIFETIME_MS = 10 * 60 * 1000; + +/** + * Failed code attempts before the challenge is consumed and the user must + * restart sign-in. + * + * The rate limiter fails open on DB errors, so this counter — atomically + * incremented on the token row itself — is the hard bound on guesses per + * challenge. + */ +export const TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS = 10; + +/** + * Whether a challenge must be consumed after a failed attempt. + * + * @param attempts The attempt count AFTER the failed attempt was recorded. + */ +export const shouldConsumeChallengeAfterFailure = ( + attempts: number, + maxAttempts: number = TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS, +): boolean => { + return attempts >= maxAttempts; +}; + +export type IsChallengeExpiredOptions = { + expiresAt: Date; + now: Date; +}; + +/** + * Whether a challenge has expired. The expiry instant itself counts as + * expired (`now >= expiresAt`). + */ +export const isChallengeExpired = (options: IsChallengeExpiredOptions): boolean => { + const { expiresAt, now } = options; + + return now.getTime() >= expiresAt.getTime(); +}; diff --git a/packages/lib/utils/two-factor.test.ts b/packages/lib/utils/two-factor.test.ts new file mode 100644 index 000000000..e3dad6404 --- /dev/null +++ b/packages/lib/utils/two-factor.test.ts @@ -0,0 +1,777 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateTwoFactorDeadline, + calculateTwoFactorDeadlineTimerDelay, + computeOrganisationTwoFactorEnforcementStatus, + computeTwoFactorEnforcementStatus, + evaluateInstanceTwoFactorEnforcementUpdate, + isInstanceTwoFactorEnforcementActive, + isTwoFactorDeadlineExpired, + isTwoFactorGracePeriodReduction, + isTwoFactorSatisfied, + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS, +} from './two-factor'; + +describe('isTwoFactorSatisfied', () => { + it('is satisfied only when the user is enrolled and the session is verified', () => { + expect(isTwoFactorSatisfied({ userTwoFactorEnabled: true, sessionTwoFactorVerified: true })).toBe(true); + }); + + it('is not satisfied when the session is unverified', () => { + expect(isTwoFactorSatisfied({ userTwoFactorEnabled: true, sessionTwoFactorVerified: false })).toBe(false); + }); + + it('is not satisfied when the user is not enrolled', () => { + expect(isTwoFactorSatisfied({ userTwoFactorEnabled: false, sessionTwoFactorVerified: true })).toBe(false); + expect(isTwoFactorSatisfied({ userTwoFactorEnabled: false, sessionTwoFactorVerified: false })).toBe(false); + }); +}); + +describe('calculateTwoFactorDeadline', () => { + it('adds the grace period to a single anchor', () => { + const anchor = new Date('2026-01-01T00:00:00.000Z'); + + const deadline = calculateTwoFactorDeadline({ anchors: [anchor], gracePeriodDays: 7 }); + + expect(deadline).toEqual(new Date('2026-01-08T00:00:00.000Z')); + }); + + it('uses the latest anchor when multiple are provided', () => { + const earlier = new Date('2026-01-01T00:00:00.000Z'); + const latest = new Date('2026-02-01T00:00:00.000Z'); + + const deadline = calculateTwoFactorDeadline({ + anchors: [earlier, latest], + gracePeriodDays: 1, + }); + + expect(deadline).toEqual(new Date('2026-02-02T00:00:00.000Z')); + }); + + it('ignores null and undefined anchors', () => { + const anchor = new Date('2026-01-01T00:00:00.000Z'); + + const deadline = calculateTwoFactorDeadline({ + anchors: [null, anchor, undefined], + gracePeriodDays: 0, + }); + + expect(deadline).toEqual(anchor); + }); + + it('returns the anchor instant for a 0 day grace period', () => { + const anchor = new Date('2026-01-01T12:34:56.000Z'); + + const deadline = calculateTwoFactorDeadline({ anchors: [anchor], gracePeriodDays: 0 }); + + expect(deadline).toEqual(anchor); + }); + + it('returns null when no anchors are provided', () => { + expect(calculateTwoFactorDeadline({ anchors: [], gracePeriodDays: 7 })).toBeNull(); + expect(calculateTwoFactorDeadline({ anchors: [null, undefined], gracePeriodDays: 7 })).toBeNull(); + }); +}); + +describe('isTwoFactorDeadlineExpired', () => { + const deadline = new Date('2026-01-08T00:00:00.000Z'); + + it('is not expired before the deadline', () => { + const now = new Date('2026-01-07T23:59:59.999Z'); + + expect(isTwoFactorDeadlineExpired({ deadline, now })).toBe(false); + }); + + it('counts the deadline instant itself as expired', () => { + const now = new Date('2026-01-08T00:00:00.000Z'); + + expect(isTwoFactorDeadlineExpired({ deadline, now })).toBe(true); + }); + + it('is expired after the deadline', () => { + const now = new Date('2026-01-08T00:00:00.001Z'); + + expect(isTwoFactorDeadlineExpired({ deadline, now })).toBe(true); + }); +}); + +describe('isTwoFactorGracePeriodReduction', () => { + const anchor = new Date('2026-01-01T00:00:00.000Z'); + const now = new Date('2026-01-02T00:00:00.000Z'); + + it('detects a reduced grace period while the previous grace is active', () => { + expect( + isTwoFactorGracePeriodReduction({ + previous: { anchors: [anchor], gracePeriodDays: 30 }, + next: { anchors: [anchor], gracePeriodDays: 7 }, + now, + }), + ).toBe(true); + }); + + it('detects a reduction caused by an earlier anchor set', () => { + const laterEnforcedFrom = new Date('2026-01-10T00:00:00.000Z'); + + expect( + isTwoFactorGracePeriodReduction({ + previous: { anchors: [anchor, laterEnforcedFrom], gracePeriodDays: 7 }, + next: { anchors: [anchor], gracePeriodDays: 7 }, + now, + }), + ).toBe(true); + }); + + it('is not a reduction when the deadline stays the same', () => { + expect( + isTwoFactorGracePeriodReduction({ + previous: { anchors: [anchor], gracePeriodDays: 7 }, + next: { anchors: [anchor], gracePeriodDays: 7 }, + now, + }), + ).toBe(false); + }); + + it('is not a reduction when the deadline moves later', () => { + expect( + isTwoFactorGracePeriodReduction({ + previous: { anchors: [anchor], gracePeriodDays: 7 }, + next: { anchors: [anchor], gracePeriodDays: 30 }, + now, + }), + ).toBe(false); + }); + + it('is not a reduction when the previous grace has already expired', () => { + const nowAfterExpiry = new Date('2026-03-01T00:00:00.000Z'); + + expect( + isTwoFactorGracePeriodReduction({ + previous: { anchors: [anchor], gracePeriodDays: 7 }, + next: { anchors: [anchor], gracePeriodDays: 0 }, + now: nowAfterExpiry, + }), + ).toBe(false); + }); + + it('is not a reduction when enforcement was not previously configured', () => { + expect( + isTwoFactorGracePeriodReduction({ + previous: null, + next: { anchors: [anchor], gracePeriodDays: 0 }, + now, + }), + ).toBe(false); + }); + + it('is not a reduction when the update disables enforcement', () => { + expect( + isTwoFactorGracePeriodReduction({ + previous: { anchors: [anchor], gracePeriodDays: 7 }, + next: null, + now, + }), + ).toBe(false); + }); +}); + +describe('computeTwoFactorEnforcementStatus', () => { + const anchor = new Date('2026-01-01T00:00:00.000Z'); + const deadline = new Date('2026-01-08T00:00:00.000Z'); + + const graceWindow = { anchors: [anchor], gracePeriodDays: 7 }; + + it('is not required when no grace window is configured', () => { + expect( + computeTwoFactorEnforcementStatus({ + graceWindow: null, + userTwoFactorEnabled: false, + sessionTwoFactorVerified: false, + now: new Date('2026-01-01T00:00:00.000Z'), + }), + ).toEqual({ required: false }); + }); + + it('is not required when the grace window has no anchors', () => { + expect( + computeTwoFactorEnforcementStatus({ + graceWindow: { anchors: [null, undefined], gracePeriodDays: 7 }, + userTwoFactorEnabled: false, + sessionTwoFactorVerified: false, + now: new Date('2026-01-01T00:00:00.000Z'), + }), + ).toEqual({ required: false }); + }); + + it('is required but not blocked within grace while unsatisfied', () => { + expect( + computeTwoFactorEnforcementStatus({ + graceWindow, + userTwoFactorEnabled: false, + sessionTwoFactorVerified: false, + now: new Date('2026-01-02T00:00:00.000Z'), + }), + ).toEqual({ + required: true, + deadline, + isDeadlineExpired: false, + isSatisfied: false, + isBlocked: false, + }); + }); + + it('is required and satisfied within grace when enrolled and verified', () => { + expect( + computeTwoFactorEnforcementStatus({ + graceWindow, + userTwoFactorEnabled: true, + sessionTwoFactorVerified: true, + now: new Date('2026-01-02T00:00:00.000Z'), + }), + ).toEqual({ + required: true, + deadline, + isDeadlineExpired: false, + isSatisfied: true, + isBlocked: false, + }); + }); + + it('blocks after the deadline while unsatisfied', () => { + expect( + computeTwoFactorEnforcementStatus({ + graceWindow, + userTwoFactorEnabled: false, + sessionTwoFactorVerified: false, + now: new Date('2026-02-01T00:00:00.000Z'), + }), + ).toEqual({ + required: true, + deadline, + isDeadlineExpired: true, + isSatisfied: false, + isBlocked: true, + }); + }); + + it('does not block after the deadline when satisfied', () => { + expect( + computeTwoFactorEnforcementStatus({ + graceWindow, + userTwoFactorEnabled: true, + sessionTwoFactorVerified: true, + now: new Date('2026-02-01T00:00:00.000Z'), + }), + ).toEqual({ + required: true, + deadline, + isDeadlineExpired: true, + isSatisfied: true, + isBlocked: false, + }); + }); + + it('does not block when enrolled but the session is unverified within grace', () => { + const status = computeTwoFactorEnforcementStatus({ + graceWindow, + userTwoFactorEnabled: true, + sessionTwoFactorVerified: false, + now: new Date('2026-01-02T00:00:00.000Z'), + }); + + expect(status).toMatchObject({ required: true, isSatisfied: false, isBlocked: false }); + }); + + it('blocks an enrolled user with an unverified session after the deadline', () => { + const status = computeTwoFactorEnforcementStatus({ + graceWindow, + userTwoFactorEnabled: true, + sessionTwoFactorVerified: false, + now: new Date('2026-02-01T00:00:00.000Z'), + }); + + expect(status).toMatchObject({ required: true, isSatisfied: false, isBlocked: true }); + }); + + it('counts the deadline instant itself as expired', () => { + const status = computeTwoFactorEnforcementStatus({ + graceWindow, + userTwoFactorEnabled: false, + sessionTwoFactorVerified: false, + now: deadline, + }); + + expect(status).toMatchObject({ required: true, isDeadlineExpired: true, isBlocked: true }); + }); + + it('is not expired just before the deadline instant', () => { + const status = computeTwoFactorEnforcementStatus({ + graceWindow, + userTwoFactorEnabled: false, + sessionTwoFactorVerified: false, + now: new Date(deadline.getTime() - 1), + }); + + expect(status).toMatchObject({ required: true, isDeadlineExpired: false, isBlocked: false }); + }); + + it('uses the latest anchor for the deadline', () => { + const laterEnforcedFrom = new Date('2026-01-10T00:00:00.000Z'); + + const status = computeTwoFactorEnforcementStatus({ + graceWindow: { anchors: [anchor, laterEnforcedFrom], gracePeriodDays: 7 }, + userTwoFactorEnabled: false, + sessionTwoFactorVerified: false, + now: new Date('2026-01-09T00:00:00.000Z'), + }); + + expect(status).toMatchObject({ + required: true, + deadline: new Date('2026-01-17T00:00:00.000Z'), + isDeadlineExpired: false, + }); + }); + + it('blocks immediately with a 0 day grace period', () => { + const status = computeTwoFactorEnforcementStatus({ + graceWindow: { anchors: [anchor], gracePeriodDays: 0 }, + userTwoFactorEnabled: false, + sessionTwoFactorVerified: false, + now: anchor, + }); + + expect(status).toMatchObject({ required: true, deadline: anchor, isBlocked: true }); + }); +}); + +describe('isInstanceTwoFactorEnforcementActive', () => { + it('is active when the setting exists, is enabled and the license grants the flag', () => { + expect(isInstanceTwoFactorEnforcementActive({ setting: { enabled: true }, isLicensed: true })).toBe(true); + }); + + it('is inactive when the setting row is missing', () => { + expect(isInstanceTwoFactorEnforcementActive({ setting: null, isLicensed: true })).toBe(false); + }); + + it('is inactive when the setting is disabled', () => { + expect(isInstanceTwoFactorEnforcementActive({ setting: { enabled: false }, isLicensed: true })).toBe(false); + }); + + it('is inactive on an unlicensed instance even when a row is enabled', () => { + expect(isInstanceTwoFactorEnforcementActive({ setting: { enabled: true }, isLicensed: false })).toBe(false); + }); +}); + +describe('computeOrganisationTwoFactorEnforcementStatus', () => { + const memberCreatedAt = new Date('2026-01-01T00:00:00.000Z'); + const graceStartedAt = new Date('2025-12-01T00:00:00.000Z'); + + const baseOptions = { + memberCreatedAt, + userTwoFactorEnabled: false, + userTwoFactorGraceStartedAt: graceStartedAt, + sessionTwoFactorVerified: false, + }; + + it('is not required when the organisation does not require 2FA', () => { + const status = computeOrganisationTwoFactorEnforcementStatus({ + ...baseOptions, + organisationSettings: { + twoFactorRequired: false, + twoFactorGracePeriodDays: 7, + twoFactorEnforcedFrom: new Date('2026-01-05T00:00:00.000Z'), + }, + now: new Date('2026-12-01T00:00:00.000Z'), + }); + + expect(status).toEqual({ required: false }); + }); + + it('derives the deadline from max(member.createdAt, enforcedFrom, graceStartedAt) + gracePeriodDays', () => { + const enforcedFrom = new Date('2026-01-10T00:00:00.000Z'); + + const status = computeOrganisationTwoFactorEnforcementStatus({ + ...baseOptions, + organisationSettings: { + twoFactorRequired: true, + twoFactorGracePeriodDays: 7, + twoFactorEnforcedFrom: enforcedFrom, + }, + now: new Date('2026-01-12T00:00:00.000Z'), + }); + + expect(status).toMatchObject({ + required: true, + deadline: new Date('2026-01-17T00:00:00.000Z'), + isDeadlineExpired: false, + isBlocked: false, + }); + }); + + it('anchors on member.createdAt when enforcedFrom is null and grace started earlier', () => { + const status = computeOrganisationTwoFactorEnforcementStatus({ + ...baseOptions, + organisationSettings: { + twoFactorRequired: true, + twoFactorGracePeriodDays: 7, + twoFactorEnforcedFrom: null, + }, + now: new Date('2026-01-08T00:00:00.000Z'), + }); + + expect(status).toMatchObject({ + required: true, + deadline: new Date('2026-01-08T00:00:00.000Z'), + isDeadlineExpired: true, + isBlocked: true, + }); + }); + + it('an admin 2FA reset (later graceStartedAt) restarts the organisation grace window', () => { + const restartedGraceStartedAt = new Date('2026-02-01T00:00:00.000Z'); + + const status = computeOrganisationTwoFactorEnforcementStatus({ + ...baseOptions, + userTwoFactorGraceStartedAt: restartedGraceStartedAt, + organisationSettings: { + twoFactorRequired: true, + twoFactorGracePeriodDays: 7, + twoFactorEnforcedFrom: null, + }, + now: new Date('2026-02-02T00:00:00.000Z'), + }); + + expect(status).toMatchObject({ + required: true, + deadline: new Date('2026-02-08T00:00:00.000Z'), + isDeadlineExpired: false, + isBlocked: false, + }); + }); + + it('is satisfied (never blocked) for an enrolled user with a verified session', () => { + const status = computeOrganisationTwoFactorEnforcementStatus({ + ...baseOptions, + userTwoFactorEnabled: true, + sessionTwoFactorVerified: true, + organisationSettings: { + twoFactorRequired: true, + twoFactorGracePeriodDays: 0, + twoFactorEnforcedFrom: null, + }, + now: new Date('2027-01-01T00:00:00.000Z'), + }); + + expect(status).toMatchObject({ + required: true, + isSatisfied: true, + isDeadlineExpired: true, + isBlocked: false, + }); + }); + + it('an enrolled user with an unverified session (null/API context) is not satisfied', () => { + const status = computeOrganisationTwoFactorEnforcementStatus({ + ...baseOptions, + userTwoFactorEnabled: true, + sessionTwoFactorVerified: false, + organisationSettings: { + twoFactorRequired: true, + twoFactorGracePeriodDays: 0, + twoFactorEnforcedFrom: null, + }, + now: new Date('2027-01-01T00:00:00.000Z'), + }); + + expect(status).toMatchObject({ + required: true, + isSatisfied: false, + isBlocked: true, + }); + }); + + it('blocks organisation access immediately with a 0 day grace period', () => { + const status = computeOrganisationTwoFactorEnforcementStatus({ + ...baseOptions, + organisationSettings: { + twoFactorRequired: true, + twoFactorGracePeriodDays: 0, + twoFactorEnforcedFrom: null, + }, + now: memberCreatedAt, + }); + + expect(status).toMatchObject({ required: true, deadline: memberCreatedAt, isBlocked: true }); + }); +}); + +describe('evaluateInstanceTwoFactorEnforcementUpdate', () => { + const now = new Date('2026-06-01T00:00:00.000Z'); + + const satisfiedActor = { userTwoFactorEnabled: true, sessionTwoFactorVerified: true }; + const unsatisfiedActor = { userTwoFactorEnabled: false, sessionTwoFactorVerified: false }; + + const storedDisabled = { enabled: false, gracePeriodDays: 7, enforcedFrom: null }; + + // Enabled with an active grace window: enforcedFrom 1 day ago + 30 days. + const storedEnabledActiveGrace = { + enabled: true, + gracePeriodDays: 30, + enforcedFrom: '2026-05-31T00:00:00.000Z', + }; + + describe('license gate', () => { + it('rejects enabling on an unlicensed instance', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedDisabled, + update: { enabled: true, gracePeriodDays: 7 }, + isLicensed: false, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ allowed: false, reason: 'UNLICENSED' }); + }); + + it('rejects changing values while enabled on an unlicensed instance', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedEnabledActiveGrace, + update: { enabled: true, gracePeriodDays: 60 }, + isLicensed: false, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ allowed: false, reason: 'UNLICENSED' }); + }); + + it('rejects an unlicensed disabled→disabled no-op write', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedDisabled, + update: { enabled: false, gracePeriodDays: 7 }, + isLicensed: false, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ allowed: false, reason: 'UNLICENSED' }); + }); + + it('rejects an unlicensed disable that also changes the grace period', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedEnabledActiveGrace, + update: { enabled: false, gracePeriodDays: 60 }, + isLicensed: false, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ allowed: false, reason: 'UNLICENSED' }); + }); + + it('allows an unlicensed disable-only update with values unchanged from stored', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedEnabledActiveGrace, + update: { enabled: false, gracePeriodDays: storedEnabledActiveGrace.gracePeriodDays }, + isLicensed: false, + // Even an unenrolled admin may disable — walking the policy back must + // never be gated on satisfying it. + actor: unsatisfiedActor, + now, + }); + + expect(decision).toEqual({ + allowed: true, + next: { + enabled: false, + gracePeriodDays: storedEnabledActiveGrace.gracePeriodDays, + enforcedFrom: storedEnabledActiveGrace.enforcedFrom, + }, + }); + }); + }); + + describe('enable-time guard', () => { + it('rejects enabling when the acting admin does not satisfy the policy', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedDisabled, + update: { enabled: true, gracePeriodDays: 0 }, + isLicensed: true, + actor: unsatisfiedActor, + now, + }); + + expect(decision).toEqual({ allowed: false, reason: 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR' }); + }); + + it('rejects enabling when the actor is enrolled but the session is unverified', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedDisabled, + update: { enabled: true, gracePeriodDays: 7 }, + isLicensed: true, + actor: { userTwoFactorEnabled: true, sessionTwoFactorVerified: false }, + now, + }); + + expect(decision).toEqual({ allowed: false, reason: 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR' }); + }); + + it('does not apply the guard when updating values while already enabled', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedEnabledActiveGrace, + update: { enabled: true, gracePeriodDays: 60 }, + isLicensed: true, + actor: unsatisfiedActor, + now, + }); + + expect(decision).toMatchObject({ allowed: true }); + }); + }); + + describe('enforcedFrom handling', () => { + it('sets enforcedFrom to now on an off→on transition', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedDisabled, + update: { enabled: true, gracePeriodDays: 14 }, + isLicensed: true, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ + allowed: true, + next: { enabled: true, gracePeriodDays: 14, enforcedFrom: now.toISOString() }, + }); + }); + + it('preserves enforcedFrom when the policy stays enabled', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedEnabledActiveGrace, + update: { enabled: true, gracePeriodDays: 60 }, + isLicensed: true, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ + allowed: true, + next: { enabled: true, gracePeriodDays: 60, enforcedFrom: storedEnabledActiveGrace.enforcedFrom }, + }); + }); + + it('preserves enforcedFrom on disable', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedEnabledActiveGrace, + update: { enabled: false, gracePeriodDays: storedEnabledActiveGrace.gracePeriodDays }, + isLicensed: true, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ + allowed: true, + next: { + enabled: false, + gracePeriodDays: storedEnabledActiveGrace.gracePeriodDays, + enforcedFrom: storedEnabledActiveGrace.enforcedFrom, + }, + }); + }); + }); + + describe('grace-reduction acknowledgement', () => { + it('rejects reducing an active grace period without acknowledgement', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedEnabledActiveGrace, + update: { enabled: true, gracePeriodDays: 1 }, + isLicensed: true, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ allowed: false, reason: 'GRACE_REDUCTION_NOT_ACKNOWLEDGED' }); + }); + + it('allows reducing an active grace period with acknowledgement', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedEnabledActiveGrace, + update: { enabled: true, gracePeriodDays: 1, acknowledgeGracePeriodReduction: true }, + isLicensed: true, + actor: satisfiedActor, + now, + }); + + expect(decision).toEqual({ + allowed: true, + next: { enabled: true, gracePeriodDays: 1, enforcedFrom: storedEnabledActiveGrace.enforcedFrom }, + }); + }); + + it('does not require acknowledgement when enabling for the first time', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: storedDisabled, + update: { enabled: true, gracePeriodDays: 0 }, + isLicensed: true, + actor: satisfiedActor, + now, + }); + + expect(decision).toMatchObject({ allowed: true }); + }); + + it('does not require acknowledgement when tightening an already-expired window', () => { + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: { + enabled: true, + gracePeriodDays: 7, + enforcedFrom: '2026-01-01T00:00:00.000Z', + }, + update: { enabled: true, gracePeriodDays: 0 }, + isLicensed: true, + actor: satisfiedActor, + now, + }); + + expect(decision).toMatchObject({ allowed: true }); + }); + }); +}); + +describe('calculateTwoFactorDeadlineTimerDelay', () => { + const now = new Date('2026-06-01T00:00:00.000Z'); + + it('returns the exact delay for a deadline within the timer range', () => { + const deadline = new Date(now.getTime() + 5_000); + + expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBe(5_000); + }); + + it('returns 0 for a deadline at the current instant (deadline counts as expired)', () => { + expect(calculateTwoFactorDeadlineTimerDelay({ deadline: now, now })).toBe(0); + }); + + it('returns 0 for a past deadline', () => { + const deadline = new Date(now.getTime() - 60_000); + + expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBe(0); + }); + + it('returns the delay at exactly the setTimeout maximum', () => { + const deadline = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS); + + expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBe(MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS); + }); + + it('returns null when the deadline exceeds the setTimeout maximum', () => { + const deadline = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS + 1); + + expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBeNull(); + }); + + it('returns null for a deadline months away', () => { + const deadline = new Date('2026-12-01T00:00:00.000Z'); + + expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBeNull(); + }); +}); diff --git a/packages/lib/utils/two-factor.ts b/packages/lib/utils/two-factor.ts new file mode 100644 index 000000000..ec010c00b --- /dev/null +++ b/packages/lib/utils/two-factor.ts @@ -0,0 +1,449 @@ +/** + * Pure 2FA enforcement policy helpers. + * + * Everything in this file must remain free of I/O so the enforcement policy + * can be unit tested directly. + */ + +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + +export type IsTwoFactorSatisfiedOptions = { + userTwoFactorEnabled: boolean; + sessionTwoFactorVerified: boolean; +}; + +/** + * Whether a user + session pair satisfies 2FA enforcement. + * + * The user must have 2FA enrolled AND the current session must have passed a + * second factor (TOTP/backup challenge, UV passkey sign-in, or enabling 2FA + * mid-session). + */ +export const isTwoFactorSatisfied = (options: IsTwoFactorSatisfiedOptions): boolean => { + const { userTwoFactorEnabled, sessionTwoFactorVerified } = options; + + return userTwoFactorEnabled && sessionTwoFactorVerified; +}; + +export type CalculateTwoFactorDeadlineOptions = { + /** + * Anchor dates for the grace window. Null/undefined entries are ignored, + * the latest remaining anchor wins. + */ + anchors: Array; + + /** + * Number of grace days after the latest anchor. 0 means the deadline is the + * anchor instant itself (immediate enforcement). + */ + gracePeriodDays: number; +}; + +/** + * Shared deadline calculation for both instance and organisation enforcement: + * `max(anchors) + gracePeriodDays`. + * + * Returns `null` when no anchor is provided. + */ +export const calculateTwoFactorDeadline = (options: CalculateTwoFactorDeadlineOptions): Date | null => { + const { anchors, gracePeriodDays } = options; + + const anchorTimes = anchors + .filter((anchor): anchor is Date => anchor instanceof Date) + .map((anchor) => anchor.getTime()); + + if (anchorTimes.length === 0) { + return null; + } + + const latestAnchorTime = Math.max(...anchorTimes); + + return new Date(latestAnchorTime + gracePeriodDays * MILLISECONDS_PER_DAY); +}; + +export type IsTwoFactorDeadlineExpiredOptions = { + deadline: Date; + now: Date; +}; + +/** + * Whether a deadline has expired. The deadline instant itself counts as + * expired (`now >= deadline`). + */ +export const isTwoFactorDeadlineExpired = (options: IsTwoFactorDeadlineExpiredOptions): boolean => { + const { deadline, now } = options; + + return now.getTime() >= deadline.getTime(); +}; + +/** + * The maximum delay `setTimeout` reliably supports (2^31 - 1 ms, ~24.8 days). + * Longer delays overflow the signed 32-bit timer and fire immediately. + */ +export const MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS = 2 ** 31 - 1; + +export type CalculateTwoFactorDeadlineTimerDelayOptions = { + deadline: Date; + now: Date; +}; + +/** + * Milliseconds until a 2FA enforcement deadline, for scheduling a client-side + * timer that navigates to enrolment the instant the grace window closes. + * + * Returns: + * - `0` when the deadline is already reached/past (fire immediately), + * - the positive delay when it fits within the `setTimeout` range, + * - `null` when the deadline is further away than `setTimeout` can represent — + * no timer should be scheduled (a later navigation/session refresh will + * re-evaluate long before ~24.8 days elapse). + */ +export const calculateTwoFactorDeadlineTimerDelay = ( + options: CalculateTwoFactorDeadlineTimerDelayOptions, +): number | null => { + const { deadline, now } = options; + + const delay = deadline.getTime() - now.getTime(); + + if (delay <= 0) { + return 0; + } + + if (delay > MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS) { + return null; + } + + return delay; +}; + +export type TwoFactorGraceWindow = { + anchors: Array; + gracePeriodDays: number; +}; + +export type IsTwoFactorGracePeriodReductionOptions = { + /** + * The currently stored grace window, or null when enforcement is not + * currently configured. + */ + previous: TwoFactorGraceWindow | null; + + /** + * The proposed grace window, or null when the update disables enforcement. + */ + next: TwoFactorGraceWindow | null; + + now: Date; +}; + +/** + * Whether a settings update reduces an *active* grace window. + * + * Only true when the previous configuration produced a deadline that has not + * yet expired and the new configuration moves that deadline earlier. Enabling + * enforcement for the first time, disabling it, or tightening an + * already-expired window are not reductions. + */ +export const isTwoFactorGracePeriodReduction = (options: IsTwoFactorGracePeriodReductionOptions): boolean => { + const { previous, next, now } = options; + + const previousDeadline = previous ? calculateTwoFactorDeadline(previous) : null; + const nextDeadline = next ? calculateTwoFactorDeadline(next) : null; + + if (!previousDeadline || !nextDeadline) { + return false; + } + + const isPreviousGraceActive = !isTwoFactorDeadlineExpired({ deadline: previousDeadline, now }); + + return isPreviousGraceActive && nextDeadline.getTime() < previousDeadline.getTime(); +}; + +/** + * Shared 2FA enforcement status shape, used by both instance and organisation + * enforcement. + * + * `isBlocked` is computed server-side once (`isDeadlineExpired && !isSatisfied`) + * — consumers branch only on `isBlocked`; the deadline fields exist for + * banners/copy. + * + * This type lives here (not in server-only code) so client code such as the + * session provider can import it. + */ +export type TTwoFactorEnforcementStatus = + | { required: false } + | { + required: true; + deadline: Date; + isDeadlineExpired: boolean; + isSatisfied: boolean; + isBlocked: boolean; + }; + +export type ComputeTwoFactorEnforcementStatusOptions = { + /** + * The grace window derived from the applicable enforcement policy, or null + * when enforcement is not configured/active. + */ + graceWindow: TwoFactorGraceWindow | null; + + userTwoFactorEnabled: boolean; + + /** + * Whether the current session passed a second factor. Contexts without a + * session (e.g. API access) never count as verified. + */ + sessionTwoFactorVerified: boolean; + + now: Date; +}; + +/** + * Pure derivation of the 2FA enforcement status for a user + session against + * a grace window. + * + * Server-only getters wrap this with the I/O needed to load the policy; the + * policy math itself stays unit-testable. + */ +export const computeTwoFactorEnforcementStatus = ( + options: ComputeTwoFactorEnforcementStatusOptions, +): TTwoFactorEnforcementStatus => { + const { graceWindow, userTwoFactorEnabled, sessionTwoFactorVerified, now } = options; + + if (!graceWindow) { + return { required: false }; + } + + const deadline = calculateTwoFactorDeadline(graceWindow); + + // Defensive: a window without any anchor has no enforceable deadline. + if (!deadline) { + return { required: false }; + } + + const isSatisfied = isTwoFactorSatisfied({ userTwoFactorEnabled, sessionTwoFactorVerified }); + const isDeadlineExpired = isTwoFactorDeadlineExpired({ deadline, now }); + + return { + required: true, + deadline, + isDeadlineExpired, + isSatisfied, + isBlocked: isDeadlineExpired && !isSatisfied, + }; +}; + +export type OrganisationTwoFactorEnforcementSettings = { + twoFactorRequired: boolean; + twoFactorGracePeriodDays: number; + twoFactorEnforcedFrom: Date | null; +}; + +export type ComputeOrganisationTwoFactorEnforcementStatusOptions = { + /** + * The organisation's global settings 2FA fields. + */ + organisationSettings: OrganisationTwoFactorEnforcementSettings; + + /** + * When the user joined the organisation. Joining is never blocked — the + * grace window starts at join. + */ + memberCreatedAt: Date; + + userTwoFactorEnabled: boolean; + + /** + * Restarted by an admin 2FA reset, which restarts organisation grace too. + */ + userTwoFactorGraceStartedAt: Date; + + /** + * Whether the current session passed a second factor. Contexts without a + * session (e.g. API access) never count as verified — machine access is + * exempt from enforcement anyway, this only keeps the shape honest. + */ + sessionTwoFactorVerified: boolean; + + now: Date; +}; + +/** + * Pure derivation of the per-organisation 2FA enforcement status for a + * member + session. + * + * Deadline: `max(member.createdAt, orgSettings.twoFactorEnforcedFrom, + * user.twoFactorGraceStartedAt) + orgSettings.twoFactorGracePeriodDays`. + */ +export const computeOrganisationTwoFactorEnforcementStatus = ( + options: ComputeOrganisationTwoFactorEnforcementStatusOptions, +): TTwoFactorEnforcementStatus => { + const { + organisationSettings, + memberCreatedAt, + userTwoFactorEnabled, + userTwoFactorGraceStartedAt, + sessionTwoFactorVerified, + now, + } = options; + + return computeTwoFactorEnforcementStatus({ + graceWindow: organisationSettings.twoFactorRequired + ? { + anchors: [memberCreatedAt, organisationSettings.twoFactorEnforcedFrom, userTwoFactorGraceStartedAt], + gracePeriodDays: organisationSettings.twoFactorGracePeriodDays, + } + : null, + userTwoFactorEnabled, + sessionTwoFactorVerified, + now, + }); +}; + +export type InstanceTwoFactorEnforcementStoredConfig = { + enabled: boolean; + gracePeriodDays: number; + + /** + * ISO datetime string, or null when enforcement was never enabled. Kept as + * a string to match the stored `site.two-factor-enforcement` row shape. + */ + enforcedFrom: string | null; +}; + +export type EvaluateInstanceTwoFactorEnforcementUpdateOptions = { + /** + * The currently stored configuration (schema defaults when the row is + * absent or malformed). + */ + stored: InstanceTwoFactorEnforcementStoredConfig; + + update: { + enabled: boolean; + gracePeriodDays: number; + acknowledgeGracePeriodReduction?: boolean; + }; + + /** + * Whether the instance license grants `instanceTwoFactorEnforcement`. + */ + isLicensed: boolean; + + /** + * The acting admin's own 2FA state, used for the enable-time guard. + */ + actor: IsTwoFactorSatisfiedOptions; + + now: Date; +}; + +export type InstanceTwoFactorEnforcementUpdateDecision = + | { + allowed: true; + + /** + * The configuration to persist. `enforcedFrom` is reset to `now` on an + * off→on transition and preserved otherwise. + */ + next: InstanceTwoFactorEnforcementStoredConfig; + } + | { + allowed: false; + reason: 'UNLICENSED' | 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR' | 'GRACE_REDUCTION_NOT_ACKNOWLEDGED'; + }; + +/** + * Pure policy decision for updating the instance-wide 2FA enforcement + * setting. The `admin.updateTwoFactorEnforcement` route delegates to this so + * the entire decision is unit-testable without I/O. + * + * Rules, in evaluation order: + * + * 1. License gate: enabling — or changing any value while enabled — requires + * the license flag. The ONLY unlicensed update allowed is disable-only: an + * enabled→disabled transition with all other values unchanged from the + * stored row (so an instance whose license lapsed can always turn the + * policy off, but not tweak it). + * 2. Enable-time guard: an off→on transition requires the acting admin to + * already satisfy the policy being enabled (2FA enrolled AND second factor + * verified on this session). Prevents self-lockout — a 0-day grace would + * instantly block the actor from the very settings route that undoes it — + * and removes the instant-DoS lever. + * 3. Grace-reduction acknowledgement: shortening an active grace window + * requires an explicit `acknowledgeGracePeriodReduction: true`. + */ +export const evaluateInstanceTwoFactorEnforcementUpdate = ( + options: EvaluateInstanceTwoFactorEnforcementUpdateOptions, +): InstanceTwoFactorEnforcementUpdateDecision => { + const { stored, update, isLicensed, actor, now } = options; + + const isEnabling = update.enabled && !stored.enabled; + + if (!isLicensed) { + const isDisableOnly = stored.enabled && !update.enabled && update.gracePeriodDays === stored.gracePeriodDays; + + if (!isDisableOnly) { + return { allowed: false, reason: 'UNLICENSED' }; + } + } + + if (isEnabling && !isTwoFactorSatisfied(actor)) { + return { allowed: false, reason: 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR' }; + } + + const nextEnforcedFrom = isEnabling ? now.toISOString() : stored.enforcedFrom; + + const isGraceReduction = isTwoFactorGracePeriodReduction({ + previous: stored.enabled + ? { + anchors: [stored.enforcedFrom ? new Date(stored.enforcedFrom) : null], + gracePeriodDays: stored.gracePeriodDays, + } + : null, + next: update.enabled + ? { + anchors: [nextEnforcedFrom ? new Date(nextEnforcedFrom) : null], + gracePeriodDays: update.gracePeriodDays, + } + : null, + now, + }); + + if (isGraceReduction && update.acknowledgeGracePeriodReduction !== true) { + return { allowed: false, reason: 'GRACE_REDUCTION_NOT_ACKNOWLEDGED' }; + } + + return { + allowed: true, + next: { + enabled: update.enabled, + gracePeriodDays: update.gracePeriodDays, + enforcedFrom: nextEnforcedFrom, + }, + }; +}; + +export type IsInstanceTwoFactorEnforcementActiveOptions = { + /** + * The parsed `site.two-factor-enforcement` setting row, or null when the + * row is missing or fails to parse. + */ + setting: { enabled: boolean } | null; + + /** + * Whether the instance license grants the `instanceTwoFactorEnforcement` + * flag. + */ + isLicensed: boolean; +}; + +/** + * Activation decision for instance-wide 2FA enforcement. + * + * A manually inserted row on an unlicensed instance is a silent noop. + */ +export const isInstanceTwoFactorEnforcementActive = (options: IsInstanceTwoFactorEnforcementActiveOptions): boolean => { + const { setting, isLicensed } = options; + + return Boolean(setting?.enabled) && isLicensed; +}; diff --git a/packages/prisma/migrations/20260828000000_add_two_factor_enforcement/migration.sql b/packages/prisma/migrations/20260828000000_add_two_factor_enforcement/migration.sql new file mode 100644 index 000000000..ab5c4ed55 --- /dev/null +++ b/packages/prisma/migrations/20260828000000_add_two_factor_enforcement/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "twoFactorEnforcedFrom" TIMESTAMP(3), +ADD COLUMN "twoFactorGracePeriodDays" INTEGER NOT NULL DEFAULT 7, +ADD COLUMN "twoFactorRequired" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "authMethod" TEXT NOT NULL DEFAULT 'unknown', +ADD COLUMN "twoFactorVerified" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "twoFactorGraceStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- Backfill: existing users' grace windows anchor at account creation, not at +-- migration time. +UPDATE "User" SET "twoFactorGraceStartedAt" = "createdAt"; diff --git a/packages/prisma/migrations/20260828000001_add_verification_token_attempts/migration.sql b/packages/prisma/migrations/20260828000001_add_verification_token_attempts/migration.sql new file mode 100644 index 000000000..0f65b9f4a --- /dev/null +++ b/packages/prisma/migrations/20260828000001_add_verification_token_attempts/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "VerificationToken" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0; diff --git a/packages/prisma/migrations/20260828000002_add_two_factor_admin_reset_audit_log/migration.sql b/packages/prisma/migrations/20260828000002_add_two_factor_admin_reset_audit_log/migration.sql new file mode 100644 index 000000000..e26767e46 --- /dev/null +++ b/packages/prisma/migrations/20260828000002_add_two_factor_admin_reset_audit_log/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'AUTH_2FA_ADMIN_RESET'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 13768e34f..557065efc 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -63,6 +63,10 @@ model User { twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? + // Anchor for 2FA enforcement grace periods. Backfilled from `createdAt` for + // existing users; reset by an admin 2FA reset to restart grace windows. + twoFactorGraceStartedAt DateTime @default(now()) + folders Folder[] envelopes Envelope[] @@ -94,6 +98,7 @@ enum UserSecurityAuditLogType { ORGANISATION_SSO_UNLINK AUTH_2FA_DISABLE AUTH_2FA_ENABLE + AUTH_2FA_ADMIN_RESET PASSKEY_CREATED PASSKEY_DELETED PASSKEY_UPDATED @@ -160,8 +165,14 @@ model VerificationToken { expires DateTime createdAt DateTime @default(now()) metadata Json? - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Failed-attempt counter for tokens that accept guessable input (2FA + // challenge codes). Atomically incremented; the token is consumed once it + // reaches the cap. + attempts Int @default(0) + + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum WebhookTriggerEvents { @@ -372,6 +383,15 @@ model Session { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Plain string, not a PG enum. Validated by the zod enum in + // packages/lib/types/session-auth-method.ts. + authMethod String @default("unknown") + + // Whether a second factor was passed at sign-in (TOTP/backup challenge or UV + // passkey) or 2FA was enabled mid-session. Rows predating this column + // backfill false — those sessions never qualify; users must re-login. + twoFactorVerified Boolean @default(false) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@ -993,6 +1013,13 @@ model OrganisationGlobalSettings { // AI features settings. aiFeaturesEnabled Boolean @default(false) + + // 2FA enforcement settings. Org-level only — deliberately NOT mirrored in + // TeamGlobalSettings. `extractDerivedTeamSettings` must skip these keys. + twoFactorRequired Boolean @default(false) + twoFactorGracePeriodDays Int @default(7) + // Set server-side on each off→on transition of `twoFactorRequired`. + twoFactorEnforcedFrom DateTime? } /// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';", "import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';"]) diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 5ffcec7fb..38d6ed90d 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -5,6 +5,8 @@ "types": "./index.ts", "license": "MIT", "scripts": { + "test": "vitest run", + "test:watch": "vitest", "clean": "rimraf node_modules" }, "dependencies": { diff --git a/packages/trpc/server/admin-router/get-two-factor-enforcement.ts b/packages/trpc/server/admin-router/get-two-factor-enforcement.ts new file mode 100644 index 000000000..a90126adf --- /dev/null +++ b/packages/trpc/server/admin-router/get-two-factor-enforcement.ts @@ -0,0 +1,23 @@ +import { getInstanceTwoFactorEnforcementConfig } from '@documenso/lib/server-only/2fa/get-instance-two-factor-enforcement-config'; + +import { adminProcedure } from '../trpc'; +import { + ZGetTwoFactorEnforcementRequestSchema, + ZGetTwoFactorEnforcementResponseSchema, +} from './get-two-factor-enforcement.types'; + +/** + * Returns the raw stored instance 2FA enforcement configuration together with + * the license/activation state for the admin settings UI ("configured but + * inactive" needs the stored values the enforcement policy getter hides). + * + * 2FA enforcement metadata: inherits `'none'` from the admin base — admin + * procedures carry no organisation scope, while the INSTANCE assert still + * applies via `adminMiddleware`. + */ +export const getTwoFactorEnforcementRoute = adminProcedure + .input(ZGetTwoFactorEnforcementRequestSchema) + .output(ZGetTwoFactorEnforcementResponseSchema) + .query(async () => { + return await getInstanceTwoFactorEnforcementConfig(); + }); diff --git a/packages/trpc/server/admin-router/get-two-factor-enforcement.types.ts b/packages/trpc/server/admin-router/get-two-factor-enforcement.types.ts new file mode 100644 index 000000000..94d7de5fb --- /dev/null +++ b/packages/trpc/server/admin-router/get-two-factor-enforcement.types.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const ZGetTwoFactorEnforcementRequestSchema = z.void(); + +export const ZGetTwoFactorEnforcementResponseSchema = z.object({ + enabled: z.boolean(), + gracePeriodDays: z.number().int().min(0).max(365), + enforcedFrom: z.string().datetime().nullable(), + isLicensed: z.boolean(), + isActive: z.boolean(), +}); + +export type TGetTwoFactorEnforcementRequest = z.infer; +export type TGetTwoFactorEnforcementResponse = z.infer; diff --git a/packages/trpc/server/admin-router/reset-two-factor-authentication.ts b/packages/trpc/server/admin-router/reset-two-factor-authentication.ts index db02ab8fe..0657d7fa5 100644 --- a/packages/trpc/server/admin-router/reset-two-factor-authentication.ts +++ b/packages/trpc/server/admin-router/reset-two-factor-authentication.ts @@ -1,5 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; +import { UserSecurityAuditLogType } from '@prisma/client'; import { adminProcedure } from '../trpc'; import { ZResetTwoFactorRequestSchema, ZResetTwoFactorResponseSchema } from './reset-two-factor-authentication.types'; @@ -16,14 +18,45 @@ export const resetTwoFactorRoute = adminProcedure }, }); - return await resetTwoFactor({ userId }); + return await resetTwoFactor({ + userId, + actorUserId: ctx.user.id, + requestMetadata: ctx.metadata.requestMetadata, + }); }); export type ResetTwoFactorOptions = { userId: number; + + /** + * The acting admin. Self-reset is forbidden — an admin who loses their + * authenticator goes through the normal recovery paths instead of quietly + * restarting their own grace window. + */ + actorUserId: number; + + requestMetadata?: RequestMetadata; }; -export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => { +/** + * Admin reset of a user's 2FA configuration. + * + * Also restarts the user's grace window (`twoFactorGraceStartedAt = now`) — + * for BOTH instance and organisation enforcement, since the org deadline + * anchors on this field too. Without the restart, a reset under active + * enforcement would instantly block the user. + * + * Because a reset silently extends org grace across a trust boundary + * (instance admin → org policy), it is made traceable via a + * `AUTH_2FA_ADMIN_RESET` security audit log entry. + */ +export const resetTwoFactor = async ({ userId, actorUserId, requestMetadata }: ResetTwoFactorOptions) => { + if (userId === actorUserId) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You cannot reset your own two factor authentication', + }); + } + const user = await prisma.user.findFirst({ where: { id: userId, @@ -34,14 +67,28 @@ export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' }); } - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - twoFactorEnabled: false, - twoFactorBackupCodes: null, - twoFactorSecret: null, - }, + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorEnabled: false, + twoFactorBackupCodes: null, + twoFactorSecret: null, + // Restart the grace window: anchors both the instance deadline and, + // through the org deadline's max-of-anchors, every org deadline. + twoFactorGraceStartedAt: new Date(), + }, + }); + + await tx.userSecurityAuditLog.create({ + data: { + userId: user.id, + type: UserSecurityAuditLogType.AUTH_2FA_ADMIN_RESET, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); }); }; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 45db5ccbc..58e042405 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -30,6 +30,7 @@ import { findUserTeamsRoute } from './find-user-teams'; import { getAdminOrganisationRoute } from './get-admin-organisation'; import { getAdminTeamRoute } from './get-admin-team'; import { getEmailDomainRoute } from './get-email-domain'; +import { getTwoFactorEnforcementRoute } from './get-two-factor-enforcement'; import { getUserRoute } from './get-user'; import { promoteMemberToOwnerRoute } from './promote-member-to-owner'; import { reregisterEmailDomainRoute } from './reregister-email-domain'; @@ -44,6 +45,7 @@ import { updateOrganisationMemberRoleRoute } from './update-organisation-member- import { updateRecipientRoute } from './update-recipient'; import { updateSiteSettingRoute } from './update-site-setting'; import { updateSubscriptionClaimRoute } from './update-subscription-claim'; +import { updateTwoFactorEnforcementRoute } from './update-two-factor-enforcement'; import { updateUserRoute } from './update-user'; export const adminRouter = router({ @@ -121,4 +123,6 @@ export const adminRouter = router({ }, search: adminSearchRoute, updateSiteSetting: updateSiteSettingRoute, + getTwoFactorEnforcement: getTwoFactorEnforcementRoute, + updateTwoFactorEnforcement: updateTwoFactorEnforcementRoute, }); diff --git a/packages/trpc/server/admin-router/update-site-setting.types.ts b/packages/trpc/server/admin-router/update-site-setting.types.ts index fa78087c0..fcdc53736 100644 --- a/packages/trpc/server/admin-router/update-site-setting.types.ts +++ b/packages/trpc/server/admin-router/update-site-setting.types.ts @@ -1,7 +1,20 @@ -import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema'; +import { ZSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner'; +import { ZSiteSettingsEmailBlocklistSchema } from '@documenso/lib/server-only/site-settings/schemas/email-blocklist'; +import { ZSiteSettingsTelemetrySchema } from '@documenso/lib/server-only/site-settings/schemas/telemetry'; import { z } from 'zod'; -export const ZUpdateSiteSettingRequestSchema = ZSiteSettingSchema; +/** + * Write union for the generic site-setting update route. Deliberately + * EXCLUDES `ZSiteSettingsTwoFactorEnforcementSchema` (which remains in the + * read union): the 2FA enforcement setting is only writable via its dedicated + * admin procedure, which carries the license assert, the enable-time guard + * and the grace-reduction acknowledgement. + */ +export const ZUpdateSiteSettingRequestSchema = z.union([ + ZSiteSettingsBannerSchema, + ZSiteSettingsEmailBlocklistSchema, + ZSiteSettingsTelemetrySchema, +]); export const ZUpdateSiteSettingResponseSchema = z.void(); diff --git a/packages/trpc/server/admin-router/update-two-factor-enforcement.ts b/packages/trpc/server/admin-router/update-two-factor-enforcement.ts new file mode 100644 index 000000000..ea42555a7 --- /dev/null +++ b/packages/trpc/server/admin-router/update-two-factor-enforcement.ts @@ -0,0 +1,101 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getInstanceTwoFactorEnforcementConfig } from '@documenso/lib/server-only/2fa/get-instance-two-factor-enforcement-config'; +import { SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID } from '@documenso/lib/server-only/site-settings/schemas/two-factor-enforcement'; +import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; +import { evaluateInstanceTwoFactorEnforcementUpdate } from '@documenso/lib/utils/two-factor'; +import type { Session } from '@prisma/client'; + +import { adminProcedure } from '../trpc'; +import { + ZUpdateTwoFactorEnforcementRequestSchema, + ZUpdateTwoFactorEnforcementResponseSchema, +} from './update-two-factor-enforcement.types'; + +/** + * Dedicated write route for the `site.two-factor-enforcement` setting. The + * generic `admin.updateSiteSetting` deliberately excludes this setting from + * its write union — this route carries the license assert, the enable-time + * guard and the grace-reduction acknowledgement. + * + * The full policy decision (license gate incl. the unlicensed disable-only + * exception, enable-time guard, grace-reduction acknowledgement, server-side + * `enforcedFrom` reset on off→on) lives in the pure + * `evaluateInstanceTwoFactorEnforcementUpdate` helper so it is unit-testable. + * + * 2FA enforcement metadata: inherits `'none'` from the admin base — admin + * procedures carry no organisation scope, while the INSTANCE assert still + * applies via `adminMiddleware`. + */ +export const updateTwoFactorEnforcementRoute = adminProcedure + .input(ZUpdateTwoFactorEnforcementRequestSchema) + .output(ZUpdateTwoFactorEnforcementResponseSchema) + .mutation(async ({ ctx, input }) => { + const { enabled, gracePeriodDays, acknowledgeGracePeriodReduction } = input; + + // Explicitly asserted local, mirroring `update-organisation-settings`: + // avoids control-flow narrowing differences between workspace tsconfigs. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const requestSession = ctx.session as Pick | null; + + ctx.logger.info({ + input: { + enabled, + gracePeriodDays, + }, + }); + + const storedConfig = await getInstanceTwoFactorEnforcementConfig(); + + const decision = evaluateInstanceTwoFactorEnforcementUpdate({ + stored: { + enabled: storedConfig.enabled, + gracePeriodDays: storedConfig.gracePeriodDays, + enforcedFrom: storedConfig.enforcedFrom, + }, + update: { + enabled, + gracePeriodDays, + acknowledgeGracePeriodReduction, + }, + isLicensed: storedConfig.isLicensed, + actor: { + userTwoFactorEnabled: ctx.user.twoFactorEnabled, + sessionTwoFactorVerified: requestSession?.twoFactorVerified ?? false, + }, + now: new Date(), + }); + + if (!decision.allowed) { + switch (decision.reason) { + case 'UNLICENSED': + throw new AppError(AppErrorCode.FORBIDDEN, { + message: + 'Your license does not include instance-wide two-factor enforcement. Without the license the only permitted change is disabling the currently stored configuration.', + statusCode: 403, + }); + + case 'ENABLE_REQUIRES_ACTOR_TWO_FACTOR': + throw new AppError(AppErrorCode.TWO_FACTOR_REQUIRED, { + message: + 'You must have two-factor authentication enabled and verified on this session before requiring it for the instance.', + statusCode: 403, + }); + + case 'GRACE_REDUCTION_NOT_ACKNOWLEDGED': + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'This change reduces the active two-factor authentication grace period and must be explicitly acknowledged.', + }); + } + } + + await upsertSiteSetting({ + id: SITE_SETTINGS_TWO_FACTOR_ENFORCEMENT_ID, + enabled: decision.next.enabled, + data: { + gracePeriodDays: decision.next.gracePeriodDays, + enforcedFrom: decision.next.enforcedFrom, + }, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/admin-router/update-two-factor-enforcement.types.ts b/packages/trpc/server/admin-router/update-two-factor-enforcement.types.ts new file mode 100644 index 000000000..104786e1b --- /dev/null +++ b/packages/trpc/server/admin-router/update-two-factor-enforcement.types.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const ZUpdateTwoFactorEnforcementRequestSchema = z.object({ + enabled: z.boolean(), + gracePeriodDays: z.number().int().min(0).max(365), + + /** + * Required (as `true`) when the update reduces an active grace window. + */ + acknowledgeGracePeriodReduction: z.boolean().optional(), +}); + +export const ZUpdateTwoFactorEnforcementResponseSchema = z.void(); + +export type TUpdateTwoFactorEnforcementRequest = z.infer; +export type TUpdateTwoFactorEnforcementResponse = z.infer; diff --git a/packages/trpc/server/api-token-router/create-api-token.ts b/packages/trpc/server/api-token-router/create-api-token.ts index b48322478..453e91b97 100644 --- a/packages/trpc/server/api-token-router/create-api-token.ts +++ b/packages/trpc/server/api-token-router/create-api-token.ts @@ -4,11 +4,13 @@ import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateApiTokenRequestSchema, ZCreateApiTokenResponseSchema } from './create-api-token.types'; export const createApiTokenRoute = authenticatedProcedure .input(ZCreateApiTokenRequestSchema) .output(ZCreateApiTokenResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { tokenName, teamId, expirationDate } = input; diff --git a/packages/trpc/server/api-token-router/delete-api-token.ts b/packages/trpc/server/api-token-router/delete-api-token.ts index 822b6f04d..a1d9a55d9 100644 --- a/packages/trpc/server/api-token-router/delete-api-token.ts +++ b/packages/trpc/server/api-token-router/delete-api-token.ts @@ -1,11 +1,13 @@ import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteApiTokenRequestSchema, ZDeleteApiTokenResponseSchema } from './delete-api-token.types'; export const deleteApiTokenRoute = authenticatedProcedure .input(ZDeleteApiTokenRequestSchema) .output(ZDeleteApiTokenResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { id, teamId } = input; diff --git a/packages/trpc/server/api-token-router/get-api-tokens.ts b/packages/trpc/server/api-token-router/get-api-tokens.ts index 4473c8d42..e014154a1 100644 --- a/packages/trpc/server/api-token-router/get-api-tokens.ts +++ b/packages/trpc/server/api-token-router/get-api-tokens.ts @@ -1,11 +1,13 @@ import { getApiTokens } from '@documenso/lib/server-only/public-api/get-api-tokens'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { ZGetApiTokensRequestSchema, ZGetApiTokensResponseSchema } from './get-api-tokens.types'; export const getApiTokensRoute = authenticatedProcedure .input(ZGetApiTokensRequestSchema) .output(ZGetApiTokensResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ ctx }) => { const { teamId } = ctx; diff --git a/packages/trpc/server/auth-router/create-passkey-authentication-options.ts b/packages/trpc/server/auth-router/create-passkey-authentication-options.ts index 6b507f7ca..9eccde4e9 100644 --- a/packages/trpc/server/auth-router/create-passkey-authentication-options.ts +++ b/packages/trpc/server/auth-router/create-passkey-authentication-options.ts @@ -1,6 +1,7 @@ import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZCreatePasskeyAuthenticationOptionsRequestSchema, ZCreatePasskeyAuthenticationOptionsResponseSchema, @@ -9,6 +10,8 @@ import { export const createPasskeyAuthenticationOptionsRoute = authenticatedProcedure .input(ZCreatePasskeyAuthenticationOptionsRequestSchema) .output(ZCreatePasskeyAuthenticationOptionsResponseSchema) + // 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) .mutation(async ({ ctx, input }) => { return await createPasskeyAuthenticationOptions({ userId: ctx.user.id, diff --git a/packages/trpc/server/auth-router/create-passkey-registration-options.ts b/packages/trpc/server/auth-router/create-passkey-registration-options.ts index 969da6d98..2f9a9a31f 100644 --- a/packages/trpc/server/auth-router/create-passkey-registration-options.ts +++ b/packages/trpc/server/auth-router/create-passkey-registration-options.ts @@ -1,6 +1,7 @@ import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZCreatePasskeyRegistrationOptionsRequestSchema, ZCreatePasskeyRegistrationOptionsResponseSchema, @@ -9,6 +10,8 @@ import { export const createPasskeyRegistrationOptionsRoute = authenticatedProcedure .input(ZCreatePasskeyRegistrationOptionsRequestSchema) .output(ZCreatePasskeyRegistrationOptionsResponseSchema) + // 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) .mutation(async ({ ctx }) => { return await createPasskeyRegistrationOptions({ userId: ctx.user.id, diff --git a/packages/trpc/server/auth-router/create-passkey.ts b/packages/trpc/server/auth-router/create-passkey.ts index 8acabaa2a..8794189b8 100644 --- a/packages/trpc/server/auth-router/create-passkey.ts +++ b/packages/trpc/server/auth-router/create-passkey.ts @@ -2,11 +2,14 @@ import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; import type { RegistrationResponseJSON } from '@simplewebauthn/server'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZCreatePasskeyRequestSchema, ZCreatePasskeyResponseSchema } from './create-passkey.types'; export const createPasskeyRoute = authenticatedProcedure .input(ZCreatePasskeyRequestSchema) .output(ZCreatePasskeyResponseSchema) + // 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) .mutation(async ({ ctx, input }) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const verificationResponse = input.verificationResponse as RegistrationResponseJSON; diff --git a/packages/trpc/server/auth-router/delete-passkey.ts b/packages/trpc/server/auth-router/delete-passkey.ts index af1cdc6b7..a377b2198 100644 --- a/packages/trpc/server/auth-router/delete-passkey.ts +++ b/packages/trpc/server/auth-router/delete-passkey.ts @@ -1,11 +1,14 @@ import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZDeletePasskeyRequestSchema, ZDeletePasskeyResponseSchema } from './delete-passkey.types'; export const deletePasskeyRoute = authenticatedProcedure .input(ZDeletePasskeyRequestSchema) .output(ZDeletePasskeyResponseSchema) + // 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) .mutation(async ({ ctx, input }) => { const { passkeyId } = input; diff --git a/packages/trpc/server/auth-router/find-passkeys.ts b/packages/trpc/server/auth-router/find-passkeys.ts index ff7e5be79..9b8531526 100644 --- a/packages/trpc/server/auth-router/find-passkeys.ts +++ b/packages/trpc/server/auth-router/find-passkeys.ts @@ -1,11 +1,14 @@ import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZFindPasskeysRequestSchema, ZFindPasskeysResponseSchema } from './find-passkeys.types'; export const findPasskeysRoute = authenticatedProcedure .input(ZFindPasskeysRequestSchema) .output(ZFindPasskeysResponseSchema) + // 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) .query(async ({ input, ctx }) => { const { page, perPage, orderBy } = input; diff --git a/packages/trpc/server/auth-router/update-passkey.ts b/packages/trpc/server/auth-router/update-passkey.ts index eeec06653..38c77a553 100644 --- a/packages/trpc/server/auth-router/update-passkey.ts +++ b/packages/trpc/server/auth-router/update-passkey.ts @@ -1,11 +1,14 @@ import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZUpdatePasskeyRequestSchema, ZUpdatePasskeyResponseSchema } from './update-passkey.types'; export const updatePasskeyRoute = authenticatedProcedure .input(ZUpdatePasskeyRequestSchema) .output(ZUpdatePasskeyResponseSchema) + // 2FA enforcement: user-level passkey credential management; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) .mutation(async ({ ctx, input }) => { const { passkeyId, name } = input; diff --git a/packages/trpc/server/document-router/attachment/create-attachment.ts b/packages/trpc/server/document-router/attachment/create-attachment.ts index 844f16cb8..4bcadfc5b 100644 --- a/packages/trpc/server/document-router/attachment/create-attachment.ts +++ b/packages/trpc/server/document-router/attachment/create-attachment.ts @@ -4,6 +4,7 @@ import { createAttachment } from '@documenso/lib/server-only/envelope-attachment import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { ZCreateAttachmentRequestSchema, ZCreateAttachmentResponseSchema } from './create-attachment.types'; export const createAttachmentRoute = authenticatedProcedure @@ -20,6 +21,7 @@ export const createAttachmentRoute = authenticatedProcedure }) .input(ZCreateAttachmentRequestSchema) .output(ZCreateAttachmentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const userId = ctx.user.id; diff --git a/packages/trpc/server/document-router/attachment/delete-attachment.ts b/packages/trpc/server/document-router/attachment/delete-attachment.ts index f965bb2e9..1e728b5f2 100644 --- a/packages/trpc/server/document-router/attachment/delete-attachment.ts +++ b/packages/trpc/server/document-router/attachment/delete-attachment.ts @@ -2,6 +2,7 @@ import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment import { ZGenericSuccessResponse } from '../../schema'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { ZDeleteAttachmentRequestSchema, ZDeleteAttachmentResponseSchema } from './delete-attachment.types'; export const deleteAttachmentRoute = authenticatedProcedure @@ -18,6 +19,7 @@ export const deleteAttachmentRoute = authenticatedProcedure }) .input(ZDeleteAttachmentRequestSchema) .output(ZDeleteAttachmentResponseSchema) + .use(twoFactorScope((input) => ({ attachment: input.id }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const userId = ctx.user.id; diff --git a/packages/trpc/server/document-router/attachment/find-attachments.ts b/packages/trpc/server/document-router/attachment/find-attachments.ts index de348440c..017e6a67a 100644 --- a/packages/trpc/server/document-router/attachment/find-attachments.ts +++ b/packages/trpc/server/document-router/attachment/find-attachments.ts @@ -4,6 +4,7 @@ import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { ZFindAttachmentsRequestSchema, ZFindAttachmentsResponseSchema } from './find-attachments.types'; export const findAttachmentsRoute = authenticatedProcedure @@ -20,6 +21,7 @@ export const findAttachmentsRoute = authenticatedProcedure }) .input(ZFindAttachmentsRequestSchema) .output(ZFindAttachmentsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .query(async ({ input, ctx }) => { const { documentId } = input; const { teamId } = ctx; diff --git a/packages/trpc/server/document-router/attachment/update-attachment.ts b/packages/trpc/server/document-router/attachment/update-attachment.ts index d5f5cf244..b689a55e8 100644 --- a/packages/trpc/server/document-router/attachment/update-attachment.ts +++ b/packages/trpc/server/document-router/attachment/update-attachment.ts @@ -2,6 +2,7 @@ import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment import { ZGenericSuccessResponse } from '../../schema'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { ZUpdateAttachmentRequestSchema, ZUpdateAttachmentResponseSchema } from './update-attachment.types'; export const updateAttachmentRoute = authenticatedProcedure @@ -18,6 +19,7 @@ export const updateAttachmentRoute = authenticatedProcedure }) .input(ZUpdateAttachmentRequestSchema) .output(ZUpdateAttachmentResponseSchema) + .use(twoFactorScope((input) => ({ attachment: input.id }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const userId = ctx.user.id; diff --git a/packages/trpc/server/document-router/create-document-temporary.ts b/packages/trpc/server/document-router/create-document-temporary.ts index be39038c9..77f58a318 100644 --- a/packages/trpc/server/document-router/create-document-temporary.ts +++ b/packages/trpc/server/document-router/create-document-temporary.ts @@ -8,6 +8,7 @@ import { prisma } from '@documenso/prisma'; import { DocumentDataType, EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { createDocumentTemporaryMeta, ZCreateDocumentTemporaryRequestSchema, @@ -24,6 +25,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure .meta(createDocumentTemporaryMeta) .input(ZCreateDocumentTemporaryRequestSchema) .output(ZCreateDocumentTemporaryResponseSchema) + .use(twoFactorScopeFromCtx()) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; diff --git a/packages/trpc/server/document-router/create-document.ts b/packages/trpc/server/document-router/create-document.ts index f2e84364e..18e28fcb4 100644 --- a/packages/trpc/server/document-router/create-document.ts +++ b/packages/trpc/server/document-router/create-document.ts @@ -8,6 +8,7 @@ import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { createDocumentMeta, ZCreateDocumentRequestSchema, @@ -18,6 +19,7 @@ export const createDocumentRoute = authenticatedProcedure .meta(createDocumentMeta) .input(ZCreateDocumentRequestSchema) .output(ZCreateDocumentResponseSchema) + .use(twoFactorScopeFromCtx()) .mutation(async ({ input, ctx }) => { const { user, teamId } = ctx; diff --git a/packages/trpc/server/document-router/delete-document.ts b/packages/trpc/server/document-router/delete-document.ts index 85aa46561..06355a44e 100644 --- a/packages/trpc/server/document-router/delete-document.ts +++ b/packages/trpc/server/document-router/delete-document.ts @@ -2,6 +2,7 @@ import { deleteDocument } from '@documenso/lib/server-only/document/delete-docum import { ZGenericSuccessResponse } from '../schema'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { deleteDocumentMeta, ZDeleteDocumentRequestSchema, @@ -12,6 +13,7 @@ export const deleteDocumentRoute = authenticatedProcedure .meta(deleteDocumentMeta) .input(ZDeleteDocumentRequestSchema) .output(ZDeleteDocumentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId } = input; diff --git a/packages/trpc/server/document-router/distribute-document.ts b/packages/trpc/server/document-router/distribute-document.ts index f0b3c5333..ca453f1a7 100644 --- a/packages/trpc/server/document-router/distribute-document.ts +++ b/packages/trpc/server/document-router/distribute-document.ts @@ -3,6 +3,7 @@ import { updateDocumentMeta } from '@documenso/lib/server-only/document-meta/ups import { mapEnvelopeToDocumentLite } from '@documenso/lib/utils/document'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { distributeDocumentMeta, ZDistributeDocumentRequestSchema, @@ -13,6 +14,7 @@ export const distributeDocumentRoute = authenticatedProcedure .meta(distributeDocumentMeta) .input(ZDistributeDocumentRequestSchema) .output(ZDistributeDocumentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, meta = {} } = input; diff --git a/packages/trpc/server/document-router/download-document-audit-logs.ts b/packages/trpc/server/document-router/download-document-audit-logs.ts index 398fbfb6c..e4fc88150 100644 --- a/packages/trpc/server/document-router/download-document-audit-logs.ts +++ b/packages/trpc/server/document-router/download-document-audit-logs.ts @@ -5,6 +5,7 @@ import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-aud import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDownloadDocumentAuditLogsRequestSchema, ZDownloadDocumentAuditLogsResponseSchema, @@ -13,6 +14,7 @@ import { export const downloadDocumentAuditLogsRoute = authenticatedProcedure .input(ZDownloadDocumentAuditLogsRequestSchema) .output(ZDownloadDocumentAuditLogsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId } = input; diff --git a/packages/trpc/server/document-router/download-document-beta.ts b/packages/trpc/server/document-router/download-document-beta.ts index c58187f76..6f4ea193e 100644 --- a/packages/trpc/server/document-router/download-document-beta.ts +++ b/packages/trpc/server/document-router/download-document-beta.ts @@ -6,6 +6,7 @@ import type { DocumentData } from '@prisma/client'; import { DocumentDataType, DocumentStatus, EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { downloadDocumentMeta, ZDownloadDocumentRequestSchema, @@ -16,6 +17,7 @@ export const downloadDocumentBetaRoute = authenticatedProcedure .meta(downloadDocumentMeta) .input(ZDownloadDocumentRequestSchema) .output(ZDownloadDocumentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { documentId, version } = input; diff --git a/packages/trpc/server/document-router/download-document-certificate.ts b/packages/trpc/server/document-router/download-document-certificate.ts index 5180af962..1427e7b68 100644 --- a/packages/trpc/server/document-router/download-document-certificate.ts +++ b/packages/trpc/server/document-router/download-document-certificate.ts @@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDownloadDocumentCertificateRequestSchema, ZDownloadDocumentCertificateResponseSchema, @@ -15,6 +16,7 @@ import { export const downloadDocumentCertificateRoute = authenticatedProcedure .input(ZDownloadDocumentCertificateRequestSchema) .output(ZDownloadDocumentCertificateResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId } = input; diff --git a/packages/trpc/server/document-router/download-document.ts b/packages/trpc/server/document-router/download-document.ts index 92fb68f40..2908efca3 100644 --- a/packages/trpc/server/document-router/download-document.ts +++ b/packages/trpc/server/document-router/download-document.ts @@ -1,4 +1,5 @@ import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { downloadDocumentMeta, ZDownloadDocumentRequestSchema, @@ -9,6 +10,7 @@ export const downloadDocumentRoute = authenticatedProcedure .meta(downloadDocumentMeta) .input(ZDownloadDocumentRequestSchema) .output(ZDownloadDocumentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .query(({ input, ctx }) => { const { documentId, version } = input; diff --git a/packages/trpc/server/document-router/duplicate-document.ts b/packages/trpc/server/document-router/duplicate-document.ts index adfcc24b8..122477f8b 100644 --- a/packages/trpc/server/document-router/duplicate-document.ts +++ b/packages/trpc/server/document-router/duplicate-document.ts @@ -1,6 +1,7 @@ import { duplicateEnvelope } from '@documenso/lib/server-only/envelope/duplicate-envelope'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { duplicateDocumentMeta, ZDuplicateDocumentRequestSchema, @@ -11,6 +12,7 @@ export const duplicateDocumentRoute = authenticatedProcedure .meta(duplicateDocumentMeta) .input(ZDuplicateDocumentRequestSchema) .output(ZDuplicateDocumentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { documentId } = input; diff --git a/packages/trpc/server/document-router/find-document-audit-logs.ts b/packages/trpc/server/document-router/find-document-audit-logs.ts index 025566bdb..1c8f04a36 100644 --- a/packages/trpc/server/document-router/find-document-audit-logs.ts +++ b/packages/trpc/server/document-router/find-document-audit-logs.ts @@ -1,6 +1,7 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindDocumentAuditLogsRequestSchema, ZFindDocumentAuditLogsResponseSchema, @@ -9,6 +10,7 @@ import { export const findDocumentAuditLogsRoute = authenticatedProcedure .input(ZFindDocumentAuditLogsRequestSchema) .output(ZFindDocumentAuditLogsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .query(async ({ input, ctx }) => { const { teamId } = ctx; diff --git a/packages/trpc/server/document-router/find-documents-internal.ts b/packages/trpc/server/document-router/find-documents-internal.ts index 5503e3a0b..fee567b0c 100644 --- a/packages/trpc/server/document-router/find-documents-internal.ts +++ b/packages/trpc/server/document-router/find-documents-internal.ts @@ -3,6 +3,7 @@ import { getStats } from '@documenso/lib/server-only/document/get-stats'; import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { ZFindDocumentsInternalRequestSchema, ZFindDocumentsInternalResponseSchema, @@ -11,6 +12,7 @@ import { export const findDocumentsInternalRoute = authenticatedProcedure .input(ZFindDocumentsInternalRequestSchema) .output(ZFindDocumentsInternalResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { user, teamId } = ctx; diff --git a/packages/trpc/server/document-router/find-documents.ts b/packages/trpc/server/document-router/find-documents.ts index f82e8b1ff..24688336e 100644 --- a/packages/trpc/server/document-router/find-documents.ts +++ b/packages/trpc/server/document-router/find-documents.ts @@ -2,12 +2,14 @@ import { findDocuments } from '@documenso/lib/server-only/document/find-document import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { ZFindDocumentsMeta, ZFindDocumentsRequestSchema, ZFindDocumentsResponseSchema } from './find-documents.types'; export const findDocumentsRoute = authenticatedProcedure .meta(ZFindDocumentsMeta) .input(ZFindDocumentsRequestSchema) .output(ZFindDocumentsResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { user, teamId } = ctx; diff --git a/packages/trpc/server/document-router/find-inbox.ts b/packages/trpc/server/document-router/find-inbox.ts index 13e360a1d..05469f47e 100644 --- a/packages/trpc/server/document-router/find-inbox.ts +++ b/packages/trpc/server/document-router/find-inbox.ts @@ -5,11 +5,14 @@ import type { Envelope, Prisma } from '@prisma/client'; import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types'; export const findInboxRoute = authenticatedProcedure .input(ZFindInboxRequestSchema) .output(ZFindInboxResponseSchema) + // 2FA enforcement: recipient-perspective inbox (documents where the user is a signer); signing access is token-authorized by design, not organisation-member access. Instance assert still applies. + .use(twoFactorInstanceOnly()) .query(async ({ input, ctx }) => { const { page, perPage } = input; diff --git a/packages/trpc/server/document-router/get-document-by-token.ts b/packages/trpc/server/document-router/get-document-by-token.ts index f3c34f453..e79e1dab5 100644 --- a/packages/trpc/server/document-router/get-document-by-token.ts +++ b/packages/trpc/server/document-router/get-document-by-token.ts @@ -3,11 +3,18 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { TWO_FACTOR_SKIP, twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetDocumentByTokenRequestSchema, ZGetDocumentByTokenResponseSchema } from './get-document-by-token.types'; export const getDocumentByTokenRoute = authenticatedProcedure .input(ZGetDocumentByTokenRequestSchema) .output(ZGetDocumentByTokenResponseSchema) + // Token-authorized: the handler authorizes purely via the recipient token + // (the session only narrows by email), so both asserts are skipped when the + // token is present — mirroring the handler's own authorization input. The + // schema requires a non-empty token, so the fallback (instance assert only, + // no organisation scope) is unreachable in practice but kept explicit. + .use(twoFactorScope((input) => (input.token ? TWO_FACTOR_SKIP : {}))) .query(async ({ input, ctx }) => { const { token } = input; diff --git a/packages/trpc/server/document-router/get-document.ts b/packages/trpc/server/document-router/get-document.ts index 7032c20bb..79d85b051 100644 --- a/packages/trpc/server/document-router/get-document.ts +++ b/packages/trpc/server/document-router/get-document.ts @@ -1,12 +1,14 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { getDocumentMeta, ZGetDocumentRequestSchema, ZGetDocumentResponseSchema } from './get-document.types'; export const getDocumentRoute = authenticatedProcedure .meta(getDocumentMeta) .input(ZGetDocumentRequestSchema) .output(ZGetDocumentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { documentId } = input; diff --git a/packages/trpc/server/document-router/get-documents-by-ids.ts b/packages/trpc/server/document-router/get-documents-by-ids.ts index 309f9381c..2faef1677 100644 --- a/packages/trpc/server/document-router/get-documents-by-ids.ts +++ b/packages/trpc/server/document-router/get-documents-by-ids.ts @@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { getDocumentsByIdsMeta, ZGetDocumentsByIdsRequestSchema, @@ -14,6 +15,7 @@ export const getDocumentsByIdsRoute = authenticatedProcedure .meta(getDocumentsByIdsMeta) .input(ZGetDocumentsByIdsRequestSchema) .output(ZGetDocumentsByIdsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentIds }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { documentIds } = input; diff --git a/packages/trpc/server/document-router/get-inbox-count.ts b/packages/trpc/server/document-router/get-inbox-count.ts index 0f303cc3b..7a2a01f88 100644 --- a/packages/trpc/server/document-router/get-inbox-count.ts +++ b/packages/trpc/server/document-router/get-inbox-count.ts @@ -2,11 +2,14 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZGetInboxCountRequestSchema, ZGetInboxCountResponseSchema } from './get-inbox-count.types'; export const getInboxCountRoute = authenticatedProcedure .input(ZGetInboxCountRequestSchema) .output(ZGetInboxCountResponseSchema) + // 2FA enforcement: recipient-perspective inbox count; see find-inbox. Instance assert still applies. + .use(twoFactorInstanceOnly()) .query(async ({ input, ctx }) => { const { readStatus } = input ?? {}; diff --git a/packages/trpc/server/document-router/redistribute-document.ts b/packages/trpc/server/document-router/redistribute-document.ts index 9c77b4780..dcd1958f6 100644 --- a/packages/trpc/server/document-router/redistribute-document.ts +++ b/packages/trpc/server/document-router/redistribute-document.ts @@ -2,6 +2,7 @@ import { resendDocument } from '@documenso/lib/server-only/document/resend-docum import { ZGenericSuccessResponse } from '../schema'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { redistributeDocumentMeta, ZRedistributeDocumentRequestSchema, @@ -12,6 +13,7 @@ export const redistributeDocumentRoute = authenticatedProcedure .meta(redistributeDocumentMeta) .input(ZRedistributeDocumentRequestSchema) .output(ZRedistributeDocumentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, recipients } = input; diff --git a/packages/trpc/server/document-router/search-document.ts b/packages/trpc/server/document-router/search-document.ts index c755a6b46..a252e499e 100644 --- a/packages/trpc/server/document-router/search-document.ts +++ b/packages/trpc/server/document-router/search-document.ts @@ -1,11 +1,13 @@ import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { ZSearchDocumentRequestSchema, ZSearchDocumentResponseSchema } from './search-document.types'; export const searchDocumentRoute = authenticatedProcedure .input(ZSearchDocumentRequestSchema) .output(ZSearchDocumentResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { query } = input; diff --git a/packages/trpc/server/document-router/update-document.ts b/packages/trpc/server/document-router/update-document.ts index c6d355563..b769838cc 100644 --- a/packages/trpc/server/document-router/update-document.ts +++ b/packages/trpc/server/document-router/update-document.ts @@ -2,6 +2,7 @@ import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envel import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { updateDocumentMeta, ZUpdateDocumentRequestSchema, @@ -15,6 +16,7 @@ export const updateDocumentRoute = authenticatedProcedure .meta(updateDocumentMeta) .input(ZUpdateDocumentRequestSchema) .output(ZUpdateDocumentResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, data, meta = {} } = input; diff --git a/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts index 4823c1dd3..86b1c214d 100644 --- a/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts +++ b/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts @@ -6,6 +6,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateOrganisationEmailDomainRequestSchema, ZCreateOrganisationEmailDomainResponseSchema, @@ -14,6 +15,7 @@ import { export const createOrganisationEmailDomainRoute = authenticatedProcedure .input(ZCreateOrganisationEmailDomainRequestSchema) .output(ZCreateOrganisationEmailDomainResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ input, ctx }) => { const { organisationId, domain } = input; const { user } = ctx; diff --git a/packages/trpc/server/enterprise-router/create-organisation-email.ts b/packages/trpc/server/enterprise-router/create-organisation-email.ts index 6a047da37..f9302138d 100644 --- a/packages/trpc/server/enterprise-router/create-organisation-email.ts +++ b/packages/trpc/server/enterprise-router/create-organisation-email.ts @@ -5,6 +5,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateOrganisationEmailRequestSchema, ZCreateOrganisationEmailResponseSchema, @@ -13,6 +14,7 @@ import { export const createOrganisationEmailRoute = authenticatedProcedure .input(ZCreateOrganisationEmailRequestSchema) .output(ZCreateOrganisationEmailResponseSchema) + .use(twoFactorScope((input) => ({ organisationEmailDomain: input.emailDomainId }))) .mutation(async ({ input, ctx }) => { const { email, emailName, emailDomainId } = input; const { user } = ctx; diff --git a/packages/trpc/server/enterprise-router/create-subscription.ts b/packages/trpc/server/enterprise-router/create-subscription.ts index 11482c9a2..20fb84e3a 100644 --- a/packages/trpc/server/enterprise-router/create-subscription.ts +++ b/packages/trpc/server/enterprise-router/create-subscription.ts @@ -7,10 +7,12 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateSubscriptionRequestSchema } from './create-subscription.types'; export const createSubscriptionRoute = authenticatedProcedure .input(ZCreateSubscriptionRequestSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId, priceId } = input; diff --git a/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts index 1edadfbd7..a5d87364d 100644 --- a/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts +++ b/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts @@ -6,6 +6,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteOrganisationEmailDomainRequestSchema, ZDeleteOrganisationEmailDomainResponseSchema, @@ -14,6 +15,7 @@ import { export const deleteOrganisationEmailDomainRoute = authenticatedProcedure .input(ZDeleteOrganisationEmailDomainRequestSchema) .output(ZDeleteOrganisationEmailDomainResponseSchema) + .use(twoFactorScope((input) => ({ organisationEmailDomain: input.emailDomainId }))) .mutation(async ({ input, ctx }) => { const { emailDomainId } = input; const { user } = ctx; diff --git a/packages/trpc/server/enterprise-router/delete-organisation-email.ts b/packages/trpc/server/enterprise-router/delete-organisation-email.ts index 770649d71..010c822b9 100644 --- a/packages/trpc/server/enterprise-router/delete-organisation-email.ts +++ b/packages/trpc/server/enterprise-router/delete-organisation-email.ts @@ -4,6 +4,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteOrganisationEmailRequestSchema, ZDeleteOrganisationEmailResponseSchema, @@ -12,6 +13,7 @@ import { export const deleteOrganisationEmailRoute = authenticatedProcedure .input(ZDeleteOrganisationEmailRequestSchema) .output(ZDeleteOrganisationEmailResponseSchema) + .use(twoFactorScope((input) => ({ organisationEmail: input.emailId }))) .mutation(async ({ input, ctx }) => { const { emailId } = input; const { user } = ctx; diff --git a/packages/trpc/server/enterprise-router/find-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/find-organisation-email-domain.ts index 8e85e08b3..4f0669690 100644 --- a/packages/trpc/server/enterprise-router/find-organisation-email-domain.ts +++ b/packages/trpc/server/enterprise-router/find-organisation-email-domain.ts @@ -6,6 +6,7 @@ import type { EmailDomainStatus } from '@prisma/client'; import { Prisma } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindOrganisationEmailDomainsRequestSchema, ZFindOrganisationEmailDomainsResponseSchema, @@ -14,6 +15,7 @@ import { export const findOrganisationEmailDomainsRoute = authenticatedProcedure .input(ZFindOrganisationEmailDomainsRequestSchema) .output(ZFindOrganisationEmailDomainsResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ input, ctx }) => { const { organisationId, emailDomainId, statuses, query, page, perPage } = input; const { user } = ctx; diff --git a/packages/trpc/server/enterprise-router/find-organisation-emails.ts b/packages/trpc/server/enterprise-router/find-organisation-emails.ts index 88aa0726c..98e320998 100644 --- a/packages/trpc/server/enterprise-router/find-organisation-emails.ts +++ b/packages/trpc/server/enterprise-router/find-organisation-emails.ts @@ -5,6 +5,7 @@ import { prisma } from '@documenso/prisma'; import { Prisma } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindOrganisationEmailsRequestSchema, ZFindOrganisationEmailsResponseSchema, @@ -13,6 +14,7 @@ import { export const findOrganisationEmailsRoute = authenticatedProcedure .input(ZFindOrganisationEmailsRequestSchema) .output(ZFindOrganisationEmailsResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ input, ctx }) => { const { organisationId, emailDomainId, query, page, perPage } = input; const { user } = ctx; diff --git a/packages/trpc/server/enterprise-router/get-invoices.ts b/packages/trpc/server/enterprise-router/get-invoices.ts index 7693d7559..c3a2fab4f 100644 --- a/packages/trpc/server/enterprise-router/get-invoices.ts +++ b/packages/trpc/server/enterprise-router/get-invoices.ts @@ -6,10 +6,12 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetInvoicesRequestSchema } from './get-invoices.types'; export const getInvoicesRoute = authenticatedProcedure .input(ZGetInvoicesRequestSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ ctx, input }) => { const { organisationId } = input; diff --git a/packages/trpc/server/enterprise-router/get-organisation-authentication-portal.ts b/packages/trpc/server/enterprise-router/get-organisation-authentication-portal.ts index 650611aa6..20af2c58f 100644 --- a/packages/trpc/server/enterprise-router/get-organisation-authentication-portal.ts +++ b/packages/trpc/server/enterprise-router/get-organisation-authentication-portal.ts @@ -4,6 +4,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetOrganisationAuthenticationPortalRequestSchema, ZGetOrganisationAuthenticationPortalResponseSchema, @@ -12,6 +13,7 @@ import { export const getOrganisationAuthenticationPortalRoute = authenticatedProcedure .input(ZGetOrganisationAuthenticationPortalRequestSchema) .output(ZGetOrganisationAuthenticationPortalResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ input, ctx }) => { const { organisationId } = input; diff --git a/packages/trpc/server/enterprise-router/get-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/get-organisation-email-domain.ts index 803f0013a..5fd4f42a8 100644 --- a/packages/trpc/server/enterprise-router/get-organisation-email-domain.ts +++ b/packages/trpc/server/enterprise-router/get-organisation-email-domain.ts @@ -4,6 +4,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetOrganisationEmailDomainRequestSchema, ZGetOrganisationEmailDomainResponseSchema, @@ -12,6 +13,7 @@ import { export const getOrganisationEmailDomainRoute = authenticatedProcedure .input(ZGetOrganisationEmailDomainRequestSchema) .output(ZGetOrganisationEmailDomainResponseSchema) + .use(twoFactorScope((input) => ({ organisationEmailDomain: input.emailDomainId }))) .query(async ({ input, ctx }) => { const { emailDomainId } = input; diff --git a/packages/trpc/server/enterprise-router/get-plans.ts b/packages/trpc/server/enterprise-router/get-plans.ts index 617990eec..745aa6aba 100644 --- a/packages/trpc/server/enterprise-router/get-plans.ts +++ b/packages/trpc/server/enterprise-router/get-plans.ts @@ -3,29 +3,33 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; -export const getPlansRoute = authenticatedProcedure.query(async ({ ctx }) => { - const userId = ctx.user.id; +export const getPlansRoute = authenticatedProcedure + // 2FA enforcement: public billing plan catalog; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) + .query(async ({ ctx }) => { + const userId = ctx.user.id; - const plans = await getInternalClaimPlans(); + const plans = await getInternalClaimPlans(); - let canCreateFreeOrganisation = false; + let canCreateFreeOrganisation = false; - if (IS_BILLING_ENABLED()) { - const numberOfFreeOrganisations = await prisma.organisation.count({ - where: { - ownerUserId: userId, - subscription: { - is: null, + if (IS_BILLING_ENABLED()) { + const numberOfFreeOrganisations = await prisma.organisation.count({ + where: { + ownerUserId: userId, + subscription: { + is: null, + }, }, - }, - }); + }); - canCreateFreeOrganisation = numberOfFreeOrganisations === 0; - } + canCreateFreeOrganisation = numberOfFreeOrganisations === 0; + } - return { - plans, - canCreateFreeOrganisation, - }; -}); + return { + plans, + canCreateFreeOrganisation, + }; + }); diff --git a/packages/trpc/server/enterprise-router/get-subscription.ts b/packages/trpc/server/enterprise-router/get-subscription.ts index f3368d792..39f1b9c19 100644 --- a/packages/trpc/server/enterprise-router/get-subscription.ts +++ b/packages/trpc/server/enterprise-router/get-subscription.ts @@ -11,10 +11,12 @@ import { prisma } from '@documenso/prisma'; import type { Logger } from 'pino'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetSubscriptionRequestSchema } from './get-subscription.types'; export const getSubscriptionRoute = authenticatedProcedure .input(ZGetSubscriptionRequestSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ ctx, input }) => { const { organisationId } = input; diff --git a/packages/trpc/server/enterprise-router/manage-subscription.ts b/packages/trpc/server/enterprise-router/manage-subscription.ts index 7a950a123..4dce1d5ea 100644 --- a/packages/trpc/server/enterprise-router/manage-subscription.ts +++ b/packages/trpc/server/enterprise-router/manage-subscription.ts @@ -7,10 +7,12 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZManageSubscriptionRequestSchema } from './manage-subscription.types'; export const manageSubscriptionRoute = authenticatedProcedure .input(ZManageSubscriptionRequestSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId } = input; diff --git a/packages/trpc/server/enterprise-router/sync-subscription.ts b/packages/trpc/server/enterprise-router/sync-subscription.ts index 384cdc1a5..be390700b 100644 --- a/packages/trpc/server/enterprise-router/sync-subscription.ts +++ b/packages/trpc/server/enterprise-router/sync-subscription.ts @@ -8,11 +8,13 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZSyncSubscriptionRequestSchema, ZSyncSubscriptionResponseSchema } from './sync-subscription.types'; export const syncSubscriptionRoute = authenticatedProcedure .input(ZSyncSubscriptionRequestSchema) .output(ZSyncSubscriptionResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId } = input; diff --git a/packages/trpc/server/enterprise-router/update-organisation-authentication-portal.ts b/packages/trpc/server/enterprise-router/update-organisation-authentication-portal.ts index 1fa56407a..636767529 100644 --- a/packages/trpc/server/enterprise-router/update-organisation-authentication-portal.ts +++ b/packages/trpc/server/enterprise-router/update-organisation-authentication-portal.ts @@ -7,6 +7,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateOrganisationAuthenticationPortalRequestSchema, ZUpdateOrganisationAuthenticationPortalResponseSchema, @@ -15,6 +16,7 @@ import { export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedure .input(ZUpdateOrganisationAuthenticationPortalRequestSchema) .output(ZUpdateOrganisationAuthenticationPortalResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ input, ctx }) => { const { organisationId, data } = input; const { user } = ctx; diff --git a/packages/trpc/server/enterprise-router/update-organisation-email.ts b/packages/trpc/server/enterprise-router/update-organisation-email.ts index 59ca52435..280d4b9f8 100644 --- a/packages/trpc/server/enterprise-router/update-organisation-email.ts +++ b/packages/trpc/server/enterprise-router/update-organisation-email.ts @@ -4,6 +4,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateOrganisationEmailRequestSchema, ZUpdateOrganisationEmailResponseSchema, @@ -12,6 +13,7 @@ import { export const updateOrganisationEmailRoute = authenticatedProcedure .input(ZUpdateOrganisationEmailRequestSchema) .output(ZUpdateOrganisationEmailResponseSchema) + .use(twoFactorScope((input) => ({ organisationEmail: input.emailId }))) .mutation(async ({ input, ctx }) => { const { emailId, emailName } = input; const { user } = ctx; diff --git a/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts index 436e1a33d..739317541 100644 --- a/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts +++ b/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts @@ -6,6 +6,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZVerifyOrganisationEmailDomainRequestSchema, ZVerifyOrganisationEmailDomainResponseSchema, @@ -14,6 +15,7 @@ import { export const verifyOrganisationEmailDomainRoute = authenticatedProcedure .input(ZVerifyOrganisationEmailDomainRequestSchema) .output(ZVerifyOrganisationEmailDomainResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ input, ctx }) => { const { organisationId, emailDomainId } = input; const { user } = ctx; diff --git a/packages/trpc/server/envelope-router/attachment/create-attachment.ts b/packages/trpc/server/envelope-router/attachment/create-attachment.ts index 04aba6667..118a475ba 100644 --- a/packages/trpc/server/envelope-router/attachment/create-attachment.ts +++ b/packages/trpc/server/envelope-router/attachment/create-attachment.ts @@ -1,6 +1,7 @@ import { createAttachment } from '@documenso/lib/server-only/envelope-attachment/create-attachment'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { createAttachmentMeta, ZCreateAttachmentRequestSchema, @@ -11,6 +12,7 @@ export const createAttachmentRoute = authenticatedProcedure .meta(createAttachmentMeta) .input(ZCreateAttachmentRequestSchema) .output(ZCreateAttachmentResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const userId = ctx.user.id; diff --git a/packages/trpc/server/envelope-router/attachment/delete-attachment.ts b/packages/trpc/server/envelope-router/attachment/delete-attachment.ts index 9775d3bf0..4cdade534 100644 --- a/packages/trpc/server/envelope-router/attachment/delete-attachment.ts +++ b/packages/trpc/server/envelope-router/attachment/delete-attachment.ts @@ -2,6 +2,7 @@ import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment import { ZGenericSuccessResponse } from '../../schema'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { deleteAttachmentMeta, ZDeleteAttachmentRequestSchema, @@ -12,6 +13,7 @@ export const deleteAttachmentRoute = authenticatedProcedure .meta(deleteAttachmentMeta) .input(ZDeleteAttachmentRequestSchema) .output(ZDeleteAttachmentResponseSchema) + .use(twoFactorScope((input) => ({ attachment: input.id }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const userId = ctx.user.id; diff --git a/packages/trpc/server/envelope-router/attachment/find-attachments.ts b/packages/trpc/server/envelope-router/attachment/find-attachments.ts index 6e8af76a4..22bf83d96 100644 --- a/packages/trpc/server/envelope-router/attachment/find-attachments.ts +++ b/packages/trpc/server/envelope-router/attachment/find-attachments.ts @@ -3,6 +3,7 @@ import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope import { findAttachmentsByToken } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-token'; import { maybeAuthenticatedProcedure } from '../../trpc'; +import { TWO_FACTOR_SKIP, twoFactorScope } from '../../two-factor-enforcement/enforce'; import { findAttachmentsMeta, ZFindAttachmentsRequestSchema, @@ -13,6 +14,10 @@ export const findAttachmentsRoute = maybeAuthenticatedProcedure .meta(findAttachmentsMeta) .input(ZFindAttachmentsRequestSchema) .output(ZFindAttachmentsResponseSchema) + // Mirrors the handler's own branch: with a token the call is + // token-authorized, without one it asserts against the envelope's + // organisation. + .use(twoFactorScope((input) => (input.token ? TWO_FACTOR_SKIP : { envelope: input.envelopeId }))) .query(async ({ input, ctx }) => { const { envelopeId, token } = input; diff --git a/packages/trpc/server/envelope-router/attachment/update-attachment.ts b/packages/trpc/server/envelope-router/attachment/update-attachment.ts index fb131fc05..335fd9899 100644 --- a/packages/trpc/server/envelope-router/attachment/update-attachment.ts +++ b/packages/trpc/server/envelope-router/attachment/update-attachment.ts @@ -2,6 +2,7 @@ import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment import { ZGenericSuccessResponse } from '../../schema'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { updateAttachmentMeta, ZUpdateAttachmentRequestSchema, @@ -12,6 +13,7 @@ export const updateAttachmentRoute = authenticatedProcedure .meta(updateAttachmentMeta) .input(ZUpdateAttachmentRequestSchema) .output(ZUpdateAttachmentResponseSchema) + .use(twoFactorScope((input) => ({ attachment: input.id }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const userId = ctx.user.id; diff --git a/packages/trpc/server/envelope-router/bulk-cancel-envelopes.ts b/packages/trpc/server/envelope-router/bulk-cancel-envelopes.ts index 4c1effb07..69aeee267 100644 --- a/packages/trpc/server/envelope-router/bulk-cancel-envelopes.ts +++ b/packages/trpc/server/envelope-router/bulk-cancel-envelopes.ts @@ -5,11 +5,13 @@ import { EnvelopeType } from '@prisma/client'; import pMap from 'p-map'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZBulkCancelEnvelopesRequestSchema, ZBulkCancelEnvelopesResponseSchema } from './bulk-cancel-envelopes.types'; export const bulkCancelEnvelopesRoute = authenticatedProcedure .input(ZBulkCancelEnvelopesRequestSchema) .output(ZBulkCancelEnvelopesResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeIds }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { envelopeIds, reason } = input; diff --git a/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts b/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts index 057118258..817a79619 100644 --- a/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts +++ b/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts @@ -6,12 +6,14 @@ import { EnvelopeType } from '@prisma/client'; import pMap from 'p-map'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZBulkDeleteEnvelopesRequestSchema, ZBulkDeleteEnvelopesResponseSchema } from './bulk-delete-envelopes.types'; export const bulkDeleteEnvelopesRoute = authenticatedProcedure // .meta(bulkDeleteEnvelopesMeta) // Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized. .input(ZBulkDeleteEnvelopesRequestSchema) .output(ZBulkDeleteEnvelopesResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeIds }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { envelopeIds } = input; diff --git a/packages/trpc/server/envelope-router/bulk-move-envelopes.ts b/packages/trpc/server/envelope-router/bulk-move-envelopes.ts index bb2f5dfed..f6ee5b515 100644 --- a/packages/trpc/server/envelope-router/bulk-move-envelopes.ts +++ b/packages/trpc/server/envelope-router/bulk-move-envelopes.ts @@ -5,12 +5,14 @@ import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZBulkMoveEnvelopesRequestSchema, ZBulkMoveEnvelopesResponseSchema } from './bulk-move-envelopes.types'; export const bulkMoveEnvelopesRoute = authenticatedProcedure // .meta(bulkMoveEnvelopesMeta) .input(ZBulkMoveEnvelopesRequestSchema) .output(ZBulkMoveEnvelopesResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeIds }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { envelopeIds, envelopeType, folderId } = input; diff --git a/packages/trpc/server/envelope-router/cancel-envelope.ts b/packages/trpc/server/envelope-router/cancel-envelope.ts index 766b40105..dda0faa2d 100644 --- a/packages/trpc/server/envelope-router/cancel-envelope.ts +++ b/packages/trpc/server/envelope-router/cancel-envelope.ts @@ -2,6 +2,7 @@ import { cancelDocument } from '@documenso/lib/server-only/document/cancel-docum import { ZGenericSuccessResponse } from '../schema'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { cancelEnvelopeMeta, ZCancelEnvelopeRequestSchema, @@ -12,6 +13,7 @@ export const cancelEnvelopeRoute = authenticatedProcedure .meta(cancelEnvelopeMeta) .input(ZCancelEnvelopeRequestSchema) .output(ZCancelEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId, reason } = input; diff --git a/packages/trpc/server/envelope-router/create-envelope-items.ts b/packages/trpc/server/envelope-router/create-envelope-items.ts index ba9c62020..6bb6d40f8 100644 --- a/packages/trpc/server/envelope-router/create-envelope-items.ts +++ b/packages/trpc/server/envelope-router/create-envelope-items.ts @@ -5,6 +5,7 @@ import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { createEnvelopeItemsMeta, ZCreateEnvelopeItemsRequestSchema, @@ -15,6 +16,7 @@ export const createEnvelopeItemsRoute = authenticatedProcedure .meta(createEnvelopeItemsMeta) .input(ZCreateEnvelopeItemsRequestSchema) .output(ZCreateEnvelopeItemsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.payload.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId, metadata } = ctx; const { payload, files } = input; diff --git a/packages/trpc/server/envelope-router/create-envelope.ts b/packages/trpc/server/envelope-router/create-envelope.ts index 1a5038a10..27045c46b 100644 --- a/packages/trpc/server/envelope-router/create-envelope.ts +++ b/packages/trpc/server/envelope-router/create-envelope.ts @@ -12,6 +12,7 @@ import { match, P } from 'ts-pattern'; import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import type { TCreateEnvelopeRequest } from './create-envelope.types'; import { createEnvelopeMeta, @@ -23,6 +24,7 @@ export const createEnvelopeRoute = authenticatedProcedure .meta(createEnvelopeMeta) .input(ZCreateEnvelopeRequestSchema) .output(ZCreateEnvelopeResponseSchema) + .use(twoFactorScopeFromCtx()) .mutation(async ({ input, ctx }) => { ctx.logger.info({ input: { diff --git a/packages/trpc/server/envelope-router/delete-envelope-item.ts b/packages/trpc/server/envelope-router/delete-envelope-item.ts index 04b7d88c6..ba8e329fc 100644 --- a/packages/trpc/server/envelope-router/delete-envelope-item.ts +++ b/packages/trpc/server/envelope-router/delete-envelope-item.ts @@ -6,6 +6,7 @@ import { prisma } from '@documenso/prisma'; import { ZGenericSuccessResponse } from '../schema'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { deleteEnvelopeItemMeta, ZDeleteEnvelopeItemRequestSchema, @@ -16,6 +17,7 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure .meta(deleteEnvelopeItemMeta) .input(ZDeleteEnvelopeItemRequestSchema) .output(ZDeleteEnvelopeItemResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId, metadata } = ctx; const { envelopeId, envelopeItemId } = input; diff --git a/packages/trpc/server/envelope-router/delete-envelope.ts b/packages/trpc/server/envelope-router/delete-envelope.ts index cf36adc99..0b249360d 100644 --- a/packages/trpc/server/envelope-router/delete-envelope.ts +++ b/packages/trpc/server/envelope-router/delete-envelope.ts @@ -7,6 +7,7 @@ import { match } from 'ts-pattern'; import { ZGenericSuccessResponse } from '../schema'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { deleteEnvelopeMeta, ZDeleteEnvelopeRequestSchema, @@ -17,6 +18,7 @@ export const deleteEnvelopeRoute = authenticatedProcedure .meta(deleteEnvelopeMeta) .input(ZDeleteEnvelopeRequestSchema) .output(ZDeleteEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId } = input; diff --git a/packages/trpc/server/envelope-router/distribute-envelope.ts b/packages/trpc/server/envelope-router/distribute-envelope.ts index e75c3efe3..1e04195a1 100644 --- a/packages/trpc/server/envelope-router/distribute-envelope.ts +++ b/packages/trpc/server/envelope-router/distribute-envelope.ts @@ -3,6 +3,7 @@ import { updateDocumentMeta } from '@documenso/lib/server-only/document-meta/ups import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { distributeEnvelopeMeta, ZDistributeEnvelopeRequestSchema, @@ -13,6 +14,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure .meta(distributeEnvelopeMeta) .input(ZDistributeEnvelopeRequestSchema) .output(ZDistributeEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId, meta = {} } = input; diff --git a/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.ts b/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.ts index 3bd8fc74a..1266e4023 100644 --- a/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.ts +++ b/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.ts @@ -1,4 +1,5 @@ import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { downloadEnvelopeAuditLogPdfMeta, ZDownloadEnvelopeAuditLogPdfRequestSchema, @@ -9,6 +10,7 @@ export const downloadEnvelopeAuditLogPdfRoute = authenticatedProcedure .meta(downloadEnvelopeAuditLogPdfMeta) .input(ZDownloadEnvelopeAuditLogPdfRequestSchema) .output(ZDownloadEnvelopeAuditLogPdfResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .query(({ input, ctx }) => { const { envelopeId } = input; diff --git a/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.ts b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.ts index 9fe430bb4..efc8d8def 100644 --- a/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.ts +++ b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.ts @@ -1,4 +1,5 @@ import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { downloadEnvelopeCertificatePdfMeta, ZDownloadEnvelopeCertificatePdfRequestSchema, @@ -9,6 +10,7 @@ export const downloadEnvelopeCertificatePdfRoute = authenticatedProcedure .meta(downloadEnvelopeCertificatePdfMeta) .input(ZDownloadEnvelopeCertificatePdfRequestSchema) .output(ZDownloadEnvelopeCertificatePdfResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .query(({ input, ctx }) => { const { envelopeId } = input; diff --git a/packages/trpc/server/envelope-router/download-envelope-item.ts b/packages/trpc/server/envelope-router/download-envelope-item.ts index d2d81fc10..86b8c8d11 100644 --- a/packages/trpc/server/envelope-router/download-envelope-item.ts +++ b/packages/trpc/server/envelope-router/download-envelope-item.ts @@ -1,4 +1,5 @@ import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { downloadEnvelopeItemMeta, ZDownloadEnvelopeItemRequestSchema, @@ -9,6 +10,7 @@ export const downloadEnvelopeItemRoute = authenticatedProcedure .meta(downloadEnvelopeItemMeta) .input(ZDownloadEnvelopeItemRequestSchema) .output(ZDownloadEnvelopeItemResponseSchema) + .use(twoFactorScope((input) => ({ envelopeItem: input.envelopeItemId }))) .query(({ input, ctx }) => { const { envelopeItemId, version } = input; diff --git a/packages/trpc/server/envelope-router/duplicate-envelope.ts b/packages/trpc/server/envelope-router/duplicate-envelope.ts index 1ce518b00..7557ef373 100644 --- a/packages/trpc/server/envelope-router/duplicate-envelope.ts +++ b/packages/trpc/server/envelope-router/duplicate-envelope.ts @@ -1,6 +1,7 @@ import { duplicateEnvelope } from '@documenso/lib/server-only/envelope/duplicate-envelope'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { duplicateEnvelopeMeta, ZDuplicateEnvelopeRequestSchema, @@ -11,6 +12,7 @@ export const duplicateEnvelopeRoute = authenticatedProcedure .meta(duplicateEnvelopeMeta) .input(ZDuplicateEnvelopeRequestSchema) .output(ZDuplicateEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId, includeRecipients, includeFields } = input; diff --git a/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.ts b/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.ts index 75301f49f..3a405375d 100644 --- a/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.ts +++ b/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.ts @@ -1,6 +1,7 @@ import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { createEnvelopeFieldsMeta, ZCreateEnvelopeFieldsRequestSchema, @@ -11,6 +12,7 @@ export const createEnvelopeFieldsRoute = authenticatedProcedure .meta(createEnvelopeFieldsMeta) .input(ZCreateEnvelopeFieldsRequestSchema) .output(ZCreateEnvelopeFieldsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId, metadata } = ctx; const { envelopeId, data: fields } = input; diff --git a/packages/trpc/server/envelope-router/envelope-fields/delete-envelope-field.ts b/packages/trpc/server/envelope-router/envelope-fields/delete-envelope-field.ts index 5ac157064..472aecc36 100644 --- a/packages/trpc/server/envelope-router/envelope-fields/delete-envelope-field.ts +++ b/packages/trpc/server/envelope-router/envelope-fields/delete-envelope-field.ts @@ -8,6 +8,7 @@ import { EnvelopeType } from '@prisma/client'; import { ZGenericSuccessResponse } from '../../schema'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { deleteEnvelopeFieldMeta, ZDeleteEnvelopeFieldRequestSchema, @@ -18,6 +19,7 @@ export const deleteEnvelopeFieldRoute = authenticatedProcedure .meta(deleteEnvelopeFieldMeta) .input(ZDeleteEnvelopeFieldRequestSchema) .output(ZDeleteEnvelopeFieldResponseSchema) + .use(twoFactorScope((input) => ({ field: input.fieldId }))) .mutation(async ({ input, ctx }) => { const { user, teamId, metadata } = ctx; const { fieldId } = input; diff --git a/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.ts b/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.ts index 7a7abab48..12a86bac6 100644 --- a/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.ts +++ b/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.ts @@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma'; import { FieldType } from '@prisma/client'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { ZGetEnvelopeFieldSignaturesRequestSchema, ZGetEnvelopeFieldSignaturesResponseSchema, @@ -12,6 +13,7 @@ import { export const getEnvelopeFieldSignaturesRoute = authenticatedProcedure .input(ZGetEnvelopeFieldSignaturesRequestSchema) .output(ZGetEnvelopeFieldSignaturesResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { envelopeId } = input; diff --git a/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field.ts b/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field.ts index 8525156a8..1879ea68b 100644 --- a/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field.ts +++ b/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field.ts @@ -1,6 +1,7 @@ import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { getEnvelopeFieldMeta, ZGetEnvelopeFieldRequestSchema, @@ -11,6 +12,7 @@ export const getEnvelopeFieldRoute = authenticatedProcedure .meta(getEnvelopeFieldMeta) .input(ZGetEnvelopeFieldRequestSchema) .output(ZGetEnvelopeFieldResponseSchema) + .use(twoFactorScope((input) => ({ field: input.fieldId }))) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { fieldId } = input; diff --git a/packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.ts b/packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.ts index 390caea84..c7c1feabf 100644 --- a/packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.ts +++ b/packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.ts @@ -1,6 +1,7 @@ import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { updateEnvelopeFieldsMeta, ZUpdateEnvelopeFieldsRequestSchema, @@ -11,6 +12,7 @@ export const updateEnvelopeFieldsRoute = authenticatedProcedure .meta(updateEnvelopeFieldsMeta) .input(ZUpdateEnvelopeFieldsRequestSchema) .output(ZUpdateEnvelopeFieldsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId } = ctx; const { envelopeId, data: fields } = input; diff --git a/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.ts b/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.ts index e80197277..50face5db 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.ts @@ -1,6 +1,7 @@ import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { createEnvelopeRecipientsMeta, ZCreateEnvelopeRecipientsRequestSchema, @@ -11,6 +12,7 @@ export const createEnvelopeRecipientsRoute = authenticatedProcedure .meta(createEnvelopeRecipientsMeta) .input(ZCreateEnvelopeRecipientsRequestSchema) .output(ZCreateEnvelopeRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId, metadata } = ctx; const { envelopeId, data: recipients } = input; diff --git a/packages/trpc/server/envelope-router/envelope-recipients/delete-envelope-recipient.ts b/packages/trpc/server/envelope-router/envelope-recipients/delete-envelope-recipient.ts index 9d6e94879..6d2e2ad61 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/delete-envelope-recipient.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/delete-envelope-recipient.ts @@ -2,6 +2,7 @@ import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/de import { ZGenericSuccessResponse } from '../../schema'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { deleteEnvelopeRecipientMeta, ZDeleteEnvelopeRecipientRequestSchema, @@ -12,6 +13,7 @@ export const deleteEnvelopeRecipientRoute = authenticatedProcedure .meta(deleteEnvelopeRecipientMeta) .input(ZDeleteEnvelopeRecipientRequestSchema) .output(ZDeleteEnvelopeRecipientResponseSchema) + .use(twoFactorScope((input) => ({ recipient: input.recipientId }))) .mutation(async ({ input, ctx }) => { const { user, teamId, metadata } = ctx; const { recipientId } = input; diff --git a/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts b/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts index 4372b9291..3f654d6fa 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts @@ -4,6 +4,7 @@ import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { getEnvelopeRecipientMeta, ZGetEnvelopeRecipientRequestSchema, @@ -14,6 +15,7 @@ export const getEnvelopeRecipientRoute = authenticatedProcedure .meta(getEnvelopeRecipientMeta) .input(ZGetEnvelopeRecipientRequestSchema) .output(ZGetEnvelopeRecipientResponseSchema) + .use(twoFactorScope((input) => ({ recipient: input.recipientId }))) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { recipientId } = input; diff --git a/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.ts b/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.ts index f56971bdb..f1a375431 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.ts @@ -5,6 +5,7 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { rejectEnvelopeRecipientOnBehalfOfMeta, ZRejectEnvelopeRecipientOnBehalfOfRequestSchema, @@ -15,6 +16,7 @@ export const rejectEnvelopeRecipientOnBehalfOfRoute = authenticatedProcedure .meta(rejectEnvelopeRecipientOnBehalfOfMeta) .input(ZRejectEnvelopeRecipientOnBehalfOfRequestSchema) .output(ZRejectEnvelopeRecipientOnBehalfOfResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { envelopeId, recipientId, reason, actAsEmail } = input; diff --git a/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.ts b/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.ts index 10668ca49..1e5f33c43 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.ts @@ -1,6 +1,7 @@ import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients'; import { authenticatedProcedure } from '../../trpc'; +import { twoFactorScope } from '../../two-factor-enforcement/enforce'; import { updateEnvelopeRecipientsMeta, ZUpdateEnvelopeRecipientsRequestSchema, @@ -11,6 +12,7 @@ export const updateEnvelopeRecipientsRoute = authenticatedProcedure .meta(updateEnvelopeRecipientsMeta) .input(ZUpdateEnvelopeRecipientsRequestSchema) .output(ZUpdateEnvelopeRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId } = ctx; const { envelopeId, data: recipients } = input; diff --git a/packages/trpc/server/envelope-router/find-envelope-audit-logs.ts b/packages/trpc/server/envelope-router/find-envelope-audit-logs.ts index d617eddfb..a95513ab3 100644 --- a/packages/trpc/server/envelope-router/find-envelope-audit-logs.ts +++ b/packages/trpc/server/envelope-router/find-envelope-audit-logs.ts @@ -6,6 +6,7 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { findEnvelopeAuditLogsMeta, ZFindEnvelopeAuditLogsRequestSchema, @@ -16,6 +17,7 @@ export const findEnvelopeAuditLogsRoute = authenticatedProcedure .meta(findEnvelopeAuditLogsMeta) .input(ZFindEnvelopeAuditLogsRequestSchema) .output(ZFindEnvelopeAuditLogsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .query(async ({ input, ctx }) => { const { envelopeId, page = 1, perPage = 50, orderByColumn = 'createdAt', orderByDirection = 'desc' } = input; diff --git a/packages/trpc/server/envelope-router/find-envelopes.ts b/packages/trpc/server/envelope-router/find-envelopes.ts index a845c0723..289878bf9 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.ts @@ -1,12 +1,14 @@ import { findEnvelopes } from '@documenso/lib/server-only/envelope/find-envelopes'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { findEnvelopesMeta, ZFindEnvelopesRequestSchema, ZFindEnvelopesResponseSchema } from './find-envelopes.types'; export const findEnvelopesRoute = authenticatedProcedure .meta(findEnvelopesMeta) .input(ZFindEnvelopesRequestSchema) .output(ZFindEnvelopesResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { user, teamId } = ctx; diff --git a/packages/trpc/server/envelope-router/get-editor-envelope.ts b/packages/trpc/server/envelope-router/get-editor-envelope.ts index d0af5278f..e2d9f3c55 100644 --- a/packages/trpc/server/envelope-router/get-editor-envelope.ts +++ b/packages/trpc/server/envelope-router/get-editor-envelope.ts @@ -1,11 +1,13 @@ import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetEditorEnvelopeRequestSchema, ZGetEditorEnvelopeResponseSchema } from './get-editor-envelope.types'; export const getEditorEnvelopeRoute = authenticatedProcedure .input(ZGetEditorEnvelopeRequestSchema) .output(ZGetEditorEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { envelopeId } = input; diff --git a/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts b/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts index d44c063eb..ce70bcd8c 100644 --- a/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts +++ b/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts @@ -5,6 +5,7 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; import { maybeAuthenticatedProcedure } from '../trpc'; +import { TWO_FACTOR_SKIP, twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetEnvelopeItemsByTokenRequestSchema, ZGetEnvelopeItemsByTokenResponseSchema, @@ -15,6 +16,12 @@ import { export const getEnvelopeItemsByTokenRoute = maybeAuthenticatedProcedure .input(ZGetEnvelopeItemsByTokenRequestSchema) .output(ZGetEnvelopeItemsByTokenResponseSchema) + // Recipient access is token-authorized (skip both asserts); user access + // asserts against the envelope's organisation — the same branch the + // handler authorizes on, so a token can't be smuggled into the user path. + .use( + twoFactorScope((input) => (input.access.type === 'recipient' ? TWO_FACTOR_SKIP : { envelope: input.envelopeId })), + ) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; diff --git a/packages/trpc/server/envelope-router/get-envelope-items.ts b/packages/trpc/server/envelope-router/get-envelope-items.ts index a8f5b14e2..a02a40245 100644 --- a/packages/trpc/server/envelope-router/get-envelope-items.ts +++ b/packages/trpc/server/envelope-router/get-envelope-items.ts @@ -3,12 +3,14 @@ import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-e import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetEnvelopeItemsRequestSchema, ZGetEnvelopeItemsResponseSchema } from './get-envelope-items.types'; // Not intended for V2 API usage. export const getEnvelopeItemsRoute = authenticatedProcedure .input(ZGetEnvelopeItemsRequestSchema) .output(ZGetEnvelopeItemsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { envelopeId } = input; diff --git a/packages/trpc/server/envelope-router/get-envelope.ts b/packages/trpc/server/envelope-router/get-envelope.ts index 5bb6209b8..3cd1e3a22 100644 --- a/packages/trpc/server/envelope-router/get-envelope.ts +++ b/packages/trpc/server/envelope-router/get-envelope.ts @@ -1,12 +1,14 @@ import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { getEnvelopeMeta, ZGetEnvelopeRequestSchema, ZGetEnvelopeResponseSchema } from './get-envelope.types'; export const getEnvelopeRoute = authenticatedProcedure .meta(getEnvelopeMeta) .input(ZGetEnvelopeRequestSchema) .output(ZGetEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { envelopeId } = input; diff --git a/packages/trpc/server/envelope-router/get-envelopes-by-ids.ts b/packages/trpc/server/envelope-router/get-envelopes-by-ids.ts index afb1fcee1..5c5b739dd 100644 --- a/packages/trpc/server/envelope-router/get-envelopes-by-ids.ts +++ b/packages/trpc/server/envelope-router/get-envelopes-by-ids.ts @@ -1,6 +1,7 @@ import { getEnvelopesByIds } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { getEnvelopesByIdsMeta, ZGetEnvelopesByIdsRequestSchema, @@ -11,6 +12,18 @@ export const getEnvelopesByIdsRoute = authenticatedProcedure .meta(getEnvelopesByIdsMeta) .input(ZGetEnvelopesByIdsRequestSchema) .output(ZGetEnvelopesByIdsResponseSchema) + .use( + twoFactorScope((input) => { + switch (input.ids.type) { + case 'envelopeId': + return { envelope: input.ids.ids }; + case 'documentId': + return { document: input.ids.ids }; + case 'templateId': + return { template: input.ids.ids }; + } + }), + ) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { ids } = input; diff --git a/packages/trpc/server/envelope-router/redistribute-envelope.ts b/packages/trpc/server/envelope-router/redistribute-envelope.ts index a57d4ceb3..c9d3bbe4d 100644 --- a/packages/trpc/server/envelope-router/redistribute-envelope.ts +++ b/packages/trpc/server/envelope-router/redistribute-envelope.ts @@ -2,6 +2,7 @@ import { resendDocument } from '@documenso/lib/server-only/document/resend-docum import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { redistributeEnvelopeMeta, ZRedistributeEnvelopeRequestSchema, @@ -12,6 +13,7 @@ export const redistributeEnvelopeRoute = authenticatedProcedure .meta(redistributeEnvelopeMeta) .input(ZRedistributeEnvelopeRequestSchema) .output(ZRedistributeEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId, recipients } = input; diff --git a/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts b/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts index 65c993c56..0b8486d4a 100644 --- a/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts +++ b/packages/trpc/server/envelope-router/replace-envelope-item-pdf.ts @@ -6,6 +6,7 @@ import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZReplaceEnvelopeItemPdfRequestSchema, ZReplaceEnvelopeItemPdfResponseSchema, @@ -20,6 +21,7 @@ import { export const replaceEnvelopeItemPdfRoute = authenticatedProcedure .input(ZReplaceEnvelopeItemPdfRequestSchema) .output(ZReplaceEnvelopeItemPdfResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.payload.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId, metadata } = ctx; const { payload, file } = input; diff --git a/packages/trpc/server/envelope-router/save-as-template.ts b/packages/trpc/server/envelope-router/save-as-template.ts index 2e483a48d..93873184b 100644 --- a/packages/trpc/server/envelope-router/save-as-template.ts +++ b/packages/trpc/server/envelope-router/save-as-template.ts @@ -1,11 +1,13 @@ import { duplicateEnvelope } from '@documenso/lib/server-only/envelope/duplicate-envelope'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZSaveAsTemplateRequestSchema, ZSaveAsTemplateResponseSchema } from './save-as-template.types'; export const saveAsTemplateRoute = authenticatedProcedure .input(ZSaveAsTemplateRequestSchema) .output(ZSaveAsTemplateResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { envelopeId, includeRecipients, includeFields } = input; const { teamId } = ctx; diff --git a/packages/trpc/server/envelope-router/set-envelope-fields.ts b/packages/trpc/server/envelope-router/set-envelope-fields.ts index 071c8baf1..9a22fc2d3 100644 --- a/packages/trpc/server/envelope-router/set-envelope-fields.ts +++ b/packages/trpc/server/envelope-router/set-envelope-fields.ts @@ -4,12 +4,14 @@ import { EnvelopeType } from '@prisma/client'; import { match } from 'ts-pattern'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZSetEnvelopeFieldsRequestSchema, ZSetEnvelopeFieldsResponseSchema } from './set-envelope-fields.types'; // Note: This is intended to always be an internal route. export const setEnvelopeFieldsRoute = authenticatedProcedure .input(ZSetEnvelopeFieldsRequestSchema) .output(ZSetEnvelopeFieldsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId, envelopeType, fields } = input; diff --git a/packages/trpc/server/envelope-router/set-envelope-recipients.ts b/packages/trpc/server/envelope-router/set-envelope-recipients.ts index 029cbfc65..bd73c0eab 100644 --- a/packages/trpc/server/envelope-router/set-envelope-recipients.ts +++ b/packages/trpc/server/envelope-router/set-envelope-recipients.ts @@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client'; import { match } from 'ts-pattern'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZSetEnvelopeRecipientsRequestSchema, ZSetEnvelopeRecipientsResponseSchema, @@ -12,6 +13,7 @@ import { export const setEnvelopeRecipientsRoute = authenticatedProcedure .input(ZSetEnvelopeRecipientsRequestSchema) .output(ZSetEnvelopeRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId, envelopeType, recipients } = input; diff --git a/packages/trpc/server/envelope-router/signing-status-envelope.ts b/packages/trpc/server/envelope-router/signing-status-envelope.ts index 57fa8ec10..c079b618c 100644 --- a/packages/trpc/server/envelope-router/signing-status-envelope.ts +++ b/packages/trpc/server/envelope-router/signing-status-envelope.ts @@ -3,6 +3,7 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; import { maybeAuthenticatedProcedure } from '../trpc'; +import { TWO_FACTOR_SKIP, twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZSigningStatusEnvelopeRequestSchema, ZSigningStatusEnvelopeResponseSchema, @@ -12,6 +13,9 @@ import { export const signingStatusEnvelopeRoute = maybeAuthenticatedProcedure .input(ZSigningStatusEnvelopeRequestSchema) .output(ZSigningStatusEnvelopeResponseSchema) + // Token-authorized: the signing token is the authorization. An empty token + // carries no organisation scope either way (instance assert only). + .use(twoFactorScope((input) => (input.token ? TWO_FACTOR_SKIP : {}))) .query(async ({ input, ctx }) => { const { token } = input; diff --git a/packages/trpc/server/envelope-router/update-envelope-items.ts b/packages/trpc/server/envelope-router/update-envelope-items.ts index a106edafe..5e033f66b 100644 --- a/packages/trpc/server/envelope-router/update-envelope-items.ts +++ b/packages/trpc/server/envelope-router/update-envelope-items.ts @@ -5,6 +5,7 @@ import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { updateEnvelopeItemsMeta, ZUpdateEnvelopeItemsRequestSchema, @@ -15,6 +16,7 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure .meta(updateEnvelopeItemsMeta) .input(ZUpdateEnvelopeItemsRequestSchema) .output(ZUpdateEnvelopeItemsResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId } = ctx; const { envelopeId, data } = input; diff --git a/packages/trpc/server/envelope-router/update-envelope.ts b/packages/trpc/server/envelope-router/update-envelope.ts index 4e92d2ad4..abf4d53ed 100644 --- a/packages/trpc/server/envelope-router/update-envelope.ts +++ b/packages/trpc/server/envelope-router/update-envelope.ts @@ -1,6 +1,7 @@ import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { updateEnvelopeMeta, ZUpdateEnvelopeRequestSchema, @@ -11,6 +12,7 @@ export const updateEnvelopeRoute = authenticatedProcedure .meta(updateEnvelopeMeta) .input(ZUpdateEnvelopeRequestSchema) .output(ZUpdateEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId, data, meta = {} } = input; diff --git a/packages/trpc/server/envelope-router/use-envelope.ts b/packages/trpc/server/envelope-router/use-envelope.ts index 608b430d9..31b52fa74 100644 --- a/packages/trpc/server/envelope-router/use-envelope.ts +++ b/packages/trpc/server/envelope-router/use-envelope.ts @@ -9,12 +9,14 @@ import { EnvelopeType } from '@prisma/client'; import { match, P } from 'ts-pattern'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { useEnvelopeMeta, ZUseEnvelopeRequestSchema, ZUseEnvelopeResponseSchema } from './use-envelope.types'; export const useEnvelopeRoute = authenticatedProcedure .meta(useEnvelopeMeta) .input(ZUseEnvelopeRequestSchema) .output(ZUseEnvelopeResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.payload.envelopeId }))) .mutation(async ({ input, ctx }) => { const { user, teamId } = ctx; diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 2028c37f6..f6ada7da6 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -12,6 +12,7 @@ import { EnvelopeType } from '@prisma/client'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; import { authenticatedProcedure, procedure, router } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateDocumentFieldRequestSchema, ZCreateDocumentFieldResponseSchema, @@ -59,6 +60,7 @@ export const fieldRouter = router({ }) .input(ZGetFieldRequestSchema) .output(ZGetFieldResponseSchema) + .use(twoFactorScope((input) => ({ field: input.fieldId }))) .query(async ({ input, ctx }) => { const { teamId } = ctx; const { fieldId } = input; @@ -94,6 +96,7 @@ export const fieldRouter = router({ }) .input(ZCreateDocumentFieldRequestSchema) .output(ZCreateDocumentFieldResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, field } = input; @@ -142,6 +145,7 @@ export const fieldRouter = router({ }) .input(ZCreateDocumentFieldsRequestSchema) .output(ZCreateDocumentFieldsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, fields } = input; @@ -186,6 +190,7 @@ export const fieldRouter = router({ }) .input(ZUpdateDocumentFieldRequestSchema) .output(ZUpdateDocumentFieldResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, field } = input; @@ -228,6 +233,7 @@ export const fieldRouter = router({ }) .input(ZUpdateDocumentFieldsRequestSchema) .output(ZUpdateDocumentFieldsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, fields } = input; @@ -268,6 +274,7 @@ export const fieldRouter = router({ }) .input(ZDeleteDocumentFieldRequestSchema) .output(ZSuccessResponseSchema) + .use(twoFactorScope((input) => ({ field: input.fieldId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { fieldId } = input; @@ -294,6 +301,7 @@ export const fieldRouter = router({ setFieldsForDocument: authenticatedProcedure .input(ZSetDocumentFieldsRequestSchema) .output(ZSetDocumentFieldsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, fields } = input; @@ -344,6 +352,7 @@ export const fieldRouter = router({ }) .input(ZCreateTemplateFieldRequestSchema) .output(ZCreateTemplateFieldResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, field } = input; @@ -392,6 +401,7 @@ export const fieldRouter = router({ }) .input(ZGetFieldRequestSchema) .output(ZGetFieldResponseSchema) + .use(twoFactorScope((input) => ({ field: input.fieldId }))) .query(async ({ input, ctx }) => { const { teamId } = ctx; const { fieldId } = input; @@ -427,6 +437,7 @@ export const fieldRouter = router({ }) .input(ZCreateTemplateFieldsRequestSchema) .output(ZCreateTemplateFieldsResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, fields } = input; @@ -471,6 +482,7 @@ export const fieldRouter = router({ }) .input(ZUpdateTemplateFieldRequestSchema) .output(ZUpdateTemplateFieldResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, field } = input; @@ -513,6 +525,7 @@ export const fieldRouter = router({ }) .input(ZUpdateTemplateFieldsRequestSchema) .output(ZUpdateTemplateFieldsResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, fields } = input; @@ -553,6 +566,7 @@ export const fieldRouter = router({ }) .input(ZDeleteTemplateFieldRequestSchema) .output(ZSuccessResponseSchema) + .use(twoFactorScope((input) => ({ field: input.fieldId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { fieldId } = input; @@ -578,6 +592,7 @@ export const fieldRouter = router({ setFieldsForTemplate: authenticatedProcedure .input(ZSetFieldsForTemplateRequestSchema) .output(ZSetFieldsForTemplateResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, fields } = input; diff --git a/packages/trpc/server/folder-router/router.ts b/packages/trpc/server/folder-router/router.ts index 755b23452..33f9955ba 100644 --- a/packages/trpc/server/folder-router/router.ts +++ b/packages/trpc/server/folder-router/router.ts @@ -9,6 +9,7 @@ import { updateFolder } from '@documenso/lib/server-only/folder/update-folder'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; import { authenticatedProcedure, router } from '../trpc'; +import { twoFactorScope, twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { ZCreateFolderRequestSchema, ZCreateFolderResponseSchema, @@ -30,6 +31,7 @@ export const folderRouter = router({ getFolders: authenticatedProcedure .input(ZGetFoldersSchema) .output(ZGetFoldersResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { parentId, type } = input; @@ -79,6 +81,7 @@ export const folderRouter = router({ }) .input(ZFindFoldersRequestSchema) .output(ZFindFoldersResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { parentId, type, page, perPage } = input; @@ -106,6 +109,7 @@ export const folderRouter = router({ findFoldersInternal: authenticatedProcedure .input(ZFindFoldersInternalRequestSchema) .output(ZFindFoldersInternalResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { parentId, type } = input; @@ -155,6 +159,7 @@ export const folderRouter = router({ }) .input(ZCreateFolderRequestSchema) .output(ZCreateFolderResponseSchema) + .use(twoFactorScopeFromCtx()) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { name, parentId, type } = input; @@ -207,6 +212,7 @@ export const folderRouter = router({ }) .input(ZUpdateFolderRequestSchema) .output(ZUpdateFolderResponseSchema) + .use(twoFactorScope((input) => ({ folder: input.folderId }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { folderId, data } = input; @@ -244,6 +250,7 @@ export const folderRouter = router({ }) .input(ZDeleteFolderRequestSchema) .output(ZSuccessResponseSchema) + .use(twoFactorScope((input) => ({ folder: input.folderId }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { folderId } = input; diff --git a/packages/trpc/server/organisation-router/accept-organisation-member-invite.ts b/packages/trpc/server/organisation-router/accept-organisation-member-invite.ts index 7df7b6872..6f3443246 100644 --- a/packages/trpc/server/organisation-router/accept-organisation-member-invite.ts +++ b/packages/trpc/server/organisation-router/accept-organisation-member-invite.ts @@ -1,6 +1,7 @@ import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation'; import { maybeAuthenticatedProcedure } from '../trpc'; +import { twoFactorRemediation } from '../two-factor-enforcement/enforce'; import { ZAcceptOrganisationMemberInviteRequestSchema, ZAcceptOrganisationMemberInviteResponseSchema, @@ -9,6 +10,8 @@ import { export const acceptOrganisationMemberInviteRoute = maybeAuthenticatedProcedure .input(ZAcceptOrganisationMemberInviteRequestSchema) .output(ZAcceptOrganisationMemberInviteResponseSchema) + // 2FA enforcement: REMEDIATION — joining is never blocked, access is; a blocked user must be able to accept an invite (org grace starts at join). Instance assert still applies. + .use(twoFactorRemediation()) .mutation(async ({ input }) => { const { token } = input; diff --git a/packages/trpc/server/organisation-router/create-organisation-group.ts b/packages/trpc/server/organisation-router/create-organisation-group.ts index 7dfd261f5..71feddf3a 100644 --- a/packages/trpc/server/organisation-router/create-organisation-group.ts +++ b/packages/trpc/server/organisation-router/create-organisation-group.ts @@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma'; import { OrganisationGroupType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateOrganisationGroupRequestSchema, ZCreateOrganisationGroupResponseSchema, @@ -16,6 +17,7 @@ export const createOrganisationGroupRoute = authenticatedProcedure // .meta(createOrganisationGroupMeta) .input(ZCreateOrganisationGroupRequestSchema) .output(ZCreateOrganisationGroupResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ input, ctx }) => { const { organisationId, organisationRole, name, memberIds } = input; const { user } = ctx; diff --git a/packages/trpc/server/organisation-router/create-organisation-member-invites.ts b/packages/trpc/server/organisation-router/create-organisation-member-invites.ts index e468ce6fa..1584c2bcc 100644 --- a/packages/trpc/server/organisation-router/create-organisation-member-invites.ts +++ b/packages/trpc/server/organisation-router/create-organisation-member-invites.ts @@ -1,6 +1,7 @@ import { createOrganisationMemberInvites } from '@documenso/lib/server-only/organisation/create-organisation-member-invites'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateOrganisationMemberInvitesRequestSchema, ZCreateOrganisationMemberInvitesResponseSchema, @@ -9,6 +10,7 @@ import { export const createOrganisationMemberInvitesRoute = authenticatedProcedure .input(ZCreateOrganisationMemberInvitesRequestSchema) .output(ZCreateOrganisationMemberInvitesResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId, invitations } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/organisation-router/create-organisation.ts b/packages/trpc/server/organisation-router/create-organisation.ts index 6aa8331e1..046798619 100644 --- a/packages/trpc/server/organisation-router/create-organisation.ts +++ b/packages/trpc/server/organisation-router/create-organisation.ts @@ -8,12 +8,15 @@ import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; import { prisma } from '@documenso/prisma'; import { OrganisationType, SubscriptionStatus } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZCreateOrganisationRequestSchema, ZCreateOrganisationResponseSchema } from './create-organisation.types'; export const createOrganisationRoute = authenticatedProcedure // .meta(createOrganisationMeta) .input(ZCreateOrganisationRequestSchema) .output(ZCreateOrganisationResponseSchema) + // 2FA enforcement: creates a brand-new organisation; there is no existing organisation scope to enforce. Instance assert still applies. + .use(twoFactorInstanceOnly()) .mutation(async ({ input, ctx }) => { const { name, priceId } = input; const { user } = ctx; diff --git a/packages/trpc/server/organisation-router/decline-organisation-member-invite.ts b/packages/trpc/server/organisation-router/decline-organisation-member-invite.ts index 89423bc39..e3e789064 100644 --- a/packages/trpc/server/organisation-router/decline-organisation-member-invite.ts +++ b/packages/trpc/server/organisation-router/decline-organisation-member-invite.ts @@ -3,6 +3,7 @@ import { prisma } from '@documenso/prisma'; import { OrganisationMemberInviteStatus } from '@prisma/client'; import { maybeAuthenticatedProcedure } from '../trpc'; +import { twoFactorRemediation } from '../two-factor-enforcement/enforce'; import { ZDeclineOrganisationMemberInviteRequestSchema, ZDeclineOrganisationMemberInviteResponseSchema, @@ -11,6 +12,8 @@ import { export const declineOrganisationMemberInviteRoute = maybeAuthenticatedProcedure .input(ZDeclineOrganisationMemberInviteRequestSchema) .output(ZDeclineOrganisationMemberInviteResponseSchema) + // 2FA enforcement: REMEDIATION — a member must always be able to walk away; declining an invite must never be blocked. Instance assert still applies. + .use(twoFactorRemediation()) .mutation(async ({ input }) => { const { token } = input; diff --git a/packages/trpc/server/organisation-router/delete-organisation-group.ts b/packages/trpc/server/organisation-router/delete-organisation-group.ts index 33bac9dc6..ef47255e5 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-group.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-group.ts @@ -6,6 +6,7 @@ import { prisma } from '@documenso/prisma'; import { OrganisationGroupType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteOrganisationGroupRequestSchema, ZDeleteOrganisationGroupResponseSchema, @@ -15,6 +16,7 @@ export const deleteOrganisationGroupRoute = authenticatedProcedure // .meta(deleteOrganisationGroupMeta) .input(ZDeleteOrganisationGroupRequestSchema) .output(ZDeleteOrganisationGroupResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ input, ctx }) => { const { groupId, organisationId } = input; const { user } = ctx; diff --git a/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts b/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts index 834239270..ed18b4a55 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-member-invites.ts @@ -5,6 +5,7 @@ import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } fr import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteOrganisationMemberInvitesRequestSchema, ZDeleteOrganisationMemberInvitesResponseSchema, @@ -14,6 +15,7 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure // .meta(deleteOrganisationMemberInvitesMeta) .input(ZDeleteOrganisationMemberInvitesRequestSchema) .output(ZDeleteOrganisationMemberInvitesResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId, invitationIds } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/organisation-router/delete-organisation-member.ts b/packages/trpc/server/organisation-router/delete-organisation-member.ts index 36ff79a20..e3bcdb217 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-member.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-member.ts @@ -1,4 +1,5 @@ import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteOrganisationMemberRequestSchema, ZDeleteOrganisationMemberResponseSchema, @@ -9,6 +10,7 @@ export const deleteOrganisationMemberRoute = authenticatedProcedure // .meta(deleteOrganisationMemberMeta) .input(ZDeleteOrganisationMemberRequestSchema) .output(ZDeleteOrganisationMemberResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId, organisationMemberId } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index 6a436ddff..81b8fa925 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -13,6 +13,7 @@ import { import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteOrganisationMembersRequestSchema, ZDeleteOrganisationMembersResponseSchema, @@ -22,6 +23,7 @@ export const deleteOrganisationMembersRoute = authenticatedProcedure // .meta(deleteOrganisationMembersMeta) .input(ZDeleteOrganisationMembersRequestSchema) .output(ZDeleteOrganisationMembersResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId, organisationMemberIds } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/organisation-router/delete-organisation.ts b/packages/trpc/server/organisation-router/delete-organisation.ts index f622fe643..63693f265 100644 --- a/packages/trpc/server/organisation-router/delete-organisation.ts +++ b/packages/trpc/server/organisation-router/delete-organisation.ts @@ -5,12 +5,14 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteOrganisationRequestSchema, ZDeleteOrganisationResponseSchema } from './delete-organisation.types'; export const deleteOrganisationRoute = authenticatedProcedure // .meta(deleteOrganisationMeta) .input(ZDeleteOrganisationRequestSchema) .output(ZDeleteOrganisationResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ input, ctx }) => { const { organisationId } = input; const { user } = ctx; diff --git a/packages/trpc/server/organisation-router/find-organisation-groups.ts b/packages/trpc/server/organisation-router/find-organisation-groups.ts index b3e0343ea..00869c2c7 100644 --- a/packages/trpc/server/organisation-router/find-organisation-groups.ts +++ b/packages/trpc/server/organisation-router/find-organisation-groups.ts @@ -6,6 +6,7 @@ import type { OrganisationGroupType, OrganisationMemberRole } from '@prisma/clie import { Prisma } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindOrganisationGroupsRequestSchema, ZFindOrganisationGroupsResponseSchema, @@ -15,6 +16,7 @@ export const findOrganisationGroupsRoute = authenticatedProcedure // .meta(findOrganisationGroupsMeta) .input(ZFindOrganisationGroupsRequestSchema) .output(ZFindOrganisationGroupsResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ input, ctx }) => { const { organisationId, types, query, page, perPage, organisationGroupId, organisationRoles, excludeTeamId } = input; diff --git a/packages/trpc/server/organisation-router/find-organisation-member-invites.ts b/packages/trpc/server/organisation-router/find-organisation-member-invites.ts index 85201cec7..23c5e9440 100644 --- a/packages/trpc/server/organisation-router/find-organisation-member-invites.ts +++ b/packages/trpc/server/organisation-router/find-organisation-member-invites.ts @@ -7,6 +7,7 @@ import type { OrganisationMemberInviteStatus } from '@prisma/client'; import { Prisma } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindOrganisationMemberInvitesRequestSchema, ZFindOrganisationMemberInvitesResponseSchema, @@ -16,6 +17,7 @@ export const findOrganisationMemberInvitesRoute = authenticatedProcedure // .meta(getOrganisationMemberInvitesMeta) .input(ZFindOrganisationMemberInvitesRequestSchema) .output(ZFindOrganisationMemberInvitesResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ input, ctx }) => { const { organisationId, query, page, perPage, status } = input; const { user } = ctx; diff --git a/packages/trpc/server/organisation-router/find-organisation-members.ts b/packages/trpc/server/organisation-router/find-organisation-members.ts index db75726cd..87756fbe1 100644 --- a/packages/trpc/server/organisation-router/find-organisation-members.ts +++ b/packages/trpc/server/organisation-router/find-organisation-members.ts @@ -5,6 +5,7 @@ import { prisma } from '@documenso/prisma'; import { Prisma } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindOrganisationMembersRequestSchema, ZFindOrganisationMembersResponseSchema, @@ -14,6 +15,7 @@ export const findOrganisationMembersRoute = authenticatedProcedure // .meta(getOrganisationMembersMeta) .input(ZFindOrganisationMembersRequestSchema) .output(ZFindOrganisationMembersResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ input, ctx }) => { const { organisationId } = input; const { id } = ctx.user; @@ -40,6 +42,7 @@ export const findOrganisationMembersRoute = authenticatedProcedure createdAt: organisationMember.createdAt, currentOrganisationRole: getHighestOrganisationRoleInGroup(groups), avatarImageId: organisationMember.user.avatarImageId, + twoFactorEnabled: organisationMember.user.twoFactorEnabled, groups, }; }), @@ -126,6 +129,7 @@ export const findOrganisationMembers = async ({ email: true, name: true, avatarImageId: true, + twoFactorEnabled: true, }, }, organisationGroupMembers: { diff --git a/packages/trpc/server/organisation-router/find-organisation-members.types.ts b/packages/trpc/server/organisation-router/find-organisation-members.types.ts index 1136ebd56..4a87dea78 100644 --- a/packages/trpc/server/organisation-router/find-organisation-members.types.ts +++ b/packages/trpc/server/organisation-router/find-organisation-members.types.ts @@ -34,6 +34,13 @@ export const ZFindOrganisationMembersResponseSchema = ZFindResultResponse.extend email: z.string(), name: z.string(), avatarImageId: z.string().nullable(), + + /** + * Per-member 2FA compliance indicator. Enrolment is genuine compliance + * under the satisfaction rule (`twoFactorEnabled && session verified` — + * the session half is per-login, enrolment is the durable part). + */ + twoFactorEnabled: z.boolean(), currentOrganisationRole: OrganisationMemberRoleSchema, groups: z.array( OrganisationGroupSchema.pick({ diff --git a/packages/trpc/server/organisation-router/get-organisation-member-invites.ts b/packages/trpc/server/organisation-router/get-organisation-member-invites.ts index 15f88fdd3..875b0e490 100644 --- a/packages/trpc/server/organisation-router/get-organisation-member-invites.ts +++ b/packages/trpc/server/organisation-router/get-organisation-member-invites.ts @@ -1,6 +1,7 @@ import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZGetOrganisationMemberInvitesRequestSchema, ZGetOrganisationMemberInvitesResponseSchema, @@ -10,6 +11,8 @@ export const getOrganisationMemberInvitesRoute = authenticatedProcedure // .meta(getOrganisationMemberInvitesMeta) .input(ZGetOrganisationMemberInvitesRequestSchema) .output(ZGetOrganisationMemberInvitesResponseSchema) + // 2FA enforcement: lists the current user's own pending invites (keyed by their email) so they can accept/decline — required for remediation. Instance assert still applies. + .use(twoFactorInstanceOnly()) .query(async ({ input, ctx }) => { const { user } = ctx; diff --git a/packages/trpc/server/organisation-router/get-organisation-quota-flags.ts b/packages/trpc/server/organisation-router/get-organisation-quota-flags.ts index 2b0c408ea..68f7514fe 100644 --- a/packages/trpc/server/organisation-router/get-organisation-quota-flags.ts +++ b/packages/trpc/server/organisation-router/get-organisation-quota-flags.ts @@ -4,6 +4,7 @@ import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period'; import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetOrganisationQuotaFlagsRequestSchema, ZGetOrganisationQuotaFlagsResponseSchema, @@ -12,6 +13,7 @@ import { export const getOrganisationQuotaFlagsRoute = authenticatedProcedure .input(ZGetOrganisationQuotaFlagsRequestSchema) .output(ZGetOrganisationQuotaFlagsResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ input, ctx }) => { const { organisationId } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/organisation-router/get-organisation-session.ts b/packages/trpc/server/organisation-router/get-organisation-session.ts index 33a1005a9..8e631556a 100644 --- a/packages/trpc/server/organisation-router/get-organisation-session.ts +++ b/packages/trpc/server/organisation-router/get-organisation-session.ts @@ -1,25 +1,58 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations'; import { buildTeamWhereQuery, extractDerivedTeamSettings, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams'; +import { computeOrganisationTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor'; import { prisma } from '@documenso/prisma'; +import type { Session, User } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorBootstrap } from '../two-factor-enforcement/enforce'; import type { TGetOrganisationSessionResponse } from './get-organisation-session.types'; import { ZGetOrganisationSessionResponseSchema } from './get-organisation-session.types'; /** * Get all the organisations and teams a user belongs to. + * + * Carries the enforcement `bootstrap` exemption: a 2FA-blocked client must + * still be able to discover its own enforcement state (this payload carries + * the per-organisation status the layouts render the 403/banner from), so it + * is exempt from both the instance and organisation asserts. */ export const getOrganisationSessionRoute = authenticatedProcedure + // 2FA enforcement: BOOTSTRAP — see the docblock above. + .use(twoFactorBootstrap()) .output(ZGetOrganisationSessionResponseSchema) .query(async ({ ctx }) => { - return await getOrganisationSession({ userId: ctx.user.id }); + return await getOrganisationSession({ + userId: ctx.user.id, + user: ctx.user, + session: ctx.session, + }); }); +export type GetOrganisationSessionOptions = { + userId: number; + + /** + * 2FA fields of the user, used to derive the per-organisation enforcement + * status. + */ + user: Pick; + + /** + * The current session, or null for contexts that carry no session (e.g. + * API access), which never count as 2FA-verified. Machine access is exempt + * from enforcement anyway — this only keeps the derived shape honest. + */ + session: Pick | null; +}; + export const getOrganisationSession = async ({ userId, -}: { - userId: number; -}): Promise => { + user, + session, +}: GetOrganisationSessionOptions): Promise => { + const now = new Date(); + const organisations = await prisma.organisation.findMany({ where: { members: { @@ -32,6 +65,15 @@ export const getOrganisationSession = async ({ organisationClaim: true, organisationGlobalSettings: true, subscription: true, + members: { + where: { + userId, + }, + take: 1, + select: { + createdAt: true, + }, + }, groups: { where: { organisationGroupMembers: { @@ -70,10 +112,24 @@ export const getOrganisationSession = async ({ }); return organisations.map((organisation) => { - const { organisationGlobalSettings } = organisation; + // `members` is destructured OUT of the payload: it exists only to derive + // the enforcement status below (root.tsx serialises this return value + // directly without passing through the output schema). + const { organisationGlobalSettings, members, ...organisationPayload } = organisation; + + const [member] = members; return { - ...organisation, + ...organisationPayload, + twoFactorEnforcement: computeOrganisationTwoFactorEnforcementStatus({ + organisationSettings: organisationGlobalSettings, + // Defensive fallback: the membership filter guarantees a member row. + memberCreatedAt: member?.createdAt ?? organisation.createdAt, + userTwoFactorEnabled: user.twoFactorEnabled, + userTwoFactorGraceStartedAt: user.twoFactorGraceStartedAt, + sessionTwoFactorVerified: session?.twoFactorVerified ?? false, + now, + }), teams: organisation.teams.map((team) => { const derivedSettings = extractDerivedTeamSettings(organisationGlobalSettings, team.teamGlobalSettings); diff --git a/packages/trpc/server/organisation-router/get-organisation-session.types.ts b/packages/trpc/server/organisation-router/get-organisation-session.types.ts index b80cbc6c1..cb7ca8b7b 100644 --- a/packages/trpc/server/organisation-router/get-organisation-session.types.ts +++ b/packages/trpc/server/organisation-router/get-organisation-session.types.ts @@ -5,6 +5,24 @@ import { TeamEmailSchema } from '@documenso/prisma/generated/zod/modelSchema/Tea import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; import { z } from 'zod'; +/** + * The shared 2FA enforcement status shape (`TTwoFactorEnforcementStatus` from + * `@documenso/lib/utils/two-factor`) expressed as a zod schema so it can ride + * along in the organisation session output. + */ +export const ZTwoFactorEnforcementStatusSchema = z.union([ + z.object({ + required: z.literal(false), + }), + z.object({ + required: z.literal(true), + deadline: z.date(), + isDeadlineExpired: z.boolean(), + isSatisfied: z.boolean(), + isBlocked: z.boolean(), + }), +]); + export const ZGetOrganisationSessionResponseSchema = ZOrganisationSchema.extend({ teams: z.array( TeamSchema.pick({ @@ -24,6 +42,13 @@ export const ZGetOrganisationSessionResponseSchema = ZOrganisationSchema.extend( ), subscription: SubscriptionSchema.nullable(), currentOrganisationRole: z.nativeEnum(OrganisationMemberRole), + + /** + * Per-organisation 2FA enforcement status for the current user + session, + * computed server-side. Client layouts derive the org 403 screen and grace + * banner from this without extra queries. + */ + twoFactorEnforcement: ZTwoFactorEnforcementStatusSchema, }).array(); export type TGetOrganisationSessionResponse = z.infer; diff --git a/packages/trpc/server/organisation-router/get-organisation.ts b/packages/trpc/server/organisation-router/get-organisation.ts index 357365edf..3749657c5 100644 --- a/packages/trpc/server/organisation-router/get-organisation.ts +++ b/packages/trpc/server/organisation-router/get-organisation.ts @@ -2,12 +2,14 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetOrganisationRequestSchema, ZGetOrganisationResponseSchema } from './get-organisation.types'; export const getOrganisationRoute = authenticatedProcedure // .meta(getOrganisationMeta) .input(ZGetOrganisationRequestSchema) .output(ZGetOrganisationResponseSchema) + .use(twoFactorScope((input) => ({ organisationReference: input.organisationReference }))) .query(async ({ input, ctx }) => { const { organisationReference } = input; diff --git a/packages/trpc/server/organisation-router/get-organisations.ts b/packages/trpc/server/organisation-router/get-organisations.ts index 0440f260e..3d05565bb 100644 --- a/packages/trpc/server/organisation-router/get-organisations.ts +++ b/packages/trpc/server/organisation-router/get-organisations.ts @@ -2,12 +2,15 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorInstanceOnly } from '../two-factor-enforcement/enforce'; import { ZGetOrganisationsRequestSchema, ZGetOrganisationsResponseSchema } from './get-organisations.types'; export const getOrganisationsRoute = authenticatedProcedure // .meta(getOrganisationsMeta) .input(ZGetOrganisationsRequestSchema) .output(ZGetOrganisationsResponseSchema) + // 2FA enforcement: organisation listing for the org switcher; exposes the same surface as the 'bootstrap' session payload a blocked client already receives. Instance assert still applies. + .use(twoFactorInstanceOnly()) .query(async ({ ctx }) => { const { user } = ctx; diff --git a/packages/trpc/server/organisation-router/leave-organisation.ts b/packages/trpc/server/organisation-router/leave-organisation.ts index 466870f41..4cafdf806 100644 --- a/packages/trpc/server/organisation-router/leave-organisation.ts +++ b/packages/trpc/server/organisation-router/leave-organisation.ts @@ -4,11 +4,14 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorRemediation } from '../two-factor-enforcement/enforce'; import { ZLeaveOrganisationRequestSchema, ZLeaveOrganisationResponseSchema } from './leave-organisation.types'; export const leaveOrganisationRoute = authenticatedProcedure .input(ZLeaveOrganisationRequestSchema) .output(ZLeaveOrganisationResponseSchema) + // 2FA enforcement: REMEDIATION — a blocked member must always be able to leave the organisation. Instance assert still applies. + .use(twoFactorRemediation()) .mutation(async ({ ctx, input }) => { const { organisationId } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/organisation-router/resend-organisation-member-invite.ts b/packages/trpc/server/organisation-router/resend-organisation-member-invite.ts index e83c1658a..d0c173d26 100644 --- a/packages/trpc/server/organisation-router/resend-organisation-member-invite.ts +++ b/packages/trpc/server/organisation-router/resend-organisation-member-invite.ts @@ -6,6 +6,7 @@ import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } fr import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZResendOrganisationMemberInviteRequestSchema, ZResendOrganisationMemberInviteResponseSchema, @@ -15,6 +16,7 @@ export const resendOrganisationMemberInviteRoute = authenticatedProcedure // .meta(resendOrganisationMemberInviteMeta) .input(ZResendOrganisationMemberInviteRequestSchema) .output(ZResendOrganisationMemberInviteResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId, invitationId } = input; diff --git a/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts b/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts index a4ee8ffcd..e269d0a35 100644 --- a/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts +++ b/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts @@ -7,6 +7,7 @@ import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations' import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateOrganisationBrandingLogoRequestSchema, ZUpdateOrganisationBrandingLogoResponseSchema, @@ -15,6 +16,9 @@ import { export const updateOrganisationBrandingLogoRoute = authenticatedProcedure .input(ZUpdateOrganisationBrandingLogoRequestSchema) .output(ZUpdateOrganisationBrandingLogoResponseSchema) + // Multipart route: the resolver runs on the PARSED zfd input, so the + // organisation ID comes from the structured `payload` object. + .use(twoFactorScope((input) => ({ organisation: input.payload.organisationId }))) .mutation(async ({ ctx, input }) => { const { user } = ctx; const { payload, brandingLogo } = input; diff --git a/packages/trpc/server/organisation-router/update-organisation-group.ts b/packages/trpc/server/organisation-router/update-organisation-group.ts index c5be77bd2..94963d41c 100644 --- a/packages/trpc/server/organisation-router/update-organisation-group.ts +++ b/packages/trpc/server/organisation-router/update-organisation-group.ts @@ -8,6 +8,7 @@ import { OrganisationGroupType } from '@documenso/prisma/generated/types'; import { unique } from 'remeda'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateOrganisationGroupRequestSchema, ZUpdateOrganisationGroupResponseSchema, @@ -17,6 +18,7 @@ export const updateOrganisationGroupRoute = authenticatedProcedure // .meta(updateOrganisationGroupMeta) .input(ZUpdateOrganisationGroupRequestSchema) .output(ZUpdateOrganisationGroupResponseSchema) + .use(twoFactorScope((input) => ({ organisationGroup: input.id }))) .mutation(async ({ input, ctx }) => { const { id, ...data } = input; const { user } = ctx; diff --git a/packages/trpc/server/organisation-router/update-organisation-members.ts b/packages/trpc/server/organisation-router/update-organisation-members.ts index e2ee193e5..f75b82877 100644 --- a/packages/trpc/server/organisation-router/update-organisation-members.ts +++ b/packages/trpc/server/organisation-router/update-organisation-members.ts @@ -10,6 +10,7 @@ import { prisma } from '@documenso/prisma'; import { OrganisationGroupType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateOrganisationMemberRequestSchema, ZUpdateOrganisationMemberResponseSchema, @@ -19,6 +20,7 @@ export const updateOrganisationMemberRoute = authenticatedProcedure // .meta(updateOrganisationMemberMeta) .input(ZUpdateOrganisationMemberRequestSchema) .output(ZUpdateOrganisationMemberResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { organisationId, organisationMemberId, data } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.ts b/packages/trpc/server/organisation-router/update-organisation-settings.ts index c4ba5b26d..f2485331d 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.ts @@ -3,10 +3,12 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors'; import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { type SanitizeBrandingCssWarning, sanitizeBrandingCss } from '@documenso/lib/utils/sanitize-branding-css'; +import { isTwoFactorGracePeriodReduction, isTwoFactorSatisfied } from '@documenso/lib/utils/two-factor'; import { prisma } from '@documenso/prisma'; -import { OrganisationType, Prisma } from '@prisma/client'; +import { OrganisationType, Prisma, type Session } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateOrganisationSettingsRequestSchema, ZUpdateOrganisationSettingsResponseSchema, @@ -15,9 +17,16 @@ import { export const updateOrganisationSettingsRoute = authenticatedProcedure .input(ZUpdateOrganisationSettingsRequestSchema) .output(ZUpdateOrganisationSettingsResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ ctx, input }) => { const { user } = ctx; - const { organisationId, data } = input; + const { organisationId, data, acknowledgeGracePeriodReduction } = input; + + // Explicitly asserted local: `ctx.session` is null for API-token callers. + // The assertion (rather than an annotation) avoids control-flow narrowing + // differences between workspace tsconfigs. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const requestSession = ctx.session as Pick | null; ctx.logger.info({ input: { @@ -57,6 +66,10 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure // AI features settings. aiFeaturesEnabled, + + // 2FA enforcement settings. + twoFactorRequired, + twoFactorGracePeriodDays, } = data; if (Object.values(data).length === 0) { @@ -65,11 +78,20 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure }); } + const isTouchingTwoFactorSettings = twoFactorRequired !== undefined || twoFactorGracePeriodDays !== undefined; + + // Touching the 2FA enforcement fields requires the stricter + // MANAGE_ORGANISATION_SECURITY permission (ADMIN only). All other fields + // keep MANAGE_ORGANISATION. + const requiredRoles = isTouchingTwoFactorSettings + ? ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION_SECURITY'] + : ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION']; + const organisation = await prisma.organisation.findFirst({ where: buildOrganisationWhereQuery({ organisationId, userId: user.id, - roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + roles: requiredRoles, }), include: { organisationGlobalSettings: true, @@ -82,6 +104,73 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure }); } + const currentSettings = organisation.organisationGlobalSettings; + + // Server-side `twoFactorEnforcedFrom` handling: set on each off→on + // transition of the require flag, preserved otherwise. + const isEnablingTwoFactorRequired = twoFactorRequired === true && !currentSettings.twoFactorRequired; + + let twoFactorEnforcedFrom: Date | undefined; + + if (isTouchingTwoFactorSettings) { + const now = new Date(); + + if (isEnablingTwoFactorRequired) { + twoFactorEnforcedFrom = now; + } + + // Enable-time guard: enabling enforcement requires the acting user to + // already satisfy the policy being enabled. This prevents self-lockout + // (a 0-day grace would instantly block the actor from the very settings + // route that undoes it) and removes the instant-DoS lever of enabling a + // policy the actor themselves cannot pass. API-token callers carry no + // session and can never prove a verified second factor, so they cannot + // enable enforcement either. + if (isEnablingTwoFactorRequired) { + const isActingUserSatisfied = isTwoFactorSatisfied({ + userTwoFactorEnabled: user.twoFactorEnabled, + sessionTwoFactorVerified: requestSession?.twoFactorVerified ?? false, + }); + + if (!isActingUserSatisfied) { + throw new AppError(AppErrorCode.TWO_FACTOR_REQUIRED, { + message: + 'You must have two-factor authentication enabled and verified on this session before requiring it for the organisation.', + statusCode: 403, + }); + } + } + + // Grace-reduction acknowledgement: when the change shortens an active + // grace window, require an explicit acknowledgement from the client. + const nextTwoFactorRequired = twoFactorRequired ?? currentSettings.twoFactorRequired; + const nextGracePeriodDays = twoFactorGracePeriodDays ?? currentSettings.twoFactorGracePeriodDays; + const nextEnforcedFrom = twoFactorEnforcedFrom ?? currentSettings.twoFactorEnforcedFrom; + + const isGraceReduction = isTwoFactorGracePeriodReduction({ + previous: currentSettings.twoFactorRequired + ? { + anchors: [currentSettings.twoFactorEnforcedFrom], + gracePeriodDays: currentSettings.twoFactorGracePeriodDays, + } + : null, + next: nextTwoFactorRequired + ? { + anchors: [nextEnforcedFrom], + gracePeriodDays: nextGracePeriodDays, + } + : null, + now, + }); + + if (isGraceReduction && acknowledgeGracePeriodReduction !== true) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'This change reduces the active two-factor authentication grace period and must be explicitly acknowledged.', + }); + } + } + // Validate that the email ID belongs to the organisation. if (emailId) { const email = await prisma.organisationEmail.findFirst({ @@ -185,6 +274,12 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure // AI features settings. aiFeaturesEnabled, + + // 2FA enforcement settings. `twoFactorEnforcedFrom` is only set + // on an off→on transition (undefined preserves the stored value). + twoFactorRequired, + twoFactorGracePeriodDays, + twoFactorEnforcedFrom, }, }, }, diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts index 62caa07fc..53493fab1 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts @@ -13,6 +13,13 @@ import { z } from 'zod'; export const ZUpdateOrganisationSettingsRequestSchema = z.object({ organisationId: z.string(), + + /** + * Required (as `true`) when the update reduces an active 2FA enforcement + * grace window, so the client explicitly acknowledges that members lose + * remaining grace time. + */ + acknowledgeGracePeriodReduction: z.boolean().optional(), data: z.object({ // Document related settings. documentVisibility: z.nativeEnum(DocumentVisibility).optional(), @@ -45,6 +52,11 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({ // AI features settings. aiFeaturesEnabled: z.boolean().optional(), + + // 2FA enforcement settings. Touching these requires the + // MANAGE_ORGANISATION_SECURITY permission (ADMIN only). + twoFactorRequired: z.boolean().optional(), + twoFactorGracePeriodDays: z.number().int().min(0).max(365).optional(), }), }); diff --git a/packages/trpc/server/organisation-router/update-organisation.ts b/packages/trpc/server/organisation-router/update-organisation.ts index e32b87d63..9f9a9ff38 100644 --- a/packages/trpc/server/organisation-router/update-organisation.ts +++ b/packages/trpc/server/organisation-router/update-organisation.ts @@ -7,12 +7,14 @@ import { Prisma } from '@prisma/client'; import { z } from 'zod'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateOrganisationRequestSchema, ZUpdateOrganisationResponseSchema } from './update-organisation.types'; export const updateOrganisationRoute = authenticatedProcedure // .meta(updateOrganisationMeta) .input(ZUpdateOrganisationRequestSchema) .output(ZUpdateOrganisationResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ input, ctx }) => { const { organisationId, data } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index f75610609..cb20fae18 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -7,6 +7,7 @@ import { submitSupportTicket } from '@documenso/lib/server-only/user/submit-supp import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; import { authenticatedProcedure, router } from '../trpc'; +import { twoFactorInstanceOnly, twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindUserSecurityAuditLogsSchema, ZSetProfileImageMutationSchema, @@ -17,6 +18,8 @@ import { export const profileRouter = router({ findUserSecurityAuditLogs: authenticatedProcedure .input(ZFindUserSecurityAuditLogsSchema) + // 2FA enforcement: user-level security audit log; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) .query(async ({ input, ctx }) => { return await findUserSecurityAuditLogs({ userId: ctx.user.id, @@ -24,67 +27,95 @@ export const profileRouter = router({ }); }), - updateProfile: authenticatedProcedure.input(ZUpdateProfileMutationSchema).mutation(async ({ input, ctx }) => { - const { name, signature } = input; + updateProfile: authenticatedProcedure + .input(ZUpdateProfileMutationSchema) + // 2FA enforcement: user-level profile update; no organisation scope. Instance assert still applies. + .use(twoFactorInstanceOnly()) + .mutation(async ({ input, ctx }) => { + const { name, signature } = input; - await updateProfile({ - userId: ctx.user.id, - name, - signature, - requestMetadata: ctx.metadata.requestMetadata, - }); - }), - - deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { - ctx.logger.info({ - input: { + await updateProfile({ userId: ctx.user.id, - }, - }); + name, + signature, + requestMetadata: ctx.metadata.requestMetadata, + }); + }), - await deleteUser({ - id: ctx.user.id, - }); - }), + deleteAccount: authenticatedProcedure + // 2FA enforcement: user-level account deletion; a blocked user must be able to delete their own account. Instance assert still applies. + .use(twoFactorInstanceOnly()) + .mutation(async ({ ctx }) => { + ctx.logger.info({ + input: { + userId: ctx.user.id, + }, + }); - setProfileImage: authenticatedProcedure.input(ZSetProfileImageMutationSchema).mutation(async ({ input, ctx }) => { - const { bytes, teamId, organisationId } = input; + await deleteUser({ + id: ctx.user.id, + }); + }), - ctx.logger.info({ - input: { - teamId, - organisationId, - }, - }); + setProfileImage: authenticatedProcedure + .input(ZSetProfileImageMutationSchema) + .use( + twoFactorScope((input) => ({ + organisation: input.organisationId ?? undefined, + team: input.teamId ?? undefined, + })), + ) + .mutation(async ({ input, ctx }) => { + const { bytes, teamId, organisationId } = input; - let target: SetAvatarImageOptions['target'] = { - type: 'user', - }; + ctx.logger.info({ + input: { + teamId, + organisationId, + }, + }); - if (teamId) { - target = { - type: 'team', - teamId, + let target: SetAvatarImageOptions['target'] = { + type: 'user', }; - } - if (organisationId) { - target = { - type: 'organisation', - organisationId, - }; - } + if (teamId) { + target = { + type: 'team', + teamId, + }; + } - return await setAvatarImage({ - userId: ctx.user.id, - target, - bytes, - requestMetadata: ctx.metadata, - }); - }), + if (organisationId) { + target = { + type: 'organisation', + organisationId, + }; + } + + return await setAvatarImage({ + userId: ctx.user.id, + target, + bytes, + requestMetadata: ctx.metadata, + }); + }), submitSupportTicket: authenticatedProcedure .input(ZSubmitSupportTicketMutationSchema) + .use( + twoFactorScope((input) => { + // The legacy string team ID is only in scope when it parses to a real + // ID — mirroring the handler's own validation branch; a malformed + // value is rejected by the handler, not resolved here. + const teamId = input.teamId ? Number(input.teamId) : null; + + return { + organisation: input.organisationId, + team: teamId !== null && Number.isInteger(teamId) && teamId > 0 ? teamId : undefined, + }; + }), + ) .mutation(async ({ input, ctx }) => { const { subject, message, organisationId, teamId } = input; diff --git a/packages/trpc/server/recipient-router/find-recipient-suggestions.ts b/packages/trpc/server/recipient-router/find-recipient-suggestions.ts index 34a33b667..eee7c9ddd 100644 --- a/packages/trpc/server/recipient-router/find-recipient-suggestions.ts +++ b/packages/trpc/server/recipient-router/find-recipient-suggestions.ts @@ -1,6 +1,7 @@ import { getRecipientSuggestions } from '@documenso/lib/server-only/recipient/get-recipient-suggestions'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { ZGetRecipientSuggestionsRequestSchema, ZGetRecipientSuggestionsResponseSchema, @@ -12,6 +13,7 @@ import { export const findRecipientSuggestionsRoute = authenticatedProcedure .input(ZGetRecipientSuggestionsRequestSchema) .output(ZGetRecipientSuggestionsResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { query } = input; diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 29fff7d9c..f586508d0 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -14,6 +14,7 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; import { authenticatedProcedure, procedure, router } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { findRecipientSuggestionsRoute } from './find-recipient-suggestions'; import { ZCompleteDocumentWithTokenMutationSchema, @@ -67,6 +68,7 @@ export const recipientRouter = router({ }) .input(ZGetRecipientRequestSchema) .output(ZGetRecipientResponseSchema) + .use(twoFactorScope((input) => ({ recipient: input.recipientId }))) .query(async ({ input, ctx }) => { const { teamId } = ctx; const { recipientId } = input; @@ -102,6 +104,7 @@ export const recipientRouter = router({ }) .input(ZCreateDocumentRecipientRequestSchema) .output(ZCreateDocumentRecipientResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, recipient } = input; @@ -143,6 +146,7 @@ export const recipientRouter = router({ }) .input(ZCreateDocumentRecipientsRequestSchema) .output(ZCreateDocumentRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, recipients } = input; @@ -182,6 +186,7 @@ export const recipientRouter = router({ }) .input(ZUpdateDocumentRecipientRequestSchema) .output(ZUpdateDocumentRecipientResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, recipient } = input; @@ -223,6 +228,7 @@ export const recipientRouter = router({ }) .input(ZUpdateDocumentRecipientsRequestSchema) .output(ZUpdateDocumentRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, recipients } = input; @@ -262,6 +268,7 @@ export const recipientRouter = router({ }) .input(ZDeleteDocumentRecipientRequestSchema) .output(ZSuccessResponseSchema) + .use(twoFactorScope((input) => ({ recipient: input.recipientId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { recipientId } = input; @@ -288,6 +295,7 @@ export const recipientRouter = router({ setDocumentRecipients: authenticatedProcedure .input(ZSetDocumentRecipientsRequestSchema) .output(ZSetDocumentRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ document: input.documentId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { documentId, recipients } = input; @@ -334,6 +342,7 @@ export const recipientRouter = router({ }) .input(ZGetRecipientRequestSchema) .output(ZGetRecipientResponseSchema) + .use(twoFactorScope((input) => ({ recipient: input.recipientId }))) .query(async ({ input, ctx }) => { const { teamId } = ctx; const { recipientId } = input; @@ -369,6 +378,7 @@ export const recipientRouter = router({ }) .input(ZCreateTemplateRecipientRequestSchema) .output(ZCreateTemplateRecipientResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, recipient } = input; @@ -410,6 +420,7 @@ export const recipientRouter = router({ }) .input(ZCreateTemplateRecipientsRequestSchema) .output(ZCreateTemplateRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, recipients } = input; @@ -449,6 +460,7 @@ export const recipientRouter = router({ }) .input(ZUpdateTemplateRecipientRequestSchema) .output(ZUpdateTemplateRecipientResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, recipient } = input; @@ -490,6 +502,7 @@ export const recipientRouter = router({ }) .input(ZUpdateTemplateRecipientsRequestSchema) .output(ZUpdateTemplateRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, recipients } = input; @@ -529,6 +542,7 @@ export const recipientRouter = router({ }) .input(ZDeleteTemplateRecipientRequestSchema) .output(ZSuccessResponseSchema) + .use(twoFactorScope((input) => ({ recipient: input.recipientId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { recipientId } = input; @@ -555,6 +569,7 @@ export const recipientRouter = router({ setTemplateRecipients: authenticatedProcedure .input(ZSetTemplateRecipientsRequestSchema) .output(ZSetTemplateRecipientsResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, recipients } = input; diff --git a/packages/trpc/server/team-router/create-team-groups.ts b/packages/trpc/server/team-router/create-team-groups.ts index c29da6784..1847e316e 100644 --- a/packages/trpc/server/team-router/create-team-groups.ts +++ b/packages/trpc/server/team-router/create-team-groups.ts @@ -7,12 +7,14 @@ import { prisma } from '@documenso/prisma'; import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/generated/types'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateTeamGroupsRequestSchema, ZCreateTeamGroupsResponseSchema } from './create-team-groups.types'; export const createTeamGroupsRoute = authenticatedProcedure // .meta(createTeamGroupsMeta) .input(ZCreateTeamGroupsRequestSchema) .output(ZCreateTeamGroupsResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { teamId, groups } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/create-team-members.ts b/packages/trpc/server/team-router/create-team-members.ts index 8820e764e..dcca518e4 100644 --- a/packages/trpc/server/team-router/create-team-members.ts +++ b/packages/trpc/server/team-router/create-team-members.ts @@ -8,11 +8,13 @@ import { OrganisationGroupType, TeamMemberRole } from '@prisma/client'; import { match } from 'ts-pattern'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateTeamMembersRequestSchema, ZCreateTeamMembersResponseSchema } from './create-team-members.types'; export const createTeamMembersRoute = authenticatedProcedure .input(ZCreateTeamMembersRequestSchema) .output(ZCreateTeamMembersResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { teamId, organisationMembers } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/create-team.ts b/packages/trpc/server/team-router/create-team.ts index 927276402..1f54dd00d 100644 --- a/packages/trpc/server/team-router/create-team.ts +++ b/packages/trpc/server/team-router/create-team.ts @@ -1,12 +1,14 @@ import { createTeam } from '@documenso/lib/server-only/team/create-team'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZCreateTeamRequestSchema, ZCreateTeamResponseSchema } from './create-team.types'; export const createTeamRoute = authenticatedProcedure // .meta(createOrganisationGroupMeta) .input(ZCreateTeamRequestSchema) .output(ZCreateTeamResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .mutation(async ({ input, ctx }) => { const { teamName, teamUrl, organisationId, inheritMembers } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/delete-team-group.ts b/packages/trpc/server/team-router/delete-team-group.ts index 50a3cc68b..048c5038d 100644 --- a/packages/trpc/server/team-router/delete-team-group.ts +++ b/packages/trpc/server/team-router/delete-team-group.ts @@ -6,12 +6,14 @@ import { prisma } from '@documenso/prisma'; import { OrganisationGroupType, OrganisationMemberRole } from '@documenso/prisma/generated/types'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteTeamGroupRequestSchema, ZDeleteTeamGroupResponseSchema } from './delete-team-group.types'; export const deleteTeamGroupRoute = authenticatedProcedure // .meta(deleteTeamGroupMeta) .input(ZDeleteTeamGroupRequestSchema) .output(ZDeleteTeamGroupResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { teamGroupId, teamId } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/delete-team-member.ts b/packages/trpc/server/team-router/delete-team-member.ts index db25a355a..cfffe81d7 100644 --- a/packages/trpc/server/team-router/delete-team-member.ts +++ b/packages/trpc/server/team-router/delete-team-member.ts @@ -6,12 +6,14 @@ import { prisma } from '@documenso/prisma'; import { OrganisationGroupType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteTeamMemberRequestSchema, ZDeleteTeamMemberResponseSchema } from './delete-team-member.types'; export const deleteTeamMemberRoute = authenticatedProcedure // .meta(deleteTeamMemberMeta) .input(ZDeleteTeamMemberRequestSchema) .output(ZDeleteTeamMemberResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ ctx, input }) => { const { teamId, memberId } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/delete-team.ts b/packages/trpc/server/team-router/delete-team.ts index 99b319669..5b18f82b4 100644 --- a/packages/trpc/server/team-router/delete-team.ts +++ b/packages/trpc/server/team-router/delete-team.ts @@ -6,12 +6,14 @@ import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { TeamMemberRole } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types'; export const deleteTeamRoute = authenticatedProcedure // .meta(deleteTeamMeta) .input(ZDeleteTeamRequestSchema) .output(ZDeleteTeamResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { teamId, transferTeamId } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/find-team-groups.ts b/packages/trpc/server/team-router/find-team-groups.ts index 772cfaf15..430867d41 100644 --- a/packages/trpc/server/team-router/find-team-groups.ts +++ b/packages/trpc/server/team-router/find-team-groups.ts @@ -6,12 +6,14 @@ import { Prisma } from '@prisma/client'; import { unique } from 'remeda'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindTeamGroupsRequestSchema, ZFindTeamGroupsResponseSchema } from './find-team-groups.types'; export const findTeamGroupsRoute = authenticatedProcedure // .meta(getTeamGroupsMeta) .input(ZFindTeamGroupsRequestSchema) .output(ZFindTeamGroupsResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .query(async ({ input, ctx }) => { const { teamId, types, query, page, perPage, teamGroupId, organisationRoles } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/find-team-members.ts b/packages/trpc/server/team-router/find-team-members.ts index 4db427c9a..01c905849 100644 --- a/packages/trpc/server/team-router/find-team-members.ts +++ b/packages/trpc/server/team-router/find-team-members.ts @@ -1,11 +1,13 @@ import { findTeamMembers } from '@documenso/lib/server-only/team/find-team-members'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindTeamMembersRequestSchema, ZFindTeamMembersResponseSchema } from './find-team-members.types'; export const findTeamMembersRoute = authenticatedProcedure .input(ZFindTeamMembersRequestSchema) .output(ZFindTeamMembersResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .query(async ({ input, ctx }) => { const { teamId, query, page, perPage } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/find-teams.ts b/packages/trpc/server/team-router/find-teams.ts index e4466d96c..e7f33ba65 100644 --- a/packages/trpc/server/team-router/find-teams.ts +++ b/packages/trpc/server/team-router/find-teams.ts @@ -1,12 +1,14 @@ import { findTeams } from '@documenso/lib/server-only/team/find-teams'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindTeamsRequestSchema, ZFindTeamsResponseSchema } from './find-teams.types'; export const findTeamsRoute = authenticatedProcedure // .meta(getTeamsMeta) .input(ZFindTeamsRequestSchema) .output(ZFindTeamsResponseSchema) + .use(twoFactorScope((input) => ({ organisation: input.organisationId }))) .query(async ({ ctx, input }) => { const { organisationId } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/get-team-members.ts b/packages/trpc/server/team-router/get-team-members.ts index a5cf413d3..6d6324721 100644 --- a/packages/trpc/server/team-router/get-team-members.ts +++ b/packages/trpc/server/team-router/get-team-members.ts @@ -1,12 +1,14 @@ import { getTeamMembers } from '@documenso/lib/server-only/team/get-team-members'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetTeamMembersRequestSchema, ZGetTeamMembersResponseSchema } from './get-team-members.types'; export const getTeamMembersRoute = authenticatedProcedure // .meta(getTeamMembersMeta) .input(ZGetTeamMembersRequestSchema) .output(ZGetTeamMembersResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .query(async ({ input, ctx }) => { const { teamId } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/get-team.ts b/packages/trpc/server/team-router/get-team.ts index 3b7db0871..d20c4caea 100644 --- a/packages/trpc/server/team-router/get-team.ts +++ b/packages/trpc/server/team-router/get-team.ts @@ -1,12 +1,14 @@ import { getTeam } from '@documenso/lib/server-only/team/get-team'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZGetTeamRequestSchema, ZGetTeamResponseSchema } from './get-team.types'; export const getTeamRoute = authenticatedProcedure // .meta(getTeamMeta) .input(ZGetTeamRequestSchema) .output(ZGetTeamResponseSchema) + .use(twoFactorScope((input) => ({ teamReference: input.teamReference }))) .query(async ({ input, ctx }) => { const { teamReference } = input; diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts index aa9cb1819..2a195d93d 100644 --- a/packages/trpc/server/team-router/router.ts +++ b/packages/trpc/server/team-router/router.ts @@ -5,6 +5,7 @@ import { resendTeamEmailVerification } from '@documenso/lib/server-only/team/res import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure, router } from '../trpc'; +import { twoFactorInstanceOnly, twoFactorScope } from '../two-factor-enforcement/enforce'; import { completeTeamEmailVerificationRoute } from './complete-team-email-verification'; import { createTeamRoute } from './create-team'; import { createTeamGroupsRoute } from './create-team-groups'; @@ -57,54 +58,66 @@ export const teamRouter = router({ // Old routes (to be migrated) // Todo: Refactor into routes. email: { - get: authenticatedProcedure.query(async ({ ctx }) => { - const teamEmail = await prisma.teamEmail.findUnique({ - where: { - email: ctx.user.email, - }, - include: { - team: { - select: { - id: true, - name: true, - url: true, + get: authenticatedProcedure + // 2FA enforcement: 'none' — looks up the team email record keyed by the + // caller's own email address; no organisation resource identifier is + // present. The instance assert still applies. + .use(twoFactorInstanceOnly()) + .query(async ({ ctx }) => { + const teamEmail = await prisma.teamEmail.findUnique({ + where: { + email: ctx.user.email, + }, + include: { + team: { + select: { + id: true, + name: true, + url: true, + }, }, }, - }, - }); + }); - return teamEmail || null; - }), - update: authenticatedProcedure.input(ZUpdateTeamEmailMutationSchema).mutation(async ({ input, ctx }) => { - ctx.logger.info({ - input: { - teamId: input.teamId, - }, - }); + return teamEmail || null; + }), + update: authenticatedProcedure + .input(ZUpdateTeamEmailMutationSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) + .mutation(async ({ input, ctx }) => { + ctx.logger.info({ + input: { + teamId: input.teamId, + }, + }); - await updateTeamEmail({ - userId: ctx.user.id, - ...input, - }); - }), - delete: authenticatedProcedure.input(ZDeleteTeamEmailMutationSchema).mutation(async ({ input, ctx }) => { - const { teamId } = input; + await updateTeamEmail({ + userId: ctx.user.id, + ...input, + }); + }), + delete: authenticatedProcedure + .input(ZDeleteTeamEmailMutationSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) + .mutation(async ({ input, ctx }) => { + const { teamId } = input; - ctx.logger.info({ - input: { + ctx.logger.info({ + input: { + teamId, + }, + }); + + await deleteTeamEmail({ + userId: ctx.user.id, + userEmail: ctx.user.email, teamId, - }, - }); - - await deleteTeamEmail({ - userId: ctx.user.id, - userEmail: ctx.user.email, - teamId, - }); - }), + }); + }), verification: { send: authenticatedProcedure .input(ZCreateTeamEmailVerificationMutationSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { teamId, email, name } = input; @@ -126,6 +139,7 @@ export const teamRouter = router({ complete: completeTeamEmailVerificationRoute, resend: authenticatedProcedure .input(ZResendTeamEmailVerificationMutationSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { teamId } = input; @@ -142,6 +156,7 @@ export const teamRouter = router({ }), delete: authenticatedProcedure .input(ZDeleteTeamEmailVerificationMutationSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { teamId } = input; diff --git a/packages/trpc/server/team-router/update-team-branding-logo.ts b/packages/trpc/server/team-router/update-team-branding-logo.ts index 2196bb068..6ae9aa77e 100644 --- a/packages/trpc/server/team-router/update-team-branding-logo.ts +++ b/packages/trpc/server/team-router/update-team-branding-logo.ts @@ -7,6 +7,7 @@ import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateTeamBrandingLogoRequestSchema, ZUpdateTeamBrandingLogoResponseSchema, @@ -15,6 +16,9 @@ import { export const updateTeamBrandingLogoRoute = authenticatedProcedure .input(ZUpdateTeamBrandingLogoRequestSchema) .output(ZUpdateTeamBrandingLogoResponseSchema) + // Multipart route: the resolver runs on the PARSED zfd input, so the team + // ID comes from the structured `payload` object. + .use(twoFactorScope((input) => ({ team: input.payload.teamId }))) .mutation(async ({ ctx, input }) => { const { user } = ctx; const { payload, brandingLogo } = input; diff --git a/packages/trpc/server/team-router/update-team-group.ts b/packages/trpc/server/team-router/update-team-group.ts index cd11f92d2..91cfde0ba 100644 --- a/packages/trpc/server/team-router/update-team-group.ts +++ b/packages/trpc/server/team-router/update-team-group.ts @@ -6,12 +6,14 @@ import { prisma } from '@documenso/prisma'; import { OrganisationGroupType } from '@documenso/prisma/generated/types'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateTeamGroupRequestSchema, ZUpdateTeamGroupResponseSchema } from './update-team-group.types'; export const updateTeamGroupRoute = authenticatedProcedure // .meta(updateTeamGroupMeta) .input(ZUpdateTeamGroupRequestSchema) .output(ZUpdateTeamGroupResponseSchema) + .use(twoFactorScope((input) => ({ teamGroup: input.id }))) .mutation(async ({ input, ctx }) => { const { id, data } = input; const { user } = ctx; diff --git a/packages/trpc/server/team-router/update-team-member.ts b/packages/trpc/server/team-router/update-team-member.ts index b0ddd0204..2cfc503b2 100644 --- a/packages/trpc/server/team-router/update-team-member.ts +++ b/packages/trpc/server/team-router/update-team-member.ts @@ -8,12 +8,14 @@ import { OrganisationGroupType, TeamMemberRole } from '@documenso/prisma/generat import { match } from 'ts-pattern'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateTeamMemberRequestSchema, ZUpdateTeamMemberResponseSchema } from './update-team-member.types'; export const updateTeamMemberRoute = authenticatedProcedure // .meta(updateTeamMemberMeta) .input(ZUpdateTeamMemberRequestSchema) .output(ZUpdateTeamMemberResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ ctx, input }) => { const { teamId, memberId, data } = input; const userId = ctx.user.id; diff --git a/packages/trpc/server/team-router/update-team-settings.ts b/packages/trpc/server/team-router/update-team-settings.ts index eea40e90d..d258c9cea 100644 --- a/packages/trpc/server/team-router/update-team-settings.ts +++ b/packages/trpc/server/team-router/update-team-settings.ts @@ -9,11 +9,13 @@ import { prisma } from '@documenso/prisma'; import { OrganisationType, Prisma } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateTeamSettingsRequestSchema, ZUpdateTeamSettingsResponseSchema } from './update-team-settings.types'; export const updateTeamSettingsRoute = authenticatedProcedure .input(ZUpdateTeamSettingsRequestSchema) .output(ZUpdateTeamSettingsResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ ctx, input }) => { const { user } = ctx; const { teamId, data } = input; diff --git a/packages/trpc/server/team-router/update-team.ts b/packages/trpc/server/team-router/update-team.ts index 6dae1f829..8cc93de12 100644 --- a/packages/trpc/server/team-router/update-team.ts +++ b/packages/trpc/server/team-router/update-team.ts @@ -2,12 +2,14 @@ import { updateTeam } from '@documenso/lib/server-only/team/update-team'; import { updateTeamPublicProfile } from '@documenso/lib/server-only/team/update-team-public-profile'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZUpdateTeamRequestSchema, ZUpdateTeamResponseSchema } from './update-team.types'; export const updateTeamRoute = authenticatedProcedure // .meta(updateTeamMeta) .input(ZUpdateTeamRequestSchema) .output(ZUpdateTeamResponseSchema) + .use(twoFactorScope((input) => ({ team: input.teamId }))) .mutation(async ({ input, ctx }) => { const { teamId, data } = input; diff --git a/packages/trpc/server/template-router/get-templates-by-ids.ts b/packages/trpc/server/template-router/get-templates-by-ids.ts index ba696f160..16b3d355f 100644 --- a/packages/trpc/server/template-router/get-templates-by-ids.ts +++ b/packages/trpc/server/template-router/get-templates-by-ids.ts @@ -6,6 +6,7 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { getTemplatesByIdsMeta, ZGetTemplatesByIdsRequestSchema, @@ -16,6 +17,7 @@ export const getTemplatesByIdsRoute = authenticatedProcedure .meta(getTemplatesByIdsMeta) .input(ZGetTemplatesByIdsRequestSchema) .output(ZGetTemplatesByIdsResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateIds }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { templateIds } = input; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index e11e1f25d..5816fd070 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -35,6 +35,7 @@ import { DocumentDataType, EnvelopeType } from '@prisma/client'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; +import { TWO_FACTOR_SKIP, twoFactorScope, twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { getTemplatesByIdsRoute } from './get-templates-by-ids'; import { ZBulkSendTemplateMutationSchema, @@ -83,6 +84,7 @@ export const templateRouter = router({ }) .input(ZFindTemplatesRequestSchema) .output(ZFindTemplatesResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { teamId } = ctx; @@ -136,6 +138,7 @@ export const templateRouter = router({ findOrganisationTemplates: authenticatedProcedure .input(ZFindOrganisationTemplatesRequestSchema) .output(ZFindTemplatesResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { teamId } = ctx; @@ -183,6 +186,7 @@ export const templateRouter = router({ getOrganisationTemplateById: authenticatedProcedure .input(ZGetOrganisationTemplateByIdRequestSchema) .output(ZGetOrganisationTemplateByIdResponseSchema) + .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) .query(async ({ input, ctx }) => { const { teamId } = ctx; const { envelopeId } = input; @@ -214,6 +218,7 @@ export const templateRouter = router({ }) .input(ZGetTemplateByIdRequestSchema) .output(ZGetTemplateByIdResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .query(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId } = input; @@ -261,6 +266,7 @@ export const templateRouter = router({ }) .input(ZCreateTemplateMutationSchema) .output(ZCreateTemplateResponseSchema) + .use(twoFactorScopeFromCtx()) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; @@ -351,6 +357,7 @@ export const templateRouter = router({ }) .input(ZCreateTemplateV2RequestSchema) .output(ZCreateTemplateV2ResponseSchema) + .use(twoFactorScopeFromCtx()) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; @@ -437,6 +444,7 @@ export const templateRouter = router({ }) .input(ZUpdateTemplateRequestSchema) .output(ZUpdateTemplateResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, data, meta } = input; @@ -483,6 +491,7 @@ export const templateRouter = router({ }) .input(ZDuplicateTemplateMutationSchema) .output(ZDuplicateTemplateResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId } = input; @@ -522,6 +531,7 @@ export const templateRouter = router({ }) .input(ZDeleteTemplateMutationSchema) .output(ZSuccessResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId } = input; @@ -562,6 +572,7 @@ export const templateRouter = router({ }) .input(ZCreateDocumentFromTemplateRequestSchema) .output(ZCreateDocumentFromTemplateResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ ctx, input }) => { const { teamId } = ctx; const { @@ -664,6 +675,13 @@ export const templateRouter = router({ // }) .input(ZCreateDocumentFromDirectTemplateRequestSchema) .output(ZCreateDocumentFromDirectTemplateResponseSchema) + // Token-authorized: authorization derives from the direct-template token + // (the session is incidental — an instance-blocked signed-in user must + // still be able to use someone else's direct template), so both asserts + // are skipped when the token is present — mirroring the handler, which + // authorizes via the token alone. The schema requires a non-empty token, + // so the fallback (instance assert only) is unreachable in practice. + .use(twoFactorScope((input) => (input.directTemplateToken ? TWO_FACTOR_SKIP : {}))) .mutation(async ({ input, ctx }) => { const { directRecipientName, @@ -717,6 +735,7 @@ export const templateRouter = router({ }) .input(ZCreateTemplateDirectLinkRequestSchema) .output(ZCreateTemplateDirectLinkResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, directRecipientId } = input; @@ -794,6 +813,7 @@ export const templateRouter = router({ }) .input(ZDeleteTemplateDirectLinkRequestSchema) .output(ZSuccessResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId } = input; @@ -828,6 +848,7 @@ export const templateRouter = router({ }) .input(ZToggleTemplateDirectLinkRequestSchema) .output(ZToggleTemplateDirectLinkResponseSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; const { templateId, enabled } = input; @@ -846,51 +867,54 @@ export const templateRouter = router({ /** * @private */ - uploadBulkSend: authenticatedProcedure.input(ZBulkSendTemplateMutationSchema).mutation(async ({ ctx, input }) => { - const { templateId, teamId, csv, sendImmediately } = input; - const { user } = ctx; + uploadBulkSend: authenticatedProcedure + .input(ZBulkSendTemplateMutationSchema) + .use(twoFactorScope((input) => ({ template: input.templateId }))) + .mutation(async ({ ctx, input }) => { + const { templateId, teamId, csv, sendImmediately } = input; + const { user } = ctx; - ctx.logger.info({ - input: { - templateId, + ctx.logger.info({ + input: { + templateId, + teamId, + }, + }); + + if (csv.length > 4 * 1024 * 1024) { + throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { + message: 'File size exceeds 4MB limit', + statusCode: 400, + }); + } + + const template = await getTemplateById({ + id: { + type: 'templateId', + id: templateId, + }, teamId, - }, - }); - - if (csv.length > 4 * 1024 * 1024) { - throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { - message: 'File size exceeds 4MB limit', - statusCode: 400, - }); - } - - const template = await getTemplateById({ - id: { - type: 'templateId', - id: templateId, - }, - teamId, - userId: user.id, - }); - - if (!template) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Template not found', - }); - } - - await jobs.triggerJob({ - name: 'internal.bulk-send-template', - payload: { userId: user.id, - teamId, - templateId, - csvContent: csv, - sendImmediately, - requestMetadata: ctx.metadata.requestMetadata, - }, - }); + }); - return { success: true }; - }), + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Template not found', + }); + } + + await jobs.triggerJob({ + name: 'internal.bulk-send-template', + payload: { + userId: user.id, + teamId, + templateId, + csvContent: csv, + sendImmediately, + requestMetadata: ctx.metadata.requestMetadata, + }, + }); + + return { success: true }; + }), }); diff --git a/packages/trpc/server/template-router/search-template.ts b/packages/trpc/server/template-router/search-template.ts index dc86c3d99..17bcf6068 100644 --- a/packages/trpc/server/template-router/search-template.ts +++ b/packages/trpc/server/template-router/search-template.ts @@ -1,11 +1,13 @@ import { searchTemplatesWithKeyword } from '@documenso/lib/server-only/template/search-templates-with-keyword'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { ZSearchTemplateRequestSchema, ZSearchTemplateResponseSchema } from './search-template.types'; export const searchTemplateRoute = authenticatedProcedure .input(ZSearchTemplateRequestSchema) .output(ZSearchTemplateResponseSchema) + .use(twoFactorScopeFromCtx()) .query(async ({ input, ctx }) => { const { query } = input; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 2d2f8673c..6514b3634 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -10,9 +10,20 @@ import type { AnyZodObject } from 'zod'; import { dataTransformer } from '../utils/data-transformer'; import type { TrpcContext } from './context'; +import { twoFactorInstanceOnly } from './two-factor-enforcement/enforce'; + +/** + * Marker set on the session-reachable base procedures so the 2FA enforcement + * drift guard can statically distinguish them from plain `procedure` routes. + */ +export type TrpcAuthType = 'authenticated' | 'maybeAuthenticated' | 'admin'; // Can't import type from trpc-to-openapi because it breaks build, not sure why. export type TrpcRouteMeta = { + /** + * Set by the base procedures below — do not set this on individual routes. + */ + trpcAuthType?: TrpcAuthType; openapi?: { enabled?: boolean; method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; @@ -150,6 +161,12 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me // authenticated TRPC call here. assertUserNotDisabled(ctx.user); + // 2FA enforcement is owned by the route-level inline middlewares + // (`twoFactorScope` and friends, see `./two-factor-enforcement/enforce.ts`) + // which run after `.input()` parsing. Nothing runs here — the drift guard + // (`./two-factor-enforcement/drift-guard.test.ts`) fails CI for any + // session-reachable procedure without an inline enforcement middleware. + // Recreate the logger with a sub request ID to differentiate between batched // requests, as well as identifying attributes so every subsequent log line // (including errors) inherits them. @@ -261,6 +278,11 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat const sessionUser = ctx.user && !ctx.user.disabled ? ctx.user : null; const sessionRecord = sessionUser ? ctx.session : null; + // 2FA enforcement for the session branch is owned by the route-level inline + // middlewares (`maybeAuthenticated` serves session users too, e.g. + // attachment reads — they are NOT exempt). Anonymous requests carry no + // session and pass through the inline middleware untouched. + // Resolve `auth` once so it stays in sync between the logger bindings and // the outgoing metadata. const auth = sessionRecord ? 'session' : null; @@ -321,6 +343,10 @@ export const adminMiddleware = t.middleware(async ({ ctx, next, path }) => { }); } + // 2FA enforcement (instance admins are subject to it) is owned by the + // `twoFactorInstanceOnly` middleware attached to the `adminProcedure` base + // below. + // Recreate the logger with a sub request ID to differentiate between batched // requests, as well as identifying attributes so every subsequent log line // (including errors) inherits them. @@ -390,7 +416,23 @@ export const procedureMiddleware = t.middleware(async ({ ctx, next, path }) => { */ export const router = t.router; export const procedure = t.procedure.use(procedureMiddleware); -export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware); + +// The `trpcAuthType` markers below let the 2FA enforcement drift guard test +// statically identify session-reachable procedures when walking the app +// router. Route-level `.meta()` calls shallow-merge on top, so the marker +// survives per-route metadata. +export const authenticatedProcedure = t.procedure.meta({ trpcAuthType: 'authenticated' }).use(authenticatedMiddleware); // While this is functionally the same as `procedure`, it's useful for indicating purpose -export const maybeAuthenticatedProcedure = t.procedure.use(maybeAuthenticatedMiddleware); -export const adminProcedure = t.procedure.use(adminMiddleware); +export const maybeAuthenticatedProcedure = t.procedure + .meta({ trpcAuthType: 'maybeAuthenticated' }) + .use(maybeAuthenticatedMiddleware); +export const adminProcedure = t.procedure + .meta({ trpcAuthType: 'admin' }) + .use(adminMiddleware) + // 2FA enforcement: admin procedures operate in the instance-admin context, + // not as an organisation member — organisation enforcement targets member + // access to organisation/team resources, so the entire admin router is + // instance-assert-only. The INSTANCE assert applies to admins ("admins are + // subject to enforcement"). Attached at the base so every admin route + // inherits it; individual admin routes must not attach their own. + .use(twoFactorInstanceOnly()); diff --git a/packages/trpc/server/two-factor-enforcement/drift-guard.test.ts b/packages/trpc/server/two-factor-enforcement/drift-guard.test.ts new file mode 100644 index 000000000..fbb82d071 --- /dev/null +++ b/packages/trpc/server/two-factor-enforcement/drift-guard.test.ts @@ -0,0 +1,207 @@ +/** + * 2FA enforcement drift guard. + * + * Static, import-only structural test (no queries are executed): every + * procedure built on a session-reachable base (authenticated / admin / + * maybeAuthenticated — identified via the `trpcAuthType` marker set on the + * base procedures) MUST carry EXACTLY ONE inline enforcement middleware + * (`twoFactorScope` / `twoFactorScopeFromCtx` / `twoFactorInstanceOnly` / + * `twoFactorRemediation` / `twoFactorBootstrap`), detected by its `_type` + * tag while walking `_def.middlewares`. + * + * CONTRACT (see `./enforce.ts` and the session middlewares in `../trpc.ts`): + * the session middlewares run NO enforcement of their own — the runtime is + * FAIL-OPEN for a procedure without an inline enforcement middleware. THIS + * TEST is the enforcement net: the coverage assertion below fails any + * session-reachable procedure without one. Do not weaken it. + * + * The `instanceOnly`, `remediation` and `bootstrap` variants are explicit, + * justified allow-lists — their exact membership is asserted below so + * additions are always deliberate and reviewed. Every allow-listed call site + * must carry a justification comment. + */ +import { describe, expect, it } from 'vitest'; + +import { appRouter } from '../router'; +import type { TrpcRouteMeta } from '../trpc'; +import type { TwoFactorInlineVariant } from './enforce'; +import { getTwoFactorInlineMiddlewareVariant, TWO_FACTOR_INLINE_MIDDLEWARE_TYPE } from './enforce'; + +type ProcedureEntry = { + path: string; + meta: TrpcRouteMeta | undefined; + variants: TwoFactorInlineVariant[]; +}; + +const getProcedures = (): ProcedureEntry[] => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const procedures = appRouter._def.procedures as unknown as Record< + string, + { + _def: { + meta?: TrpcRouteMeta; + middlewares?: Array<{ _type?: string; _twoFactorVariant?: TwoFactorInlineVariant }>; + }; + } + >; + + return Object.entries(procedures).map(([path, procedure]) => ({ + path, + meta: procedure._def.meta, + variants: (procedure._def.middlewares ?? []).flatMap((middleware) => { + const variant = getTwoFactorInlineMiddlewareVariant(middleware); + + return variant ? [variant] : []; + }), + })); +}; + +const sessionReachableProcedures = () => + getProcedures().filter( + (procedure) => + procedure.meta?.trpcAuthType === 'authenticated' || + procedure.meta?.trpcAuthType === 'maybeAuthenticated' || + procedure.meta?.trpcAuthType === 'admin', + ); + +const proceduresWithVariant = (variant: TwoFactorInlineVariant) => + sessionReachableProcedures() + .filter((procedure) => procedure.variants.includes(variant)) + .map((procedure) => procedure.path) + .sort(); + +/** + * Non-admin procedures with `twoFactorInstanceOnly()` — no organisation + * scope; the instance assert still applies. Every entry must carry a + * justification comment at its call site. Additions must be deliberate — + * organisation-scope resolution is always preferred over instance-only. + * + * The admin router is excluded here because ALL admin procedures inherit + * `twoFactorInstanceOnly()` from the `adminProcedure` base (instance-admin + * context, not organisation-member access) — asserted separately below. + */ +const INSTANCE_ONLY_ALLOW_LIST = [ + 'auth.passkey.create', + 'auth.passkey.createAuthenticationOptions', + 'auth.passkey.createRegistrationOptions', + 'auth.passkey.delete', + 'auth.passkey.find', + 'auth.passkey.update', + 'document.inbox.find', + 'document.inbox.getCount', + 'enterprise.billing.plans.get', + 'organisation.create', + 'organisation.getMany', + 'organisation.member.invite.getMany', + 'profile.deleteAccount', + 'profile.findUserSecurityAuditLogs', + 'profile.updateProfile', + 'team.email.get', +].sort(); + +/** + * Org-assert exemptions for remediation (`twoFactorRemediation()`): a + * blocked member must always be able to walk away (leave / decline) or join + * (accept — joining is never blocked). The instance assert still applies. + */ +const REMEDIATION_ALLOW_LIST = [ + 'organisation.leave', + 'organisation.member.invite.accept', + 'organisation.member.invite.decline', +].sort(); + +/** + * The session bootstrap (`twoFactorBootstrap()`): a blocked client must be + * able to discover its own enforcement state. Exempt from BOTH asserts. + */ +const BOOTSTRAP_ALLOW_LIST = ['organisation.internal.getOrganisationSession'].sort(); + +describe('2FA enforcement drift guard', () => { + it('every session-reachable procedure carries exactly one enforcement middleware', () => { + // THE enforcement net. The session middlewares run no enforcement of + // their own, so a procedure without an inline enforcement middleware + // slips through at runtime — it must fail here instead. + const uncovered = sessionReachableProcedures() + .filter((procedure) => procedure.variants.length === 0) + .map((procedure) => procedure.path); + + expect( + uncovered, + `No 2FA enforcement middleware (twoFactorScope / twoFactorScopeFromCtx / a named variant) on: ${uncovered.join(', ')}`, + ).toEqual([]); + + // Duplicates would assert twice with potentially conflicting scopes — + // exactly one middleware must own each route. + const duplicated = sessionReachableProcedures() + .filter((procedure) => procedure.variants.length > 1) + .map((procedure) => `${procedure.path} (${procedure.variants.join(' + ')})`); + + expect(duplicated, `Multiple 2FA enforcement middlewares on: ${duplicated.join(', ')}`).toEqual([]); + }); + + it('the appRouter contains session-reachable procedures (sanity check)', () => { + // Guards against the marker meta being silently dropped, which would make + // the coverage assertion above vacuously pass. + expect(sessionReachableProcedures().length).toBeGreaterThan(150); + }); + + it('enforcement middlewares are detectable (sanity floor)', () => { + // Guards against the `_type` tag being silently dropped — which would + // make the coverage assertion fail loudly, but this pins the expected + // scale explicitly so a partial regression cannot hide. + const covered = sessionReachableProcedures().filter((procedure) => procedure.variants.length > 0); + + expect(covered.length).toBeGreaterThan(150); + + const scoped = proceduresWithVariant('scope'); + + expect(scoped.length).toBeGreaterThanOrEqual(43); + }); + + it("the non-admin 'instanceOnly' allow-list matches exactly", () => { + const instanceOnlyDeclared = sessionReachableProcedures() + .filter((procedure) => procedure.variants.includes('instanceOnly') && procedure.meta?.trpcAuthType !== 'admin') + .map((procedure) => procedure.path) + .sort(); + + expect(instanceOnlyDeclared).toEqual(INSTANCE_ONLY_ALLOW_LIST); + }); + + it("the 'remediation' allow-list matches exactly", () => { + expect(proceduresWithVariant('remediation')).toEqual(REMEDIATION_ALLOW_LIST); + }); + + it("the 'bootstrap' allow-list matches exactly", () => { + expect(proceduresWithVariant('bootstrap')).toEqual(BOOTSTRAP_ALLOW_LIST); + }); + + it('a middleware carrying only the _type tag does not count as coverage (spoof hardening)', () => { + // `createInlineMiddleware` always sets BOTH tags. A hand-written + // middleware that only sets `_type` must not satisfy the coverage + // assertion — it would tag a procedure as enforced without running any + // enforcement logic. + expect(getTwoFactorInlineMiddlewareVariant({ _type: TWO_FACTOR_INLINE_MIDDLEWARE_TYPE })).toBeNull(); + + expect( + getTwoFactorInlineMiddlewareVariant({ + _type: TWO_FACTOR_INLINE_MIDDLEWARE_TYPE, + _twoFactorVariant: 'remediation', + }), + ).toBe('remediation'); + + expect(getTwoFactorInlineMiddlewareVariant({ _type: 'input' })).toBeNull(); + }); + + it('every admin procedure inherits instance-only enforcement from the admin base', () => { + const adminProcedures = getProcedures().filter((procedure) => procedure.path.startsWith('admin.')); + + expect(adminProcedures.length).toBeGreaterThan(30); + + for (const procedure of adminProcedures) { + expect(procedure.meta?.trpcAuthType, `${procedure.path} must be built on adminProcedure`).toBe('admin'); + expect(procedure.variants, `${procedure.path} must inherit exactly the base 'instanceOnly' middleware`).toEqual([ + 'instanceOnly', + ]); + } + }); +}); diff --git a/packages/trpc/server/two-factor-enforcement/enforce.ts b/packages/trpc/server/two-factor-enforcement/enforce.ts new file mode 100644 index 000000000..9386c667a --- /dev/null +++ b/packages/trpc/server/two-factor-enforcement/enforce.ts @@ -0,0 +1,338 @@ +import type { + EnforcementSession, + EnforcementUser, + OrganisationTwoFactorScope, +} from '@documenso/lib/server-only/2fa/org-enforcement'; +import { assertOrganisationScopesTwoFactorEnforcement } from '@documenso/lib/server-only/2fa/org-enforcement'; +import { isTwoFactorSatisfied } from '@documenso/lib/utils/two-factor'; +import type { TRPCMiddlewareFunction } from '@trpc/server'; + +import type { TrpcContext } from '../context'; +import type { TrpcRouteMeta } from '../trpc'; +import { + getInstanceStatusCached, + getRequestCache, + loadAllOrgScopesCached, + loadOrgScopesCached, + resolveResourceOrgIdsCached, + throwTwoFactorRequired, +} from './resolution'; +import type { TwoFactorScopeResult } from './scope'; +import { normalizeTwoFactorScopeResult, TWO_FACTOR_CTX_TEAM, TWO_FACTOR_SKIP } from './scope'; + +export type { TwoFactorScopeDescriptor, TwoFactorScopeResult } from './scope'; +export { TWO_FACTOR_CTX_TEAM, TWO_FACTOR_SKIP } from './scope'; + +/** + * Inline (route-level) 2FA enforcement. + * + * `twoFactorScope` / `twoFactorScopeFromCtx` produce plain tRPC middlewares + * for `.use()`, placed anywhere after `.input()`: + * + * ```ts + * export const getEnvelopeRoute = authenticatedProcedure + * .meta(getEnvelopeMeta) + * .input(ZGetEnvelopeRequestSchema) + * .output(ZGetEnvelopeResponseSchema) + * .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) + * .query(async ({ input, ctx }) => { ... }); + * ``` + * + * The scope resolver is a plain typed function of the PARSED input — the + * `TInput` generic is inferred from `.use()`'s expected middleware signature + * (which carries the builder's accumulated input type), so referencing a + * field the schema does not produce is a compile error. No marker meta is + * involved. + * + * These middlewares are the ONLY 2FA enforcement path: the session + * middlewares in `../trpc.ts` run no enforcement of their own, so the + * runtime is FAIL-OPEN for a procedure that carries none of them. The drift + * guard (`drift-guard.test.ts`) walks `_def.middlewares` for the `_type` tag + * below and fails CI for any session-reachable procedure without exactly one + * enforcement middleware. That test is the enforcement net — do not weaken + * it. + * + * Enforcement ordering: + * + * - `.input()` parsing runs first, so a validation failure surfaces BEFORE + * any enforcement error. + * - This middleware then runs with typed input: + * a. Machine (API token) and anonymous calls pass through — enforcement + * targets session auth only (machine access is exempt by design). + * b. Satisfied users (enrolled + verified session) early-out cheaply. + * c. The resolver runs. `TWO_FACTOR_SKIP` skips BOTH asserts — an + * instance-blocked user must still be able to open someone else's + * signing link while signed in, and deciding that requires the parsed + * input. + * d. Instance assert, then organisation assert over the resolved scopes + * (cache/budget/batching machinery in `./resolution.ts`). + * - The resolution lands on `ctx.twoFactorEnforcement` for handlers (future + * authz unification). + * + * The named variants (`twoFactorInstanceOnly` / `twoFactorRemediation` / + * `twoFactorBootstrap`) are justified exemption allow-lists — see their + * docblocks below; the drift guard snapshot-asserts their exact membership. + */ + +/** + * The enforcement outcome, attached to `ctx` for downstream consumption. + */ +export type TwoFactorEnforcementResolution = + | { state: 'skipped'; reason: 'machineOrAnonymous' | 'tokenAuthorized' | 'bootstrapExempt' } + | { state: 'satisfied' } + | { state: 'asserted'; organisationIds: string[] }; + +/** + * Runtime tag on the inline middleware function so the drift guard can verify + * — by walking `procedure._def.middlewares` — that every session-reachable + * procedure actually carries an enforcement middleware. + */ +export const TWO_FACTOR_INLINE_MIDDLEWARE_TYPE = 'twoFactorEnforcementInline'; + +/** + * The enforcement variant, tagged onto the middleware function alongside + * `_type` so the drift guard can snapshot-assert the allow-lists of the + * exemption variants: + * + * - `'scope'`: `twoFactorScope` / `twoFactorScopeFromCtx` — full enforcement + * (instance assert + organisation assert over the resolved scopes). + * - `'instanceOnly'`: `twoFactorInstanceOnly` — no organisation scope + * (justified allow-list); the instance assert still applies. + * - `'remediation'`: `twoFactorRemediation` — organisation assert exempt (a + * blocked member must always be able to walk away); the instance assert + * still applies. + * - `'bootstrap'`: `twoFactorBootstrap` — BOTH asserts exempt (a blocked + * client must be able to discover its own enforcement state). + */ +export type TwoFactorInlineVariant = 'scope' | 'instanceOnly' | 'remediation' | 'bootstrap'; + +type TaggedMiddleware = { + _type?: string | undefined; + _twoFactorVariant?: TwoFactorInlineVariant | undefined; +}; + +export const isTwoFactorInlineMiddleware = (middleware: TaggedMiddleware): boolean => + middleware._type === TWO_FACTOR_INLINE_MIDDLEWARE_TYPE; + +/** + * The variant of an inline enforcement middleware, or `null` when the + * middleware is not one. Used by the drift guard to snapshot-assert the + * exemption allow-lists. + * + * Both tags are required: `createInlineMiddleware` always sets + * `_twoFactorVariant`, so a middleware carrying only the `_type` tag is a + * hand-written impostor — it must NOT count as coverage (the drift guard then + * reports the procedure as unenforced instead of silently accepting it). + */ +export const getTwoFactorInlineMiddlewareVariant = (middleware: TaggedMiddleware): TwoFactorInlineVariant | null => { + if (!isTwoFactorInlineMiddleware(middleware)) { + return null; + } + + return middleware._twoFactorVariant ?? null; +}; + +/** + * Mirrors tRPC's internal `Overwrite` (not exported from `@trpc/server`): + * the shape of the context after the base procedure's middlewares have + * applied their overrides. Used to give scope resolvers the real, narrowed + * context type (e.g. non-null `user` on `authenticatedProcedure`). + */ +type ContextOverwrite = TWith extends object ? Omit & TWith : TWith; + +/** + * The minimal, auth-relevant view of the context this middleware needs. The + * session middlewares only ever spread the existing context, so these fields + * are never overridden with different types — the cast below is safe. + */ +type InlineEnforcementContextView = { + req: Request; + teamId: number | undefined; + user: EnforcementUser | null; + session: NonNullable | null; +}; + +type InlineMiddleware = TRPCMiddlewareFunction< + TrpcContext, + TrpcRouteMeta, + TContextOverridesIn, + { twoFactorEnforcement: TwoFactorEnforcementResolution }, + TInput +>; + +const createInlineMiddleware = ( + resolveScope: (input: TInput, ctx: ContextOverwrite) => TwoFactorScopeResult, + variant: TwoFactorInlineVariant = 'scope', +): InlineMiddleware => { + const middleware: InlineMiddleware = async ({ ctx, input, next }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const { req, teamId, user, session } = ctx as unknown as InlineEnforcementContextView; + + const attachResolution = async (twoFactorEnforcement: TwoFactorEnforcementResolution) => + await next({ ctx: { twoFactorEnforcement } }); + + // Machine access (API token: `session === null`) is exempt by design, and + // anonymous `maybeAuthenticated` calls carry nothing to enforce against — + // enforcement targets session auth only. + if (!user || !session) { + return await attachResolution({ state: 'skipped', reason: 'machineOrAnonymous' }); + } + + // Bootstrap: BOTH asserts exempt — the blocked client must be able to + // discover its own enforcement state. + if (variant === 'bootstrap') { + return await attachResolution({ state: 'skipped', reason: 'bootstrapExempt' }); + } + + // Cheap early-out: a satisfied user can never be blocked at either level + // (isBlocked = isDeadlineExpired && !isSatisfied). Runs before the + // resolver so the common compliant case costs nothing. + if ( + isTwoFactorSatisfied({ + userTwoFactorEnabled: user.twoFactorEnabled, + sessionTwoFactorVerified: session.twoFactorVerified, + }) + ) { + return await attachResolution({ state: 'satisfied' }); + } + + // The middleware's `ctx` and the resolver's declared `ctx` are the same + // shape (tRPC types it as `Overwrite`); + // the cast only bridges tRPC's internal `Overwrite`/`Simplify` aliases + // and our structural mirror of them. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const scope = normalizeTwoFactorScopeResult( + resolveScope(input, ctx as ContextOverwrite), + ); + + // Token-authorized: skip BOTH asserts (see the ordering comment above). + if (scope.type === 'skip') { + return await attachResolution({ state: 'skipped', reason: 'tokenAuthorized' }); + } + + const cache = getRequestCache(req); + + const instanceStatus = await getInstanceStatusCached(cache, user, session); + + if (instanceStatus.required && instanceStatus.isBlocked) { + throwTwoFactorRequired(); + } + + let scopes: OrganisationTwoFactorScope[]; + + if (scope.type === 'ctxTeam') { + // Team context from the `x-team-id` header. Without a team header the + // procedure is a cross-organisation query, so every membership + // organisation is in scope (conservative). + if (teamId !== undefined && teamId > 0) { + const organisationIds = await resolveResourceOrgIdsCached(cache, user.id, { + kind: 'team', + teamIds: [teamId], + }); + + scopes = await loadOrgScopesCached(cache, user.id, organisationIds); + } else { + scopes = await loadAllOrgScopesCached(cache, user.id); + } + } else { + const organisationIds: string[] = []; + + for (const resource of scope.resources) { + organisationIds.push(...(await resolveResourceOrgIdsCached(cache, user.id, resource))); + } + + scopes = await loadOrgScopesCached(cache, user.id, organisationIds); + } + + assertOrganisationScopesTwoFactorEnforcement({ user, session, scopes }); + + return await attachResolution({ + state: 'asserted', + organisationIds: scopes.map((organisationScope) => organisationScope.organisationId), + }); + }; + + middleware._type = TWO_FACTOR_INLINE_MIDDLEWARE_TYPE; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (middleware as TaggedMiddleware)._twoFactorVariant = variant; + + return middleware; +}; + +/** + * Route-level 2FA enforcement scoped by a typed resolver over the parsed + * input. Pass to `.use()` after `.input()`: + * + * ```ts + * .use(twoFactorScope((input) => ({ envelope: input.envelopeId }))) + * ``` + * + * `TInput` and `TContextOverridesIn` are inferred from `.use()`'s expected + * middleware signature (inference from the contextual type of the call), so + * both the input and the context the resolver sees are the builder's real + * accumulated types — no annotations needed at the call site. + * + * The resolver returns: + * - a scope descriptor, e.g. `{ envelope: input.envelopeId }` (values may be + * arrays; `{}` = no organisation scope, instance assert only); + * - an array of descriptors; + * - `TWO_FACTOR_SKIP` — token-authorized call, skip both asserts; + * - `TWO_FACTOR_CTX_TEAM` — scope to the `x-team-id` team context (prefer + * `twoFactorScopeFromCtx()` when the whole route scopes this way). + */ +export const twoFactorScope = ( + resolveScope: (input: TInput, ctx: ContextOverwrite) => TwoFactorScopeResult, +): InlineMiddleware => createInlineMiddleware(resolveScope); + +/** + * Route-level 2FA enforcement scoped to the team context from the + * `x-team-id` header (`ctx.teamId`) — the inline equivalent of the meta + * path's `'ctxTeam'` declaration. Without a team header the procedure is + * treated as a cross-organisation query and every membership organisation is + * in scope (conservative). + * + * ```ts + * .use(twoFactorScopeFromCtx()) + * ``` + */ +export const twoFactorScopeFromCtx = (): InlineMiddleware => + createInlineMiddleware(() => TWO_FACTOR_CTX_TEAM); + +/** + * Route-level 2FA enforcement with NO organisation scope: the instance assert + * still applies, the organisation assert never runs. + * + * This is a justified allow-list — every `.use(twoFactorInstanceOnly())` + * call site must carry a comment explaining why the route has no + * organisation scope, and the drift guard snapshot-asserts the exact + * membership so additions are always deliberate. Organisation-scope + * resolution (`twoFactorScope` / `twoFactorScopeFromCtx`) is always + * preferred over this. + */ +export const twoFactorInstanceOnly = (): InlineMiddleware => + createInlineMiddleware(() => ({}), 'instanceOnly'); + +/** + * Route-level 2FA enforcement for remediation routes: the instance assert + * still applies, but the organisation assert is exempt — a blocked member + * must always be able to walk away (organisation.leave, invite + * accept/decline; joining is never blocked, access is). + * + * Justified allow-list: every call site needs a justification comment, and + * the drift guard snapshot-asserts the exact membership. + */ +export const twoFactorRemediation = (): InlineMiddleware => + createInlineMiddleware(() => ({}), 'remediation'); + +/** + * Route-level 2FA enforcement exemption for the session bootstrap: BOTH the + * instance and organisation asserts are exempt — a blocked client must be + * able to discover its own enforcement state + * (organisation.internal.getOrganisationSession). + * + * Justified allow-list: every call site needs a justification comment, and + * the drift guard snapshot-asserts the exact membership. + */ +export const twoFactorBootstrap = (): InlineMiddleware => + createInlineMiddleware(() => TWO_FACTOR_SKIP, 'bootstrap'); diff --git a/packages/trpc/server/two-factor-enforcement/resolution.ts b/packages/trpc/server/two-factor-enforcement/resolution.ts new file mode 100644 index 000000000..35311d0c9 --- /dev/null +++ b/packages/trpc/server/two-factor-enforcement/resolution.ts @@ -0,0 +1,497 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status'; +import type { + EnforcementSession, + EnforcementUser, + OrganisationTwoFactorScope, +} from '@documenso/lib/server-only/2fa/org-enforcement'; +import { loadOrganisationTwoFactorScopes } from '@documenso/lib/server-only/2fa/org-enforcement'; +import { mapDocumentIdToSecondaryId, mapTemplateIdToSecondaryId } from '@documenso/lib/utils/envelope'; +import { prisma } from '@documenso/prisma'; + +import type { TwoFactorEnforcementResourceIds } from './scope'; +import { assertScopeBudget } from './scope'; + +/** + * Shared resolution machinery for the inline 2FA enforcement path + * (`twoFactorScope` / `twoFactorScopeFromCtx` and the named exemption + * variants in `./enforce.ts`, running post-`.input()` as route-level + * middlewares). + * + * Holds the per-request cache, the membership-qualified resource → org batch + * lookups, the budget caps and the instance-status cache. Policy decisions + * (which asserts apply, skip semantics) live with the callers. + */ + +/** + * Per-request enforcement cache, keyed by the underlying `Request` object so + * batched tRPC calls within one HTTP request share lookups. `WeakMap` keeps + * this leak-free without touching the context type. + */ +export type RequestEnforcementCache = { + instanceStatusPromise?: ReturnType; + allOrgScopesPromise?: Promise; + + /** + * Organisation ID → enforcement scope (or null when the user is not a + * member / the organisation does not exist). + */ + orgScopeCache: Map; + + /** + * `${kind}:${id}` → resolved organisation IDs for a resource. + */ + resourceOrgIdCache: Map; + + /** + * Budget tracking: unique resource IDs + organisation IDs touched by this + * request. Exceeding the cap rejects the request. + */ + uniqueScopeKeys: Set; +}; + +const requestCaches = new WeakMap(); + +export const getRequestCache = (req: Request): RequestEnforcementCache => { + let cache = requestCaches.get(req); + + if (!cache) { + cache = { + orgScopeCache: new Map(), + resourceOrgIdCache: new Map(), + uniqueScopeKeys: new Set(), + }; + + requestCaches.set(req, cache); + } + + return cache; +}; + +const trackScopeKeys = (cache: RequestEnforcementCache, keys: Array, prefix: string): void => { + for (const key of keys) { + cache.uniqueScopeKeys.add(`${prefix}:${key}`); + } + + assertScopeBudget(cache.uniqueScopeKeys.size); +}; + +type OrgMemberQualifier = { + organisation: { + members: { + some: { + userId: number; + }; + }; + }; +}; + +const buildOrgMemberQualifier = (userId: number): OrgMemberQualifier => ({ + organisation: { + members: { + some: { + userId, + }, + }, + }, +}); + +/** + * Maps extracted resource IDs to the owning organisation IDs with a single + * membership-qualified batch query per resource kind. + * + * IDs that do not resolve (nonexistent, or not accessible to the user) simply + * contribute no scope — the handler's own authorization rejects those + * requests, and enforcement only concerns organisations the user can access. + */ +const resolveResourceOrganisationIds = async ( + userId: number, + resourceIds: TwoFactorEnforcementResourceIds, +): Promise => { + switch (resourceIds.kind) { + case 'organisation': { + return resourceIds.organisationIds; + } + + case 'organisationReference': { + if (resourceIds.references.length === 0) { + return []; + } + + const organisations = await prisma.organisation.findMany({ + where: { + OR: [{ id: { in: resourceIds.references } }, { url: { in: resourceIds.references } }], + members: { some: { userId } }, + }, + select: { id: true }, + }); + + return organisations.map((organisation) => organisation.id); + } + + case 'team': { + if (resourceIds.teamIds.length === 0) { + return []; + } + + const teams = await prisma.team.findMany({ + where: { + id: { in: resourceIds.teamIds }, + ...buildOrgMemberQualifier(userId), + }, + select: { organisationId: true }, + }); + + return teams.map((team) => team.organisationId); + } + + case 'teamReference': { + const teamIds = resourceIds.references.filter((reference): reference is number => typeof reference === 'number'); + const teamUrls = resourceIds.references.filter((reference): reference is string => typeof reference === 'string'); + + if (teamIds.length === 0 && teamUrls.length === 0) { + return []; + } + + const teams = await prisma.team.findMany({ + where: { + OR: [{ id: { in: teamIds } }, { url: { in: teamUrls } }], + ...buildOrgMemberQualifier(userId), + }, + select: { organisationId: true }, + }); + + return teams.map((team) => team.organisationId); + } + + case 'envelope': + case 'document': + case 'template': { + const envelopeIds = resourceIds.kind === 'envelope' ? resourceIds.envelopeIds : []; + + const secondaryIds = [ + ...(resourceIds.kind !== 'template' ? resourceIds.documentIds.map((id) => mapDocumentIdToSecondaryId(id)) : []), + ...(resourceIds.kind !== 'document' ? resourceIds.templateIds.map((id) => mapTemplateIdToSecondaryId(id)) : []), + ]; + + if (envelopeIds.length === 0 && secondaryIds.length === 0) { + return []; + } + + const envelopes = await prisma.envelope.findMany({ + where: { + OR: [{ id: { in: envelopeIds } }, { secondaryId: { in: secondaryIds } }], + team: { organisation: { members: { some: { userId } } } }, + }, + select: { team: { select: { organisationId: true } } }, + }); + + return envelopes.map((envelope) => envelope.team.organisationId); + } + + case 'envelopeItem': { + if (resourceIds.envelopeItemIds.length === 0) { + return []; + } + + const envelopeItems = await prisma.envelopeItem.findMany({ + where: { + id: { in: resourceIds.envelopeItemIds }, + envelope: { team: { organisation: { members: { some: { userId } } } } }, + }, + select: { envelope: { select: { team: { select: { organisationId: true } } } } }, + }); + + return envelopeItems.map((item) => item.envelope.team.organisationId); + } + + case 'field': { + if (resourceIds.fieldIds.length === 0) { + return []; + } + + const fields = await prisma.field.findMany({ + where: { + id: { in: resourceIds.fieldIds }, + envelope: { team: { organisation: { members: { some: { userId } } } } }, + }, + select: { envelope: { select: { team: { select: { organisationId: true } } } } }, + }); + + return fields.map((field) => field.envelope.team.organisationId); + } + + case 'recipient': { + if (resourceIds.recipientIds.length === 0) { + return []; + } + + const recipients = await prisma.recipient.findMany({ + where: { + id: { in: resourceIds.recipientIds }, + envelope: { team: { organisation: { members: { some: { userId } } } } }, + }, + select: { envelope: { select: { team: { select: { organisationId: true } } } } }, + }); + + return recipients.map((recipient) => recipient.envelope.team.organisationId); + } + + case 'attachment': { + if (resourceIds.attachmentIds.length === 0) { + return []; + } + + const attachments = await prisma.envelopeAttachment.findMany({ + where: { + id: { in: resourceIds.attachmentIds }, + envelope: { team: { organisation: { members: { some: { userId } } } } }, + }, + select: { envelope: { select: { team: { select: { organisationId: true } } } } }, + }); + + return attachments.map((attachment) => attachment.envelope.team.organisationId); + } + + case 'folder': { + if (resourceIds.folderIds.length === 0) { + return []; + } + + const folders = await prisma.folder.findMany({ + where: { + id: { in: resourceIds.folderIds }, + team: { organisation: { members: { some: { userId } } } }, + }, + select: { team: { select: { organisationId: true } } }, + }); + + return folders.map((folder) => folder.team.organisationId); + } + + case 'webhook': { + if (resourceIds.webhookIds.length === 0) { + return []; + } + + const webhooks = await prisma.webhook.findMany({ + where: { + id: { in: resourceIds.webhookIds }, + team: { organisation: { members: { some: { userId } } } }, + }, + select: { team: { select: { organisationId: true } } }, + }); + + return webhooks.map((webhook) => webhook.team.organisationId); + } + + case 'organisationGroup': { + if (resourceIds.groupIds.length === 0) { + return []; + } + + const groups = await prisma.organisationGroup.findMany({ + where: { + id: { in: resourceIds.groupIds }, + organisation: { members: { some: { userId } } }, + }, + select: { organisationId: true }, + }); + + return groups.map((group) => group.organisationId); + } + + case 'teamGroup': { + if (resourceIds.groupIds.length === 0) { + return []; + } + + const teamGroups = await prisma.teamGroup.findMany({ + where: { + id: { in: resourceIds.groupIds }, + team: { organisation: { members: { some: { userId } } } }, + }, + select: { team: { select: { organisationId: true } } }, + }); + + return teamGroups.map((teamGroup) => teamGroup.team.organisationId); + } + + case 'organisationEmail': { + if (resourceIds.emailIds.length === 0) { + return []; + } + + const emails = await prisma.organisationEmail.findMany({ + where: { + id: { in: resourceIds.emailIds }, + organisation: { members: { some: { userId } } }, + }, + select: { organisationId: true }, + }); + + return emails.map((email) => email.organisationId); + } + + case 'organisationEmailDomain': { + if (resourceIds.emailDomainIds.length === 0) { + return []; + } + + const emailDomains = await prisma.emailDomain.findMany({ + where: { + id: { in: resourceIds.emailDomainIds }, + organisation: { members: { some: { userId } } }, + }, + select: { organisationId: true }, + }); + + return emailDomains.map((emailDomain) => emailDomain.organisationId); + } + } +}; + +const resourceIdsToCacheKeys = (resourceIds: TwoFactorEnforcementResourceIds): Array => { + switch (resourceIds.kind) { + case 'organisation': + return resourceIds.organisationIds; + case 'organisationReference': + return resourceIds.references; + case 'team': + return resourceIds.teamIds; + case 'teamReference': + return resourceIds.references; + case 'envelope': + return [...resourceIds.envelopeIds, ...resourceIds.documentIds, ...resourceIds.templateIds]; + case 'document': + return resourceIds.documentIds; + case 'template': + return resourceIds.templateIds; + case 'envelopeItem': + return resourceIds.envelopeItemIds; + case 'field': + return resourceIds.fieldIds; + case 'recipient': + return resourceIds.recipientIds; + case 'attachment': + return resourceIds.attachmentIds; + case 'folder': + return resourceIds.folderIds; + case 'webhook': + return resourceIds.webhookIds; + case 'organisationGroup': + case 'teamGroup': + return resourceIds.groupIds; + case 'organisationEmail': + return resourceIds.emailIds; + case 'organisationEmailDomain': + return resourceIds.emailDomainIds; + } +}; + +export const loadOrgScopesCached = async ( + cache: RequestEnforcementCache, + userId: number, + organisationIds: string[], +): Promise => { + const uniqueIds = [...new Set(organisationIds)]; + + trackScopeKeys(cache, uniqueIds, 'org'); + + const missingIds = uniqueIds.filter((id) => !cache.orgScopeCache.has(id)); + + if (missingIds.length > 0) { + const scopes = await loadOrganisationTwoFactorScopes({ userId, organisationIds: missingIds }); + + for (const scope of scopes) { + cache.orgScopeCache.set(scope.organisationId, scope); + } + + for (const id of missingIds) { + if (!cache.orgScopeCache.has(id)) { + cache.orgScopeCache.set(id, null); + } + } + } + + return uniqueIds.flatMap((id) => { + const scope = cache.orgScopeCache.get(id); + + return scope ? [scope] : []; + }); +}; + +export const loadAllOrgScopesCached = async ( + cache: RequestEnforcementCache, + userId: number, +): Promise => { + if (!cache.allOrgScopesPromise) { + cache.allOrgScopesPromise = loadOrganisationTwoFactorScopes({ userId }); + } + + const scopes = await cache.allOrgScopesPromise; + + trackScopeKeys( + cache, + scopes.map((scope) => scope.organisationId), + 'org', + ); + + return scopes; +}; + +export const resolveResourceOrgIdsCached = async ( + cache: RequestEnforcementCache, + userId: number, + resourceIds: TwoFactorEnforcementResourceIds, +): Promise => { + const cacheKeys = resourceIdsToCacheKeys(resourceIds).map((key) => `${resourceIds.kind}:${key}`); + + trackScopeKeys(cache, cacheKeys, 'resource'); + + const cachedOrgIds: string[] = []; + const isFullyCached = cacheKeys.every((key) => cache.resourceOrgIdCache.has(key)); + + if (isFullyCached) { + for (const key of cacheKeys) { + cachedOrgIds.push(...(cache.resourceOrgIdCache.get(key) ?? [])); + } + + return cachedOrgIds; + } + + // Partial cache hits are rare (identical calls within a batch are the + // common case), so on any miss we resolve the full set in one batch query + // and cache the combined result under every key. + const organisationIds = await resolveResourceOrganisationIds(userId, resourceIds); + + for (const key of cacheKeys) { + cache.resourceOrgIdCache.set(key, organisationIds); + } + + return organisationIds; +}; + +/** + * Instance-wide enforcement status, cached per request so batched calls share + * the setting lookup. + */ +export const getInstanceStatusCached = async ( + cache: RequestEnforcementCache, + user: EnforcementUser, + session: NonNullable, +): ReturnType => { + if (!cache.instanceStatusPromise) { + cache.instanceStatusPromise = getTwoFactorEnforcementStatus({ user, session }); + } + + return await cache.instanceStatusPromise; +}; + +export const throwTwoFactorRequired = (): never => { + throw new AppError(AppErrorCode.TWO_FACTOR_REQUIRED, { + message: 'Two-factor authentication is required to access this resource.', + userMessage: 'Two-factor authentication is required. Please enable it to continue.', + statusCode: 403, + }); +}; diff --git a/packages/trpc/server/two-factor-enforcement/scope.test.ts b/packages/trpc/server/two-factor-enforcement/scope.test.ts new file mode 100644 index 000000000..88af937d8 --- /dev/null +++ b/packages/trpc/server/two-factor-enforcement/scope.test.ts @@ -0,0 +1,137 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { describe, expect, it } from 'vitest'; + +import { + assertScopeBudget, + normalizeTwoFactorScopeResult, + TWO_FACTOR_CTX_TEAM, + TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS, + TWO_FACTOR_ENFORCEMENT_MAX_UNIQUE_SCOPES, + TWO_FACTOR_SKIP, +} from './scope'; + +describe('normalizeTwoFactorScopeResult', () => { + it('maps the sentinels to their outcomes', () => { + expect(normalizeTwoFactorScopeResult(TWO_FACTOR_SKIP)).toEqual({ type: 'skip' }); + expect(normalizeTwoFactorScopeResult(TWO_FACTOR_CTX_TEAM)).toEqual({ type: 'ctxTeam' }); + }); + + it('normalizes an empty descriptor to no resources (instance assert only)', () => { + expect(normalizeTwoFactorScopeResult({})).toEqual({ type: 'resources', resources: [] }); + }); + + it('normalizes single IDs and ID arrays', () => { + expect(normalizeTwoFactorScopeResult({ envelope: 'envelope_1' })).toEqual({ + type: 'resources', + resources: [{ kind: 'envelope', envelopeIds: ['envelope_1'], documentIds: [], templateIds: [] }], + }); + + expect(normalizeTwoFactorScopeResult({ envelope: ['a', 'b'] })).toEqual({ + type: 'resources', + resources: [{ kind: 'envelope', envelopeIds: ['a', 'b'], documentIds: [], templateIds: [] }], + }); + }); + + it('normalizes every resource kind to its resource-IDs variant', () => { + const result = normalizeTwoFactorScopeResult({ + organisation: 'org_1', + organisationReference: 'my-org-url', + team: 1, + teamReference: 7, + envelope: 'envelope_1', + document: 2, + template: 3, + envelopeItem: 'item_1', + field: 4, + recipient: 5, + attachment: 'attachment_1', + folder: 'folder_1', + webhook: 'webhook_1', + organisationGroup: 'group_1', + teamGroup: 'team_group_1', + organisationEmail: 'email_1', + organisationEmailDomain: 'domain_1', + }); + + expect(result).toEqual({ + type: 'resources', + resources: [ + { kind: 'organisation', organisationIds: ['org_1'] }, + { kind: 'organisationReference', references: ['my-org-url'] }, + { kind: 'team', teamIds: [1] }, + { kind: 'teamReference', references: [7] }, + { kind: 'envelope', envelopeIds: ['envelope_1'], documentIds: [], templateIds: [] }, + { kind: 'document', documentIds: [2] }, + { kind: 'template', templateIds: [3] }, + { kind: 'envelopeItem', envelopeItemIds: ['item_1'] }, + { kind: 'field', fieldIds: [4] }, + { kind: 'recipient', recipientIds: [5] }, + { kind: 'attachment', attachmentIds: ['attachment_1'] }, + { kind: 'folder', folderIds: ['folder_1'] }, + { kind: 'webhook', webhookIds: ['webhook_1'] }, + { kind: 'organisationGroup', groupIds: ['group_1'] }, + { kind: 'teamGroup', groupIds: ['team_group_1'] }, + { kind: 'organisationEmail', emailIds: ['email_1'] }, + { kind: 'organisationEmailDomain', emailDomainIds: ['domain_1'] }, + ], + }); + }); + + it('accepts a string-or-number teamReference (URL slug or ID)', () => { + expect(normalizeTwoFactorScopeResult({ teamReference: ['my-team-url', 7] })).toEqual({ + type: 'resources', + resources: [{ kind: 'teamReference', references: ['my-team-url', 7] }], + }); + }); + + it('merges descriptor arrays per kind so each kind resolves in one batch', () => { + expect(normalizeTwoFactorScopeResult([{ envelope: 'a' }, { envelope: ['b'], recipient: 7 }])).toEqual({ + type: 'resources', + resources: [ + { kind: 'envelope', envelopeIds: ['a', 'b'], documentIds: [], templateIds: [] }, + { kind: 'recipient', recipientIds: [7] }, + ], + }); + }); + + it('accepts ID sets at the bulk cap', () => { + const ids = Array.from({ length: TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS }, (_, i) => `envelope_${i}`); + + expect(normalizeTwoFactorScopeResult({ envelope: ids })).toEqual({ + type: 'resources', + resources: [{ kind: 'envelope', envelopeIds: ids, documentIds: [], templateIds: [] }], + }); + }); + + it('rejects ID sets above the bulk cap instead of truncating', () => { + const oversized = Array.from({ length: TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS + 1 }, (_, i) => `envelope_${i}`); + + try { + normalizeTwoFactorScopeResult({ envelope: oversized }); + expect.unreachable(); + } catch (error) { + expect(AppError.parseError(error).code).toBe(AppErrorCode.LIMIT_EXCEEDED); + } + }); + + it('rejects when merged descriptors exceed the bulk cap combined', () => { + const half = Array.from({ length: TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS / 2 + 1 }, (_, i) => `envelope_${i}`); + + expect(() => normalizeTwoFactorScopeResult([{ envelope: half }, { envelope: half }])).toThrowError(AppError); + }); +}); + +describe('assertScopeBudget', () => { + it('allows counts within the budget', () => { + expect(() => assertScopeBudget(TWO_FACTOR_ENFORCEMENT_MAX_UNIQUE_SCOPES)).not.toThrow(); + }); + + it('rejects counts above the budget', () => { + try { + assertScopeBudget(TWO_FACTOR_ENFORCEMENT_MAX_UNIQUE_SCOPES + 1); + expect.unreachable(); + } catch (error) { + expect(AppError.parseError(error).code).toBe(AppErrorCode.LIMIT_EXCEEDED); + } + }); +}); diff --git a/packages/trpc/server/two-factor-enforcement/scope.ts b/packages/trpc/server/two-factor-enforcement/scope.ts new file mode 100644 index 000000000..b3d4625e0 --- /dev/null +++ b/packages/trpc/server/two-factor-enforcement/scope.ts @@ -0,0 +1,293 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; + +/** + * Pure (I/O free) building blocks for the inline (`.use()`-based) 2FA + * enforcement path: the scope descriptor a route-level resolver returns, the + * sentinels for the non-resource outcomes, the normalizer that maps a + * resolver result onto the shared resource-resolution machinery, and the + * request budget caps. + * + * Everything here is unit-testable without a database. + */ + +/** + * Maximum number of IDs a single bulk array may contain before the request is + * rejected (not truncated). + */ +export const TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS = 100; + +/** + * Maximum number of cumulative unique organisation scopes a single request + * (including batched tRPC calls) may resolve before being rejected. + */ +export const TWO_FACTOR_ENFORCEMENT_MAX_UNIQUE_SCOPES = 200; + +/** + * The resource-ID sets consumed by the shared organisation resolution + * machinery (`./resolution.ts`). Each variant maps to a single + * membership-qualified batch lookup resolving the IDs to their owning + * organisation(s). + */ +export type TwoFactorEnforcementResourceIds = + | { kind: 'organisation'; organisationIds: string[] } + | { kind: 'organisationReference'; references: string[] } + | { kind: 'team'; teamIds: number[] } + | { kind: 'teamReference'; references: Array } + | { kind: 'envelope'; envelopeIds: string[]; documentIds: number[]; templateIds: number[] } + | { kind: 'document'; documentIds: number[] } + | { kind: 'template'; templateIds: number[] } + | { kind: 'envelopeItem'; envelopeItemIds: string[] } + | { kind: 'field'; fieldIds: number[] } + | { kind: 'recipient'; recipientIds: number[] } + | { kind: 'attachment'; attachmentIds: string[] } + | { kind: 'folder'; folderIds: string[] } + | { kind: 'webhook'; webhookIds: string[] } + | { kind: 'organisationGroup'; groupIds: string[] } + | { kind: 'teamGroup'; groupIds: string[] } + | { kind: 'organisationEmail'; emailIds: string[] } + | { kind: 'organisationEmailDomain'; emailDomainIds: string[] }; + +/** + * Tracks the cumulative number of unique organisation scopes resolved within + * a single request and rejects when the budget is exceeded. + */ +export const assertScopeBudget = (uniqueScopeCount: number): void => { + if (uniqueScopeCount > TWO_FACTOR_ENFORCEMENT_MAX_UNIQUE_SCOPES) { + throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { + message: 'Too many organisation scopes resolved for a single request.', + statusCode: 400, + }); + } +}; + +/** + * Sentinel: the call is token-authorized — authorization derives from a token + * in the input and the session cookie is incidental, so BOTH the instance and + * organisation asserts are skipped (an instance-blocked user must still be + * able to open someone else's signing link while signed in). + */ +export const TWO_FACTOR_SKIP = Symbol('TWO_FACTOR_SKIP'); + +/** + * Sentinel: the procedure operates in the team context derived from + * `ctx.teamId` (the `x-team-id` header). When no team header is present the + * procedure is treated as a cross-organisation query and every organisation + * the user belongs to is in scope (conservative). + */ +export const TWO_FACTOR_CTX_TEAM = Symbol('TWO_FACTOR_CTX_TEAM'); + +/** + * Organisation scope declared as a function of the parsed input. Each key is + * a resource kind, each value the ID (or IDs) whose owning organisation(s) + * are in scope for the request. + * + * An empty descriptor (`{}`) means "no organisation scope" — the instance + * assert still applies. + * + * The reference kinds (`organisationReference` / `teamReference`) accept an + * ID or a URL slug and resolve via a membership-qualified OR lookup. + */ +export type TwoFactorScopeDescriptor = { + organisation?: string | string[]; + organisationReference?: string | string[]; + team?: number | number[]; + teamReference?: string | number | Array; + envelope?: string | string[]; + document?: number | number[]; + template?: number | number[]; + envelopeItem?: string | string[]; + field?: number | number[]; + recipient?: number | number[]; + attachment?: string | string[]; + folder?: string | string[]; + webhook?: string | string[]; + organisationGroup?: string | string[]; + teamGroup?: string | string[]; + organisationEmail?: string | string[]; + organisationEmailDomain?: string | string[]; +}; + +export type TwoFactorScopeResult = + | TwoFactorScopeDescriptor + | TwoFactorScopeDescriptor[] + | typeof TWO_FACTOR_SKIP + | typeof TWO_FACTOR_CTX_TEAM; + +export type NormalizedTwoFactorScope = + | { type: 'skip' } + | { type: 'ctxTeam' } + | { type: 'resources'; resources: TwoFactorEnforcementResourceIds[] }; + +const toArray = (value: T | T[] | undefined): T[] => { + if (value === undefined) { + return []; + } + + return Array.isArray(value) ? value : [value]; +}; + +/** + * The bulk cap applied to the already-parsed IDs a resolver returns. Rejects + * rather than truncates. + */ +const assertBulkCap = (ids: unknown[]): void => { + if (ids.length > TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS) { + throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { + message: 'Too many resource IDs for 2FA enforcement resolution.', + statusCode: 400, + }); + } +}; + +/** + * Normalizes a resolver result into either a sentinel outcome or the + * resource-ID sets consumed by the shared organisation resolution machinery. + * + * Descriptors are merged per kind so a multi-descriptor result still resolves + * with a single batch query per resource kind. + */ +export const normalizeTwoFactorScopeResult = (result: TwoFactorScopeResult): NormalizedTwoFactorScope => { + if (result === TWO_FACTOR_SKIP) { + return { type: 'skip' }; + } + + if (result === TWO_FACTOR_CTX_TEAM) { + return { type: 'ctxTeam' }; + } + + const descriptors = Array.isArray(result) ? result : [result]; + + const merged: { + organisation: string[]; + organisationReference: string[]; + team: number[]; + teamReference: Array; + envelope: string[]; + document: number[]; + template: number[]; + envelopeItem: string[]; + field: number[]; + recipient: number[]; + attachment: string[]; + folder: string[]; + webhook: string[]; + organisationGroup: string[]; + teamGroup: string[]; + organisationEmail: string[]; + organisationEmailDomain: string[]; + } = { + organisation: [], + organisationReference: [], + team: [], + teamReference: [], + envelope: [], + document: [], + template: [], + envelopeItem: [], + field: [], + recipient: [], + attachment: [], + folder: [], + webhook: [], + organisationGroup: [], + teamGroup: [], + organisationEmail: [], + organisationEmailDomain: [], + }; + + for (const descriptor of descriptors) { + merged.organisation.push(...toArray(descriptor.organisation)); + merged.organisationReference.push(...toArray(descriptor.organisationReference)); + merged.team.push(...toArray(descriptor.team)); + merged.teamReference.push(...toArray(descriptor.teamReference)); + merged.envelope.push(...toArray(descriptor.envelope)); + merged.document.push(...toArray(descriptor.document)); + merged.template.push(...toArray(descriptor.template)); + merged.envelopeItem.push(...toArray(descriptor.envelopeItem)); + merged.field.push(...toArray(descriptor.field)); + merged.recipient.push(...toArray(descriptor.recipient)); + merged.attachment.push(...toArray(descriptor.attachment)); + merged.folder.push(...toArray(descriptor.folder)); + merged.webhook.push(...toArray(descriptor.webhook)); + merged.organisationGroup.push(...toArray(descriptor.organisationGroup)); + merged.teamGroup.push(...toArray(descriptor.teamGroup)); + merged.organisationEmail.push(...toArray(descriptor.organisationEmail)); + merged.organisationEmailDomain.push(...toArray(descriptor.organisationEmailDomain)); + } + + for (const ids of Object.values(merged)) { + assertBulkCap(ids); + } + + const resources: TwoFactorEnforcementResourceIds[] = []; + + if (merged.organisation.length > 0) { + resources.push({ kind: 'organisation', organisationIds: merged.organisation }); + } + + if (merged.organisationReference.length > 0) { + resources.push({ kind: 'organisationReference', references: merged.organisationReference }); + } + + if (merged.team.length > 0) { + resources.push({ kind: 'team', teamIds: merged.team }); + } + + if (merged.teamReference.length > 0) { + resources.push({ kind: 'teamReference', references: merged.teamReference }); + } + + if (merged.envelope.length > 0) { + resources.push({ kind: 'envelope', envelopeIds: merged.envelope, documentIds: [], templateIds: [] }); + } + + if (merged.document.length > 0) { + resources.push({ kind: 'document', documentIds: merged.document }); + } + + if (merged.template.length > 0) { + resources.push({ kind: 'template', templateIds: merged.template }); + } + + if (merged.envelopeItem.length > 0) { + resources.push({ kind: 'envelopeItem', envelopeItemIds: merged.envelopeItem }); + } + + if (merged.field.length > 0) { + resources.push({ kind: 'field', fieldIds: merged.field }); + } + + if (merged.recipient.length > 0) { + resources.push({ kind: 'recipient', recipientIds: merged.recipient }); + } + + if (merged.attachment.length > 0) { + resources.push({ kind: 'attachment', attachmentIds: merged.attachment }); + } + + if (merged.folder.length > 0) { + resources.push({ kind: 'folder', folderIds: merged.folder }); + } + + if (merged.webhook.length > 0) { + resources.push({ kind: 'webhook', webhookIds: merged.webhook }); + } + + if (merged.organisationGroup.length > 0) { + resources.push({ kind: 'organisationGroup', groupIds: merged.organisationGroup }); + } + + if (merged.teamGroup.length > 0) { + resources.push({ kind: 'teamGroup', groupIds: merged.teamGroup }); + } + + if (merged.organisationEmail.length > 0) { + resources.push({ kind: 'organisationEmail', emailIds: merged.organisationEmail }); + } + + if (merged.organisationEmailDomain.length > 0) { + resources.push({ kind: 'organisationEmailDomain', emailDomainIds: merged.organisationEmailDomain }); + } + + return { type: 'resources', resources }; +}; diff --git a/packages/trpc/server/webhook-router/find-webhook-calls.ts b/packages/trpc/server/webhook-router/find-webhook-calls.ts index 56f2f021e..6867bcb15 100644 --- a/packages/trpc/server/webhook-router/find-webhook-calls.ts +++ b/packages/trpc/server/webhook-router/find-webhook-calls.ts @@ -6,11 +6,13 @@ import { prisma } from '@documenso/prisma'; import type { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZFindWebhookCallsRequestSchema, ZFindWebhookCallsResponseSchema } from './find-webhook-calls.types'; export const findWebhookCallsRoute = authenticatedProcedure .input(ZFindWebhookCallsRequestSchema) .output(ZFindWebhookCallsResponseSchema) + .use(twoFactorScope((input) => ({ webhook: input.webhookId }))) .query(async ({ input, ctx }) => { const { webhookId, page, perPage, status, query, events } = input; diff --git a/packages/trpc/server/webhook-router/resend-webhook-call.ts b/packages/trpc/server/webhook-router/resend-webhook-call.ts index a68ac52b1..71c791969 100644 --- a/packages/trpc/server/webhook-router/resend-webhook-call.ts +++ b/packages/trpc/server/webhook-router/resend-webhook-call.ts @@ -6,11 +6,13 @@ import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; +import { twoFactorScope } from '../two-factor-enforcement/enforce'; import { ZResendWebhookCallRequestSchema, ZResendWebhookCallResponseSchema } from './resend-webhook-call.types'; export const resendWebhookCallRoute = authenticatedProcedure .input(ZResendWebhookCallRequestSchema) .output(ZResendWebhookCallResponseSchema) + .use(twoFactorScope((input) => ({ webhook: input.webhookId }))) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { webhookId, webhookCallId } = input; diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index 423a39e1b..28fcc1c67 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -9,6 +9,7 @@ import { fireAndForget } from '@documenso/lib/universal/fire-and-forget'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure, router } from '../trpc'; +import { twoFactorScopeFromCtx } from '../two-factor-enforcement/enforce'; import { findWebhookCallsRoute } from './find-webhook-calls'; import { resendWebhookCallRoute } from './resend-webhook-call'; import { @@ -25,7 +26,7 @@ export const webhookRouter = router({ resend: resendWebhookCallRoute, }, - getTeamWebhooks: authenticatedProcedure.query(async ({ ctx }) => { + getTeamWebhooks: authenticatedProcedure.use(twoFactorScopeFromCtx()).query(async ({ ctx }) => { ctx.logger.info({ input: { teamId: ctx.teamId, @@ -35,102 +36,117 @@ export const webhookRouter = router({ return await getWebhooksByTeamId(ctx.teamId, ctx.user.id); }), - getWebhookById: authenticatedProcedure.input(ZGetWebhookByIdRequestSchema).query(async ({ input, ctx }) => { - const { id } = input; + getWebhookById: authenticatedProcedure + .input(ZGetWebhookByIdRequestSchema) + .use(twoFactorScopeFromCtx()) + .query(async ({ input, ctx }) => { + const { id } = input; - ctx.logger.info({ - input: { - id, - }, - }); - - return await getWebhookById({ - id, - userId: ctx.user.id, - teamId: ctx.teamId, - }); - }), - - createWebhook: authenticatedProcedure.input(ZCreateWebhookRequestSchema).mutation(async ({ input, ctx }) => { - const { enabled, eventTriggers, secret, webhookUrl } = input; - - const webhook = await createWebhook({ - enabled, - secret, - webhookUrl, - eventTriggers, - teamId: ctx.teamId, - userId: ctx.user.id, - }); - - fireAndForget(async () => { - const team = await prisma.team.findFirst({ - where: { id: ctx.teamId }, - select: { organisationId: true }, - }); - - captureServerEvent({ - event: 'App: Webhook Created', - userId: ctx.user.id, - teamId: ctx.teamId, - organisationId: team?.organisationId, - properties: { - triggerCount: eventTriggers.length, + ctx.logger.info({ + input: { + id, }, }); - }); - return webhook; - }), - - deleteWebhook: authenticatedProcedure.input(ZDeleteWebhookRequestSchema).mutation(async ({ input, ctx }) => { - const { id } = input; - - ctx.logger.info({ - input: { + return await getWebhookById({ id, - }, - }); + userId: ctx.user.id, + teamId: ctx.teamId, + }); + }), - return await deleteWebhookById({ - id, - teamId: ctx.teamId, - userId: ctx.user.id, - }); - }), + createWebhook: authenticatedProcedure + .input(ZCreateWebhookRequestSchema) + .use(twoFactorScopeFromCtx()) + .mutation(async ({ input, ctx }) => { + const { enabled, eventTriggers, secret, webhookUrl } = input; - editWebhook: authenticatedProcedure.input(ZEditWebhookRequestSchema).mutation(async ({ input, ctx }) => { - const { id, ...data } = input; + const webhook = await createWebhook({ + enabled, + secret, + webhookUrl, + eventTriggers, + teamId: ctx.teamId, + userId: ctx.user.id, + }); - ctx.logger.info({ - input: { + fireAndForget(async () => { + const team = await prisma.team.findFirst({ + where: { id: ctx.teamId }, + select: { organisationId: true }, + }); + + captureServerEvent({ + event: 'App: Webhook Created', + userId: ctx.user.id, + teamId: ctx.teamId, + organisationId: team?.organisationId, + properties: { + triggerCount: eventTriggers.length, + }, + }); + }); + + return webhook; + }), + + deleteWebhook: authenticatedProcedure + .input(ZDeleteWebhookRequestSchema) + .use(twoFactorScopeFromCtx()) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + return await deleteWebhookById({ id, - }, - }); + teamId: ctx.teamId, + userId: ctx.user.id, + }); + }), - return await editWebhook({ - id, - data, - userId: ctx.user.id, - teamId: ctx.teamId, - }); - }), + editWebhook: authenticatedProcedure + .input(ZEditWebhookRequestSchema) + .use(twoFactorScopeFromCtx()) + .mutation(async ({ input, ctx }) => { + const { id, ...data } = input; - testWebhook: authenticatedProcedure.input(ZTriggerTestWebhookRequestSchema).mutation(async ({ input, ctx }) => { - const { id, event } = input; + ctx.logger.info({ + input: { + id, + }, + }); - ctx.logger.info({ - input: { + return await editWebhook({ + id, + data, + userId: ctx.user.id, + teamId: ctx.teamId, + }); + }), + + testWebhook: authenticatedProcedure + .input(ZTriggerTestWebhookRequestSchema) + .use(twoFactorScopeFromCtx()) + .mutation(async ({ input, ctx }) => { + const { id, event } = input; + + ctx.logger.info({ + input: { + id, + event, + }, + }); + + return await triggerTestWebhook({ id, event, - }, - }); - - return await triggerTestWebhook({ - id, - event, - userId: ctx.user.id, - teamId: ctx.teamId, - }); - }), + userId: ctx.user.id, + teamId: ctx.teamId, + }); + }), }); diff --git a/packages/trpc/vitest.config.ts b/packages/trpc/vitest.config.ts new file mode 100644 index 000000000..7e7a0af2a --- /dev/null +++ b/packages/trpc/vitest.config.ts @@ -0,0 +1,13 @@ +import { lingui } from '@lingui/vite-plugin'; +import macrosPlugin from 'vite-plugin-babel-macros'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // The drift guard test imports the full `appRouter`, which transitively + // pulls modules using lingui macros — the same transform pipeline as the + // app build is required for the import to succeed. + plugins: [macrosPlugin(), lingui()], + test: { + include: ['**/*.test.ts'], + }, +});