Skip to content

Commit 9b5bf3d

Browse files
os-zhuangclaude
andauthored
feat(plugin-auth): password history / no-reuse (ADR-0069 D1, P1) (#2371)
Adds `password_history_count` (0-24, 0=off) auth setting. On /change-password and /reset-password a new password matching the current password or any of the last N hashes is rejected with PASSWORD_REUSE. A bounded `sys_account.previous_password_hashes` JSON ring (system-managed, hidden) backs the check, maintained by before/after hooks (capture old hash, append on success). Reuses better-auth's native `password.verify` (no bespoke crypto) and resolves the reset-flow user via better-auth's own reset-token verification lookup. Default-off / additive (no upgrade behavior change); ADR-0049 (enforcement ships with the setting). Verified live (dogfood, history_count=3): change P1->P2 ok; change back to P1 (historical) -> 400 PASSWORD_REUSE; change P2->P2 (current) -> 400 PASSWORD_REUSE; change to a fresh password ok; the ring column persists bounded real hashes. Unit: 135 plugin-auth (incl. parse/reuse/ring helpers with a stub verify + where-aware sys_account mock) + service-settings + platform-objects green; builds incl. strict DTS green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 82ff91c commit 9b5bf3d

8 files changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/service-settings': minor
4+
'@objectstack/plugin-auth': minor
5+
---
6+
7+
Auth: password history / no-reuse (ADR-0069 D1, P1)
8+
9+
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).
10+
11+
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.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,17 @@ export const SysAccount = ObjectSchema.create({
192192
required: false,
193193
description: 'Hashed password for email/password provider',
194194
}),
195+
196+
// ADR-0069 D1 — bounded ring of previous password hashes (JSON array of
197+
// strings), used to reject password reuse on change/reset. Maintained by
198+
// the auth manager; never exposed in UI.
199+
previous_password_hashes: Field.textarea({
200+
label: 'Previous Password Hashes',
201+
required: false,
202+
readonly: true,
203+
hidden: true,
204+
description: 'JSON array of prior password hashes (bounded by password_history_count); reuse-prevention only. System-managed.',
205+
}),
195206
},
196207

197208
indexes: [

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1778,4 +1778,76 @@ describe('AuthManager', () => {
17781778
});
17791779
});
17801780
});
1781+
1782+
// ADR-0069 D1: password history (reject reuse). Custom logic, but reuses
1783+
// better-auth's native hash/verify — tested here with a stub verify + a
1784+
// where-aware sys_account mock.
1785+
describe('password history / reuse prevention (ADR-0069 D1)', () => {
1786+
const SECRET = 'test-secret-at-least-32-chars-long';
1787+
// verify() returns true when the candidate equals the plaintext that a hash
1788+
// encodes — we model hashes as `hash:<plaintext>` for the stub.
1789+
const stubVerify = async ({ password, hash }: { password: string; hash: string }) =>
1790+
hash === `hash:${password}`;
1791+
const makeEngine = (account: any) => ({
1792+
findOne: vi.fn(async (_o: string, q: any) => {
1793+
const w = q?.where ?? {};
1794+
if (!account) return null;
1795+
const ok = Object.entries(w).every(([k, v]) => (account as any)[k] === v);
1796+
return ok ? account : null;
1797+
}),
1798+
update: vi.fn(async () => ({})),
1799+
});
1800+
const mgr = (engine: any, extra: any = {}) => {
1801+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1802+
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra });
1803+
warn.mockRestore();
1804+
return m;
1805+
};
1806+
const acct = (over: any = {}) => ({
1807+
id: 'a1', user_id: 'u1', provider_id: 'credential',
1808+
password: 'hash:current', previous_password_hashes: JSON.stringify(['hash:old1', 'hash:old2']),
1809+
...over,
1810+
});
1811+
1812+
it('parseHashes tolerates null/garbage', () => {
1813+
const m = mgr(makeEngine(null));
1814+
expect((m as any).parseHashes(undefined)).toEqual([]);
1815+
expect((m as any).parseHashes('not json')).toEqual([]);
1816+
expect((m as any).parseHashes('["a","b"]')).toEqual(['a', 'b']);
1817+
});
1818+
1819+
it('is a no-op when history depth is 0', async () => {
1820+
const engine = makeEngine(acct());
1821+
const m = mgr(engine, { passwordHistoryCount: 0 });
1822+
await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).resolves.toBeUndefined();
1823+
expect(engine.findOne).not.toHaveBeenCalled();
1824+
});
1825+
1826+
it('rejects reuse of the CURRENT password', async () => {
1827+
const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 });
1828+
await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).rejects.toMatchObject({
1829+
body: { code: 'PASSWORD_REUSE' },
1830+
});
1831+
});
1832+
1833+
it('rejects reuse of a HISTORICAL password', async () => {
1834+
const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 });
1835+
await expect((m as any).assertPasswordNotReused('u1', 'old2', stubVerify)).rejects.toMatchObject({
1836+
body: { code: 'PASSWORD_REUSE' },
1837+
});
1838+
});
1839+
1840+
it('accepts a fresh password and returns the current hash for the after-hook', async () => {
1841+
const m = mgr(makeEngine(acct()), { passwordHistoryCount: 5 });
1842+
await expect((m as any).assertPasswordNotReused('u1', 'brandnew', stubVerify)).resolves.toBe('hash:current');
1843+
});
1844+
1845+
it('recordPasswordHistory prepends the old hash, dedupes, and trims to the depth', async () => {
1846+
const engine = makeEngine(acct({ previous_password_hashes: JSON.stringify(['hash:old1', 'hash:old2']) }));
1847+
const m = mgr(engine, { passwordHistoryCount: 2 });
1848+
await (m as any).recordPasswordHistory('u1', 'hash:current');
1849+
const written = JSON.parse(engine.update.mock.calls[0][1].previous_password_hashes);
1850+
expect(written).toEqual(['hash:current', 'hash:old1']); // prepend + trim to 2
1851+
});
1852+
});
17811853
});

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

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,14 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
294294
/** Minimum distinct character classes required (1-4). Default 3. */
295295
passwordMinClasses?: number;
296296

