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
21 changes: 21 additions & 0 deletions .changeset/adr-0069-d1-password-expiry.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions packages/core/src/security/auth-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
69 changes: 69 additions & 0 deletions packages/core/src/security/auth-gate.ts
Original file line number Diff line number Diff line change
@@ -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.',
};
}
1 change: 1 addition & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@ export {
type ResolveAuthzInput,
type ResolveLocalizationInput,
} from './resolve-authz-context.js';
export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js';
11 changes: 11 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading