Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/adr-0069-d3-enforced-mfa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/platform-objects': minor
'@objectstack/service-settings': minor
'@objectstack/plugin-auth': minor
---

Auth: enforced MFA (ADR-0069 D3, P1)

Completes the session-validation gate: when `mfa_required` (new `auth` setting) is on, an authenticated user without TOTP enrolled is blocked from protected resources with `403 MFA_REQUIRED` once their `mfa_grace_period_days` (default 7) window elapses — while the two-factor enrollment endpoints stay reachable so they can comply. Reuses the `authGate` seam shipped in #2388 (a second posture branch in `computeAuthGate`).

- New `auth` settings `mfa_required` (toggle) + `mfa_grace_period_days`; enabling `mfa_required` also force-enables the `twoFactor` plugin so `/two-factor/*` enrollment exists.
- New `sys_user.mfa_required_at` column — the grace clock, stamped lazily the first time a user is seen required-but-unenrolled.
- `isAuthGateActive()` now also trips on `mfa_required` (still zero-overhead when off).

Default-off / additive (no upgrade behavior change); per ADR-0049 the setting ships with its enforcement.

**Needs an objectui follow-up**: the Console should handle a `403 MFA_REQUIRED` by showing the TOTP-enrollment prompt. Per-org `sys_organization.require_mfa` and the dispatcher/MCP gate remain follow-ups (#2375).
10 changes: 10 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,16 @@ export const SysUser = ObjectSchema.create({
description: 'Timestamp of the last password change. Backs password_expiry_days; system-managed.',
}),

// ADR-0069 D3 — when enforced MFA first applied to this user; starts the
// grace clock. Stamped lazily at session validation; system-managed.
mfa_required_at: Field.datetime({
label: 'MFA Required At',
required: false,
readonly: true,
group: 'Admin',
description: 'When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed.',
}),

ai_access: Field.boolean({
label: 'AI Access',
defaultValue: false,
Expand Down
67 changes: 67 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1908,4 +1908,71 @@ describe('AuthManager', () => {
expect(arg.password_changed_at instanceof Date).toBe(true);
});
});

// ADR-0069 D3: enforced MFA — reuses the auth-gate seam (same computeAuthGate).
describe('enforced MFA gate (ADR-0069 D3)', () => {
const SECRET = 'test-secret-at-least-32-chars-long';
const makeEngine = (user: any) => ({
findOne: vi.fn(async (_o: string, q: any) => {
const w = q?.where ?? {};
if (!user) return null;
return Object.entries(w).every(([k, v]) => (user as any)[k] === v) ? user : null;
}),
update: vi.fn(async () => ({})),
});
const mgr = (engine: any, extra: any = {}) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra });
warn.mockRestore();
return m;
};

it('isAuthGateActive is true when mfaRequired (even with expiry off)', () => {
expect(mgr(makeEngine(null), { mfaRequired: true }).isAuthGateActive()).toBe(true);
expect(mgr(makeEngine(null), {}).isAuthGateActive()).toBe(false);
});

it('blocks MFA_REQUIRED for an un-enrolled user past the grace window', async () => {
const old = new Date(Date.now() - 30 * 86_400_000).toISOString();
const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: old }), {
mfaRequired: true, mfaGracePeriodDays: 7,
});
const g = await (m as any).computeAuthGate('u1', undefined, false);
expect(g?.code).toBe('MFA_REQUIRED');
});

it('does NOT block within the grace window', async () => {
const recent = new Date(Date.now() - 1 * 86_400_000).toISOString();
const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: recent }), {
mfaRequired: true, mfaGracePeriodDays: 7,
});
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
});

it('stamps mfa_required_at the first time (null) and does not block yet', async () => {
const engine = makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: null });
const m = mgr(engine, { mfaRequired: true, mfaGracePeriodDays: 7 });
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
const wrote = engine.update.mock.calls.find((c: any[]) => 'mfa_required_at' in (c[1] ?? {}));
expect(wrote).toBeTruthy();
expect(wrote[1].mfa_required_at instanceof Date).toBe(true);
});

