Skip to content

Commit 366d468

Browse files
os-zhuangclaude
andcommitted
feat(auth): session controls — idle / absolute / concurrent (ADR-0069 D4, P2)
- 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 <noreply@anthropic.com>
1 parent e011d42 commit 366d468

7 files changed

Lines changed: 289 additions & 0 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: session controls — idle timeout, absolute max lifetime, concurrent cap (ADR-0069 D4, P2)
8+
9+
Adds three `auth` session-control settings (all 0 = off):
10+
11+
- `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.
12+
- `session_absolute_max_hours` — cap total session lifetime regardless of refresh; revoked once `created_at` is older than the cap.
13+
- `max_concurrent_sessions_per_user` — on sign-in, keep the newest N live sessions and revoke the rest (oldest first).
14+
15+
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.
16+
17+
Default-off / additive (no upgrade behavior change); per ADR-0049 each setting ships with its enforcement.

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,30 @@ export const SysSession = ObjectSchema.create({
107107
group: 'Session',
108108
}),
109109

110+
// ── ADR-0069 D4 — session controls (idle / absolute / revoke) ──
111+
last_activity_at: Field.datetime({
112+
label: 'Last Activity At',
113+
required: false,
114+
readonly: true,
115+
group: 'Session',
116+
description: 'Timestamp of the last request on this session; drives idle-timeout. System-managed.',
117+
}),
118+
revoked_at: Field.datetime({
119+
label: 'Revoked At',
120+
required: false,
121+
readonly: true,
122+
group: 'Session',
123+
description: 'When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed.',
124+
}),
125+
revoke_reason: Field.text({
126+
label: 'Revoke Reason',
127+
required: false,
128+
maxLength: 64,
129+
readonly: true,
130+
group: 'Session',
131+
description: 'Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …).',
132+
}),
133+
110134
// ── Active context (multi-org/team) ──────────────────────────
111135
active_organization_id: Field.lookup('sys_organization', {
112136
label: 'Active Organization',

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,4 +2036,89 @@ describe('AuthManager', () => {
20362036
expect(engine.findOne).not.toHaveBeenCalled();
20372037
});
20382038
});
2039+
2040+
// ADR-0069 D4 — session controls (idle / absolute / concurrent).
2041+
describe('session controls (ADR-0069 D4)', () => {
2042+
const SECRET = 'test-secret-at-least-32-chars-long';
2043+
const mgr = (engine: any, extra: any = {}) => {
2044+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
2045+
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra });
2046+
warn.mockRestore();
2047+
return m;
2048+
};
2049+
const oneEngine = (row: any) => ({
2050+
findOne: vi.fn(async () => row),
2051+
update: vi.fn(async () => ({})),
2052+
find: vi.fn(async () => []),
2053+
});
2054+
2055+
it('is a no-op when idle + absolute are both off', async () => {
2056+
const engine = oneEngine({ id: 's1', created_at: new Date(0).toISOString() });
2057+
const m = mgr(engine, {});
2058+
await (m as any).enforceSessionControls('s1', undefined);
2059+
expect(engine.findOne).not.toHaveBeenCalled();
2060+
});
2061+
2062+
it('revokes (absolute_max) when the session is older than the absolute cap', async () => {
2063+
const old = new Date(Date.now() - 100 * 3_600_000).toISOString();
2064+
const engine = oneEngine({ id: 's1', created_at: old, last_activity_at: null, revoked_at: null });
2065+
const m = mgr(engine, { sessionAbsoluteMaxHours: 24 });
2066+
await (m as any).enforceSessionControls('s1', undefined);
2067+
const patch = engine.update.mock.calls[0][1];
2068+
expect(patch.revoke_reason).toBe('absolute_max');
2069+
expect(patch.revoked_at instanceof Date).toBe(true);
2070+
expect(patch.expires_at instanceof Date).toBe(true);
2071+
});
2072+
2073+
it('revokes (idle_timeout) when last activity is older than the idle window', async () => {
2074+
const recentCreate = new Date(Date.now() - 60 * 60_000).toISOString();
2075+
const staleActivity = new Date(Date.now() - 30 * 60_000).toISOString();
2076+
const engine = oneEngine({ id: 's1', created_at: recentCreate, last_activity_at: staleActivity, revoked_at: null });
2077+
const m = mgr(engine, { sessionIdleTimeoutMinutes: 15 });
2078+
await (m as any).enforceSessionControls('s1', undefined);
2079+
expect(engine.update.mock.calls[0][1].revoke_reason).toBe('idle_timeout');
2080+
});
2081+
2082+
it('touches last_activity_at (not revoke) when within the idle window but stale > 60s', async () => {
2083+
const recentCreate = new Date(Date.now() - 5 * 60_000).toISOString();
2084+
const activity2minAgo = new Date(Date.now() - 2 * 60_000).toISOString();
2085+
const engine = oneEngine({ id: 's1', created_at: recentCreate, last_activity_at: activity2minAgo, revoked_at: null });
2086+
const m = mgr(engine, { sessionIdleTimeoutMinutes: 15 });
2087+
await (m as any).enforceSessionControls('s1', undefined);
2088+
const patch = engine.update.mock.calls[0][1];
2089+
expect(patch.last_activity_at instanceof Date).toBe(true);
2090+
expect(patch.revoke_reason).toBeUndefined();
2091+
});
2092+
2093+
it('does not touch when already revoked', async () => {
2094+
const engine = oneEngine({ id: 's1', created_at: new Date().toISOString(), revoked_at: new Date().toISOString() });
2095+
const m = mgr(engine, { sessionIdleTimeoutMinutes: 15 });
2096+
await (m as any).enforceSessionControls('s1', undefined);
2097+
expect(engine.update).not.toHaveBeenCalled();
2098+
});
2099+
2100+
it('enforceConcurrentCap revokes the oldest sessions past the cap', async () => {
2101+
const now = Date.now();
2102+
const sess = [
2103+
{ id: 'a', created_at: new Date(now - 4000).toISOString(), revoked_at: null },
2104+
{ id: 'b', created_at: new Date(now - 3000).toISOString(), revoked_at: null },
2105+
{ id: 'c', created_at: new Date(now - 2000).toISOString(), revoked_at: null },
2106+
{ id: 'd', created_at: new Date(now - 1000).toISOString(), revoked_at: null },
2107+
];
2108+
const engine = { findOne: vi.fn(), update: vi.fn(async () => ({})), find: vi.fn(async () => sess) };
2109+
const m = mgr(engine, { maxConcurrentSessions: 2 });
2110+
await (m as any).enforceConcurrentCap('u1');
2111+
// keeps newest 2 (d, c) → revokes oldest 2 (b, a)
2112+
const revokedIds = engine.update.mock.calls.map((c: any[]) => c[1].id).sort();
2113+
expect(revokedIds).toEqual(['a', 'b']);
2114+
expect(engine.update.mock.calls[0][1].revoke_reason).toBe('concurrent_cap');
2115+
});
2116+
2117+
it('enforceConcurrentCap is a no-op when the cap is 0', async () => {
2118+
const engine = { findOne: vi.fn(), update: vi.fn(), find: vi.fn(async () => []) };
2119+
const m = mgr(engine, { maxConcurrentSessions: 0 });
2120+
await (m as any).enforceConcurrentCap('u1');
2121+
expect(engine.find).not.toHaveBeenCalled();
2122+
});
2123+
});
20392124
});

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,16 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
322322
/** Days a user may defer MFA enrollment before the hard block. Default 7. */
323323
mfaGracePeriodDays?: number;
324324

325+
/**
326+
* ADR-0069 D4 — session controls. Enforced in `customSession` (idle/absolute)
327+
* and the sign-in hook (concurrent). 0 = off for each. A revoked session is
328+
* expired in place (`sys_session.expires_at` past + `revoked_at`/`revoke_reason`)
329+
* so better-auth returns no session on the next request (→ 401 → re-login).
330+
*/
331+
sessionIdleTimeoutMinutes?: number;
332+
sessionAbsoluteMaxHours?: number;
333+
maxConcurrentSessions?: number;
334+
325335
/**
326336
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
327337
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
@@ -851,6 +861,10 @@ export class AuthManager {
851861
succeeded = !(ctx?.context?.returned instanceof Error);
852862
}
853863
await this.recordSignInOutcome(email, succeeded);
864+
if (succeeded) {
865+
const uid = ctx?.context?.returned?.user?.id;
866+
if (typeof uid === 'string') await this.enforceConcurrentCap(uid);
867+
}
854868
}
855869
return;
856870
}
@@ -1567,6 +1581,10 @@ export class AuthManager {
15671581
// enforced MFA). Computed only when a gate feature is enabled (else
15681582
// zero extra reads on the hot path); surfaced as `user.authGate` for
15691583
// the transport seams to enforce. See computeAuthGate().
1584+
// ADR-0069 D4 — session controls (idle / absolute). Best-effort;
1585+
// revokes the session in place when exceeded (next request → 401).
1586+
await this.enforceSessionControls((session as any)?.id, (session as any)?.createdAt);
1587+
15701588
const authGate = await this.computeAuthGate(
15711589
user.id,
15721590
(session as any)?.activeOrganizationId,
@@ -2651,6 +2669,90 @@ export class AuthManager {
26512669
return true;
26522670
}
26532671

2672+
/**
2673+
* ADR-0069 D4 — idle / absolute session enforcement, run per request from
2674+
* `customSession`. No-op when both are off. Revokes (expires in place +
2675+
* stamps revoked_at/revoke_reason) when a limit is exceeded so better-auth
2676+
* returns no session on the NEXT request; otherwise touches `last_activity_at`
2677+
* (throttled to once a minute). Best-effort — never throws.
2678+
*/
2679+
private async enforceSessionControls(sessionId: string | undefined, createdAtHint: unknown): Promise<void> {
2680+
const idleMin = Math.floor(Number(this.config.sessionIdleTimeoutMinutes) || 0);
2681+
const absHrs = Math.floor(Number(this.config.sessionAbsoluteMaxHours) || 0);
2682+
if (idleMin <= 0 && absHrs <= 0) return;
2683+
const engine = this.getDataEngine();
2684+
if (!engine || !sessionId) return;
2685+
try {
2686+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2687+
const srow = await engine.findOne('sys_session', {
2688+
where: { id: sessionId },
2689+
fields: ['id', 'created_at', 'last_activity_at', 'revoked_at'],
2690+
context: SYSTEM_CTX,
2691+
} as any);
2692+
if (!srow?.id || srow.revoked_at) return;
2693+
const now = Date.now();
2694+
let reason: string | undefined;
2695+
if (absHrs > 0) {
2696+
const created = srow.created_at ?? createdAtHint;
2697+
if (created && now - new Date(created as any).getTime() > absHrs * 3_600_000) reason = 'absolute_max';
2698+
}
2699+
if (!reason && idleMin > 0) {
2700+
const last = srow.last_activity_at ?? srow.created_at ?? createdAtHint;
2701+
if (last && now - new Date(last as any).getTime() > idleMin * 60_000) reason = 'idle_timeout';
2702+
}
2703+
if (reason) {
2704+
await engine.update(
2705+
'sys_session',
2706+
{ id: sessionId, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: reason },
2707+
{ context: SYSTEM_CTX } as any,
2708+
).catch(() => undefined);
2709+
return;
2710+
}
2711+
if (idleMin > 0) {
2712+
const la = srow.last_activity_at ? new Date(srow.last_activity_at as any).getTime() : 0;
2713+
if (now - la > 60_000) {
2714+
await engine.update('sys_session', { id: sessionId, last_activity_at: new Date(now) }, { context: SYSTEM_CTX } as any).catch(() => undefined);
2715+
}
2716+
}
2717+
} catch {
2718+
// session controls are best-effort — never break a request
2719+
}
2720+
}
2721+
2722+
/**
2723+
* ADR-0069 D4 — concurrent-session cap, run from the sign-in after-hook.
2724+
* Keeps the newest `maxConcurrentSessions` live sessions for the user and
2725+
* revokes the rest (oldest first). No-op when off. Best-effort.
2726+
*/
2727+
private async enforceConcurrentCap(userId: string): Promise<void> {
2728+
const cap = Math.floor(Number(this.config.maxConcurrentSessions) || 0);
2729+
if (cap <= 0 || !userId) return;
2730+
const engine = this.getDataEngine();
2731+
if (!engine) return;
2732+
try {
2733+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2734+
const rows = await engine.find('sys_session', {
2735+
where: { user_id: userId },
2736+
fields: ['id', 'created_at', 'expires_at', 'revoked_at'],
2737+
limit: 200,
2738+
context: SYSTEM_CTX,
2739+
} as any);
2740+
const now = Date.now();
2741+
const live = (Array.isArray(rows) ? rows : [])
2742+
.filter((sn: any) => !sn.revoked_at && (!sn.expires_at || new Date(sn.expires_at).getTime() > now))
2743+
.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
2744+
for (const sn of live.slice(cap)) {
2745+
await engine.update(
2746+
'sys_session',
2747+
{ id: sn.id, expires_at: new Date(now - 1000), revoked_at: new Date(now), revoke_reason: 'concurrent_cap' },
2748+
{ context: SYSTEM_CTX } as any,
2749+
).catch(() => undefined);
2750+
}
2751+
} catch {
2752+
// best-effort — never break a successful sign-in
2753+
}
2754+
}
2755+
26542756
/**
26552757
* Returns the data engine wired into this auth manager. Used by route
26562758
* handlers (e.g. bootstrap-status) that need to query identity tables

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,18 @@ describe('AuthPlugin', () => {
707707
expect((manager as any).config.mfaGracePeriodDays).toBe(14);
708708
});
709709

710+
it('binds session-control settings (ADR-0069 D4)', async () => {
711+
const { manager } = await bootWithAuthSettings({
712+
session_idle_timeout_minutes: { value: 15, source: 'global' },
713+
session_absolute_max_hours: { value: 12, source: 'global' },
714+
max_concurrent_sessions_per_user: { value: 3, source: 'global' },
715+
});
716+
const cfg = (manager as any).config;
717+
expect(cfg.sessionIdleTimeoutMinutes).toBe(15);
718+
expect(cfg.sessionAbsoluteMaxHours).toBe(12);
719+
expect(cfg.maxConcurrentSessions).toBe(3);
720+
});
721+
710722
it('clamps mfa_grace_period_days to a max of 90', async () => {
711723
const { manager } = await bootWithAuthSettings({ mfa_grace_period_days: { value: 999, source: 'global' } });
712724
expect((manager as any).config.mfaGracePeriodDays).toBe(90);

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,25 @@ export class AuthPlugin implements Plugin {
579579
patch.session = session as AuthManagerOptions['session'];
580580
}
581581

582+
// Session controls (ADR-0069 D4) — idle / absolute / concurrent. 0 = off;
583+
// non-negative reader so an explicit 0 disables.
584+
const asNonNeg = (v: unknown): number | undefined => {
585+
const n = Math.floor(Number(v));
586+
return Number.isFinite(n) && n >= 0 ? n : undefined;
587+
};
588+
if (isExplicit('session_idle_timeout_minutes')) {
589+
const n = asNonNeg(values.session_idle_timeout_minutes);
590+
if (n !== undefined) patch.sessionIdleTimeoutMinutes = n;
591+
}
592+
if (isExplicit('session_absolute_max_hours')) {
593+
const n = asNonNeg(values.session_absolute_max_hours);
594+
if (n !== undefined) patch.sessionAbsoluteMaxHours = n;
595+
}
596+
if (isExplicit('max_concurrent_sessions_per_user')) {
597+
const n = asNonNeg(values.max_concurrent_sessions_per_user);
598+
if (n !== undefined) patch.maxConcurrentSessions = n;
599+
}
600+
582601
// Anti-abuse (ADR-0069 D2) — account lockout (custom, per-identity)
583602
// and rate-limit tuning (better-auth-native, per-IP). `asPositiveInt`
584603
// rejects 0/malformed; lockout_threshold uses a non-negative reader so

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,36 @@ const manifest = {
235235
max: 90,
236236
description: 'An active session is extended when it is older than this.',
237237
},
238+
{
239+
type: 'number',
240+
key: 'session_idle_timeout_minutes',
241+
label: 'Idle timeout (minutes)',
242+
required: false,
243+
default: 0,
244+
min: 0,
245+
max: 10080,
246+
description: 'Sign a user out after this many minutes of inactivity. 0 disables.',
247+
},
248+
{
249+
type: 'number',
250+
key: 'session_absolute_max_hours',
251+
label: 'Absolute session lifetime (hours)',
252+
required: false,
253+
default: 0,
254+
min: 0,
255+
max: 8760,
256+
description: 'Force re-authentication this many hours after sign-in, regardless of activity. 0 disables.',
257+
},
258+
{
259+
type: 'number',
260+
key: 'max_concurrent_sessions_per_user',
261+
label: 'Max concurrent sessions per user',
262+
required: false,
263+
default: 0,
264+
min: 0,
265+
max: 100,
266+
description: 'Cap simultaneous signed-in sessions per user; the oldest are signed out past the cap. 0 = unlimited.',
267+
},
238268

239269
{
240270
type: 'group',

0 commit comments

Comments
 (0)