Compare commits

...
Author SHA1 Message Date
Lucas Smith ef31a16b99 Merge branch 'main' into feat/instance-2fa 2026-09-15 14:42:25 +10:00
Lucas Smith fe2641ff57 chore: tests 2026-09-14 14:16:23 +10:00
Lucas Smith df815e5b44 feat: add instance and org 2fa enrolment and enforcement 2026-09-08 14:12:25 +10:00
243 changed files with 8325 additions and 537 deletions
+20
View File
@@ -30,6 +30,26 @@ jobs:
- name: Build app
run: npm run build
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: ./.github/actions/node-install
- name: Copy env
run: cp .env.example .env
# Includes the 2FA enforcement drift guard (packages/trpc), which is the
# only check that catches a session route without enforcement middleware.
- name: Run unit tests
run: npm run test -w @documenso/lib -w @documenso/trpc
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
@@ -9,6 +9,7 @@
"background-jobs",
"signing-certificate",
"telemetry",
"two-factor-enforcement",
"organisation-limits",
"advanced"
]
@@ -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
<Callout type="warn">
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.
</Callout>
## 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
@@ -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
<AlertDescription className="mr-2">
<Trans>
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.
</Trans>
</AlertDescription>
</div>
@@ -108,7 +109,8 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
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.
</Trans>
</AlertDescription>
</Alert>
@@ -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: _(
@@ -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',
});
}
@@ -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<typeof ZTwoFactorEnforcementFormSchema>;
/**
* 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<TTwoFactorEnforcementFormSchema>({
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 (
<div className="flex justify-center rounded-lg border py-16">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isInstanceEnforcementActive}
className="flex flex-col gap-y-4"
>
{isInstanceEnforcementActive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Instance policy takes precedence</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor authentication is enforced instance-wide by your administrator, so the organisation policy
below is not editable while the instance policy is active.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="twoFactorRequired"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Members must enable two-factor authentication to access this organisation. Joining is never
blocked — the grace period starts when a member joins.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="twoFactorGracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a member has to enable two-factor authentication after joining. 0 blocks organisation
access immediately until they enrol.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{settings.twoFactorRequired && settings.twoFactorEnforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since {i18n.date(settings.twoFactorEnforcedFrom, { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<ul className="list-disc space-y-1 pl-4">
<li>
<Trans>
API tokens are exempt: tokens minted before a member's deadline keep working after it. Blocked
members cannot mint new tokens.
</Trans>
</li>
<li>
<Trans>
Members who have not yet complied still occupy a seat and count towards your member limit.
</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Members who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their organisation access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for members</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -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<typeof ZTwoFactorEnforcementFormSchema>;
/**
* 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<TTwoFactorEnforcementFormSchema>({
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 (
<div>
<h2 className="font-semibold">
<Trans>Instance Two-Factor Enforcement</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Require every user on this instance, including administrators, to enable two-factor authentication within a
grace period.
</Trans>
</p>
{isLoading || !enforcementConfig ? (
<div className="mt-4 flex justify-center rounded-lg border py-16">
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting || !isLicensed} className="mt-4 flex flex-col gap-y-4">
{!isLicensed && !isConfiguredButInactive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Requires a license</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Instance-wide two-factor enforcement requires a Documenso license that includes this feature.
</Trans>
</AlertDescription>
</Alert>
)}
{isConfiguredButInactive && (
<Alert variant="warning">
<AlertTitle>
<Trans>Configured but inactive</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
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.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Users who have not enabled two-factor authentication by their deadline are redirected to a
forced enrolment page before they can continue.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="gracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a user has to enable two-factor authentication. 0 forces enrolment immediately
after signing up or signing in.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{enforcementConfig.isActive && enforcementConfig.enforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since{' '}
{i18n.date(new Date(enforcementConfig.enforcedFrom), { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<Trans>
API tokens are exempt: tokens minted before a user's deadline keep working after it. Blocked users
cannot mint new tokens.
</Trans>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Users who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for users</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
{isConfiguredButInactive && (
<div className="mt-4 flex justify-end">
<Button type="button" variant="destructive" loading={isUpdatePending} onClick={onDisableOnly}>
<Trans>Disable enforcement</Trans>
</Button>
</div>
)}
</form>
</Form>
)}
</div>
);
};
@@ -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:<organisationId>`.
*/
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<string[]>([]);
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 (
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-between gap-x-4 px-4 py-2 font-medium text-sm text-yellow-900">
<div className="flex items-center gap-x-2">
<AlertTriangleIcon className="h-4 w-4 flex-shrink-0" />
<span>
{candidate.isSessionUnverified ? (
<Trans>
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.
</Trans>
) : (
<Trans>
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.{' '}
<Link to={`/onboarding/2fa?returnTo=${returnTo}`} className="underline">
Enable it now
</Link>{' '}
to keep access.
</Trans>
)}
</span>
</div>
<button
type="button"
className="rounded p-1 hover:bg-yellow-300 dark:hover:bg-yellow-500"
aria-label="Dismiss"
onClick={onDismiss}
>
<XIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
@@ -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 ? (
<Badge variant="default">
<Trans>Enrolled</Trans>
</Badge>
) : (
<Badge variant="neutral">
<Trans>Not enrolled</Trans>
</Badge>
),
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
+14 -1
View File
@@ -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';
@@ -63,9 +65,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(
@@ -83,6 +95,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
user: session.user,
session: session.session,
organisations: organisations || [],
twoFactorEnforcement,
}
: null,
publicEnv: createPublicEnv(),
@@ -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 (
<GenericErrorLayout
errorCode={403}
errorCodeMap={{
403: {
heading: msg`Two-factor authentication required`,
subHeading: msg`403 Forbidden`,
message: requiresRelogin
? msg`This organisation requires two-factor authentication. 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.`
: msg`This organisation requires two-factor authentication. Enable it for your account to regain access. The rest of your account remains available.`,
},
}}
primaryButton={
requiresRelogin ? (
<Button onClick={() => void authClient.signOut()}>
<Trans>Sign out</Trans>
</Button>
) : (
<Button asChild>
<Link to={`/onboarding/2fa?returnTo=${returnTo}`}>
<Trans>Set up two-factor authentication</Trans>
</Link>
</Button>
)
}
secondaryButton={
<Button variant="ghost" asChild>
<Link to="/">
<Trans>Go home</Trans>
</Link>
</Button>
}
/>
);
}
if (orgNotFound || teamNotFound) {
return (
<GenericErrorLayout
@@ -112,6 +244,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={currentTeam || null}>
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
<TwoFactorGraceBanner />
<OrganisationBillingBanner />
<OrganisationQuotaBanner />
@@ -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
<AdminSiteBannerSection banner={banner} />
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
<AdminTwoFactorEnforcementSection />
</div>
</div>
);
@@ -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 }) => {
</Accordion>
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{canResetTwoFactor && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
@@ -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() {
<OrganisationUpdateForm />
</div>
{canExecuteOrganisationAction('MANAGE_ORGANISATION_SECURITY', organisation.currentOrganisationRole) && (
<>
<hr className="my-6" />
<SettingsHeader
title={_(msg`Two-factor authentication`)}
subtitle={_(msg`Require members of this organisation to use two-factor authentication.`)}
/>
<OrganisationTwoFactorEnforcementForm />
</>
)}
{canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -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<typeof ZTwoFactorChallengeFormSchema>;
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<TTwoFactorChallengeFormSchema>({
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 (
<div className="w-screen max-w-lg px-4">
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Checking your sign-in...</Trans>
</p>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<h1 className="font-semibold text-2xl">
<Trans>Two-Factor Authentication</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
{twoFactorAuthenticationMethod === 'totp' ? (
<Trans>Enter the code from your authenticator app to finish signing in.</Trans>
) : (
<Trans>Enter one of your backup codes to finish signing in.</Trans>
)}
</p>
<hr className="-mx-6 my-4" />
<Form {...form}>
<form className="flex w-full flex-col gap-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
{twoFactorAuthenticationMethod === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{twoFactorAuthenticationMethod === 'backup' && (
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
<Button type="button" variant="secondary" onClick={onToggleTwoFactorAuthenticationMethodClick}>
{twoFactorAuthenticationMethod === 'totp' ? (
<Trans>Use Backup Code</Trans>
) : (
<Trans>Use Authenticator</Trans>
)}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</div>
</fieldset>
</form>
</Form>
<p className="mt-6 text-center text-muted-foreground text-sm">
<Trans>
Not you?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Back to sign in
</Link>
</Trans>
</p>
</div>
</div>
);
}
@@ -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 = ({
</Trans>
</p>
{/* Non-blocking notice: accepting always succeeds; access to the
organisation blocks only after the grace period expires. */}
{organisationTwoFactorRequired && !actionIsDecline && (
<p className="mt-2 mb-4 text-muted-foreground text-sm">
{organisationTwoFactorGracePeriodDays > 0 ? (
<Trans>
This organisation requires two-factor authentication. You will need to enable it within{' '}
{organisationTwoFactorGracePeriodDays} days of joining to keep access to the organisation.
</Trans>
) : (
<Trans>
This organisation requires two-factor authentication. You will need to enable it immediately after
joining to access the organisation.
</Trans>
)}
</p>
)}
{acceptFailureReason && (
<p className="mt-2 mb-4 text-destructive text-sm">
{match(acceptFailureReason)
@@ -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
</div>
</div>
{/* Non-blocking notice: confirming always succeeds; organisation
access blocks only after the grace period expires. */}
{organisation.twoFactorRequired && (
<Alert variant="neutral">
<AlertDescription>
{organisation.twoFactorGracePeriodDays > 0 ? (
<Trans>
This organisation requires two-factor authentication. You will need to enable it within{' '}
{organisation.twoFactorGracePeriodDays} days of joining to keep access to the organisation.
</Trans>
) : (
<Trans>
This organisation requires two-factor authentication. You will need to enable it immediately after
joining to access the organisation.
</Trans>
)}
</AlertDescription>
</Alert>
)}
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`accept-conditions`}
@@ -137,6 +137,9 @@ export default function AuthoringLayout() {
teams: [team],
subscription: null,
currentOrganisationRole: OrganisationMemberRole.MEMBER,
// Hardcoded non-enforcing status: embed authoring is presign-token
// authorized (machine access), which is exempt from 2FA enforcement.
twoFactorEnforcement: { required: false },
};
return (
+385
View File
@@ -0,0 +1,385 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { AppError } from '@documenso/lib/errors/app-error';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { isTwoFactorSatisfied } from '@documenso/lib/utils/two-factor';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
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, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigate, useRevalidator } from 'react-router';
import { renderSVG } from 'uqr';
import { z } from 'zod';
import { RecoveryCodeList } from '~/components/forms/2fa/recovery-code-list';
import { appMetaTags } from '~/utils/meta';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/2fa';
export function meta() {
return appMetaTags(msg`Two-Factor Authentication`);
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
const url = new URL(request.url);
const rawReturnTo = url.searchParams.get('returnTo') ?? undefined;
const returnTo = (isValidReturnTo(rawReturnTo) && normalizeReturnTo(rawReturnTo)) || '/';
if (!session.isAuthenticated) {
throw redirect(`/signin?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);
}
// Auto-redirect ONLY when enforcement is already satisfied on arrival.
// Every other state (including "not required") renders the page — a user
// landing here after a backup-code recovery gets an explicit skip link
// instead of being bounced away.
if (
isTwoFactorSatisfied({
userTwoFactorEnabled: session.user.twoFactorEnabled,
sessionTwoFactorVerified: session.session.twoFactorVerified,
})
) {
throw redirect(returnTo);
}
const twoFactorEnforcement = await getTwoFactorEnforcementStatus({
user: session.user,
session: session.session,
});
return superLoaderJson({
returnTo,
isTwoFactorEnabled: session.user.twoFactorEnabled,
isSessionTwoFactorVerified: session.session.twoFactorVerified,
twoFactorEnforcement,
});
}
const ZEnableTwoFactorFormSchema = z.object({
token: z.string().min(6).max(6),
});
type TEnableTwoFactorFormSchema = z.infer<typeof ZEnableTwoFactorFormSchema>;
export default function OnboardingTwoFactorPage() {
const { returnTo, isTwoFactorEnabled, isSessionTwoFactorVerified, twoFactorEnforcement } =
useSuperLoaderData<typeof loader>();
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<string[] | null>(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<TEnableTwoFactorFormSchema>({
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 (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<h1 className="font-semibold text-2xl">
<Trans>Two-factor authentication</Trans>
</h1>
{twoFactorEnforcement.required && isBlocked && (
<p className="mt-2 text-muted-foreground text-sm">
<Trans>Two-factor authentication is required to continue using your account.</Trans>
</p>
)}
{twoFactorEnforcement.required && !isBlocked && (
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Two-factor authentication is required for your account from{' '}
{i18n.date(twoFactorEnforcement.deadline, { dateStyle: 'long' })}.
</Trans>
</p>
)}
<hr className="-mx-6 my-4" />
{requiresRelogin ? (
<div className="flex flex-col gap-y-4">
<p className="text-muted-foreground text-sm">
<Trans>
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.
</Trans>
</p>
<Button className="w-full sm:w-auto sm:self-end" onClick={() => void onSignOut()}>
<Trans>Sign out</Trans>
</Button>
</div>
) : recoveryCodes ? (
<div className="flex flex-col gap-y-4">
<div>
<h2 className="font-medium text-lg">
<Trans>Save your recovery codes</Trans>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Your recovery codes are listed below. Please store them in a safe place — they will not be shown
again.
</Trans>
</p>
</div>
<RecoveryCodeList recoveryCodes={recoveryCodes} />
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
<Button variant="secondary" onClick={onDownloadRecoveryCodes}>
<Trans>Download</Trans>
</Button>
<Button onClick={() => void onRecoveryCodesAcknowledged()}>
<Trans>I have saved my recovery codes</Trans>
</Button>
</div>
</div>
) : !setupData ? (
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
{hasSetupFailed ? (
<>
<p className="text-muted-foreground text-sm">
<Trans>We were unable to prepare two-factor authentication.</Trans>
</p>
<Button variant="secondary" onClick={() => void setupTwoFactor()}>
<Trans>Try again</Trans>
</Button>
</>
) : (
<>
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Preparing two-factor authentication...</Trans>
</p>
</>
)}
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onEnableSubmit)}>
<fieldset disabled={isEnabling} className="flex flex-col gap-y-4">
<p className="text-muted-foreground text-sm">
<Trans>
To enable two-factor authentication, scan the following QR code using your authenticator app.
</Trans>
</p>
<div
className="flex h-36 justify-center"
dangerouslySetInnerHTML={{
__html: renderSVG(setupData.uri),
}}
/>
<p className="text-muted-foreground text-sm">
<Trans>
If your authenticator app does not support QR codes, you can use the following code instead:
</Trans>
</p>
<p className="rounded-lg bg-muted/60 p-2 text-center font-mono text-muted-foreground tracking-widest">
{setupData.secret}
</p>
<FormField
name="token"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
<Trans>Token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={isEnabling} className="w-full sm:w-auto sm:self-end">
<Trans>Enable 2FA</Trans>
</Button>
</fieldset>
</form>
</Form>
)}
{(canSkip || !requiresRelogin) && (
<p className="mt-6 flex flex-col items-center gap-y-1 text-center text-muted-foreground text-sm">
{canSkip && !recoveryCodes && (
<Link to={returnTo} className="text-documenso-700 duration-200 hover:opacity-70">
<Trans>Skip for now</Trans>
</Link>
)}
{!requiresRelogin && (
<button
type="button"
className="text-documenso-700 duration-200 hover:opacity-70"
onClick={() => void onSignOut()}
>
<Trans>Sign out</Trans>
</button>
)}
</p>
)}
</div>
</div>
);
}
@@ -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 (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div>
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<img
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<div className="relative w-full">
<Outlet />
</div>
</div>
</main>
);
}
@@ -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<HonoEnv>().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;
@@ -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<HonoEnv>().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;
+62
View File
@@ -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<HonoEnv>()
*/
.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<HonoEnv>()
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<HonoEnv>()
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<HonoEnv>()
id: envelopeId,
},
include: {
team: {
select: {
organisationId: true,
},
},
envelopeItems: {
where: {
id: envelopeItemId,
@@ -101,6 +129,26 @@ export const filesRoute = new Hono<HonoEnv>()
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<HonoEnv>()
id: envelopeId,
},
include: {
team: {
select: {
organisationId: true,
},
},
envelopeItems: {
where: {
id: envelopeItemId,
@@ -173,6 +226,15 @@ export const filesRoute = new Hono<HonoEnv>()
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) {
@@ -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,
@@ -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,
},
});
@@ -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,
},
},
},
});
};
@@ -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);
});
@@ -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.
});
+71 -6
View File
@@ -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<AuthClientType['email-password']['a
redirectPath?: string;
};
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
redirectPath?: string;
};
// `redirectPath` is part of the request schema and validated server-side.
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'];
export class AuthClient {
public client: AuthClientType;
@@ -71,7 +71,7 @@ export class AuthClient {
const result = await response.json();
return superjson.deserialize<SessionValidationResult>(result);
return superjson.deserialize<TSessionJsonResponse>(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<TVerifyTwoFactorChallengeRequestSchema, 'csrfToken'>) => {
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);
},
};
@@ -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',
@@ -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) {
+33 -2
View File
@@ -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<Session> => {
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<Session> => {
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<SessionValida
emailVerified: true,
avatarImageId: true,
twoFactorEnabled: true,
twoFactorGraceStartedAt: true,
roles: true,
signature: true,
disabled: true,
+24 -1
View File
@@ -1,12 +1,26 @@
import { assertUserNotDisabledById } from '@documenso/lib/server-only/user/assert-user-not-disabled';
import type { TSessionAuthMethod } from '@documenso/lib/types/session-auth-method';
import type { Context } from 'hono';
import type { HonoAuthContext } from '../../types/context';
import { createSession, generateSessionToken } from '../session/session';
import { setSessionCookie } from '../session/session-cookies';
import { sweepPendingTwoFactorChallenge } from './two-factor-challenge';
type AuthorizeUser = {
userId: number;
/**
* The method the user authenticated with. Every sign-in route must pass
* this explicitly.
*/
authMethod: TSessionAuthMethod;
/**
* Whether a second factor was passed during this sign-in (inline TOTP on
* email/password login, or a UV passkey).
*/
twoFactorVerified: boolean;
};
/**
@@ -20,11 +34,20 @@ type AuthorizeUser = {
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
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);
};
@@ -59,6 +59,8 @@ export const getActiveSessions = async (c: Context | Request): Promise<ActiveSes
createdAt: true,
ipAddress: true,
userAgent: true,
authMethod: true,
twoFactorVerified: true,
},
});
};
@@ -20,6 +20,7 @@ import type { OAuthClientOptions } from '../../config';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { getOpenIdConfiguration } from './open-id';
import { createTwoFactorChallenge } from './two-factor-challenge';
type HandleOAuthCallbackUrlOptions = {
c: Context;
@@ -50,6 +51,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
user: {
select: {
id: true,
twoFactorEnabled: true,
},
},
},
@@ -57,7 +59,21 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// 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 (OAuth) auth alone only earns a pending challenge.
if (existingAccount.user.twoFactorEnabled) {
await createTwoFactorChallenge(c, {
userId: existingAccount.user.id,
metadata: {
redirectPath,
authMethod: 'oauth',
},
});
return c.redirect('/2fa-challenge', 302);
}
await onAuthorize({ userId: existingAccount.user.id, authMethod: 'oauth', twoFactorVerified: false }, c);
return c.redirect(redirectPath, 302);
}
@@ -69,11 +85,37 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
select: {
id: true,
emailVerified: true,
twoFactorEnabled: true,
},
});
// Handle existing user but no account.
if (userWithSameEmail) {
// Deferred account linking: when the target user has 2FA enabled, NO
// account mutation may happen before the code verifies. The entire link
// transaction below is deferred into the challenge metadata `action` and
// executed atomically with token consumption after the code passes.
//
// Access/ID tokens are intentionally NOT stored in the metadata — the
// deferred account row is created without them.
if (userWithSameEmail.twoFactorEnabled) {
await createTwoFactorChallenge(c, {
userId: userWithSameEmail.id,
metadata: {
redirectPath,
authMethod: 'oauth',
action: {
type: 'link-oauth-account',
provider: clientOptions.id,
providerAccountId: sub,
email,
},
},
});
return c.redirect('/2fa-challenge', 302);
}
await prisma.$transaction(async (tx) => {
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);
};
@@ -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),
},
});
@@ -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<void> => {
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<PendingTwoFactorChallenge | null> => {
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<void> => {
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<void> => {
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<void> => {
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.
}
};
+48 -162
View File
@@ -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 {
@@ -25,6 +22,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';
@@ -41,7 +39,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';
@@ -136,14 +133,16 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
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,
@@ -155,6 +154,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
}
isBackupCodeRecovery = validationResult.method === 'backup';
}
if (!user.emailVerified) {
@@ -180,11 +181,44 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
// 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.
@@ -332,7 +366,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
// 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({
@@ -470,156 +504,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
}
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.
+16 -3
View File
@@ -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<HonoAuthContext>()
});
}
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<HonoAuthContext>()
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<HonoAuthContext>()
},
});
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,
);
+18 -1
View File
@@ -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));
});
+263 -3
View File
@@ -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<HonoAuthContext>()
.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<HonoAuthContext>()
const result = await enableTwoFactorAuthentication({
user,
code,
sessionId: session.id,
requestMetadata,
});
@@ -99,14 +118,24 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
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<HonoAuthContext>()
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,
);
});
@@ -18,3 +18,17 @@ export const ZViewTwoFactorRecoveryCodesRequestSchema = z.object({
});
export type TViewTwoFactorRecoveryCodesRequestSchema = z.infer<typeof ZViewTwoFactorRecoveryCodesRequestSchema>;
/**
* 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<typeof ZVerifyTwoFactorChallengeRequestSchema>;
+6
View File
@@ -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<typeof ZPasskeyAuthorizeSchema>;
@@ -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);
}
}
};
@@ -14,7 +14,7 @@ import crypto from 'crypto';
import { DateTime } from 'luxon';
import { createElement } from 'react';
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
export type SendOrganisationAccountLinkConfirmationEmailProps = Omit<TOrganisationAccountLinkMetadata, 'email'> & {
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,
@@ -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,
});
}, []);
+6
View File
@@ -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<string, OrganisationMemberRole[]>;
/**
+4 -1
View File
@@ -113,6 +113,9 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
// 403, not 401: the session is valid — the account is forbidden from the
// resource until 2FA enforcement is satisfied.
[AppErrorCode.TWO_FACTOR_REQUIRED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
@@ -341,7 +344,7 @@ export class AppError extends Error {
() => 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);
+28 -7
View File
@@ -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(AppErrorCode.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 };
};
+48 -1
View File
@@ -9,12 +9,22 @@ import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
type EnableTwoFactorAuthenticationOptions = {
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret'>;
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,
@@ -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<TInstanceTwoFactorEnforcementConfig> => {
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,
}),
};
};
@@ -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<TSiteSettingsTwoFactorEnforcementSchema | null> => {
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;
};
@@ -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<User, 'twoFactorEnabled' | 'twoFactorGraceStartedAt'>;
/**
* The current session, or null for contexts that carry no session (e.g.
* API access), which never count as verified.
*/
session: Pick<Session, 'twoFactorVerified'> | 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<TTwoFactorEnforcementStatus> => {
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(),
});
};
@@ -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<User, 'id' | 'twoFactorEnabled' | 'twoFactorGraceStartedAt'>;
export type EnforcementSession = Pick<Session, 'twoFactorVerified'> | 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<OrganisationTwoFactorScope[]> => {
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<void> => {
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 });
};
@@ -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<User, 'id' | 'twoFactorBackupCodes'>;
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);
};
+15 -1
View File
@@ -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,
@@ -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<Parameters<typeof validateTwoFactorAuthentication>[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);
});
});
+17 -3
View File
@@ -10,11 +10,21 @@ type ValidateTwoFactorAuthenticationOptions = {
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'>;
};
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<TValidateTwoFactorAuthenticationResult> => {
if (!user.twoFactorEnabled) {
throw new AppError(AppErrorCode.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(AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
@@ -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) {
@@ -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,
});
@@ -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,
},
},
},
@@ -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,
@@ -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<typeof ZSiteSettingSchema>;
@@ -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<typeof ZSiteSettingsTwoFactorEnforcementSchema>;
+1
View File
@@ -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(),
});
/**
+15 -3
View File
@@ -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(),
}),
});
+12
View File
@@ -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<typeof ZSessionAuthMethodSchema>;
@@ -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<typeof ZTwoFactorChallengeActionSchema>;
/**
* 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<typeof ZTwoFactorChallengeMetadataSchema>;
+4
View File
@@ -139,5 +139,9 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
aiFeaturesEnabled: false,
twoFactorRequired: false,
twoFactorGracePeriodDays: 7,
twoFactorEnforcedFrom: null,
};
};
+23
View File
@@ -209,6 +209,25 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
};
};
/**
* Settings keys that exist only on `OrganisationGlobalSettings` and have no
* counterpart column on `TeamGlobalSettings`.
*
* These must be skipped when deriving team settings, otherwise
* `teamSettings[key]` resolves to `undefined` (not `null`) and overwrites the
* organisation value.
*/
const ORGANISATION_ONLY_SETTINGS_KEYS = [
'twoFactorRequired',
'twoFactorGracePeriodDays',
'twoFactorEnforcedFrom',
] as const;
type OrganisationOnlySettingsKey = (typeof ORGANISATION_ONLY_SETTINGS_KEYS)[number];
const isOrganisationOnlySettingsKey = (key: PropertyKey): key is OrganisationOnlySettingsKey =>
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) {
@@ -0,0 +1,121 @@
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);
});
});
// The `>=` boundary matters: an off-by-one here would grant one extra guess
// per challenge (or one extra valid moment past expiry).
describe('challenge consumption boundaries', () => {
it('consumes the challenge at exactly the maximum attempts, not before', () => {
expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS - 1)).toBe(false);
expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS)).toBe(true);
});
it('treats the expiry instant itself as expired', () => {
const expiresAt = new Date('2026-01-01T00:10:00.000Z');
expect(isChallengeExpired({ expiresAt, now: new Date('2026-01-01T00:09:59.999Z') })).toBe(false);
expect(isChallengeExpired({ expiresAt, now: expiresAt })).toBe(true);
});
});
@@ -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();
};
+718
View File
@@ -0,0 +1,718 @@
import { describe, expect, it } from 'vitest';
import {
calculateTwoFactorDeadline,
calculateTwoFactorDeadlineTimerDelay,
computeOrganisationTwoFactorEnforcementStatus,
computeTwoFactorEnforcementStatus,
evaluateInstanceTwoFactorEnforcementUpdate,
isInstanceTwoFactorEnforcementActive,
isTwoFactorGracePeriodReduction,
MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS,
} from './two-factor';
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('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 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 and null just past it', () => {
const atMax = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS);
const pastMax = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS + 1);
expect(calculateTwoFactorDeadlineTimerDelay({ deadline: atMax, now })).toBe(MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS);
expect(calculateTwoFactorDeadlineTimerDelay({ deadline: pastMax, now })).toBeNull();
});
});
+449
View File
@@ -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<Date | null | undefined>;
/**
* 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<Date | null | undefined>;
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;
};
@@ -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";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'AUTH_2FA_ADMIN_RESET';
+29 -2
View File
@@ -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';"])
+2
View File
@@ -5,6 +5,8 @@
"types": "./index.ts",
"license": "MIT",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"clean": "rimraf node_modules"
},
"dependencies": {
@@ -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();
});
@@ -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<typeof ZGetTwoFactorEnforcementRequestSchema>;
export type TGetTwoFactorEnforcementResponse = z.infer<typeof ZGetTwoFactorEnforcementResponseSchema>;
@@ -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,
},
});
});
};
@@ -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,
});
@@ -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();
@@ -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<Session, 'twoFactorVerified'> | 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,
});
});
@@ -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<typeof ZUpdateTwoFactorEnforcementRequestSchema>;
export type TUpdateTwoFactorEnforcementResponse = z.infer<typeof ZUpdateTwoFactorEnforcementResponseSchema>;
@@ -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;
@@ -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;
@@ -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;
@@ -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,
@@ -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,
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;

Some files were not shown because too many files have changed in this diff Show More