it('does NOT block an enrolled user (two_factor_enabled)', async () => {
const old = new Date(Date.now() - 30 * 86_400_000).toISOString();
const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: true, mfa_required_at: old }), {
mfaRequired: true, mfaGracePeriodDays: 7,
});
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
});

it('grace 0 blocks an un-enrolled user immediately (after the clock is set)', async () => {
const past = new Date(Date.now() - 1000).toISOString();
const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: past }), {
mfaRequired: true, mfaGracePeriodDays: 0,
});
const g = await (m as any).computeAuthGate('u1', undefined, false);
expect(g?.code).toBe('MFA_REQUIRED');
});
});
});
60 changes: 51 additions & 9 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,17 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
*/
passwordExpiryDays?: number;

/**
* ADR-0069 D3 — enforced MFA. When true, an authenticated user without TOTP
* enrolled (`sys_user.two_factor_enabled`) is gated out of protected resources
* (`MFA_REQUIRED`) once their grace window elapses, until they enroll. Shares
* the `customSession` → `user.authGate` seam with password expiry.
*/
mfaRequired?: boolean;

/** Days a user may defer MFA enrollment before the hard block. Default 7. */
mfaGracePeriodDays?: number;

/**
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
Expand Down Expand Up @@ -2275,7 +2286,10 @@ export class AuthManager {
* default), keeping the gate zero-overhead until an admin opts in.
*/
public isAuthGateActive(): boolean {
return (Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0);
return (
Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 ||
this.config.mfaRequired === true
);
}

