Skip to content

Commit ce0b4f6

Browse files
os-zhuangclaude
andauthored
feat(auth): password expiry via the session-validation gate (ADR-0069 D1, P1) (#2388)
* feat(auth): password expiry via the session-validation gate (ADR-0069 D1, P1) Builds the authentication-policy session gate ADR-0069 needs (the seam shared by password expiry and enforced MFA) and wires it for password expiry. - core: pure evaluateAuthGate / isAuthGateAllowlisted helper — one source of truth for the allow-list (auth + remediation + health + UI-bootstrap reads). - plugin-auth: customSession computes posture once -> user.authGate; computeAuthGate compares sys_user.password_changed_at vs password_expiry_days; password_changed_at stamped on sign-up / change / reset; isAuthGateActive() keeps it zero-overhead when off. - platform-objects: sys_user.password_changed_at column. - rest: resolveExecCtx carries authGate; enforceAuth blocks gated sessions (independent of requireAuth) with 403 { code: PASSWORD_EXPIRED }, allow-list keeps auth/remediation reachable. - service-settings: password_expiry_days setting (0 = off). Default-off / additive; null password_changed_at never expires (upgrade-safe); ADR-0049 (enforcement ships with the setting); Date timestamps (ADR-0074). Verified live (dogfood): expiry=30 + backdated password -> data GET/POST 403 PASSWORD_EXPIRED while change-password (allow-listed) works; admin + within- window users NOT gated; after change-password the gate lifts (normal RLS resumes) -> remediation loop closes. Unit: core 299 + rest 140 + plugin-auth 182 + service-settings 129 + platform-objects 63 green; full 60-pkg build (incl. strict DTS) green. The dispatcher/MCP path is a follow-up (#2375); the Console's REST surface is fully gated here. Enforced MFA (D3) reuses this seam next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auth): address CodeQL — ReDoS-safe path strip + dead-code cleanup - core/auth-gate: replace the trailing-slash regex (`/\/+$/`, flagged as polynomial ReDoS on uncontrolled path input) with an O(n) char-scan strip. - plugin-auth: declare `succeeded`/`signupOk` without the always-overwritten initial value (useless-assignment warnings). - rest-server: drop the redundant `authService &&` (already non-null here). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4f8f108 commit ce0b4f6

13 files changed

Lines changed: 442 additions & 14 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/platform-objects': minor
4+
'@objectstack/service-settings': minor
5+
'@objectstack/plugin-auth': minor
6+
'@objectstack/rest': minor
7+
---
8+
9+
Auth: password expiry — the session-validation gate (ADR-0069 D1, P1)
10+
11+
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.
12+
13+
- **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).
14+
- **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.
15+
- **platform-objects**: new `sys_user.password_changed_at` column.
16+
- **rest**: `resolveExecCtx` carries `authGate`; `enforceAuth` blocks gated sessions (independent of `requireAuth`) using the core allow-list.
17+
- **service-settings**: new `password_expiry_days` field.
18+
19+
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).
20+
21+
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.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect } from 'vitest';
3+
import { isAuthGateAllowlisted, evaluateAuthGate } from './auth-gate';
4+
5+
describe('auth-gate (ADR-0069 session gate)', () => {
6+
describe('isAuthGateAllowlisted', () => {
7+
it('allows auth + remediation + health paths (both REST and dispatcher shapes)', () => {
8+
for (const p of [
9+
'/api/v1/auth/change-password',
10+
'/api/v1/auth/two-factor/enable',
11+
'/api/v1/auth/sign-out',
12+
'/auth/sign-out',
13+
'/api/v1/health',
14+
'/api/v1/discovery',
15+
'/api/v1/me/apps',
16+
'/api/v1/auth/me/localization',
17+
]) {
18+
expect(isAuthGateAllowlisted(p)).toBe(true);
19+
}
20+
});
21+
it('blocks data / meta / settings paths', () => {
22+
for (const p of ['/api/v1/data/sys_user', '/api/v1/meta/object/foo', '/api/settings/auth', '/api/v1/ai/chat']) {
23+
expect(isAuthGateAllowlisted(p)).toBe(false);
24+
}
25+
});
26+
it('strips query and trailing slash', () => {
27+
expect(isAuthGateAllowlisted('/api/v1/auth/sign-out/?x=1')).toBe(true);
28+
expect(isAuthGateAllowlisted('/api/v1/data/x/')).toBe(false);
29+
});
30+
});
31+
32+
describe('evaluateAuthGate', () => {
33+
it('returns null when the user carries no authGate', () => {
34+
expect(evaluateAuthGate({ id: 'u1' }, '/api/v1/data/x')).toBeNull();
35+
});
36+
it('returns null on an allow-listed path even when gated', () => {
37+
const u = { id: 'u1', authGate: { code: 'PASSWORD_EXPIRED', message: 'm' } };
38+
expect(evaluateAuthGate(u, '/api/v1/auth/change-password')).toBeNull();
39+
});
40+
it('returns the gate on a blocked path', () => {
41+
const u = { id: 'u1', authGate: { code: 'PASSWORD_EXPIRED', message: 'change it' } };
42+
expect(evaluateAuthGate(u, '/api/v1/data/sys_user')).toEqual({ code: 'PASSWORD_EXPIRED', message: 'change it' });
43+
});
44+
it('falls back to a generic message when none provided', () => {
45+
const u = { authGate: { code: 'MFA_REQUIRED' } };
46+
const g = evaluateAuthGate(u, '/api/v1/data/x');
47+
expect(g?.code).toBe('MFA_REQUIRED');
48+
expect(typeof g?.message).toBe('string');
49+
});
50+
});
51+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0069 — authentication-policy session gate.
5+
*
6+
* Some auth policies (password expiry, enforced MFA) must block an
7+
* authenticated user from PROTECTED RESOURCES until they remediate, while
8+
* still letting them reach the auth endpoints (change-password, two-factor
9+
* enrollment, sign-out) and a few UI-bootstrap reads.
10+
*
11+
* The posture is computed ONCE, in the auth `customSession` enrichment, and
12+
* attached to the session user as `user.authGate = { code, message }`. The
13+
* transport seams (REST middleware, dispatcher) then call
14+
* {@link evaluateAuthGate} to decide whether THIS request is blocked. Keeping
15+
* the allow-list + decision in one pure function means the seams can never
16+
* drift on what is blocked.
17+
*/
18+
19+
export interface AuthGate {
20+
/** Stable machine code, e.g. `PASSWORD_EXPIRED` / `MFA_REQUIRED`. */
21+
code: string;
22+
/** Human-facing message. */
23+
message: string;
24+
}
25+
26+
// Endpoints a gated user MUST still reach to remediate or bootstrap the
27+
// remediation UI. Matched against the request path (query stripped). Covers
28+
// both REST (`/api/v1/auth/…`) and dispatcher (`/auth/…`) path shapes.
29+
const ALLOW_PREFIXES = ['/api/v1/auth/', '/api/auth/', '/auth/'];
30+
const ALLOW_SUFFIXES = ['/health', '/ready', '/discovery', '/me/apps', '/me/localization'];
31+
32+
/** True when `path` is exempt from the auth gate (auth + remediation + health). */
33+
export function isAuthGateAllowlisted(rawPath: string | undefined | null): boolean {
34+
if (!rawPath) return true;
35+
// Strip query + trailing slashes WITHOUT a regex (avoids ReDoS on a
36+
// path of many '/'). char 47 = '/'.
37+
let path = rawPath.split('?')[0] || '/';
38+
let end = path.length;
39+
while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
40+
path = path.slice(0, end) || '/';
41+
// Any path with an `/auth/` segment is an auth endpoint (covers project-
42+
// scoped mounts like `/api/v1/environments/:env/auth/...`).
43+
if (path.includes('/auth/')) return true;
44+
for (const p of ALLOW_PREFIXES) {
45+
if (path.startsWith(p) || path === p.replace(/\/$/, '')) return true;
46+
}
47+
for (const s of ALLOW_SUFFIXES) {
48+
if (path.endsWith(s)) return true;
49+
}
50+
return false;
51+
}
52+
53+
/**
54+
* Returns the active gate when `sessionUser` carries an `authGate` AND `path`
55+
* is not allow-listed; otherwise null. Anonymous users (no `authGate`) and
56+
* allow-listed paths always pass.
57+
*/
58+
export function evaluateAuthGate(sessionUser: any, path: string): AuthGate | null {
59+
const gate = sessionUser?.authGate;
60+
if (!gate || typeof gate.code !== 'string') return null;
61+
if (isAuthGateAllowlisted(path)) return null;
62+
return {
63+
code: gate.code,
64+
message:
65+
typeof gate.message === 'string' && gate.message
66+
? gate.message
67+
: 'Access is blocked by an authentication policy.',
68+
};
69+
}

packages/core/src/security/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,4 @@ export {
8787
type ResolveAuthzInput,
8888
type ResolveLocalizationInput,
8989
} from './resolve-authz-context.js';
90+
export { isAuthGateAllowlisted, evaluateAuthGate, type AuthGate } from './auth-gate.js';

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,17 @@ export const SysUser = ObjectSchema.create({
447447
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.',
448448
}),
449449

450+
// ADR-0069 D1 — last password change; drives password-expiry enforcement.
451+
// Stamped on sign-up / change-password / reset-password. Null = never
452+
// expires (until the user next changes their password).
453+
password_changed_at: Field.datetime({
454+
label: 'Password Changed At',
455+
required: false,
456+
readonly: true,
457+
group: 'Admin',
458+
description: 'Timestamp of the last password change. Backs password_expiry_days; system-managed.',
459+
}),
460+
450461
ai_access: Field.boolean({
451462
label: 'AI Access',
452463
defaultValue: false,

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,4 +1850,62 @@ describe('AuthManager', () => {
18501850
expect(written).toEqual(['hash:current', 'hash:old1']); // prepend + trim to 2
18511851
});
18521852
});
1853+
1854+
// ADR-0069 D1: password expiry posture + stamping (the session-gate infra).
1855+
describe('password expiry gate (ADR-0069 D1)', () => {
1856+
const SECRET = 'test-secret-at-least-32-chars-long';
1857+
const makeEngine = (user: any) => ({
1858+
findOne: vi.fn(async (_o: string, q: any) => {
1859+
const w = q?.where ?? {};
1860+
if (!user) return null;
1861+
return Object.entries(w).every(([k, v]) => (user as any)[k] === v) ? user : null;
1862+
}),
1863+
update: vi.fn(async () => ({})),
1864+
});
1865+
const mgr = (engine: any, extra: any = {}) => {
1866+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1867+
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra });
1868+
warn.mockRestore();
1869+
return m;
1870+
};
1871+
1872+
it('isAuthGateActive reflects passwordExpiryDays', () => {
1873+
expect(mgr(makeEngine(null), { passwordExpiryDays: 0 }).isAuthGateActive()).toBe(false);
1874+
expect(mgr(makeEngine(null), { passwordExpiryDays: 90 }).isAuthGateActive()).toBe(true);
1875+
});
1876+
1877+
it('computeAuthGate is a no-op (no DB read) when expiry is off', async () => {
1878+
const engine = makeEngine({ id: 'u1', password_changed_at: new Date(0).toISOString() });
1879+
const m = mgr(engine, { passwordExpiryDays: 0 });
1880+
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
1881+
expect(engine.findOne).not.toHaveBeenCalled();
1882+
});
1883+
1884+
it('returns PASSWORD_EXPIRED when password_changed_at is older than the window', async () => {
1885+
const old = new Date(Date.now() - 100 * 86_400_000).toISOString();
1886+
const m = mgr(makeEngine({ id: 'u1', password_changed_at: old }), { passwordExpiryDays: 90 });
1887+
const gate = await (m as any).computeAuthGate('u1', undefined, false);
1888+
expect(gate?.code).toBe('PASSWORD_EXPIRED');
1889+
});
1890+
1891+
it('does NOT expire when the password is within the window', async () => {
1892+
const recent = new Date(Date.now() - 10 * 86_400_000).toISOString();
1893+
const m = mgr(makeEngine({ id: 'u1', password_changed_at: recent }), { passwordExpiryDays: 90 });
1894+
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
1895+
});
1896+
1897+
it('treats a null password_changed_at as never-expired (upgrade-safe)', async () => {
1898+
const m = mgr(makeEngine({ id: 'u1', password_changed_at: null }), { passwordExpiryDays: 1 });
1899+
await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined();
1900+
});
1901+
1902+
it('stampPasswordChangedAt writes a Date (never epoch-ms) to sys_user', async () => {
1903+
const engine = makeEngine({ id: 'u1' });
1904+
const m = mgr(engine);
1905+
await (m as any).stampPasswordChangedAt('u1');
1906+
const arg = engine.update.mock.calls[0][1];
1907+
expect(arg.id).toBe('u1');
1908+
expect(arg.password_changed_at instanceof Date).toBe(true);
1909+
});
1910+
});
18531911
});

0 commit comments

Comments
 (0)