diff --git a/changelog.d/fixes/8950-fix.plan.md b/changelog.d/fixes/8950-fix.plan.md new file mode 100644 index 0000000000..a2214d6e47 --- /dev/null +++ b/changelog.d/fixes/8950-fix.plan.md @@ -0,0 +1 @@ +- fix(auth): setting first dashboard login password no longer fails with HTTP 400 PASSWORD_REQUIRED (#8950) \ No newline at end of file diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 29427f6943..a5e6627641 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -319,7 +319,12 @@ export async function PATCH(request: Request) { // honoured before T-011 — when no password is configured yet AND login // is currently disabled, allow the first write to set policy (incl. // the password itself). Once a hash exists the gate always fires. - const isColdBoot = !storedPasswordHash && passwordState.settings.requireLogin === false; + // #8950: also treat the request as cold boot when newPassword is present + // without a stored hash, so the Security tab's two-step flow (enable + // requireLogin first, then set password) does not deadlock. + const isColdBoot = + !storedPasswordHash && + (passwordState.settings.requireLogin === false || Boolean(body.newPassword)); if (!isColdBoot) { if (!body.currentPassword) { emitSettingsFailureAudit(request, actor, "PASSWORD_REQUIRED", attemptedKeys); diff --git a/tests/unit/settings/probe-8950-set-password.test.ts b/tests/unit/settings/probe-8950-set-password.test.ts new file mode 100644 index 0000000000..df51694ebf --- /dev/null +++ b/tests/unit/settings/probe-8950-set-password.test.ts @@ -0,0 +1,86 @@ +/** + * REPRO #8950 — Setting the first dashboard login password fails with HTTP 400 + * PASSWORD_REQUIRED, deadlocking every fresh install. + * + * Root cause: isColdBoot only fires while requireLogin===false, but the + * Security tab forces requireLogin ON before the password form is reachable, + * so the first newPassword write always demands a currentPassword that cannot + * exist yet. + * + * Fix: add `|| Boolean(body.newPassword)` to the cold-boot condition so that + * setting the first password is always treated as cold boot, regardless of + * the current requireLogin state. + * + * Regression guard: once a password hash exists, the gate fires as before + * (currentPassword required for security-impacting changes). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupSettingsFixture, mockSettings } from "../_mocks/settings.ts"; +import { makeManagementSessionRequest } from "../../helpers/managementSession.ts"; + +const fixture = setupSettingsFixture("probe-8950"); + +process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1"; + +const core = await import("../../../src/lib/db/core.ts"); +const settingsDb = await import("../../../src/lib/db/settings.ts"); +const runtime = await import("../../../src/lib/config/runtimeSettings.ts"); +const settingsRoute = await import("../../../src/app/api/settings/route.ts"); +const managementPassword = await import("../../../src/lib/auth/managementPassword.ts"); + +test.beforeEach(async () => { + await fixture.resetStorage(); + runtime.resetRuntimeSettingsStateForTests(); +}); + +test.after(() => { + core.resetDbInstance(); + fixture.cleanup(); +}); + +test("REPRO #8950: setting first password after requireLogin enabled should succeed", async () => { + // Simulate fresh install: no password hash, requireLogin is false. + await mockSettings({ setupComplete: true, requireLogin: false }); + + // Step 1: Enable requireLogin (what the Security tab does when you open it). + const step1 = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { requireLogin: true }, + }) + ); + assert.equal( + step1.status, + 200, + `Step 1: enabling requireLogin should succeed, got ${step1.status}` + ); + + // Step 2: Set the first password (no currentPassword because none exists yet). + const step2 = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { newPassword: "my-first-password" }, + }) + ); + + // REPRO: this fails with 400 PASSWORD_REQUIRED because isColdBoot only + // checks requireLogin===false, but the DB now has requireLogin=true. + assert.equal( + step2.status, + 200, + `Step 2: first password write should succeed without currentPassword, got ${step2.status}` + ); + const step2Body = (await step2.json()) as Record; + assert.equal( + step2Body.error, + undefined, + `Step 2 response should not have an error: ${JSON.stringify(step2Body)}` + ); + + // Verify the password was actually stored. + const configured = managementPassword.hasManagementPasswordConfigured( + (await settingsDb.getSettings()) as Record + ); + assert.equal(configured, true, "management password should be configured after first write"); +});