From 366d46893bc43a2d05f9b27222dd05b593535eb1 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:37:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20session=20controls=20=E2=80=94=20?= =?UTF-8?q?idle=20/=20absolute=20/=20concurrent=20(ADR-0069=20D4,=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platform-objects: sys_session.{last_activity_at, revoked_at, revoke_reason}. - service-settings: session_idle_timeout_minutes, session_absolute_max_hours, max_concurrent_sessions_per_user (all 0 = off). - plugin-auth: - customSession enforces idle + absolute per request: touch last_activity_at (throttled ~1/min); revoke (expire-in-place + revoked_at/revoke_reason) once the idle window or absolute cap is exceeded. - sign-in after-hook enforces the concurrent cap: keep the newest N live sessions, revoke the rest (oldest first). - revocation expires the session in place so better-auth returns null next request -> existing 401 -> login redirect; no client change. Default-off / additive; ADR-0049. better-auth GCs expired sessions, so the revoke audit is best-effort; the enforcement is not. Verified live (dogfood): concurrent cap=2 + 3 sign-ins -> oldest session revoked (revoke_reason=concurrent_cap), 2 newest alive; idle=15m with a backdated last_activity -> session killed on the next request. Unit: plugin-auth 203 + service-settings 129 + platform-objects 63 green; full build incl. strict DTS green. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0069-d4-session-controls.md | 17 +++ .../src/identity/sys-session.object.ts | 24 +++++ .../plugin-auth/src/auth-manager.test.ts | 85 +++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 102 ++++++++++++++++++ .../plugin-auth/src/auth-plugin.test.ts | 12 +++ .../plugins/plugin-auth/src/auth-plugin.ts | 19 ++++ .../src/manifests/auth.manifest.ts | 30 ++++++ 7 files changed, 289 insertions(+) create mode 100644 .changeset/adr-0069-d4-session-controls.md diff --git a/.changeset/adr-0069-d4-session-controls.md b/.changeset/adr-0069-d4-session-controls.md new file mode 100644 index 0000000000..403dfcbb83 --- /dev/null +++ b/.changeset/adr-0069-d4-session-controls.md @@ -0,0 +1,17 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/plugin-auth': minor +--- + +Auth: session controls — idle timeout, absolute max lifetime, concurrent cap (ADR-0069 D4, P2) + +Adds three `auth` session-control settings (all 0 = off): + +- `session_idle_timeout_minutes` — sign a user out after inactivity. Enforced in `customSession`: touches `sys_session.last_activity_at` (throttled to once a minute) and, once the idle window is exceeded, revokes the session. +- `session_absolute_max_hours` — cap total session lifetime regardless of refresh; revoked once `created_at` is older than the cap. +- `max_concurrent_sessions_per_user` — on sign-in, keep the newest N live sessions and revoke the rest (oldest first). + +Revocation expires the session in place (`expires_at` set to the past + `revoked_at` / `revoke_reason` stamped on new `sys_session` columns), so better-auth returns no session on the next request → the Console's existing 401 → login redirect handles it (no client change). Note: better-auth garbage-collects expired sessions, so the `revoke_reason` audit row is best-effort; the enforcement (session killed) is not. + +Default-off / additive (no upgrade behavior change); per ADR-0049 each setting ships with its enforcement. diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index 25254fe807..d9501cde76 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -107,6 +107,30 @@ export const SysSession = ObjectSchema.create({ group: 'Session', }), + // ── ADR-0069 D4 — session controls (idle / absolute / revoke) ── + last_activity_at: Field.datetime({ + label: 'Last Activity At', + required: false, + readonly: true, + group: 'Session', + description: 'Timestamp of the last request on this session; drives idle-timeout. System-managed.', + }), + revoked_at: Field.datetime({ + label: 'Revoked At', + required: false, + readonly: true, + group: 'Session', + description: 'When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed.', + }), + revoke_reason: Field.text({ + label: 'Revoke Reason', + required: false, + maxLength: 64, + readonly: true, + group: 'Session', + description: 'Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …).', + }), + // ── Active context (multi-org/team) ────────────────────────── active_organization_id: Field.lookup('sys_organization', { label: 'Active Organization', diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index bf8a65976c..390ceb82aa 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2036,4 +2036,89 @@ describe('AuthManager', () => { expect(engine.findOne).not.toHaveBeenCalled(); }); }); + + // ADR-0069 D4 — session controls (idle / absolute / concurrent). + describe('session controls (ADR-0069 D4)', () => { + const SECRET = 'test-secret-at-least-32-chars-long'; + 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; + }; + const oneEngine = (row: any) => ({ + findOne: vi.fn(async () => row), + update: vi.fn(async () => ({})), + find: vi.fn(async () => []), + }); + + it('is a no-op when idle + absolute are both off', async () => { + const engine = oneEngine({ id: 's1', created_at: new Date(0).toISOString() }); + const m = mgr(engine, {}); + await (m as any).enforceSessionControls('s1', undefined); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + + it('revokes (absolute_max) when the session is older than the absolute cap', async () => { + const old = new Date(Date.now() - 100 * 3_600_000).toISOString(); + const engine = oneEngine({ id: 's1', created_at: old, last_activity_at: null, revoked_at: null }); + const m = mgr(engine, { sessionAbsoluteMaxHours: 24 }); + await (m as any).enforceSessionControls('s1', undefined); + const patch = engine.update.mock.calls[0][1]; + expect(patch.revoke_reason).toBe('absolute_max'); + expect(patch.revoked_at instanceof Date).toBe(true); + expect(patch.expires_at instanceof Date).toBe(true); + }); + + it('revokes (idle_timeout) when last activity is older than the idle window', async () => { + const recentCreate = new Date(Date.now() - 60 * 60_000).toISOString(); + const staleActivity = new Date(Date.now() - 30 * 60_000).toISOString(); + const engine = oneEngine({ id: 's1', created_at: recentCreate, last_activity_at: staleActivity, revoked_at: null }); + const m = mgr(engine, { sessionIdleTimeoutMinutes: 15 }); + await (m as any).enforceSessionControls('s1', undefined); + expect(engine.update.mock.calls[0][1].revoke_reason).toBe('idle_timeout'); + }); + + it('touches last_activity_at (not revoke) when within the idle window but stale > 60s', async () => { + const recentCreate = new Date(Date.now() - 5 * 60_000).toISOString(); + const activity2minAgo = new Date(Date.now() - 2 * 60_000).toISOString(); + const engine = oneEngine({ id: 's1', created_at: recentCreate, last_activity_at: activity2minAgo, revoked_at: null }); + const m = mgr(engine, { sessionIdleTimeoutMinutes: 15 }); + await (m as any).enforceSessionControls('s1', undefined); + const patch = engine.update.mock.calls[0][1]; + expect(patch.last_activity_at instanceof Date).toBe(true); + expect(patch.revoke_reason).toBeUndefined(); + }); + + it('does not touch when already revoked', async () => { + const engine = oneEngine({ id: 's1', created_at: new Date().toISOString(), revoked_at: new Date().toISOString() }); + const m = mgr(engine, { sessionIdleTimeoutMinutes: 15 }); + await (m as any).enforceSessionControls('s1', undefined); + expect(engine.update).not.toHaveBeenCalled(); + }); + + it('enforceConcurrentCap revokes the oldest sessions past the cap', async () => { + const now = Date.now(); + const sess = [ + { id: 'a', created_at: new Date(now - 4000).toISOString(), revoked_at: null }, + { id: 'b', created_at: new Date(now - 3000).toISOString(), revoked_at: null }, + { id: 'c', created_at: new Date(now - 2000).toISOString(), revoked_at: null }, + { id: 'd', created_at: new Date(now - 1000).toISOString(), revoked_at: null }, + ]; + const engine = { findOne: vi.fn(), update: vi.fn(async () => ({})), find: vi.fn(async () => sess) }; + const m = mgr(engine, { maxConcurrentSessions: 2 }); + await (m as any).enforceConcurrentCap('u1'); + // keeps newest 2 (d, c) → revokes oldest 2 (b, a) + const revokedIds = engine.update.mock.calls.map((c: any[]) => c[1].id).sort(); + expect(revokedIds).toEqual(['a', 'b']); + expect(engine.update.mock.calls[0][1].revoke_reason).toBe('concurrent_cap'); + }); + + it('enforceConcurrentCap is a no-op when the cap is 0', async () => { + const engine = { findOne: vi.fn(), update: vi.fn(), find: vi.fn(async () => []) }; + const m = mgr(engine, { maxConcurrentSessions: 0 }); + await (m as any).enforceConcurrentCap('u1'); + expect(engine.find).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 2d2a3329fa..66a6708dcc 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -322,6 +322,16 @@ export interface AuthManagerOptions extends Partial { /** Days a user may defer MFA enrollment before the hard block. Default 7. */ mfaGracePeriodDays?: number; + /** + * ADR-0069 D4 — session controls. Enforced in `customSession` (idle/absolute) + * and the sign-in hook (concurrent). 0 = off for each. A revoked session is + * expired in place (`sys_session.expires_at` past + `revoked_at`/`revoke_reason`) + * so better-auth returns no session on the next request (→ 401 → re-login). + */ + sessionIdleTimeoutMinutes?: number; + sessionAbsoluteMaxHours?: number; + maxConcurrentSessions?: number; + /** * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to * better-auth's core `rateLimit`. The settings bind tightens `customRules` @@ -851,6 +861,10 @@ export class AuthManager { succeeded = !(ctx?.context?.returned instanceof Error); } await this.recordSignInOutcome(email, succeeded); + if (succeeded) { + const uid = ctx?.context?.returned?.user?.id; + if (typeof uid === 'string') await this.enforceConcurrentCap(uid); + } } return; } @@ -1567,6 +1581,10 @@ export class AuthManager { // 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(). + // ADR-0069 D4 — session controls (idle / absolute). Best-effort; + // revokes the session in place when exceeded (next request → 401). + await this.enforceSessionControls((session as any)?.id, (session as any)?.createdAt); + const authGate = await this.computeAuthGate( user.id, (session as any)?.activeOrganizationId, @@ -2651,6 +2669,90 @@ export class AuthManager { return true; } + /** + * ADR-0069 D4 — idle / absolute session enforcement, run per request from + * `customSession`. No-op when both are off. Revokes (expires in place + + * stamps revoked_at/revoke_reason) when a limit is exceeded so better-auth + * returns no session on the NEXT request; otherwise touches `last_activity_at` + * (throttled to once a minute). Best-effort — never throws. + */ + private async enforceSessionControls(sessionId: string | undefined, createdAtHint: unknown): Promise { + const idleMin = Math.floor(Number(this.config.sessionIdleTimeoutMinutes) || 0); + const absHrs = Math.floor(Number(this.config.sessionAbsoluteMaxHours) || 0); + if (idleMin <= 0 && absHrs <= 0) return; + const engine = this.getDataEngine(); + if (!engine || !sessionId) return; + try { + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const srow = await engine.findOne('sys_session', { + where: { id: sessionId }, + fields: ['id', 'created_at', 'last_activity_at', 'revoked_at'], + context: SYSTEM_CTX, + } as any); + if (!srow?.id || srow.revoked_at) return; + const now = Date.now(); + let reason: string | undefined; + if (absHrs > 0) { + const created = srow.created_at ?? createdAtHint; + if (created && now - new Date(created as any).getTime() > absHrs * 3_600_000) reason = 'absolute_max'; + } + if (!reason && idleMin > 0) { + const last = srow.last_activity_at ?? srow.created_at ?? createdAtHint; + if (last && now - new Date(last as any).getTime() > idleMin * 60_000) reason = 'idle_timeout'; + } + if (reason) { + await engine.update( + 'sys_session', + { id: sessionId, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: reason }, + { context: SYSTEM_CTX } as any, + ).catch(() => undefined); + return; + } + if (idleMin > 0) { + const la = srow.last_activity_at ? new Date(srow.last_activity_at as any).getTime() : 0; + if (now - la > 60_000) { + await engine.update('sys_session', { id: sessionId, last_activity_at: new Date(now) }, { context: SYSTEM_CTX } as any).catch(() => undefined); + } + } + } catch { + // session controls are best-effort — never break a request + } + } + + /** + * ADR-0069 D4 — concurrent-session cap, run from the sign-in after-hook. + * Keeps the newest `maxConcurrentSessions` live sessions for the user and + * revokes the rest (oldest first). No-op when off. Best-effort. + */ + private async enforceConcurrentCap(userId: string): Promise { + const cap = Math.floor(Number(this.config.maxConcurrentSessions) || 0); + if (cap <= 0 || !userId) return; + const engine = this.getDataEngine(); + if (!engine) return; + try { + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const rows = await engine.find('sys_session', { + where: { user_id: userId }, + fields: ['id', 'created_at', 'expires_at', 'revoked_at'], + limit: 200, + context: SYSTEM_CTX, + } as any); + const now = Date.now(); + const live = (Array.isArray(rows) ? rows : []) + .filter((sn: any) => !sn.revoked_at && (!sn.expires_at || new Date(sn.expires_at).getTime() > now)) + .sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); + for (const sn of live.slice(cap)) { + await engine.update( + 'sys_session', + { id: sn.id, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: 'concurrent_cap' }, + { context: SYSTEM_CTX } as any, + ).catch(() => undefined); + } + } catch { + // best-effort — never break a successful sign-in + } + } + /** * Returns the data engine wired into this auth manager. Used by route * handlers (e.g. bootstrap-status) that need to query identity tables diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index d6acd46a9b..d5f3cb161d 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -707,6 +707,18 @@ describe('AuthPlugin', () => { expect((manager as any).config.mfaGracePeriodDays).toBe(14); }); + it('binds session-control settings (ADR-0069 D4)', async () => { + const { manager } = await bootWithAuthSettings({ + session_idle_timeout_minutes: { value: 15, source: 'global' }, + session_absolute_max_hours: { value: 12, source: 'global' }, + max_concurrent_sessions_per_user: { value: 3, source: 'global' }, + }); + const cfg = (manager as any).config; + expect(cfg.sessionIdleTimeoutMinutes).toBe(15); + expect(cfg.sessionAbsoluteMaxHours).toBe(12); + expect(cfg.maxConcurrentSessions).toBe(3); + }); + 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); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 2116ebef26..f8dfed955b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -579,6 +579,25 @@ export class AuthPlugin implements Plugin { patch.session = session as AuthManagerOptions['session']; } + // Session controls (ADR-0069 D4) — idle / absolute / concurrent. 0 = off; + // non-negative reader so an explicit 0 disables. + const asNonNeg = (v: unknown): number | undefined => { + const n = Math.floor(Number(v)); + return Number.isFinite(n) && n >= 0 ? n : undefined; + }; + if (isExplicit('session_idle_timeout_minutes')) { + const n = asNonNeg(values.session_idle_timeout_minutes); + if (n !== undefined) patch.sessionIdleTimeoutMinutes = n; + } + if (isExplicit('session_absolute_max_hours')) { + const n = asNonNeg(values.session_absolute_max_hours); + if (n !== undefined) patch.sessionAbsoluteMaxHours = n; + } + if (isExplicit('max_concurrent_sessions_per_user')) { + const n = asNonNeg(values.max_concurrent_sessions_per_user); + if (n !== undefined) patch.maxConcurrentSessions = n; + } + // Anti-abuse (ADR-0069 D2) — account lockout (custom, per-identity) // and rate-limit tuning (better-auth-native, per-IP). `asPositiveInt` // rejects 0/malformed; lockout_threshold uses a non-negative reader so diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index 04ec2ce9ce..c468d7828d 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -235,6 +235,36 @@ const manifest = { max: 90, description: 'An active session is extended when it is older than this.', }, + { + type: 'number', + key: 'session_idle_timeout_minutes', + label: 'Idle timeout (minutes)', + required: false, + default: 0, + min: 0, + max: 10080, + description: 'Sign a user out after this many minutes of inactivity. 0 disables.', + }, + { + type: 'number', + key: 'session_absolute_max_hours', + label: 'Absolute session lifetime (hours)', + required: false, + default: 0, + min: 0, + max: 8760, + description: 'Force re-authentication this many hours after sign-in, regardless of activity. 0 disables.', + }, + { + type: 'number', + key: 'max_concurrent_sessions_per_user', + label: 'Max concurrent sessions per user', + required: false, + default: 0, + min: 0, + max: 100, + description: 'Cap simultaneous signed-in sessions per user; the oldest are signed out past the cap. 0 = unlimited.', + }, { type: 'group',