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
11 changes: 11 additions & 0 deletions .changeset/adr-0069-d1-password-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/platform-objects': minor
'@objectstack/service-settings': minor
'@objectstack/plugin-auth': minor
---

Auth: password history / no-reuse (ADR-0069 D1, P1)

Adds `password_history_count` (0–24, 0 = off) to the `auth` password-policy settings. On `/change-password` and `/reset-password`, a new password that matches the current password or any of the last N hashes is rejected with `PASSWORD_REUSE`. A new bounded `sys_account.previous_password_hashes` column (JSON ring, system-managed, hidden) backs the check; it is maintained by before/after hooks (capture the old hash, append on success).

Reuses better-auth's native `password.verify` (no bespoke crypto) and resolves the reset-flow user via the same token lookup better-auth uses. Default-off / additive (no upgrade behavior change); per ADR-0049 the setting ships with its enforcement.
11 changes: 11 additions & 0 deletions packages/platform-objects/src/identity/sys-account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,17 @@ export const SysAccount = ObjectSchema.create({
required: false,
description: 'Hashed password for email/password provider',
}),

// ADR-0069 D1 — bounded ring of previous password hashes (JSON array of
// strings), used to reject password reuse on change/reset. Maintained by
// the auth manager; never exposed in UI.
previous_password_hashes: Field.textarea({
label: 'Previous Password Hashes',
required: false,
readonly: true,
hidden: true,
description: 'JSON array of prior password hashes (bounded by password_history_count); reuse-prevention only. System-managed.',
}),
},

indexes: [
Expand Down
72 changes: 72 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1778,4 +1778,76 @@ describe('AuthManager', () => {
});
});
});

// ADR-0069 D1: password history (reject reuse). Custom logic, but reuses
// better-auth's native hash/verify — tested here with a stub verify + a
// where-aware sys_account mock.
describe('password history / reuse prevention (ADR-0069 D1)', () => {
const SECRET = 'test-secret-at-least-32-chars-long';
// verify() returns true when the candidate equals the plaintext that a hash
// encodes — we model hashes as `hash:<plaintext>` for the stub.
const stubVerify = async ({ password, hash }: { password: string; hash: string }) =>
hash === `hash:${password}`;
const makeEngine = (account: any) => ({
findOne: vi.fn(async (_o: string, q: any) => {
const w = q?.where ?? {};
if (!account) return null;
const ok = Object.entries(w).every(([k, v]) => (account as any)[k] === v);
return ok ? account : 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;
};
const acct = (over: any = {}) => ({
id: 'a1', user_id: 'u1', provider_id: 'credential',
password: 'hash:current', previous_password_hashes: JSON.stringify(['hash:old1', 'hash:old2']),
...over,
});

it('parseHashes tolerates null/garbage', () => {
const m = mgr(makeEngine(null));
expect((m as any).parseHashes(undefined)).toEqual([]);
expect((m as any).parseHashes('not json')).toEqual([]);
expect((m as any).parseHashes('["a","b"]')).toEqual(['a', 'b']);
});

it('is a no-op when history depth is 0', async () => {
const engine = makeEngine(acct());
const m = mgr(engine, { passwordHistoryCount: 0 });
await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).resolves.toBeUndefined();
expect(engine.findOne).not.toHaveBeenCalled();
});

it('rejects reuse of the CURRENT password', async () => {
const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 });
await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).rejects.toMatchObject({
body: { code: 'PASSWORD_REUSE' },
});
});

it('rejects reuse of a HISTORICAL password', async () => {
const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 });
await expect((m as any).assertPasswordNotReused('u1', 'old2', stubVerify)).rejects.toMatchObject({
body: { code: 'PASSWORD_REUSE' },
});
});

it('accepts a fresh password and returns the current hash for the after-hook', async () => {
const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 });
await expect((m as any).assertPasswordNotReused('u1', 'brandnew', stubVerify)).resolves.toBe('hash:current');
});

it('recordPasswordHistory prepends the old hash, dedupes, and trims to the depth', async () => {
const engine = makeEngine(acct({ previous_password_hashes: JSON.stringify(['hash:old1', 'hash:old2']) }));
const m = mgr(engine, { passwordHistoryCount: 2 });
await (m as any).recordPasswordHistory('u1', 'hash:current');
const written = JSON.parse(engine.update.mock.calls[0][1].previous_password_hashes);
expect(written).toEqual(['hash:current', 'hash:old1']); // prepend + trim to 2
});
});
});
169 changes: 169 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,14 @@
/** Minimum distinct character classes required (1-4). Default 3. */
passwordMinClasses?: number;

