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-d4-session-controls.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: 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.
24 changes: 24 additions & 0 deletions packages/platform-objects/src/identity/sys-session.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
85 changes: 85 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
102 changes: 102 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,16 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
/** 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`
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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<void> {
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
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions packages/services/service-settings/src/manifests/auth.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down