Files
documenso/packages/lib/utils/two-factor.ts
T

450 lines
13 KiB
TypeScript

/**
* 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;
};