/**
* ADR-0069 D1 — password history depth. When > 0, a password change/reset is
* rejected if the new password matches the current or any of the last
* `passwordHistoryCount` hashes (`sys_account.previous_password_hashes`).
* Reuses better-auth's native hash/verify — no bespoke crypto.
*/
passwordHistoryCount?: 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 @@ -603,6 +611,24 @@
(typeof ctx?.body?.newPassword === 'string' && ctx.body.newPassword) ||
'';
if (candidate) await this.assertPasswordComplexity(candidate);

// ── ADR-0069 D1: password history (reject reuse) ────────────
// change/reset only (sign-up has no prior history). Reuses
// better-auth's native password.verify — no bespoke crypto. Stashes
// the old hash so the after-hook appends it to the bounded ring on
// success.
if (
candidate &&
(ctx?.path === '/reset-password' || ctx?.path === '/change-password')
) {
const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => undefined);
if (userId) {
const pw = ctx?.context?.password;
const verify = typeof pw?.verify === 'function' ? pw.verify.bind(pw) : undefined;
const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
if (oldHash !== undefined) ctx.context.__osPwHistory = { userId, oldHash };
}
}
// fall through to the path's own handling below
}

Expand Down Expand Up @@ -802,6 +828,23 @@
return;
}

// ── ADR-0069 D1: commit password history on success ─────────
if (ctx?.path === '/change-password' || ctx?.path === '/reset-password') {
const stash = ctx?.context?.__osPwHistory;
if (stash?.userId) {
let succeeded = true;

Check warning

Code scanning / CodeQL

Useless assignment to local variable Warning

The initial value of succeeded is unused, since it is always overwritten.
try {
const { isAPIError } = await import('better-auth/api');
succeeded = !isAPIError(ctx?.context?.returned);
} catch {
succeeded = !(ctx?.context?.returned instanceof Error);
}
if (succeeded) await this.recordPasswordHistory(stash.userId, stash.oldHash);
delete ctx.context.__osPwHistory;
}
return;
}

if (ctx?.path !== '/sign-up/email') return;
const ep = ctx?.context?.options?.emailAndPassword;
if (ep && ctx.context.__osDisableSignUpOrig !== undefined) {
Expand Down Expand Up @@ -2186,6 +2229,132 @@
}
}

/**
* ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
* a string[] of hashes, tolerating null / malformed values.
*/
private parseHashes(raw: unknown): string[] {
if (typeof raw !== 'string' || !raw.trim()) return [];
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter((h): h is string => typeof h === 'string' && !!h) : [];
} catch {
return [];
}
}

/**
* ADR-0069 D1 — resolve the user whose password is being changed. For
* `/change-password` the caller is authenticated (session); for
* `/reset-password` the user is carried by the reset token's verification
* value (the same lookup better-auth's own handler uses).
*/
private async resolvePasswordChangeUserId(ctx: any): Promise<string | undefined> {
if (ctx?.path === '/change-password') {
const { getSessionFromCtx } = await import('better-auth/api');
const sess: any = await getSessionFromCtx(ctx).catch(() => null);
return sess?.user?.id ?? sess?.session?.userId ?? undefined;
}
if (ctx?.path === '/reset-password') {
const token = typeof ctx?.body?.token === 'string' ? ctx.body.token : '';
if (!token) return undefined;
try {
const v: any = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`);
const raw = v?.value;
if (!raw) return undefined;
if (typeof raw === 'string') {
const t = raw.trim();
if (t.startsWith('{') || t.startsWith('"')) {
try {
const o = JSON.parse(t);
return (typeof o === 'string' ? o : o?.userId) ?? undefined;
} catch {
return t;
}
}
return t;
}
return raw?.userId ?? undefined;
} catch {
return undefined;
}
}
return undefined;
}

/**
* ADR-0069 D1 — throw `PASSWORD_REUSE` when `candidate` matches the user's
* current password or any hash in the bounded history. Reuses better-auth's
* native `password.verify` (passed in) rather than re-hashing. Returns the
* current hash (for the after-hook to append) when the candidate is fresh, or
* undefined when the feature is off / nothing to compare.
*/
private async assertPasswordNotReused(
userId: string,
candidate: string,
verify?: (data: { password: string; hash: string }) => Promise<boolean>,
): Promise<string | undefined> {
const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
if (count <= 0 || typeof verify !== 'function') return undefined;
const engine = this.getDataEngine();
if (!engine) return undefined;
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
let account: any;
try {
account = await engine.findOne('sys_account', {
where: { user_id: userId, provider_id: 'credential' },
fields: ['id', 'password', 'previous_password_hashes'],
context: SYSTEM_CTX,
} as any);
} catch {
return undefined; // fail-open on lookup error
}
if (!account?.id) return undefined;
const currentHash = typeof account.password === 'string' ? account.password : '';
const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean);
for (const h of compareList) {
let match = false;
try { match = await verify({ password: candidate, hash: h }); } catch { match = false; }
if (match) {
const { APIError } = await import('better-auth/api');
throw new APIError('BAD_REQUEST', {
message: `For security you can't reuse one of your last ${count} passwords. Please choose a different one.`,
code: 'PASSWORD_REUSE',
});
}
}
return currentHash;
}

