Skip to content

Commit 90bce88

Browse files
os-zhuangclaude
andauthored
feat(auth): enforced MFA via the session-validation gate (ADR-0069 D3, P1) (#2390)
Completes the session gate (after password expiry #2388) by reusing its authGate seam for enforced MFA. - plugin-auth: computeAuthGate gains an MFA branch — when mfaRequired and the user has no TOTP enrolled (sys_user.two_factor_enabled), block with MFA_REQUIRED once the grace window (mfa_required_at + mfaGracePeriodDays) elapses. mfa_required_at is stamped lazily on first required-but-unenrolled session. isAuthGateActive() also trips on mfaRequired. - bindAuthSettings: mfa_required + mfa_grace_period_days; enabling mfa_required force-enables the twoFactor plugin so /two-factor/* enrollment exists. - platform-objects: sys_user.mfa_required_at column. - service-settings: Multi-factor settings group. Default-off / additive; ADR-0049 (enforcement ships with the setting). Verified live (dogfood): mfa_required + grace 7, un-enrolled user — within grace NOT gated (mfa_required_at stamped); past grace -> 403 MFA_REQUIRED; /two-factor/enable reachable (200, totpURI) while blocked; after enrollment the gate lifts (native 2FA verification takes over); admin within grace NOT blocked. Unit: plugin-auth 191 + service-settings 129 + platform-objects 63 + core 299 + rest 140 green; full build (incl. strict DTS) green. objectui follow-up: Console must render a TOTP-enroll prompt on 403 MFA_REQUIRED. Per-org require_mfa + dispatcher/MCP gate remain follow-ups (#2375). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 63d5403 commit 90bce88

8 files changed

Lines changed: 211 additions & 9 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/service-settings': minor
4+
'@objectstack/plugin-auth': minor
5+
---
6+
7+
Auth: enforced MFA (ADR-0069 D3, P1)
8+
9+
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`).
10+
11+
- New `auth` settings `mfa_required` (toggle) + `mfa_grace_period_days`; enabling `mfa_required` also force-enables the `twoFactor` plugin so `/two-factor/*` enrollment exists.
12+
- New `sys_user.mfa_required_at` column — the grace clock, stamped lazily the first time a user is seen required-but-unenrolled.
13+
- `isAuthGateActive()` now also trips on `mfa_required` (still zero-overhead when off).
14+
15+
Default-off / additive (no upgrade behavior change); per ADR-0049 the setting ships with its enforcement.
16+
17+
**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).

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,16 @@ export const SysUser = ObjectSchema.create({
458458
description: 'Timestamp of the last password change. Backs password_expiry_days; system-managed.',
459459
}),
460460

461+
// ADR-0069 D3 — when enforced MFA first applied to this user; starts the
462+
// grace clock. Stamped lazily at session validation; system-managed.
463+
mfa_required_at: Field.datetime({
464+
label: 'MFA Required At',
465+
required: false,
466+
readonly: true,
467+
group: 'Admin',
468+
description: 'When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed.',
469+
}),
470+
461471
ai_access: Field.boolean({
462472
label: 'AI Access',
463473
defaultValue: false,

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,4 +1908,71 @@ describe('AuthManager', () => {
19081908
expect(arg.password_changed_at instanceof Date).toBe(true);
19091909
});
19101910
});
1911+
1912+
// ADR-0069 D3: enforced MFA — reuses the auth-gate seam (same computeAuthGate).
1913+
describe('enforced MFA gate (ADR-0069 D3)', () => {
1914+
const SECRET = 'test-secret-at-least-32-chars-long';
1915+
const makeEngine = (user: any) => ({
1916+
findOne: vi.fn(async (_o: string, q: any) => {
1917+
const w = q?.where ?? {};
1918+
if (!user) return null;
1919+
return Object.entries(w).every(([k, v]) => (user as any)[k] === v) ? user : null;
1920+
}),
1921+
update: vi.fn(async () => ({})),
1922+
});
1923+
const mgr = (engine: any, extra: any = {}) => {
1924+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1925+
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra });
1926+
warn.mockRestore();
1927+
return m;
1928+
};
1929+
1930+
it('isAuthGateActive is true when mfaRequired (even with expiry off)', () => {
1931+
expect(mgr(makeEngine(null), { mfaRequired: true }).isAuthGateActive()).toBe(true);
1932+
expect(mgr(makeEngine(null), {}).isAuthGateActive()).toBe(false);
1933+
});
1934+
1935+
it('blocks MFA_REQUIRED for an un-enrolled user past the grace window', async () => {
1936+
const old = new Date(Date.now() - 30 * 86_400_000).toISOString();
1937+
const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: old }), {
1938+
mfaRequired: true, mfaGracePeriodDays: 7,
1939+
});
1940+
const g = await (m as any).computeAuthGate('u1', undefined, false);
1941+
expect(g?.code).toBe('MFA_REQUIRED');
1942+
});
1943+
1944+
it('does NOT block within the grace window', async () => {
1945+
const recent = new Date(Date.now() - 1 * 86_400_000).toISOString();
1946+
const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: recent }), {
1947+
mfaRequired: true, mfaGracePeriodDays: 7,
1948+
});
1949+
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
1950+
});
1951+
1952+
it('stamps mfa_required_at the first time (null) and does not block yet', async () => {
1953+
const engine = makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: null });
1954+
const m = mgr(engine, { mfaRequired: true, mfaGracePeriodDays: 7 });
1955+
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
1956+
const wrote = engine.update.mock.calls.find((c: any[]) => 'mfa_required_at' in (c[1] ?? {}));
1957+
expect(wrote).toBeTruthy();
1958+
expect(wrote[1].mfa_required_at instanceof Date).toBe(true);
1959+
});
1960+
1961+
it('does NOT block an enrolled user (two_factor_enabled)', async () => {
1962+
const old = new Date(Date.now() - 30 * 86_400_000).toISOString();
1963+
const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: true, mfa_required_at: old }), {
1964+
mfaRequired: true, mfaGracePeriodDays: 7,
1965+
});
1966+
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
1967+
});
1968+
1969+
it('grace 0 blocks an un-enrolled user immediately (after the clock is set)', async () => {
1970+
const past = new Date(Date.now() - 1000).toISOString();
1971+
const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: past }), {
1972+
mfaRequired: true, mfaGracePeriodDays: 0,
1973+
});
1974+
const g = await (m as any).computeAuthGate('u1', undefined, false);
1975+
expect(g?.code).toBe('MFA_REQUIRED');
1976+
});
1977+
});
19111978
});

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,17 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
311311
*/
312312
passwordExpiryDays?: number;
313313

314+
/**
315+
* ADR-0069 D3 — enforced MFA. When true, an authenticated user without TOTP
316+
* enrolled (`sys_user.two_factor_enabled`) is gated out of protected resources
317+
* (`MFA_REQUIRED`) once their grace window elapses, until they enroll. Shares
318+
* the `customSession` → `user.authGate` seam with password expiry.
319+
*/
320+
mfaRequired?: boolean;
321+
322+
/** Days a user may defer MFA enrollment before the hard block. Default 7. */
323+
mfaGracePeriodDays?: number;
324+
314325
/**
315326
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
316327
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
@@ -2275,7 +2286,10 @@ export class AuthManager {
22752286
* default), keeping the gate zero-overhead until an admin opts in.
22762287
*/
22772288
public isAuthGateActive(): boolean {
2278-
return (Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0);
2289+
return (
2290+
Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 ||
2291+
this.config.mfaRequired === true
2292+
);
22792293
}
22802294

22812295
/**
@@ -2288,28 +2302,56 @@ export class AuthManager {
22882302
private async computeAuthGate(
22892303
userId: string,
22902304
_activeOrgId: string | undefined,
2291-
_twoFactorEnabled: boolean,
2305+
_twoFactorEnabledHint: boolean,
22922306
): Promise<{ code: string; message: string } | undefined> {
22932307
const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0);
2294-
if (expiryDays <= 0) return undefined; // no gate feature active
2308+
const mfaRequired = this.config.mfaRequired === true;
2309+
if (expiryDays <= 0 && !mfaRequired) return undefined; // no gate feature active
22952310
const engine = this.getDataEngine();
22962311
if (!engine || !userId) return undefined;
22972312
try {
22982313
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
22992314
const u = await engine.findOne('sys_user', {
2300-
where: { id: userId }, fields: ['password_changed_at'], context: SYSTEM_CTX,
2315+
where: { id: userId },
2316+
fields: ['password_changed_at', 'two_factor_enabled', 'mfa_required_at'],
2317+
context: SYSTEM_CTX,
23012318
} as any);
2302-
const changed = u?.password_changed_at;
2303-
// Null = never expires (existing accounts on upgrade) until the next change.
2304-
if (changed) {
2305-
const ageMs = Date.now() - new Date(changed).getTime();
2306-
if (ageMs > expiryDays * 86_400_000) {
2319+
2320+
// ── Password expiry ───────────────────────────────────────────────
2321+
if (expiryDays > 0) {
2322+
const changed = u?.password_changed_at;
2323+
// Null = never expires (existing accounts on upgrade) until next change.
2324+
if (changed && Date.now() - new Date(changed).getTime() > expiryDays * 86_400_000) {
23072325
return {
23082326
code: 'PASSWORD_EXPIRED',
23092327
message: 'Your password has expired. Please change it to continue.',
23102328
};
23112329
}
23122330
}
2331+
2332+
// ── Enforced MFA ──────────────────────────────────────────────────
2333+
// A user without TOTP enrolled is blocked once their grace window
2334+
// elapses. The clock (`mfa_required_at`) starts the first time we see
2335+
// them required-but-unenrolled (stamped lazily, best-effort).
2336+
if (mfaRequired && !(u?.two_factor_enabled === true || u?.two_factor_enabled === 1)) {
2337+
const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7)));
2338+
let requiredAt = u?.mfa_required_at;
2339+
if (!requiredAt) {
2340+
requiredAt = new Date();
2341+
// Best-effort: start the grace clock; never block on the write.
2342+
engine
2343+
.update('sys_user', { id: userId, mfa_required_at: requiredAt }, { context: SYSTEM_CTX } as any)
2344+
.catch(() => undefined);
2345+
}
2346+
const elapsedMs = Date.now() - new Date(requiredAt).getTime();
2347+
if (elapsedMs > graceDays * 86_400_000) {
2348+
return {
2349+
code: 'MFA_REQUIRED',
2350+
message:
2351+
'Multi-factor authentication is required. Please set up an authenticator app to continue.',
2352+
};
2353+
}
2354+
}
23132355
} catch {
23142356
return undefined; // fail-open
23152357
}

packages/plugins/plugin-auth/src/auth-plugin.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,24 @@ describe('AuthPlugin', () => {
694694
expect((manager as any).isAuthGateActive()).toBe(true);
695695
});
696696

697+
it('binds mfa_required and force-enables the twoFactor plugin (ADR-0069 D3)', async () => {
698+
const { manager } = await bootWithAuthSettings({ mfa_required: { value: true, source: 'global' } });
699+
const cfg = (manager as any).config;
700+
expect(cfg.mfaRequired).toBe(true);
701+
expect(cfg.plugins?.twoFactor).toBe(true);
702+
expect((manager as any).isAuthGateActive()).toBe(true);
703+
});
704+
705+
it('binds mfa_grace_period_days', async () => {
706+
const { manager } = await bootWithAuthSettings({ mfa_grace_period_days: { value: 14, source: 'global' } });
707+
expect((manager as any).config.mfaGracePeriodDays).toBe(14);
708+
});
709+
710+
it('clamps mfa_grace_period_days to a max of 90', async () => {
711+
const { manager } = await bootWithAuthSettings({ mfa_grace_period_days: { value: 999, source: 'global' } });
712+
expect((manager as any).config.mfaGracePeriodDays).toBe(90);
713+
});
714+
697715
it('binds account-lockout settings (ADR-0069 D2)', async () => {
698716
const { manager } = await bootWithAuthSettings({
699717
lockout_threshold: { value: 5, source: 'global' },

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,24 @@ export class AuthPlugin implements Plugin {
518518
if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n);
519519
}
520520

521+
// Enforced MFA (ADR-0069 D3). Enabling it also turns the twoFactor
522+
// plugin on so the /two-factor/* enrollment endpoints exist — otherwise
523+
// gated users would have no way to comply.
524+
if (isExplicit('mfa_required')) {
525+
const on = asBoolean(values.mfa_required, false);
526+
patch.mfaRequired = on;
527+
if (on) {
528+
patch.plugins = {
529+
...(patch.plugins ?? {}),
530+
twoFactor: true,
531+
} as AuthManagerOptions['plugins'];
532+
}
533+
}
534+
if (isExplicit('mfa_grace_period_days')) {
535+
const n = Math.floor(Number(values.mfa_grace_period_days));
536+
if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n);
537+
}
538+
521539
// Session lifetime — days → seconds for better-auth's `session`
522540
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).
523541
const session: { expiresIn?: number; updateAge?: number } = {};

packages/services/service-settings/src/manifests/auth.manifest.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('authSettingsManifest', () => {
2525
expect(keys).toEqual([
2626
'email_password_enabled',
2727
'google_enabled',
28+
'mfa_required',
2829
'password_reject_breached',
2930
'password_require_complexity',
3031
'require_email_verification',
@@ -66,6 +67,7 @@ describe('authSettingsManifest', () => {
6667
expect(byKey('rate_limit_window_seconds').default).toBe(60);
6768
expect(byKey('password_history_count').default).toBe(0);
6869
expect(byKey('password_expiry_days').default).toBe(0);
70+
expect(byKey('mfa_grace_period_days').default).toBe(7);
6971
});
7072

7173
it('exposes encrypted Google OAuth credential fields', () => {

packages/services/service-settings/src/manifests/auth.manifest.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,34 @@ const manifest = {
180180
max: 3600,
181181
description: 'Sliding window over which the request cap above is counted.',
182182
},
183+
{
184+
type: 'group',
185+
id: 'multi_factor',
186+
label: 'Multi-factor',
187+
required: false,
188+
description: 'Require members to protect their account with an authenticator app (TOTP).',
189+
},
190+
{
191+
type: 'toggle',
192+
key: 'mfa_required',
193+
label: 'Require multi-factor authentication',
194+
required: false,
195+
default: false,
196+
description:
197+
'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.',
198+
visible: "${data.email_password_enabled !== false}",
199+
},
200+
{
201+
type: 'number',
202+
key: 'mfa_grace_period_days',
203+
label: 'MFA grace period (days)',
204+
required: false,
205+
default: 7,
206+
min: 0,
207+
max: 90,
208+
description: 'How long users may defer enrollment before the hard block. 0 blocks immediately.',
209+
visible: "${data.mfa_required === true}",
210+
},
183211
{
184212
type: 'group',
185213
id: 'sessions',

0 commit comments

Comments
 (0)