From 37f3fb8ce1801b86b8b6e321158c5b846a3b7426 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:14:29 +0000 Subject: [PATCH 1/8] feat(plugin-auth): a forced password change requires a password different from the current one Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- .../src/auth-manager-before-hook.ts | 10 +++- .../auth-manager-password-session-policy.ts | 60 ++++++++++++++++++- .../plugins/plugin-auth/src/auth-manager.ts | 13 +++- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts index a844b3ca5af..394d30b91df 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts @@ -177,7 +177,10 @@ export async function globalBeforeHook( // 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. + // success. While an administrator's forced change is outstanding the + // current password is refused here whatever the history depth; the + // change door hands over its current-password claim so that refusal + // answers only a caller who proved it (see the method's docblock). if ( candidate && (ctx?.path === '/reset-password' || ctx?.path === '/change-password') @@ -189,7 +192,10 @@ export async function globalBeforeHook( ctx.context.__osPwChangeUserId = 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); + const currentPassword = ctx?.path === '/change-password' + ? (typeof ctx?.body?.currentPassword === 'string' ? ctx.body.currentPassword : '') + : undefined; + const oldHash = await this.assertPasswordNotReused(userId, candidate, verify, currentPassword); if (oldHash !== undefined) ctx.context.__osPwHistory = { userId, oldHash }; } } diff --git a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts index b6da2ab9b9a..e879ec904ae 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts @@ -303,18 +303,56 @@ export async function resolvePasswordChangeUserId(this: Pick { + try { + const u = await engine.findOne('sys_user', { + where: { id: userId }, + fields: ['must_change_password'], + context, + }); + return u?.must_change_password === true || u?.must_change_password === 1; + } catch { + return false; + } +} + /** The body of `AuthManager#assertPasswordNotReused`; the method's docblock there is its contract. */ export async function assertPasswordNotReused( this: Pick, userId: string, candidate: string, verify?: (data: { password: string; hash: string }) => Promise, + currentPassword?: string, ): Promise { const count = Math.floor(Number(this.config.passwordHistoryCount) || 0); - if (count <= 0 || typeof verify !== 'function') return undefined; + if (typeof verify !== 'function') return undefined; const engine = this.getDataEngine(); if (!engine) return undefined; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + // A forced change exists to move the user OFF a password the administrator + // knows, so while it is outstanding the current password is refused as the + // new one whatever `passwordHistoryCount` says (0, the default, included). + // Every door that clears the flag (`stampPasswordChangedAt`, from the + // after-hook on `/change-password` and `/reset-password`) passes through + // here first. + const forced = await isForcedRotationPending(engine, userId, SYSTEM_CTX); + if (count <= 0 && !forced) return undefined; let account: any; try { account = await engine.findOne('sys_account', { @@ -338,6 +376,26 @@ export async function assertPasswordNotReused( } if (!account?.id) return undefined; const currentHash = typeof account.password === 'string' ? account.password : ''; + if (forced && currentHash) { + const matches = async (password: string) => { + try { return await verify({ password, hash: currentHash }); } catch { return false; } + }; + // `/change-password` carries the caller's claim of the current password, + // and better-auth verifies that claim only AFTER this hook. Judge the new + // password only once the claim holds, so this refusal never tells a caller + // who does not know the current password whether a guess equals it; a + // wrong claim is left to the endpoint's own refusal. `/reset-password` + // carries no claim: its token is the caller's authority. + const claimHolds = currentPassword === undefined || await matches(currentPassword); + if (claimHolds && await matches(candidate)) { + const { APIError } = await import('better-auth/api'); + throw new APIError('BAD_REQUEST', { + message: FORCED_ROTATION_REUSE_MESSAGE, + code: 'PASSWORD_REUSE', + }); + } + } + if (count <= 0) return undefined; const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean); for (const h of compareList) { let match = false; diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 8776e8d2f31..fef671a189b 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -5443,11 +5443,22 @@ export class AuthManager { * 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. + * + * While an administrator's forced change (`sys_user.must_change_password`) + * is outstanding, a candidate matching the CURRENT password is refused with + * `PASSWORD_REUSE` whatever `passwordHistoryCount` is, the default 0 + * included: completing the change clears the flag, and it must not clear + * while the user keeps the password the administrator issued. + * `currentPassword` is the caller's claim of the current password on + * `/change-password` (`''` when the body carries none); that refusal is + * answered only once the claim verifies. Omitted on a door that carries no + * claim (`/reset-password`, whose token is the authority). */ private assertPasswordNotReused( userId: string, candidate: string, verify?: (data: { password: string; hash: string }) => Promise, + currentPassword?: string, ): Promise { // [#25224 cut 3] The body moved to `auth-manager-password-session-policy.ts` (see `PasswordSessionPolicyHost`). const readConfig = () => this.config; @@ -5456,7 +5467,7 @@ export class AuthManager { getDataEngine: (...a) => this.getDataEngine(...a), parseHashes: (...a) => this.parseHashes(...a), }; - return assertPasswordNotReused.call(host, userId, candidate, verify); + return assertPasswordNotReused.call(host, userId, candidate, verify, currentPassword); } /** From c15343e0d229948c819a98163634793421064fd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:15:25 +0000 Subject: [PATCH 2/8] test(plugin-auth): pin the forced password change requiring a new password Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- ...orced-password-change-new-password.test.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts diff --git a/packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts b/packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts new file mode 100644 index 00000000000..239a067449d --- /dev/null +++ b/packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An administrator's forced password change (`sys_user.must_change_password`) + * is completed only by a password DIFFERENT from the current one, whatever the + * deployment's `passwordHistoryCount` is — the default 0 included. + * + * The flag is cleared by `stampPasswordChangedAt`, which the after-hook runs on + * a successful `/change-password` or `/reset-password`; both doors pass through + * the before-hook's `assertPasswordNotReused` first, and that is where the + * current password is refused while the flag is set. Driven end to end through + * `AuthManager.handleRequest` over a real `ObjectQL` engine (better-sqlite3, + * this plugin's own manifest), so the engine's `internal: true` strip of + * `sys_account.password` is live and the refusal reads the recovered hash. + * + * Pinned: + * - `/change-password` refuses the current password as the new one while the + * flag is set (400 `PASSWORD_REUSE`), and nothing changes: the flag stays + * set and the current password still signs in; + * - a different new password completes the change and clears the flag; + * - the refusal answers only a caller whose current-password claim holds: a + * wrong claim gets the endpoint's own refusal, never `PASSWORD_REUSE`; + * - `/reset-password` refuses the current password while the flag is set, and + * completes with a different one; + * - the control: with no forced change outstanding and history off, the same + * password is still accepted — the history depth's behaviour is unchanged. + */ + +import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AuthManager } from './auth-manager.js'; +import { authIdentityObjects } from './manifest.js'; +import { FORCED_ROTATION_REUSE_MESSAGE } from './auth-manager-password-session-policy.js'; +import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; +import { sqliteEnginePool } from './__tests__/sqlite-engine-pool.testkit'; +// better-auth's first module load is paid here, at module load, not inside the +// first case (AGENTS.md, Build & Test: clocked windows measure behaviour). +import 'better-auth'; +import 'better-auth/api'; +import 'better-auth/plugins'; + +const BASE = 'http://localhost:3000'; +const AUTH = `${BASE}/api/v1/auth`; +const SECRET = 'test-secret-at-least-32-chars-long-forced-rotation'; +const SYS = { context: { isSystem: true } } as never; +const EMAIL = 'issued@example.com'; +const TEMPORARY = 'Temp0rary!Issued-Pass'; +const CHOSEN = 'Ch0sen!ByThe-User-2026'; + +const engines = sqliteEnginePool(async () => { + const engine = new ObjectQL(); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + engine.registerDriver(driver, true); + await engine.init(); + for (const object of authIdentityObjects) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + return { engine, driver }; +}); +afterEach(engines.release); +afterAll(engines.destroy); + +const scrypt = memoizeScrypt(); +afterAll(scrypt.restore); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const post = (manager: AuthManager, path: string, body: unknown, headers: Record = {}) => + manager.handleRequest( + new Request(`${AUTH}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin: BASE, ...headers }, + body: JSON.stringify(body), + }), + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +async function refusal(res: Response): Promise<{ status: number; code?: string; message?: string }> { + const body = (await res.clone().json().catch(() => ({}))) as { code?: unknown; message?: unknown }; + return { + status: res.status, + ...(typeof body.code === 'string' ? { code: body.code } : {}), + ...(typeof body.message === 'string' ? { message: body.message } : {}), + }; +} + +interface Deployment { + engine: ObjectQL; + manager: AuthManager; + userId: string; + cookie: string; + sent: Array<{ template: string; data: Record }>; +} + +/** + * A user holding the password an administrator issued, with the forced change + * outstanding when `forced` — stamped the way the admin doors stamp it (the + * row, then `noteMustChangePasswordIssued`). History depth left at its default. + */ +async function deployment(forced: boolean): Promise { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const engine = await engines.take(); + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never } as never); + const sent: Deployment['sent'] = []; + manager.setEmailService({ + sendTemplate: async (input: any) => { + sent.push(input); + return { status: 'sent' }; + }, + } as never); + const signedUp = await post(manager, '/sign-up/email', { email: EMAIL, password: TEMPORARY, name: 'Issued' }); + expect(signedUp.status, await signedUp.clone().text()).toBe(200); + const userId = String(((await signedUp.clone().json()) as { user: { id: string } }).user.id); + if (forced) { + await engine.update('sys_user', { id: userId, must_change_password: true }, SYS); + manager.noteMustChangePasswordIssued(); + } + return { engine, manager, userId, cookie: cookieFrom(signedUp), sent }; +} + +async function mustChange(d: Deployment): Promise { + const row = (await d.engine.findOne('sys_user', { + where: { id: d.userId }, + fields: ['must_change_password'], + ...(SYS as object), + } as never)) as { must_change_password?: unknown } | null; + return row?.must_change_password === true || row?.must_change_password === 1; +} + +const signIn = (d: Deployment, password: string) => post(d.manager, '/sign-in/email', { email: EMAIL, password }); + +const changePassword = (d: Deployment, currentPassword: string, newPassword: string) => + post(d.manager, '/change-password', { currentPassword, newPassword }, { cookie: d.cookie }); + +async function resetToken(d: Deployment): Promise { + const requested = await post(d.manager, '/request-password-reset', { email: EMAIL, redirectTo: `${BASE}/reset` }); + expect(requested.status, await requested.clone().text()).toBe(200); + await vi.waitFor(() => expect(d.sent.some((m) => m.template === 'auth.password_reset')).toBe(true)); + return String(d.sent.find((m) => m.template === 'auth.password_reset')!.data.token); +} + +describe('a forced password change requires a password different from the current one', () => { + it('/change-password refuses the current password while the change is outstanding, and changes nothing', async () => { + const d = await deployment(true); + expect(await mustChange(d)).toBe(true); + + expect(await refusal(await changePassword(d, TEMPORARY, TEMPORARY))).toEqual({ + status: 400, + code: 'PASSWORD_REUSE', + message: FORCED_ROTATION_REUSE_MESSAGE, + }); + + expect(await mustChange(d), 'the forced change is still outstanding').toBe(true); + expect((await signIn(d, TEMPORARY)).status, 'the issued password is unchanged').toBe(200); + }, 60_000); + + it('/change-password with a different password completes the change and clears the flag', async () => { + const d = await deployment(true); + + const changed = await changePassword(d, TEMPORARY, CHOSEN); + expect(changed.status, await changed.clone().text()).toBe(200); + + expect(await mustChange(d)).toBe(false); + expect((await signIn(d, CHOSEN)).status, 'the chosen password signs in').toBe(200); + expect((await signIn(d, TEMPORARY)).status, 'the issued password no longer does').toBe(401); + }, 60_000); + + it('answers only a caller whose current-password claim holds — a wrong claim gets the endpoint’s own refusal', async () => { + const d = await deployment(true); + + const res = await refusal(await changePassword(d, 'Not!The-Current-0ne', TEMPORARY)); + expect(res.status).toBe(400); + expect(res.code).not.toBe('PASSWORD_REUSE'); + expect(res.code).toBe('INVALID_PASSWORD'); + expect(await mustChange(d)).toBe(true); + }, 60_000); + + it('/reset-password refuses the current password while the change is outstanding, and completes with a different one', async () => { + const d = await deployment(true); + const token = await resetToken(d); + + expect(await refusal(await post(d.manager, '/reset-password', { token, newPassword: TEMPORARY }))).toEqual({ + status: 400, + code: 'PASSWORD_REUSE', + message: FORCED_ROTATION_REUSE_MESSAGE, + }); + expect(await mustChange(d)).toBe(true); + + const reset = await post(d.manager, '/reset-password', { token, newPassword: CHOSEN }); + expect(reset.status, await reset.clone().text()).toBe(200); + expect(await mustChange(d)).toBe(false); + expect((await signIn(d, CHOSEN)).status).toBe(200); + }, 60_000); + + it('control: with no forced change outstanding and history off, the same password is still accepted', async () => { + const d = await deployment(false); + expect(await mustChange(d)).toBe(false); + + const changed = await changePassword(d, TEMPORARY, TEMPORARY); + expect(changed.status, await changed.clone().text()).toBe(200); + expect((await signIn(d, TEMPORARY)).status).toBe(200); + }, 60_000); +}); From b14a86e755ae7f8aa672b37f4f1768d96bb32cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:27:02 +0000 Subject: [PATCH 3/8] test(plugin-auth): the depth-0 reuse check reads only the forced-change flag Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-manager.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 942e732f00b..9fdfef4ba41 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -4382,11 +4382,13 @@ describe('AuthManager', () => { expect((m as any).parseHashes('["a","b"]')).toEqual(['a', 'b']); }); - it('is a no-op when history depth is 0', async () => { + it('is a no-op when history depth is 0 and no forced change is outstanding', 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(); + // The one read is the forced-change flag on `sys_user`; with none + // outstanding the credential row is never read. + expect(engine.findOne.mock.calls.map((c: any[]) => c[0])).toEqual(['sys_user']); }); it('rejects reuse of the CURRENT password', async () => { From edaa664059f5648ad3aef4a5b08ecb1e38a1e853 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:28:14 +0000 Subject: [PATCH 4/8] feat(console): the forced-reset screen asks for a password different from the temporary one Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- .../src/console/RemediationOverlay.tsx | 5 ++ ...ionOverlay.forcedResetNewPassword.test.tsx | 88 +++++++++++++++++++ ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + 12 files changed, 103 insertions(+) create mode 100644 ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx diff --git a/ui/packages/app-shell/src/console/RemediationOverlay.tsx b/ui/packages/app-shell/src/console/RemediationOverlay.tsx index 57111906d86..ca94db57876 100644 --- a/ui/packages/app-shell/src/console/RemediationOverlay.tsx +++ b/ui/packages/app-shell/src/console/RemediationOverlay.tsx @@ -154,6 +154,11 @@ function ChangePasswordForm({ forcedReset, message }: { forcedReset: boolean; me e.preventDefault(); setError(null); if (next !== confirm) { setError(t('auth.remediation.password.mismatch')); return; } + // A forced reset exists to move the user off the password an administrator + // issued, and the server refuses that password as the new one + // (`PASSWORD_REUSE`) whatever the history depth. Say so here, in the + // user's language, rather than sending a change that cannot complete. + if (forcedReset && next === current) { setError(t('auth.remediation.password.sameAsTemporary')); return; } setBusy(true); try { await changePassword(current, next); diff --git a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx new file mode 100644 index 00000000000..34ff0e0d577 --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx @@ -0,0 +1,88 @@ +/** + * The forced-reset screen (`PASSWORD_RESET_REQUIRED`, an administrator's + * forced password change) asks for a password different from the temporary + * one: the server refuses the current password as the new one while the forced + * change is outstanding (`PASSWORD_REUSE`, whatever the history depth), so the + * screen says so in the user's language and sends no change that cannot + * complete. An expiry (`PASSWORD_EXPIRED`) is not a forced reset and keeps its + * behaviour: the server, not this screen, judges reuse there. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; + +type Gate = { code: string; message: string }; +let gate: Gate | null = null; +const changePassword = vi.fn(async () => {}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ + remediationRequired: gate, + setRemediationRequired: vi.fn(), + changePassword, + enrollTotp: vi.fn(), + verifyTotp: vi.fn(), + signOut: vi.fn(), + }), +})); + +import { RemediationOverlay } from '../RemediationOverlay'; + +function renderWith(next: Gate, language: string) { + gate = next; + return render( + + + , + ); +} + +function submit(current: string, next: string, labels: { current: string; next: string; confirm: string }) { + fireEvent.change(screen.getByLabelText(labels.current), { target: { value: current } }); + fireEvent.change(screen.getByLabelText(labels.next), { target: { value: next } }); + fireEvent.change(screen.getByLabelText(labels.confirm), { target: { value: next } }); + fireEvent.submit(screen.getByLabelText(labels.next).closest('form') as HTMLFormElement); +} + +const EN = { current: 'Current password', next: 'New password', confirm: 'Confirm new password' }; +const ZH = { current: '当前密码', next: '新密码', confirm: '确认新密码' }; +const FORCED: Gate = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' }; + +beforeEach(() => { + changePassword.mockClear(); +}); +afterEach(cleanup); + +describe('RemediationOverlay — a forced reset asks for a new password', () => { + it('refuses the temporary password as the new one, in English, and sends nothing', async () => { + renderWith(FORCED, 'en'); + submit('Temp0rary!Issued', 'Temp0rary!Issued', EN); + expect( + await screen.findByText('Choose a new password that is different from your temporary password.'), + ).toBeTruthy(); + expect(changePassword).not.toHaveBeenCalled(); + }); + + it('says it in the user’s language (zh-CN)', async () => { + renderWith(FORCED, 'zh'); + submit('Temp0rary!Issued', 'Temp0rary!Issued', ZH); + expect(await screen.findByText('请设置一个与临时密码不同的新密码。')).toBeTruthy(); + expect(changePassword).not.toHaveBeenCalled(); + }); + + it('sends a different new password on to the server', async () => { + renderWith(FORCED, 'en'); + submit('Temp0rary!Issued', 'Ch0sen!ByThe-User', EN); + await vi.waitFor(() => expect(changePassword).toHaveBeenCalledWith('Temp0rary!Issued', 'Ch0sen!ByThe-User')); + expect(screen.queryByText('Choose a new password that is different from your temporary password.')).toBeNull(); + }); + + it('leaves an expiry to the server: the same password is sent on', async () => { + renderWith({ code: 'PASSWORD_EXPIRED', message: '' }, 'en'); + submit('Same!Passw0rd-1', 'Same!Passw0rd-1', EN); + await vi.waitFor(() => expect(changePassword).toHaveBeenCalledWith('Same!Passw0rd-1', 'Same!Passw0rd-1')); + expect(screen.queryByText('Choose a new password that is different from your temporary password.')).toBeNull(); + }); +}); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 62b6ea5abec..6a54cb229d9 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2998,6 +2998,7 @@ const ar = { submit: "تغيير كلمة المرور والمتابعة", submitting: "جارٍ التحديث…", mismatch: "كلمتا المرور الجديدتان غير متطابقتين.", + sameAsTemporary: "اختر كلمة مرور جديدة تختلف عن كلمة المرور المؤقتة.", failed: "تعذّر تغيير كلمة المرور.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 484d71e6bc6..18d75fe9adb 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2768,6 +2768,7 @@ const de = { submit: "Passwort ändern & fortfahren", submitting: "Wird aktualisiert…", mismatch: "Die neuen Passwörter stimmen nicht überein.", + sameAsTemporary: "Wählen Sie ein neues Passwort, das sich von Ihrem temporären Passwort unterscheidet.", failed: "Passwort konnte nicht geändert werden.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 08d0d0107bd..bb9653de6f4 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3316,6 +3316,7 @@ const en = { submit: 'Change password & continue', submitting: 'Updating…', mismatch: 'New passwords do not match.', + sameAsTemporary: 'Choose a new password that is different from your temporary password.', failed: 'Could not change password.', }, mfa: { diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 00a6b958e03..73d28fde44a 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2827,6 +2827,7 @@ const es = { submit: "Cambiar contraseña y continuar", submitting: "Actualizando…", mismatch: "Las nuevas contraseñas no coinciden.", + sameAsTemporary: "Elija una nueva contraseña distinta de su contraseña temporal.", failed: "No se pudo cambiar la contraseña.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 6ef6090517d..122684f92d6 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2823,6 +2823,7 @@ const fr = { submit: "Changer le mot de passe et continuer", submitting: "Mise à jour…", mismatch: "Les nouveaux mots de passe ne correspondent pas.", + sameAsTemporary: "Choisissez un nouveau mot de passe différent de votre mot de passe temporaire.", failed: "Impossible de changer le mot de passe.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index e0afc16e41e..8ac59b8b690 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2770,6 +2770,7 @@ const ja = { submit: "パスワードを変更して続行", submitting: "更新しています…", mismatch: "新しいパスワードが一致しません。", + sameAsTemporary: "仮パスワードとは異なる新しいパスワードを設定してください。", failed: "パスワードを変更できませんでした。", }, mfa: { diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index e7233e7ce7e..c08527af38e 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2768,6 +2768,7 @@ const ko = { submit: "비밀번호 변경 후 계속", submitting: "업데이트 중…", mismatch: "새 비밀번호가 일치하지 않습니다.", + sameAsTemporary: "임시 비밀번호와 다른 새 비밀번호를 설정하세요.", failed: "비밀번호를 변경할 수 없습니다.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index f856ab4c380..375903e9ed7 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2822,6 +2822,7 @@ const pt = { submit: "Alterar senha e continuar", submitting: "Atualizando…", mismatch: "As novas senhas não coincidem.", + sameAsTemporary: "Escolha uma nova senha diferente da sua senha temporária.", failed: "Não foi possível alterar a senha.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index e614861f048..629d0c22809 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2891,6 +2891,7 @@ const ru = { submit: "Изменить пароль и продолжить", submitting: "Обновление…", mismatch: "Новые пароли не совпадают.", + sameAsTemporary: "Выберите новый пароль, отличный от временного.", failed: "Не удалось изменить пароль.", }, mfa: { diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index f6e21ec3f19..ee4af375043 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2843,6 +2843,7 @@ const zh = { submit: '修改密码并继续', submitting: '正在更新…', mismatch: '两次输入的新密码不一致。', + sameAsTemporary: '请设置一个与临时密码不同的新密码。', failed: '无法修改密码。', }, mfa: { From 256485f55d77570a0e51840cf129db15fde8d0bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:33:57 +0000 Subject: [PATCH 5/8] chore(changeset): forced password change requires a new password Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- .../forced-password-change-new-password.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/forced-password-change-new-password.md diff --git a/.changeset/forced-password-change-new-password.md b/.changeset/forced-password-change-new-password.md new file mode 100644 index 00000000000..a7498e4fa61 --- /dev/null +++ b/.changeset/forced-password-change-new-password.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-auth': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(plugin-auth): a forced password change now requires a password different from the current one + +Clause-②: no +Metadata-protocol: none + +When an administrator issues a password and asks for it to be changed at next sign-in (`sys_user.must_change_password`, set by Create User, Set Password and Import Users), the change now has to move the user to a new password. While that forced change is outstanding, `/change-password` and `/reset-password` refuse the current password as the new one with `400 PASSWORD_REUSE` ("Choose a new password that is different from your temporary password."), whatever `passwordHistoryCount` is, the default 0 included, and the flag stays set until a different password is chosen. On `/change-password` this refusal is given only once the request's current password is correct; a wrong current password still gets the endpoint's own refusal. + +Nothing else changes: with no forced change outstanding, reuse is judged by `passwordHistoryCount` exactly as before. + +The Console's forced-change screen says the same thing in the user's language (all ten locale packs) when the new password matches the one typed as current, and sends no request. From 2663005b6868e391b10badb0a969bde39158baff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:00:59 +0000 Subject: [PATCH 6/8] feat(plugin-auth): a reuse refusal is given only after the current password is confirmed Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- .../src/auth-manager-before-hook.ts | 7 +-- .../auth-manager-password-session-policy.ts | 40 ++++++++-------- .../plugins/plugin-auth/src/auth-manager.ts | 8 ++-- ...orced-password-change-new-password.test.ts | 46 +++++++++++++++++-- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts index 394d30b91df..4a79557a22c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts @@ -178,9 +178,10 @@ export async function globalBeforeHook( // 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. While an administrator's forced change is outstanding the - // current password is refused here whatever the history depth; the - // change door hands over its current-password claim so that refusal - // answers only a caller who proved it (see the method's docblock). + // current password is refused here whatever the history depth. The + // change door hands over its current-password claim, and every reuse + // refusal is given only once that claim is confirmed (see the method's + // docblock). if ( candidate && (ctx?.path === '/reset-password' || ctx?.path === '/change-password') diff --git a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts index e879ec904ae..ab5b16a27e9 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts @@ -376,31 +376,29 @@ export async function assertPasswordNotReused( } if (!account?.id) return undefined; const currentHash = typeof account.password === 'string' ? account.password : ''; - if (forced && currentHash) { - const matches = async (password: string) => { - try { return await verify({ password, hash: currentHash }); } catch { return false; } - }; - // `/change-password` carries the caller's claim of the current password, - // and better-auth verifies that claim only AFTER this hook. Judge the new - // password only once the claim holds, so this refusal never tells a caller - // who does not know the current password whether a guess equals it; a - // wrong claim is left to the endpoint's own refusal. `/reset-password` - // carries no claim: its token is the caller's authority. - const claimHolds = currentPassword === undefined || await matches(currentPassword); - if (claimHolds && await matches(candidate)) { - const { APIError } = await import('better-auth/api'); - throw new APIError('BAD_REQUEST', { - message: FORCED_ROTATION_REUSE_MESSAGE, - code: 'PASSWORD_REUSE', - }); - } + const matches = async (password: string, hash: string) => { + try { return await verify({ password, hash }); } catch { return false; } + }; + // `/change-password` carries the caller's claim of the current password, and + // better-auth confirms that claim only AFTER this hook. Every reuse refusal + // below — the forced change's and the history ring's — is given only once + // the current password is confirmed; an unconfirmed claim is left to the + // endpoint's own refusal (`INVALID_PASSWORD`). `/reset-password` carries no + // claim (`currentPassword` undefined): its token is the caller's authority. + const claimHolds = currentPassword === undefined + || (!!currentHash && await matches(currentPassword, currentHash)); + if (!claimHolds) return undefined; + if (forced && currentHash && await matches(candidate, currentHash)) { + const { APIError } = await import('better-auth/api'); + throw new APIError('BAD_REQUEST', { + message: FORCED_ROTATION_REUSE_MESSAGE, + code: 'PASSWORD_REUSE', + }); } if (count <= 0) return undefined; 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) { + if (await matches(candidate, h)) { 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.`, diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index fef671a189b..ad1ae10c7f4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -5450,9 +5450,11 @@ export class AuthManager { * included: completing the change clears the flag, and it must not clear * while the user keeps the password the administrator issued. * `currentPassword` is the caller's claim of the current password on - * `/change-password` (`''` when the body carries none); that refusal is - * answered only once the claim verifies. Omitted on a door that carries no - * claim (`/reset-password`, whose token is the authority). + * `/change-password` (`''` when the body carries none); every reuse refusal + * (the forced change's and the history ring's) is given only once the claim + * verifies against the current hash, and an unconfirmed claim is left to the + * endpoint's own refusal. Omitted on a door that carries no claim + * (`/reset-password`, whose token is the authority). */ private assertPasswordNotReused( userId: string, diff --git a/packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts b/packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts index 239a067449d..e26206da11f 100644 --- a/packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts +++ b/packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts @@ -23,7 +23,11 @@ * - `/reset-password` refuses the current password while the flag is set, and * completes with a different one; * - the control: with no forced change outstanding and history off, the same - * password is still accepted — the history depth's behaviour is unchanged. + * password is still accepted — the history depth's behaviour is unchanged; + * - with history on (`passwordHistoryCount` > 0) the history ring's refusal + * follows the same rule on `/change-password`: it is given only once the + * request's current password is confirmed, and a wrong current password + * gets the endpoint's own `INVALID_PASSWORD`. */ import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; @@ -110,10 +114,10 @@ interface Deployment { * outstanding when `forced` — stamped the way the admin doors stamp it (the * row, then `noteMustChangePasswordIssued`). History depth left at its default. */ -async function deployment(forced: boolean): Promise { +async function deployment(forced: boolean, config: Record = {}): Promise { vi.spyOn(console, 'warn').mockImplementation(() => {}); const engine = await engines.take(); - const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never } as never); + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never, ...config } as never); const sent: Deployment['sent'] = []; manager.setEmailService({ sendTemplate: async (input: any) => { @@ -214,3 +218,39 @@ describe('a forced password change requires a password different from the curren expect((await signIn(d, TEMPORARY)).status).toBe(200); }, 60_000); }); + +describe('with history on, a reuse refusal is given only after the current password is confirmed', () => { + const HISTORY = { passwordHistoryCount: 3 }; + /** A user who has changed TEMPORARY to CHOSEN, so TEMPORARY is in the history ring. */ + async function withHistory(): Promise { + const d = await deployment(false, HISTORY); + const changed = await changePassword(d, TEMPORARY, CHOSEN); + expect(changed.status, await changed.clone().text()).toBe(200); + return d; + } + + it('a confirmed current password and a password from the history ring answers PASSWORD_REUSE', async () => { + const d = await withHistory(); + const res = await refusal(await changePassword(d, CHOSEN, TEMPORARY)); + expect(res.status).toBe(400); + expect(res.code).toBe('PASSWORD_REUSE'); + expect((await signIn(d, CHOSEN)).status, 'the password is unchanged').toBe(200); + }, 60_000); + + it('a confirmed current password reused as the new one answers PASSWORD_REUSE', async () => { + const d = await withHistory(); + const res = await refusal(await changePassword(d, CHOSEN, CHOSEN)); + expect(res.status).toBe(400); + expect(res.code).toBe('PASSWORD_REUSE'); + }, 60_000); + + it('a wrong current password answers the endpoint’s own INVALID_PASSWORD, whatever the new password is', async () => { + const d = await withHistory(); + for (const next of [TEMPORARY, CHOSEN]) { + const res = await refusal(await changePassword(d, 'Not!The-Current-0ne', next)); + expect(res.status, next).toBe(400); + expect(res.code, next).toBe('INVALID_PASSWORD'); + } + expect((await signIn(d, CHOSEN)).status, 'the password is unchanged').toBe(200); + }, 60_000); +}); From 2a467e5fc0fbb35db9152cb49d3469cd2b39d8e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:05:29 +0000 Subject: [PATCH 7/8] chore(changeset): reuse refusal follows the current-password confirmation Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- .changeset/forced-password-change-new-password.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/forced-password-change-new-password.md b/.changeset/forced-password-change-new-password.md index a7498e4fa61..6365f533292 100644 --- a/.changeset/forced-password-change-new-password.md +++ b/.changeset/forced-password-change-new-password.md @@ -9,8 +9,8 @@ feat(plugin-auth): a forced password change now requires a password different fr Clause-②: no Metadata-protocol: none -When an administrator issues a password and asks for it to be changed at next sign-in (`sys_user.must_change_password`, set by Create User, Set Password and Import Users), the change now has to move the user to a new password. While that forced change is outstanding, `/change-password` and `/reset-password` refuse the current password as the new one with `400 PASSWORD_REUSE` ("Choose a new password that is different from your temporary password."), whatever `passwordHistoryCount` is, the default 0 included, and the flag stays set until a different password is chosen. On `/change-password` this refusal is given only once the request's current password is correct; a wrong current password still gets the endpoint's own refusal. +When an administrator issues a password and asks for it to be changed at next sign-in (`sys_user.must_change_password`, set by Create User, Set Password and Import Users), the change now has to move the user to a new password. While that forced change is outstanding, `/change-password` and `/reset-password` refuse the current password as the new one with `400 PASSWORD_REUSE` ("Choose a new password that is different from your temporary password."), whatever `passwordHistoryCount` is, the default 0 included, and the flag stays set until a different password is chosen. On `/change-password` a reuse refusal is given only after the current password is confirmed. This now also holds for the `passwordHistoryCount` check: a request whose current password is wrong gets the endpoint's own `INVALID_PASSWORD` whatever the new password is, and a confirmed request reusing the current or a recent password still gets `PASSWORD_REUSE`. `/reset-password` is unchanged; its token is the authority. -Nothing else changes: with no forced change outstanding, reuse is judged by `passwordHistoryCount` exactly as before. +Nothing else changes: with no forced change outstanding, which passwords count as reuse is still decided by `passwordHistoryCount` exactly as before. The Console's forced-change screen says the same thing in the user's language (all ten locale packs) when the new password matches the one typed as current, and sends no request. From 44d99fe72416bcac825e75e860e6453deb0a2eff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:42:28 +0000 Subject: [PATCH 8/8] fix(plugin-auth): a failed forced-change flag read is reported, and the changeset declares the narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sys_user must_change_password read in isForcedRotationPending still fails open, but now reports through logDurabilityDegradation, the channel stampPasswordChangedAt reports its write through. The docblock and the before-hook comment say the current-password claim gate sits above the history ring as well as the forced refusal. The changeset declares Clause-② no (narrowing) with the BREAKING banner, FROM / TO and its ADR-0087 disposition. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- .../forced-password-change-new-password.md | 16 ++++++++--- .../src/auth-manager-before-hook.ts | 7 ++--- .../auth-manager-password-session-policy.ts | 27 +++++++++++++++---- .../plugin-auth/src/auth-manager.test.ts | 20 ++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 16 ++++++----- 5 files changed, 69 insertions(+), 17 deletions(-) diff --git a/.changeset/forced-password-change-new-password.md b/.changeset/forced-password-change-new-password.md index 6365f533292..1c50d7a4aa7 100644 --- a/.changeset/forced-password-change-new-password.md +++ b/.changeset/forced-password-change-new-password.md @@ -6,11 +6,21 @@ feat(plugin-auth): a forced password change now requires a password different from the current one -Clause-②: no +Clause-②: no (narrowing) Metadata-protocol: none -When an administrator issues a password and asks for it to be changed at next sign-in (`sys_user.must_change_password`, set by Create User, Set Password and Import Users), the change now has to move the user to a new password. While that forced change is outstanding, `/change-password` and `/reset-password` refuse the current password as the new one with `400 PASSWORD_REUSE` ("Choose a new password that is different from your temporary password."), whatever `passwordHistoryCount` is, the default 0 included, and the flag stays set until a different password is chosen. On `/change-password` a reuse refusal is given only after the current password is confirmed. This now also holds for the `passwordHistoryCount` check: a request whose current password is wrong gets the endpoint's own `INVALID_PASSWORD` whatever the new password is, and a confirmed request reusing the current or a recent password still gets `PASSWORD_REUSE`. `/reset-password` is unchanged; its token is the authority. + -Nothing else changes: with no forced change outstanding, which passwords count as reuse is still decided by `passwordHistoryCount` exactly as before. +**BREAKING** (an accept-set narrowing), shipped as `patch`: the metadata protocol does not move, so the level is `patch` and this banner is the carrier. + +When an administrator issues a password and asks for it to be changed at next sign-in (`sys_user.must_change_password`, set by Create User, Set Password and Import Users), the change now has to move the user to a new password. + +**FROM.** With the forced-change flag set and the default `passwordHistoryCount` (0), a `/change-password` or `/reset-password` whose new password equals the current one completed and cleared the flag, so the user kept the password the administrator issued. With `passwordHistoryCount` above 0, a `/change-password` carrying a wrong current password and a recycled new one answered `400 PASSWORD_REUSE`. + +**TO.** While the forced change is outstanding, `/change-password` and `/reset-password` refuse the current password as the new one with `400 PASSWORD_REUSE` ("Choose a new password that is different from your temporary password."), whatever `passwordHistoryCount` is, and the flag stays set until a different password is chosen. On `/change-password` a reuse refusal of either kind (the forced change's and the `passwordHistoryCount` ring's) is given only after the current password is confirmed: a request whose current password is wrong answers the endpoint's own `INVALID_PASSWORD` whatever the new password is, including a recycled one at a history depth above 0, which answered `PASSWORD_REUSE` before. A confirmed request reusing the current or a recent password still answers `PASSWORD_REUSE`. `/reset-password` carries no current password; its token is the authority, and the forced refusal applies to it directly. + +**For clients.** A client that completes a forced change by sending the issued password back as the new one (an onboarding script, a test harness) must now send a different password. + +Nothing else changes: with no forced change outstanding, which passwords count as reuse is still decided by `passwordHistoryCount` exactly as before. If the flag cannot be read, the request goes on as though no forced change were outstanding and the failure is logged through the auth manager's durability-degradation channel. The Console's forced-change screen says the same thing in the user's language (all ten locale packs) when the new password matches the one typed as current, and sends no request. diff --git a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts index 4a79557a22c..dd1bd1b551d 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts @@ -179,9 +179,10 @@ export async function globalBeforeHook( // the old hash so the after-hook appends it to the bounded ring on // success. While an administrator's forced change is outstanding the // current password is refused here whatever the history depth. The - // change door hands over its current-password claim, and every reuse - // refusal is given only once that claim is confirmed (see the method's - // docblock). + // change door hands over its current-password claim, and the claim gate + // sits above the history ring as well as the forced change: no + // `PASSWORD_REUSE` of either kind is given until that claim is confirmed + // (see the method's docblock). if ( candidate && (ctx?.path === '/reset-password' || ctx?.path === '/change-password') diff --git a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts index ab5b16a27e9..93a97d4a2ae 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts @@ -317,9 +317,17 @@ export const FORCED_ROTATION_REUSE_MESSAGE = * issued by the admin create-user, set-user-password and import-users doors) * outstanding for `userId`? Read as the system; the column is `readonly`, not * `internal`, so the row carries it. Fails open (false) on a lookup error, as - * every read in `assertPasswordNotReused` does. + * every read in `assertPasswordNotReused` does, but never silently: the + * failure is reported through the host's durability-degradation channel (the + * one `stampPasswordChangedAt` reports its write through), because the + * request then looks normal while a security control did not run. */ -async function isForcedRotationPending(engine: any, userId: string, context: unknown): Promise { +async function isForcedRotationPending( + host: Pick, + engine: any, + userId: string, + context: unknown, +): Promise { try { const u = await engine.findOne('sys_user', { where: { id: userId }, @@ -327,14 +335,23 @@ async function isForcedRotationPending(engine: any, userId: string, context: unk context, }); return u?.must_change_password === true || u?.must_change_password === 1; - } catch { + } catch (e) { + host.logDurabilityDegradation( + 'AuthManager: the sys_user forced-change flag (must_change_password) could NOT be read ' + + 'for a password change or reset — the request goes on as though no forced change were ' + + 'outstanding, so the check that a forced change moves the user to a new password did not ' + + 'run for it, and nothing else looks wrong. Remedy: make the sys_user read succeed; the ' + + 'next change or reset reads the flag again.', + e, + { object: 'sys_user', fields: ['must_change_password'] }, + ); return false; } } /** The body of `AuthManager#assertPasswordNotReused`; the method's docblock there is its contract. */ export async function assertPasswordNotReused( - this: Pick, + this: Pick, userId: string, candidate: string, verify?: (data: { password: string; hash: string }) => Promise, @@ -351,7 +368,7 @@ export async function assertPasswordNotReused( // Every door that clears the flag (`stampPasswordChangedAt`, from the // after-hook on `/change-password` and `/reset-password`) passes through // here first. - const forced = await isForcedRotationPending(engine, userId, SYSTEM_CTX); + const forced = await isForcedRotationPending(this, engine, userId, SYSTEM_CTX); if (count <= 0 && !forced) return undefined; let account: any; try { diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 9fdfef4ba41..b956a5702e1 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -4391,6 +4391,26 @@ describe('AuthManager', () => { expect(engine.findOne.mock.calls.map((c: any[]) => c[0])).toEqual(['sys_user']); }); + it('reports a failed forced-change flag read through the durability channel and fails open', async () => { + const engine = makeEngine(acct()); + engine.findOne.mockImplementation(async (o: string) => { + if (o === 'sys_user') throw new Error('sys_user read refused'); + return null; + }); + const logger = { warn: vi.fn(), info: vi.fn() }; + const m = mgr(engine, { passwordHistoryCount: 0, logger }); + logger.warn.mockClear(); + // Fail open: treated as no forced change, so depth 0 accepts. + await expect((m as any).assertPasswordNotReused('u1', 'current', stubVerify)).resolves.toBeUndefined(); + // ...but never silently: exactly one report naming the unread flag. + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][1]).toMatchObject({ + object: 'sys_user', + fields: ['must_change_password'], + error: 'sys_user read refused', + }); + }); + 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({ diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index ad1ae10c7f4..a5e7a1b4f30 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -5450,11 +5450,14 @@ export class AuthManager { * included: completing the change clears the flag, and it must not clear * while the user keeps the password the administrator issued. * `currentPassword` is the caller's claim of the current password on - * `/change-password` (`''` when the body carries none); every reuse refusal - * (the forced change's and the history ring's) is given only once the claim - * verifies against the current hash, and an unconfirmed claim is left to the - * endpoint's own refusal. Omitted on a door that carries no claim - * (`/reset-password`, whose token is the authority). + * `/change-password` (`''` when the body carries none). The claim gate sits + * above BOTH reuse refusals, not only the forced change's: the history + * ring's `PASSWORD_REUSE` is also given only once the claim verifies against + * the current hash, so a wrong current password gets the endpoint's own + * `INVALID_PASSWORD` whatever the new password is. Omitted on a door that + * carries no claim (`/reset-password`, whose token is the authority). A + * failed read of the forced-change flag fails open (treated as not set) and + * is reported through `logDurabilityDegradation`. */ private assertPasswordNotReused( userId: string, @@ -5464,9 +5467,10 @@ export class AuthManager { ): Promise { // [#25224 cut 3] The body moved to `auth-manager-password-session-policy.ts` (see `PasswordSessionPolicyHost`). const readConfig = () => this.config; - const host: Pick = { + const host: Pick = { get config() { return readConfig(); }, getDataEngine: (...a) => this.getDataEngine(...a), + logDurabilityDegradation: (...a) => this.logDurabilityDegradation(...a), parseHashes: (...a) => this.parseHashes(...a), }; return assertPasswordNotReused.call(host, userId, candidate, verify, currentPassword);