diff --git a/.changeset/adr-0069-d1-password-expiry.md b/.changeset/adr-0069-d1-password-expiry.md new file mode 100644 index 0000000000..37448a4184 --- /dev/null +++ b/.changeset/adr-0069-d1-password-expiry.md @@ -0,0 +1,21 @@ +--- +'@objectstack/core': minor +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/plugin-auth': minor +'@objectstack/rest': minor +--- + +Auth: password expiry — the session-validation gate (ADR-0069 D1, P1) + +Builds the **authentication-policy session gate** ADR-0069 needs and uses it for password expiry. When `password_expiry_days` (new `auth` setting, 0 = off) is exceeded, an authenticated user is blocked from protected REST resources with `403 PASSWORD_EXPIRED` until they change their password — while auth + remediation paths stay reachable. + +- **core**: new pure `evaluateAuthGate` / `isAuthGateAllowlisted` helper (`@objectstack/core/security`) — single source of truth for the allow-list (auth endpoints, change-password, health, UI-bootstrap reads). +- **plugin-auth**: `customSession` computes the gate posture once and attaches `user.authGate`; `computeAuthGate` reads `sys_user.password_changed_at` vs the configured window; `password_changed_at` is stamped on sign-up / change / reset; `isAuthGateActive()` keeps the gate **zero-overhead** when off. +- **platform-objects**: new `sys_user.password_changed_at` column. +- **rest**: `resolveExecCtx` carries `authGate`; `enforceAuth` blocks gated sessions (independent of `requireAuth`) using the core allow-list. +- **service-settings**: new `password_expiry_days` field. + +Default-off / additive (no upgrade behavior change); a null `password_changed_at` never expires (existing users). Per ADR-0049 the setting ships with its enforcement; timestamps written as `Date` (ADR-0074). + +This gate is the shared seam for **enforced MFA** (ADR-0069 D3), which lands next as a small addition (a second `authGate` branch). The dispatcher/MCP path is a follow-up (tracked in #2375); the REST surface the Console uses is fully gated here. diff --git a/packages/core/src/security/auth-gate.test.ts b/packages/core/src/security/auth-gate.test.ts new file mode 100644 index 0000000000..9f7f7f784e --- /dev/null +++ b/packages/core/src/security/auth-gate.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect } from 'vitest'; +import { isAuthGateAllowlisted, evaluateAuthGate } from './auth-gate'; + +describe('auth-gate (ADR-0069 session gate)', () => { + describe('isAuthGateAllowlisted', () => { + it('allows auth + remediation + health paths (both REST and dispatcher shapes)', () => { + for (const p of [ + '/api/v1/auth/change-password', + '/api/v1/auth/two-factor/enable', + '/api/v1/auth/sign-out', + '/auth/sign-out', + '/api/v1/health', + '/api/v1/discovery', + '/api/v1/me/apps', + '/api/v1/auth/me/localization', + ]) { + expect(isAuthGateAllowlisted(p)).toBe(true); + } + }); + it('blocks data / meta / settings paths', () => { + for (const p of ['/api/v1/data/sys_user', '/api/v1/meta/object/foo', '/api/settings/auth', '/api/v1/ai/chat']) { + expect(isAuthGateAllowlisted(p)).toBe(false); + } + }); + it('strips query and trailing slash', () => { + expect(isAuthGateAllowlisted('/api/v1/auth/sign-out/?x=1')).toBe(true); + expect(isAuthGateAllowlisted('/api/v1/data/x/')).toBe(false); + }); + }); + + describe('evaluateAuthGate', () => { + it('returns null when the user carries no authGate', () => { + expect(evaluateAuthGate({ id: 'u1' }, '/api/v1/data/x')).toBeNull(); + }); + it('returns null on an allow-listed path even when gated', () => { + const u = { id: 'u1', authGate: { code: 'PASSWORD_EXPIRED', message: 'm' } }; + expect(evaluateAuthGate(u, '/api/v1/auth/change-password')).toBeNull(); + }); + it('returns the gate on a blocked path', () => { + const u = { id: 'u1', authGate: { code: 'PASSWORD_EXPIRED', message: 'change it' } }; + expect(evaluateAuthGate(u, '/api/v1/data/sys_user')).toEqual({ code: 'PASSWORD_EXPIRED', message: 'change it' }); + }); + it('falls back to a generic message when none provided', () => { + const u = { authGate: { code: 'MFA_REQUIRED' } }; + const g = evaluateAuthGate(u, '/api/v1/data/x'); + expect(g?.code).toBe('MFA_REQUIRED'); + expect(typeof g?.message).toBe('string'); + }); + }); +}); diff --git a/packages/core/src/security/auth-gate.ts b/packages/core/src/security/auth-gate.ts new file mode 100644 index 0000000000..55bfdcaa84 --- /dev/null +++ b/packages/core/src/security/auth-gate.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0069 — authentication-policy session gate. + * + * Some auth policies (password expiry, enforced MFA) must block an + * authenticated user from PROTECTED RESOURCES until they remediate, while + * still letting them reach the auth endpoints (change-password, two-factor + * enrollment, sign-out) and a few UI-bootstrap reads. + * + * The posture is computed ONCE, in the auth `customSession` enrichment, and + * attached to the session user as `user.authGate = { code, message }`. The + * transport seams (REST middleware, dispatcher) then call + * {@link evaluateAuthGate} to decide whether THIS request is blocked. Keeping + * the allow-list + decision in one pure function means the seams can never + * drift on what is blocked. + */ + +export interface AuthGate { + /** Stable machine code, e.g. `PASSWORD_EXPIRED` / `MFA_REQUIRED`. */ + code: string; + /** Human-facing message. */ + message: string; +} + +// Endpoints a gated user MUST still reach to remediate or bootstrap the +// remediation UI. Matched against the request path (query stripped). Covers +// both REST (`/api/v1/auth/…`) and dispatcher (`/auth/…`) path shapes. +const ALLOW_PREFIXES = ['/api/v1/auth/', '/api/auth/', '/auth/']; +const ALLOW_SUFFIXES = ['/health', '/ready', '/discovery', '/me/apps', '/me/localization']; + +/** True when `path` is exempt from the auth gate (auth + remediation + health). */ +export function isAuthGateAllowlisted(rawPath: string | undefined | null): boolean { + if (!rawPath) return true; + // Strip query + trailing slashes WITHOUT a regex (avoids ReDoS on a + // path of many '/'). char 47 = '/'. + let path = rawPath.split('?')[0] || '/'; + let end = path.length; + while (end > 1 && path.charCodeAt(end - 1) === 47) end--; + path = path.slice(0, end) || '/'; + // Any path with an `/auth/` segment is an auth endpoint (covers project- + // scoped mounts like `/api/v1/environments/:env/auth/...`). + if (path.includes('/auth/')) return true; + for (const p of ALLOW_PREFIXES) { + if (path.startsWith(p) || path === p.replace(/\/$/, '')) return true; + } + for (const s of ALLOW_SUFFIXES) { + if (path.endsWith(s)) return true; + } + return false; +} + +/** + * Returns the active gate when `sessionUser` carries an `authGate` AND `path` + * is not allow-listed; otherwise null. Anonymous users (no `authGate`) and + * allow-listed paths always pass. + */ +export function evaluateAuthGate(sessionUser: any, path: string): AuthGate | null { + const gate = sessionUser?.authGate; + if (!gate || typeof gate.code !== 'string') return null; + if (isAuthGateAllowlisted(path)) return null; + return { + code: gate.code, + message: + typeof gate.message === 'string' && gate.message + ? gate.message + : 'Access is blocked by an authentication policy.', + }; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index b1f30b161c..2ffef4cf38 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -87,3 +87,4 @@ export { type ResolveAuthzInput, type ResolveLocalizationInput, } from './resolve-authz-context.js'; +export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js'; diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index bbf6b1ff55..ce2f715eb5 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -447,6 +447,17 @@ export const SysUser = ObjectSchema.create({ description: 'When set and in the future, sign-in is rejected (brute-force lockout). Auto-clears past this time; an admin can clear it early via Unlock.', }), + // ADR-0069 D1 — last password change; drives password-expiry enforcement. + // Stamped on sign-up / change-password / reset-password. Null = never + // expires (until the user next changes their password). + password_changed_at: Field.datetime({ + label: 'Password Changed At', + required: false, + readonly: true, + group: 'Admin', + description: 'Timestamp of the last password change. Backs password_expiry_days; system-managed.', + }), + ai_access: Field.boolean({ label: 'AI Access', defaultValue: false, diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 9c8d7e8214..35a5e95833 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1850,4 +1850,62 @@ describe('AuthManager', () => { expect(written).toEqual(['hash:current', 'hash:old1']); // prepend + trim to 2 }); }); + + // ADR-0069 D1: password expiry posture + stamping (the session-gate infra). + describe('password expiry gate (ADR-0069 D1)', () => { + 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 reflects passwordExpiryDays', () => { + expect(mgr(makeEngine(null), { passwordExpiryDays: 0 }).isAuthGateActive()).toBe(false); + expect(mgr(makeEngine(null), { passwordExpiryDays: 90 }).isAuthGateActive()).toBe(true); + }); + + it('computeAuthGate is a no-op (no DB read) when expiry is off', async () => { + const engine = makeEngine({ id: 'u1', password_changed_at: new Date(0).toISOString() }); + const m = mgr(engine, { passwordExpiryDays: 0 }); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + + it('returns PASSWORD_EXPIRED when password_changed_at is older than the window', async () => { + const old = new Date(Date.now() - 100 * 86_400_000).toISOString(); + const m = mgr(makeEngine({ id: 'u1', password_changed_at: old }), { passwordExpiryDays: 90 }); + const gate = await (m as any).computeAuthGate('u1', undefined, false); + expect(gate?.code).toBe('PASSWORD_EXPIRED'); + }); + + it('does NOT expire when the password is within the window', async () => { + const recent = new Date(Date.now() - 10 * 86_400_000).toISOString(); + const m = mgr(makeEngine({ id: 'u1', password_changed_at: recent }), { passwordExpiryDays: 90 }); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + }); + + it('treats a null password_changed_at as never-expired (upgrade-safe)', async () => { + const m = mgr(makeEngine({ id: 'u1', password_changed_at: null }), { passwordExpiryDays: 1 }); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + }); + + it('stampPasswordChangedAt writes a Date (never epoch-ms) to sys_user', async () => { + const engine = makeEngine({ id: 'u1' }); + const m = mgr(engine); + await (m as any).stampPasswordChangedAt('u1'); + const arg = engine.update.mock.calls[0][1]; + expect(arg.id).toBe('u1'); + expect(arg.password_changed_at instanceof Date).toBe(true); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 32c7fc0de7..17c10c7d56 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -302,6 +302,15 @@ export interface AuthManagerOptions extends Partial { */ passwordHistoryCount?: number; + /** + * ADR-0069 D1 — password expiry (days). When > 0, an authenticated user whose + * `sys_user.password_changed_at` is older than this is gated out of protected + * resources (`PASSWORD_EXPIRED`) until they change their password. Computed in + * `customSession` (→ `user.authGate`) and enforced at the transport seam. 0 = + * off. A null `password_changed_at` never expires (existing users on upgrade). + */ + passwordExpiryDays?: number; + /** * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to * better-auth's core `rateLimit`. The settings bind tightens `customRules` @@ -623,6 +632,9 @@ export class AuthManager { ) { const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => undefined); if (userId) { + // Stash for the after-hook (password_changed_at stamp), regardless + // of whether history is enabled. + ctx.context.__osPwChangeUserId = userId; const pw = ctx?.context?.password; const verify = typeof pw?.verify === 'function' ? pw.verify.bind(pw) : undefined; const oldHash = await this.assertPasswordNotReused(userId, candidate, verify); @@ -828,24 +840,41 @@ export class AuthManager { return; } - // ── ADR-0069 D1: commit password history on success ───────── + // ── ADR-0069 D1: on a successful change/reset — stamp + // password_changed_at (expiry) and commit password history. if (ctx?.path === '/change-password' || ctx?.path === '/reset-password') { - const stash = ctx?.context?.__osPwHistory; - if (stash?.userId) { - let succeeded = true; - try { - const { isAPIError } = await import('better-auth/api'); - succeeded = !isAPIError(ctx?.context?.returned); - } catch { - succeeded = !(ctx?.context?.returned instanceof Error); - } - if (succeeded) await this.recordPasswordHistory(stash.userId, stash.oldHash); - delete ctx.context.__osPwHistory; + let succeeded: boolean; + try { + const { isAPIError } = await import('better-auth/api'); + succeeded = !isAPIError(ctx?.context?.returned); + } catch { + succeeded = !(ctx?.context?.returned instanceof Error); + } + if (succeeded) { + const stampId = ctx?.context?.__osPwChangeUserId; + if (stampId) await this.stampPasswordChangedAt(stampId); + const stash = ctx?.context?.__osPwHistory; + if (stash?.userId) await this.recordPasswordHistory(stash.userId, stash.oldHash); } + delete ctx.context.__osPwChangeUserId; + delete ctx.context.__osPwHistory; return; } if (ctx?.path !== '/sign-up/email') return; + // ADR-0069 D1 — stamp password_changed_at for a newly-created local + // user (expiry clock starts at sign-up). Best-effort. + { + const newUserId = ctx?.context?.returned?.user?.id; + let signupOk: boolean; + try { + const { isAPIError } = await import('better-auth/api'); + signupOk = !isAPIError(ctx?.context?.returned); + } catch { + signupOk = !(ctx?.context?.returned instanceof Error); + } + if (signupOk && typeof newUserId === 'string') await this.stampPasswordChangedAt(newUserId); + } const ep = ctx?.context?.options?.emailAndPassword; if (ep && ctx.context.__osDisableSignUpOrig !== undefined) { ep.disableSignUp = ctx.context.__osDisableSignUpOrig; @@ -1518,7 +1547,21 @@ export class AuthManager { ...orgRoles, ...(platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []), ])); - return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session }; + + // ADR-0069 — authentication-policy gate posture (password expiry, + // enforced MFA). Computed only when a gate feature is enabled (else + // zero extra reads on the hot path); surfaced as `user.authGate` for + // the transport seams to enforce. See computeAuthGate(). + const authGate = await this.computeAuthGate( + user.id, + (session as any)?.activeOrganizationId, + (user as any)?.twoFactorEnabled === true, + ); + + return { + user: { ...user, roles, isPlatformAdmin: platformAdmin, ...(authGate ? { authGate } : {}) }, + session, + }; })); } @@ -2226,6 +2269,73 @@ export class AuthManager { } } + /** + * ADR-0069 — is any authentication-policy gate enabled? Cheap, synchronous; + * lets the transport seams skip session lookups entirely when off (the + * default), keeping the gate zero-overhead until an admin opts in. + */ + public isAuthGateActive(): boolean { + return (Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0); + } + + /** + * ADR-0069 — compute the auth-policy gate posture for a session. Returns an + * `{ code, message }` when the user is currently blocked (e.g. password + * expired), else undefined. No-op (and no DB read) when no gate feature is + * enabled. Fails OPEN on any lookup error — a transient hiccup must never lock + * a compliant user out. + */ + private async computeAuthGate( + userId: string, + _activeOrgId: string | undefined, + _twoFactorEnabled: 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 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, + } 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) { + return { + code: 'PASSWORD_EXPIRED', + message: 'Your password has expired. Please change it to continue.', + }; + } + } + } catch { + return undefined; // fail-open + } + return undefined; + } + + /** + * ADR-0069 D1 — stamp `sys_user.password_changed_at = now` after a password is + * set (sign-up / change / reset). Best-effort; never throws. Written as a Date + * (never epoch-ms) per ADR-0074. + */ + private async stampPasswordChangedAt(userId: string): Promise { + const engine = this.getDataEngine(); + if (!engine || !userId) return; + try { + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + await engine.update( + 'sys_user', + { id: userId, password_changed_at: new Date() }, + { context: SYSTEM_CTX } as any, + ); + } catch { + // audit stamp is best-effort — never break a valid password change + } + } + /** * ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into * a string[] of hashes, tolerating null / malformed values. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index e086058af1..028c31cf6c 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -688,6 +688,12 @@ describe('AuthPlugin', () => { expect((manager as any).config.passwordHistoryCount).toBe(0); }); + it('binds password_expiry_days (ADR-0069 D1)', async () => { + const { manager } = await bootWithAuthSettings({ password_expiry_days: { value: 90, source: 'global' } }); + expect((manager as any).config.passwordExpiryDays).toBe(90); + expect((manager as any).isAuthGateActive()).toBe(true); + }); + it('binds account-lockout settings (ADR-0069 D2)', async () => { const { manager } = await bootWithAuthSettings({ lockout_threshold: { value: 5, source: 'global' }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 632f114c30..a06b90f52a 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -511,6 +511,11 @@ export class AuthPlugin implements Plugin { const n = Math.floor(Number(values.password_history_count)); if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n); } + if (isExplicit('password_expiry_days')) { + // 0 disables expiry → non-negative reader. + const n = Math.floor(Number(values.password_expiry_days)); + if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n); + } // Session lifetime — days → seconds for better-auth's `session` // (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold). diff --git a/packages/rest/src/rest-auth-gate.test.ts b/packages/rest/src/rest-auth-gate.test.ts new file mode 100644 index 0000000000..3748ccfc9a --- /dev/null +++ b/packages/rest/src/rest-auth-gate.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +// Minimal IHttpServer / protocol stubs — we only exercise enforceAuth(). +const server: any = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), +}; +const protocol: any = {}; + +const makeRes = () => { + const state: any = { status: 200, body: undefined }; + const res: any = { + status: (c: number) => { state.status = c; return res; }, + json: (b: any) => { state.body = b; }, + header: () => res, + send: () => {}, + }; + return { res, state }; +}; + +const rest = new RestServer(server, protocol, { requireAuth: false } as any); + +describe('RestServer.enforceAuth — ADR-0069 auth-policy gate', () => { + const gate = (req: any, context: any) => { + const { res, state } = makeRes(); + const blocked = (rest as any).enforceAuth(req, res, context); + return { blocked, state }; + }; + + it('blocks a gated session (authGate) on a protected path with 403 + code', () => { + const r = gate( + { method: 'GET', path: '/api/v1/data/sys_user' }, + { userId: 'u1', authGate: { code: 'PASSWORD_EXPIRED', message: 'change it' } }, + ); + expect(r.blocked).toBe(true); + expect(r.state.status).toBe(403); + expect(r.state.body.error.code).toBe('PASSWORD_EXPIRED'); + }); + + it('lets a gated session through on an allow-listed (auth/remediation) path', () => { + const r = gate( + { method: 'POST', path: '/api/v1/auth/change-password' }, + { userId: 'u1', authGate: { code: 'PASSWORD_EXPIRED', message: 'x' } }, + ); + expect(r.blocked).toBe(false); + }); + + it('does not gate a normal authenticated session (no authGate)', () => { + const r = gate({ method: 'GET', path: '/api/v1/data/sys_user' }, { userId: 'u1' }); + expect(r.blocked).toBe(false); + }); + + it('ignores OPTIONS preflight even when gated', () => { + const r = gate( + { method: 'OPTIONS', path: '/api/v1/data/sys_user' }, + { userId: 'u1', authGate: { code: 'PASSWORD_EXPIRED', message: 'x' } }, + ); + expect(r.blocked).toBe(false); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d34c43b657..a6d2639f9b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { IHttpServer, resolveAuthzContext, resolveLocalizationContext } from '@objectstack/core'; +import { IHttpServer, resolveAuthzContext, resolveLocalizationContext, isAuthGateAllowlisted } from '@objectstack/core'; import { RouteManager } from './route-manager.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { ObjectStackProtocol } from '@objectstack/spec/api'; @@ -760,6 +760,15 @@ export class RestServer { * requests (they're internal-only), so they cannot bypass this gate. */ private enforceAuth(req: any, res: any, context: any): boolean { + // ADR-0069 — authentication-policy gate (password expiry, enforced MFA). + // Independent of `requireAuth`: a gated session (carrying `authGate`) is + // blocked from protected resources, while the core allow-list keeps auth + // + remediation reachable. Runs before the anonymous check. + const gate = context?.authGate; + if (gate && req?.method !== 'OPTIONS' && !isAuthGateAllowlisted(req?.path)) { + res.status(403).json({ error: { code: gate.code, message: gate.message } }); + return true; + } if (!this.config.api.requireAuth) return false; if (context?.userId) return false; if (req?.method === 'OPTIONS') return false; @@ -944,6 +953,18 @@ export class RestServer { userId: authz.userId, }); + // ADR-0069 — authentication-policy gate posture. Only when a gate + // feature is active (cheap sync check) do we re-read the session for + // its `user.authGate` (computed in customSession). enforceAuth() then + // blocks protected resources for a gated user. Zero cost when off. + let authGate: any; + try { + if (typeof authService.isAuthGateActive === 'function' && authService.isAuthGateActive()) { + const gatedSession: any = await getSession(headers).catch(() => undefined); + if (gatedSession?.user?.authGate) authGate = gatedSession.user.authGate; + } + } catch { /* gate is best-effort — never break context resolution */ } + return { userId: authz.userId, tenantId: authz.tenantId, @@ -954,6 +975,7 @@ export class RestServer { ...(authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {}), isSystem: false, org_user_ids: authz.org_user_ids, + ...(authGate ? { authGate } : {}), ...(localization.timezone ? { timezone: localization.timezone } : {}), ...(localization.locale ? { locale: localization.locale } : {}), ...(localization.currency ? { currency: localization.currency } : {}), diff --git a/packages/services/service-settings/src/manifests/auth.manifest.test.ts b/packages/services/service-settings/src/manifests/auth.manifest.test.ts index 285bdd6e0e..f202e584f3 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -65,6 +65,7 @@ describe('authSettingsManifest', () => { expect(byKey('rate_limit_max').default).toBe(10); 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); }); it('exposes encrypted Google OAuth credential fields', () => { diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index 3f8b270fb7..a369f4b9e8 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -117,6 +117,17 @@ const manifest = { description: 'Block reusing this many previous passwords on change/reset. 0 disables the check.', visible: "${data.email_password_enabled !== false}", }, + { + type: 'number', + key: 'password_expiry_days', + label: 'Password expiry (days)', + required: false, + default: 0, + min: 0, + max: 3650, + description: 'Force a password change after this many days. 0 disables expiry. While expired, the user is blocked from data until they change their password.', + visible: "${data.email_password_enabled !== false}", + }, { type: 'group',