/**
Expand All @@ -2288,28 +2302,56 @@ export class AuthManager {
private async computeAuthGate(
userId: string,
_activeOrgId: string | undefined,
_twoFactorEnabled: boolean,
_twoFactorEnabledHint: boolean,
): Promise<{ code: string; message: string } | undefined> {
const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0);
if (expiryDays <= 0) return undefined; // no gate feature active
const mfaRequired = this.config.mfaRequired === true;
if (expiryDays <= 0 && !mfaRequired) return undefined; // no gate feature active
const engine = this.getDataEngine();
if (!engine || !userId) return undefined;
try {
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
const u = await engine.findOne('sys_user', {
where: { id: userId }, fields: ['password_changed_at'], context: SYSTEM_CTX,
where: { id: userId },
fields: ['password_changed_at', 'two_factor_enabled', 'mfa_required_at'],
context: SYSTEM_CTX,
} as any);
const changed = u?.password_changed_at;
// Null = never expires (existing accounts on upgrade) until the next change.
if (changed) {
const ageMs = Date.now() - new Date(changed).getTime();
if (ageMs > expiryDays * 86_400_000) {

// ── Password expiry ───────────────────────────────────────────────
if (expiryDays > 0) {
const changed = u?.password_changed_at;
// Null = never expires (existing accounts on upgrade) until next change.
if (changed && Date.now() - new Date(changed).getTime() > expiryDays * 86_400_000) {
return {
code: 'PASSWORD_EXPIRED',
message: 'Your password has expired. Please change it to continue.',
};
}
}

// ── Enforced MFA ──────────────────────────────────────────────────
// A user without TOTP enrolled is blocked once their grace window
// elapses. The clock (`mfa_required_at`) starts the first time we see
// them required-but-unenrolled (stamped lazily, best-effort).
if (mfaRequired && !(u?.two_factor_enabled === true || u?.two_factor_enabled === 1)) {
const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7)));
let requiredAt = u?.mfa_required_at;
if (!requiredAt) {
requiredAt = new Date();
// Best-effort: start the grace clock; never block on the write.
engine
.update('sys_user', { id: userId, mfa_required_at: requiredAt }, { context: SYSTEM_CTX } as any)
.catch(() => undefined);
}
const elapsedMs = Date.now() - new Date(requiredAt).getTime();
if (elapsedMs > graceDays * 86_400_000) {
return {
code: 'MFA_REQUIRED',
message:
'Multi-factor authentication is required. Please set up an authenticator app to continue.',
};
}
}
} catch {
return undefined; // fail-open
}
Expand Down
18 changes: 18 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,24 @@ describe('AuthPlugin', () => {
expect((manager as any).isAuthGateActive()).toBe(true);
});

it('binds mfa_required and force-enables the twoFactor plugin (ADR-0069 D3)', async () => {
const { manager } = await bootWithAuthSettings({ mfa_required: { value: true, source: 'global' } });
const cfg = (manager as any).config;
expect(cfg.mfaRequired).toBe(true);
expect(cfg.plugins?.twoFactor).toBe(true);
expect((manager as any).isAuthGateActive()).toBe(true);
});

it('binds mfa_grace_period_days', async () => {
const { manager } = await bootWithAuthSettings({ mfa_grace_period_days: { value: 14, source: 'global' } });
expect((manager as any).config.mfaGracePeriodDays).toBe(14);
});

it('clamps mfa_grace_period_days to a max of 90', async () => {
const { manager } = await bootWithAuthSettings({ mfa_grace_period_days: { value: 999, source: 'global' } });
expect((manager as any).config.mfaGracePeriodDays).toBe(90);
});

it('binds account-lockout settings (ADR-0069 D2)', async () => {
const { manager } = await bootWithAuthSettings({
lockout_threshold: { value: 5, source: 'global' },
Expand Down
18 changes: 18 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,24 @@ export class AuthPlugin implements Plugin {
if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n);
}

// Enforced MFA (ADR-0069 D3). Enabling it also turns the twoFactor
// plugin on so the /two-factor/* enrollment endpoints exist — otherwise
// gated users would have no way to comply.
if (isExplicit('mfa_required')) {
const on = asBoolean(values.mfa_required, false);
patch.mfaRequired = on;
if (on) {
patch.plugins = {
...(patch.plugins ?? {}),
twoFactor: true,
} as AuthManagerOptions['plugins'];
}
}
if (isExplicit('mfa_grace_period_days')) {
const n = Math.floor(Number(values.mfa_grace_period_days));
if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n);
}

// Session lifetime — days → seconds for better-auth's `session`
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).
const session: { expiresIn?: number; updateAge?: number } = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('authSettingsManifest', () => {
expect(keys).toEqual([
'email_password_enabled',
'google_enabled',
'mfa_required',
'password_reject_breached',
'password_require_complexity',
'require_email_verification',
Expand Down Expand Up @@ -66,6 +67,7 @@ describe('authSettingsManifest', () => {
expect(byKey('rate_limit_window_seconds').default).toBe(60);
expect(byKey('password_history_count').default).toBe(0);
expect(byKey('password_expiry_days').default).toBe(0);
expect(byKey('mfa_grace_period_days').default).toBe(7);
});

it('exposes encrypted Google OAuth credential fields', () => {
Expand Down
28 changes: 28 additions & 0 deletions packages/services/service-settings/src/manifests/auth.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,34 @@ const manifest = {
max: 3600,
description: 'Sliding window over which the request cap above is counted.',
},
{
type: 'group',
id: 'multi_factor',
label: 'Multi-factor',
required: false,
description: 'Require members to protect their account with an authenticator app (TOTP).',
},
{
type: 'toggle',
key: 'mfa_required',
label: 'Require multi-factor authentication',
required: false,
default: false,
description:
'Users without an authenticator enrolled are blocked from data once their grace period ends. Enabling this also turns on the two-factor feature so users can enroll.',
visible: "${data.email_password_enabled !== false}",
},
{
type: 'number',
key: 'mfa_grace_period_days',
label: 'MFA grace period (days)',
required: false,
default: 7,
min: 0,
max: 90,
description: 'How long users may defer enrollment before the hard block. 0 blocks immediately.',
visible: "${data.mfa_required === true}",
},
{
type: 'group',
id: 'sessions',
Expand Down