/**
* ADR-0069 D1 — append `oldHash` to the bounded password-history ring after a
* successful change/reset. Best-effort; never throws.
*/
private async recordPasswordHistory(userId: string, oldHash: string): Promise<void> {
const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
if (count <= 0 || !oldHash) return;
const engine = this.getDataEngine();
if (!engine) return;
try {
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
const account = await engine.findOne('sys_account', {
where: { user_id: userId, provider_id: 'credential' },
fields: ['id', 'previous_password_hashes'],
context: SYSTEM_CTX,
} as any);
if (!account?.id) return;
const prev = this.parseHashes(account.previous_password_hashes);
const next = [oldHash, ...prev.filter((h) => h !== oldHash)].slice(0, count);
await engine.update(
'sys_account',
{ id: account.id, previous_password_hashes: JSON.stringify(next) },
{ context: SYSTEM_CTX } as any,
);
} catch {
// history maintenance is best-effort — never break a valid password change
}
}

/**
* ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
* out (brute-force protection). No-op when lockout is disabled
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,21 @@ describe('AuthPlugin', () => {
expect((manager as any).config.passwordRequireComplexity).toBeUndefined();
});

it('binds password_history_count (ADR-0069 D1)', async () => {
const { manager } = await bootWithAuthSettings({ password_history_count: { value: 5, source: 'global' } });
expect((manager as any).config.passwordHistoryCount).toBe(5);
});

it('clamps password_history_count to a max of 24', async () => {
const { manager } = await bootWithAuthSettings({ password_history_count: { value: 99, source: 'global' } });
expect((manager as any).config.passwordHistoryCount).toBe(24);
});

it('applies an explicit password_history_count of 0 (feature off)', async () => {
const { manager } = await bootWithAuthSettings({ password_history_count: { value: 0, source: 'global' } });
expect((manager as any).config.passwordHistoryCount).toBe(0);
});

it('binds account-lockout settings (ADR-0069 D2)', async () => {
const { manager } = await bootWithAuthSettings({
lockout_threshold: { value: 5, source: 'global' },
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,11 @@ export class AuthPlugin implements Plugin {
const n = asPositiveInt(values.password_min_classes);
if (n !== undefined) patch.passwordMinClasses = Math.min(4, Math.max(1, n));
}
if (isExplicit('password_history_count')) {
// 0 disables → use a non-negative reader (asPositiveInt rejects 0).
const n = Math.floor(Number(values.password_history_count));
if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
}

// Session lifetime — days → seconds for better-auth's `session`
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('authSettingsManifest', () => {
expect(byKey('lockout_duration_minutes').default).toBe(15);
expect(byKey('rate_limit_max').default).toBe(10);
expect(byKey('rate_limit_window_seconds').default).toBe(60);
expect(byKey('password_history_count').default).toBe(0);
});

it('exposes encrypted Google OAuth credential fields', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/services/service-settings/src/manifests/auth.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ const manifest = {
description: 'How many of the four classes (upper / lower / digit / symbol) a password must include.',
visible: "${data.email_password_enabled !== false && data.password_require_complexity === true}",
},
{
type: 'number',
key: 'password_history_count',
label: 'Password history (no reuse)',
required: false,
default: 0,
min: 0,
max: 24,
description: 'Block reusing this many previous passwords on change/reset. 0 disables the check.',
visible: "${data.email_password_enabled !== false}",
},

{
type: 'group',
Expand Down