297+
/**
298+
* ADR-0069 D1 — password history depth. When > 0, a password change/reset is
299+
* rejected if the new password matches the current or any of the last
300+
* `passwordHistoryCount` hashes (`sys_account.previous_password_hashes`).
301+
* Reuses better-auth's native hash/verify — no bespoke crypto.
302+
*/
303+
passwordHistoryCount?: number;
304+
297305
/**
298306
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
299307
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
@@ -603,6 +611,24 @@ export class AuthManager {
603611
(typeof ctx?.body?.newPassword === 'string' && ctx.body.newPassword) ||
604612
'';
605613
if (candidate) await this.assertPasswordComplexity(candidate);
614+
615+
// ── ADR-0069 D1: password history (reject reuse) ────────────
616+
// change/reset only (sign-up has no prior history). Reuses
617+
// better-auth's native password.verify — no bespoke crypto. Stashes
618+
// the old hash so the after-hook appends it to the bounded ring on
619+
// success.
620+
if (
621+
candidate &&
622+
(ctx?.path === '/reset-password' || ctx?.path === '/change-password')
623+
) {
624+
const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => undefined);
625+
if (userId) {
626+
const pw = ctx?.context?.password;
627+
const verify = typeof pw?.verify === 'function' ? pw.verify.bind(pw) : undefined;
628+
const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
629+
if (oldHash !== undefined) ctx.context.__osPwHistory = { userId, oldHash };
630+
}
631+
}
606632
// fall through to the path's own handling below
607633
}
608634

@@ -802,6 +828,23 @@ export class AuthManager {
802828
return;
803829
}
804830

831+
// ── ADR-0069 D1: commit password history on success ─────────
832+
if (ctx?.path === '/change-password' || ctx?.path === '/reset-password') {
833+
const stash = ctx?.context?.__osPwHistory;
834+
if (stash?.userId) {
835+
let succeeded = true;
836+
try {
837+
const { isAPIError } = await import('better-auth/api');
838+
succeeded = !isAPIError(ctx?.context?.returned);
839+
} catch {
840+
succeeded = !(ctx?.context?.returned instanceof Error);
841+
}
842+
if (succeeded) await this.recordPasswordHistory(stash.userId, stash.oldHash);
843+
delete ctx.context.__osPwHistory;
844+
}
845+
return;
846+
}
847+
805848
if (ctx?.path !== '/sign-up/email') return;
806849
const ep = ctx?.context?.options?.emailAndPassword;
807850
if (ep && ctx.context.__osDisableSignUpOrig !== undefined) {
@@ -2186,6 +2229,132 @@ export class AuthManager {
21862229
}
21872230
}
21882231

2232+
/**
2233+
* ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
2234+
* a string[] of hashes, tolerating null / malformed values.
2235+
*/
2236+
private parseHashes(raw: unknown): string[] {
2237+
if (typeof raw !== 'string' || !raw.trim()) return [];
2238+
try {
2239+
const arr = JSON.parse(raw);
2240+
return Array.isArray(arr) ? arr.filter((h): h is string => typeof h === 'string' && !!h) : [];
2241+
} catch {
2242+
return [];
2243+
}
2244+
}
2245+
2246+
/**
2247+
* ADR-0069 D1 — resolve the user whose password is being changed. For
2248+
* `/change-password` the caller is authenticated (session); for
2249+
* `/reset-password` the user is carried by the reset token's verification
2250+
* value (the same lookup better-auth's own handler uses).
2251+
*/
2252+
private async resolvePasswordChangeUserId(ctx: any): Promise<string | undefined> {
2253+
if (ctx?.path === '/change-password') {
2254+
const { getSessionFromCtx } = await import('better-auth/api');
2255+
const sess: any = await getSessionFromCtx(ctx).catch(() => null);
2256+
return sess?.user?.id ?? sess?.session?.userId ?? undefined;
2257+
}
2258+
if (ctx?.path === '/reset-password') {
2259+
const token = typeof ctx?.body?.token === 'string' ? ctx.body.token : '';
2260+
if (!token) return undefined;
2261+
try {
2262+
const v: any = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`);
2263+
const raw = v?.value;
2264+
if (!raw) return undefined;
2265+
if (typeof raw === 'string') {
2266+
const t = raw.trim();
2267+
if (t.startsWith('{') || t.startsWith('"')) {
2268+
try {
2269+
const o = JSON.parse(t);
2270+
return (typeof o === 'string' ? o : o?.userId) ?? undefined;
2271+
} catch {
2272+
return t;
2273+
}
2274+
}
2275+
return t;
2276+
}
2277+
return raw?.userId ?? undefined;
2278+
} catch {
2279+
return undefined;
2280+
}
2281+
}
2282+
return undefined;
2283+
}
2284+
2285+
/**
2286+
* ADR-0069 D1 — throw `PASSWORD_REUSE` when `candidate` matches the user's
2287+
* current password or any hash in the bounded history. Reuses better-auth's
2288+
* native `password.verify` (passed in) rather than re-hashing. Returns the
2289+
* current hash (for the after-hook to append) when the candidate is fresh, or
2290+
* undefined when the feature is off / nothing to compare.
2291+
*/
2292+
private async assertPasswordNotReused(
2293+
userId: string,
2294+
candidate: string,
2295+
verify?: (data: { password: string; hash: string }) => Promise<boolean>,
2296+
): Promise<string | undefined> {
2297+
const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
2298+
if (count <= 0 || typeof verify !== 'function') return undefined;
2299+
const engine = this.getDataEngine();
2300+
if (!engine) return undefined;
2301+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2302+
let account: any;
2303+
try {
2304+
account = await engine.findOne('sys_account', {
2305+
where: { user_id: userId, provider_id: 'credential' },
2306+
fields: ['id', 'password', 'previous_password_hashes'],
2307+
context: SYSTEM_CTX,
2308+
} as any);
2309+
} catch {
2310+
return undefined; // fail-open on lookup error
2311+
}
2312+
if (!account?.id) return undefined;
2313+
const currentHash = typeof account.password === 'string' ? account.password : '';
2314+
const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean);
2315+
for (const h of compareList) {
2316+
let match = false;
2317+
try { match = await verify({ password: candidate, hash: h }); } catch { match = false; }
2318+
if (match) {
2319+
const { APIError } = await import('better-auth/api');
2320+
throw new APIError('BAD_REQUEST', {
2321+
message: `For security you can't reuse one of your last ${count} passwords. Please choose a different one.`,
2322+
code: 'PASSWORD_REUSE',
2323+
});
2324+
}
2325+
}
2326+
return currentHash;
2327+
}
2328+
2329+
/**
2330+
* ADR-0069 D1 — append `oldHash` to the bounded password-history ring after a
2331+
* successful change/reset. Best-effort; never throws.
2332+
*/
2333+
private async recordPasswordHistory(userId: string, oldHash: string): Promise<void> {
2334+
const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
2335+
if (count <= 0 || !oldHash) return;
2336+
const engine = this.getDataEngine();
2337+
if (!engine) return;
2338+
try {
2339+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2340+
const account = await engine.findOne('sys_account', {
2341+
where: { user_id: userId, provider_id: 'credential' },
2342+
fields: ['id', 'previous_password_hashes'],
2343+
context: SYSTEM_CTX,
2344+
} as any);
2345+
if (!account?.id) return;
2346+
const prev = this.parseHashes(account.previous_password_hashes);
2347+
const next = [oldHash, ...prev.filter((h) => h !== oldHash)].slice(0, count);
2348+
await engine.update(
2349+
'sys_account',
2350+
{ id: account.id, previous_password_hashes: JSON.stringify(next) },
2351+
{ context: SYSTEM_CTX } as any,
2352+
);
2353+
} catch {
2354+
// history maintenance is best-effort — never break a valid password change
2355+
}
2356+
}
2357+
21892358
/**
21902359
* ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
21912360
* out (brute-force protection). No-op when lockout is disabled

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,21 @@ describe('AuthPlugin', () => {
673673
expect((manager as any).config.passwordRequireComplexity).toBeUndefined();
674674
});
675675

676+
it('binds password_history_count (ADR-0069 D1)', async () => {
677+
const { manager } = await bootWithAuthSettings({ password_history_count: { value: 5, source: 'global' } });
678+
expect((manager as any).config.passwordHistoryCount).toBe(5);
679+
});
680+
681+
it('clamps password_history_count to a max of 24', async () => {
682+
const { manager } = await bootWithAuthSettings({ password_history_count: { value: 99, source: 'global' } });
683+
expect((manager as any).config.passwordHistoryCount).toBe(24);
684+
});
685+
686+
it('applies an explicit password_history_count of 0 (feature off)', async () => {
687+
const { manager } = await bootWithAuthSettings({ password_history_count: { value: 0, source: 'global' } });
688+
expect((manager as any).config.passwordHistoryCount).toBe(0);
689+
});
690+
676691
it('binds account-lockout settings (ADR-0069 D2)', async () => {
677692
const { manager } = await bootWithAuthSettings({
678693
lockout_threshold: { value: 5, source: 'global' },

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,11 @@ export class AuthPlugin implements Plugin {
506506
const n = asPositiveInt(values.password_min_classes);
507507
if (n !== undefined) patch.passwordMinClasses = Math.min(4, Math.max(1, n));
508508
}
509+
if (isExplicit('password_history_count')) {
510+
// 0 disables → use a non-negative reader (asPositiveInt rejects 0).
511+
const n = Math.floor(Number(values.password_history_count));
512+
if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
513+
}
509514

510515
// Session lifetime — days → seconds for better-auth's `session`
511516
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ describe('authSettingsManifest', () => {
6464
expect(byKey('lockout_duration_minutes').default).toBe(15);
6565
expect(byKey('rate_limit_max').default).toBe(10);
6666
expect(byKey('rate_limit_window_seconds').default).toBe(60);
67+
expect(byKey('password_history_count').default).toBe(0);
6768
});
6869

6970
it('exposes encrypted Google OAuth credential fields', () => {

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@ const manifest = {
106106
description: 'How many of the four classes (upper / lower / digit / symbol) a password must include.',
107107
visible: "${data.email_password_enabled !== false && data.password_require_complexity === true}",
108108
},
109+
{
110+
type: 'number',
111+
key: 'password_history_count',
112+
label: 'Password history (no reuse)',
113+
required: false,
114+
default: 0,
115+
min: 0,
116+
max: 24,
117+
description: 'Block reusing this many previous passwords on change/reset. 0 disables the check.',
118+
visible: "${data.email_password_enabled !== false}",
119+
},
109120

110121
{
111122
type: 'group',

0 commit comments

Comments
 (0)