diff --git a/.changeset/forced-password-change-new-password.md b/.changeset/forced-password-change-new-password.md new file mode 100644 index 00000000000..1c50d7a4aa7 --- /dev/null +++ b/.changeset/forced-password-change-new-password.md @@ -0,0 +1,26 @@ +--- +'@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 (narrowing) +Metadata-protocol: none + + + +**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 a844b3ca5af..dd1bd1b551d 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,12 @@ 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, 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') @@ -189,7 +194,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..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 @@ -303,18 +303,73 @@ export async function resolvePasswordChangeUserId(this: Pick, + engine: any, + userId: string, + context: unknown, +): Promise { + 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 (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, + 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(this, engine, userId, SYSTEM_CTX); + if (count <= 0 && !forced) return undefined; let account: any; try { account = await engine.findOne('sys_account', { @@ -338,11 +393,29 @@ export async function assertPasswordNotReused( } if (!account?.id) return undefined; const currentHash = typeof account.password === 'string' ? account.password : ''; + 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.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 942e732f00b..b956a5702e1 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -4382,11 +4382,33 @@ 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('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 () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 8776e8d2f31..a5e7a1b4f30 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -5443,20 +5443,37 @@ 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). 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, 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; - 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); + return assertPasswordNotReused.call(host, userId, candidate, verify, currentPassword); } /** 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..e26206da11f --- /dev/null +++ b/packages/plugins/plugin-auth/src/forced-password-change-new-password.test.ts @@ -0,0 +1,256 @@ +// 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; + * - 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'; +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, 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, ...config } 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); +}); + +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); +}); 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 16822b31ca9..d8ad22c13d9 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 6a48ff7d4cd..343722cb53f 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 8bd7fbfe8be..15cae01ca34 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 0e2665a8731..1046d4d9ddd 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 292904cc1f9..4fe138a1478 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 5787bf08041..5a72635d367 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 56bdc7b7bba..c1c40dd4186 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 9f3a8ed8572..3d167e7bd6a 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 0a49aff9ad7..023f7696939 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 c9d36262cf3..3574537f7c7 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: {