+ Password management is unavailable — this deployment doesn't report password state yet.
+
+ );
+ }
+
+ const onSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (change.isPending) return;
+
+ // Checked before sending so a length failure is never attributed to the
+ // wrong thing by the flip rule below. Bytes, not characters: the backend
+ // measures with Go's `len()`.
+ if (!isPasswordWithinPolicy(next)) {
+ setError("Choose a longer password — at least 12 characters.");
+ return;
+ }
+ setError(undefined);
+
+ change.mutate(
+ { ...(asChange ? { current_password: current } : {}), new_password: next },
+ {
+ onSuccess: () => {
+ toast.success(asChange ? "Password changed" : "Password set");
+ setCurrent("");
+ setNext("");
+ },
+ onError: (mutationError) => {
+ if (!(mutationError instanceof BffError)) {
+ toast.error("Couldn't save your password. Try again.");
+ return;
+ }
+
+ if (mutationError.status === 422) {
+ setError("That current password isn't right.");
+ return;
+ }
+ if (mutationError.status === 409) {
+ // Nothing the user typed was wrong, and nothing was changed.
+ toast.error("Your session expired before the change was saved. Sign in again.");
+ return;
+ }
+ if (mutationError.status === 400 && !flipped) {
+ // The password passed the local length check, so this 400 is the
+ // shape being wrong for this account's real state. Flip once.
+ setFlipped(true);
+ setAsChange((wasChange) => !wasChange);
+ setCurrent("");
+ setError(
+ asChange
+ ? "This account has no password yet — set one below."
+ : "This account already has a password — enter your current one.",
+ );
+ return;
+ }
+ setError(mutationError.message || "Couldn't save your password. Try again.");
+ },
+ },
+ );
+ };
+
+ return (
+
This signs you out on this device only. Use the option below to sign out from all devices.
diff --git a/src/server/auth/__tests__/backend-token-exchange-password.test.ts b/src/server/auth/__tests__/backend-token-exchange-password.test.ts
new file mode 100644
index 0000000..43694e5
--- /dev/null
+++ b/src/server/auth/__tests__/backend-token-exchange-password.test.ts
@@ -0,0 +1,299 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ changeBackendPassword,
+ confirmPasswordReset,
+ requestPasswordResetCode,
+} from "@/server/auth/backend-token-exchange";
+
+/**
+ * The password HTTP client, executed rather than mocked.
+ *
+ * Every other suite in this change stubs this module, which left its 401
+ * classification — the single most consequential branch in RUK-289 — provable
+ * only by reading it. An inverted classification tells an operator with a wrong
+ * password that their session expired, and an operator with a dead session that
+ * their password is wrong on a form with no password field.
+ *
+ * Assertions read the REQUEST off the fetch stub, not the arguments object, so
+ * they pin what actually goes on the wire.
+ */
+
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ vi.stubGlobal("fetch", fetchMock);
+ fetchMock.mockReset();
+ process.env.MAINTMODE_API_BASE_URL = "http://backend.test/maintmode";
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+function respond(status: number, body = "") {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: `status ${status}`,
+ text: async () => body,
+ };
+}
+
+/** The JSON body of the Nth fetch call. */
+function sentBody(call = 0): Record {
+ return JSON.parse(fetchMock.mock.calls[call][1].body);
+}
+
+describe("changeBackendPassword — the 401 classification", () => {
+ // The inversion of this pair is invisible to every other test in the change,
+ // and it is the failure SPEC §3.4 exists to prevent.
+ it("reads a 401 as a wrong password when a current password was sent", async () => {
+ fetchMock.mockResolvedValue(respond(401, '{"code":"unauthorized"}'));
+
+ const outcome = await changeBackendPassword({
+ accessToken: "access-1",
+ currentPassword: "wrong",
+ newPassword: "a-long-enough-password",
+ refreshToken: "refresh-1",
+ });
+
+ expect(outcome).toEqual({ ok: false, kind: "wrong-current-password" });
+ });
+
+ it("reads a 401 as a stale session when none was sent", async () => {
+ fetchMock.mockResolvedValue(respond(401, '{"code":"unauthorized"}'));
+
+ const outcome = await changeBackendPassword({
+ accessToken: "access-1",
+ newPassword: "a-long-enough-password",
+ refreshToken: "stale-refresh",
+ });
+
+ // Nothing the user typed was wrong, and the backend changed nothing.
+ expect(outcome).toEqual({ ok: false, kind: "session-stale" });
+ });
+
+ it("maps 204, 400 and everything else", async () => {
+ fetchMock.mockResolvedValueOnce(respond(204));
+ await expect(
+ changeBackendPassword({ accessToken: "a", newPassword: "a-long-enough-password" }),
+ ).resolves.toEqual({ ok: true });
+
+ // A realistic envelope, not a hand-typed bare string. The previous fixture
+ // was satisfied identically by passing the WHOLE body through, which is how
+ // raw JSON reached the operator's form unnoticed.
+ fetchMock.mockResolvedValueOnce(
+ respond(
+ 400,
+ '{"code":"invalid request","message":"validation error: password does not meet the length policy"}',
+ ),
+ );
+ await expect(
+ changeBackendPassword({ accessToken: "a", newPassword: "a-long-enough-password" }),
+ ).resolves.toEqual({
+ ok: false,
+ kind: "rejected",
+ message: "validation error: password does not meet the length policy",
+ });
+
+ fetchMock.mockResolvedValueOnce(respond(503, "gateway down"));
+ await expect(
+ changeBackendPassword({ accessToken: "a", newPassword: "a-long-enough-password" }),
+ ).resolves.toEqual({ ok: false, kind: "unavailable" });
+ });
+
+ // A 200 or 202 is not success here: the contract is 204 with an empty body,
+ // and treating any 2xx as done would report a password change the backend
+ // never made.
+ // A proxy's HTML error page must not be pasted into the form whole.
+ it("falls back to a sentence when a 400 is not the expected envelope", async () => {
+ fetchMock.mockResolvedValue(respond(400, "502 Bad Gateway"));
+
+ await expect(
+ changeBackendPassword({ accessToken: "a", newPassword: "a-long-enough-password" }),
+ ).resolves.toEqual({
+ ok: false,
+ kind: "rejected",
+ message: "That password wasn't accepted.",
+ });
+ });
+
+ it("treats a 2xx that is not 204 as unavailable", async () => {
+ fetchMock.mockResolvedValue(respond(200, "{}"));
+
+ await expect(
+ changeBackendPassword({ accessToken: "a", newPassword: "a-long-enough-password" }),
+ ).resolves.toEqual({ ok: false, kind: "unavailable" });
+ });
+
+ it("treats a thrown fetch as unavailable rather than propagating", async () => {
+ fetchMock.mockRejectedValue(new Error("network down"));
+
+ await expect(
+ changeBackendPassword({ accessToken: "a", newPassword: "a-long-enough-password" }),
+ ).resolves.toEqual({ ok: false, kind: "unavailable" });
+ });
+});
+
+describe("changeBackendPassword — what goes on the wire", () => {
+ it("sends the refresh token, which is what keeps the caller signed in", async () => {
+ fetchMock.mockResolvedValue(respond(204));
+
+ await changeBackendPassword({
+ accessToken: "access-1",
+ currentPassword: "old-password",
+ newPassword: "a-long-enough-password",
+ refreshToken: "refresh-1",
+ });
+
+ // Omitting it makes the backend revoke every session including this one:
+ // the operator is signed out by their own success.
+ expect(sentBody()).toEqual({
+ current_password: "old-password",
+ new_password: "a-long-enough-password",
+ refresh_token: "refresh-1",
+ });
+ });
+
+ it("omits current_password as a KEY when the account has none", async () => {
+ fetchMock.mockResolvedValue(respond(204));
+
+ await changeBackendPassword({
+ accessToken: "access-1",
+ newPassword: "a-long-enough-password",
+ refreshToken: "refresh-1",
+ });
+
+ // Absent, not empty: the backend rejects the field outright for an account
+ // with no password, so `""` would 400 every set-password call.
+ expect("current_password" in sentBody()).toBe(false);
+ });
+
+ it("omits refresh_token as a key when there is none to send", async () => {
+ fetchMock.mockResolvedValue(respond(204));
+
+ await changeBackendPassword({ accessToken: "a", newPassword: "a-long-enough-password" });
+
+ expect("refresh_token" in sentBody()).toBe(false);
+ });
+
+ it("carries the access token as a bearer credential", async () => {
+ fetchMock.mockResolvedValue(respond(204));
+
+ await changeBackendPassword({ accessToken: "access-1", newPassword: "a-long-enough-password" });
+
+ expect(fetchMock.mock.calls[0][1].headers.authorization).toBe("Bearer access-1");
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/api/v1/me/password");
+ });
+});
+
+describe("confirmPasswordReset", () => {
+ // Without the throw, EVERY reset failure resolves — the caller reports
+ // success, the user is told their password changed and is signed out, and the
+ // password is unchanged.
+ it("throws on a failure rather than resolving", async () => {
+ fetchMock.mockResolvedValue(respond(401, '{"code":"otp_session_mismatch"}'));
+
+ await expect(
+ confirmPasswordReset({
+ email: "op@example.test",
+ code: "123456",
+ sessionNonce: "nonce-1",
+ newPassword: "a-long-enough-password",
+ }),
+ ).rejects.toMatchObject({ status: 401, responseBody: '{"code":"otp_session_mismatch"}' });
+ });
+
+ it("resolves on 204", async () => {
+ fetchMock.mockResolvedValue(respond(204));
+
+ await expect(
+ confirmPasswordReset({
+ email: "op@example.test",
+ code: "123456",
+ sessionNonce: "nonce-1",
+ newPassword: "a-long-enough-password",
+ }),
+ ).resolves.toBeUndefined();
+ });
+
+ it("sends the nonce under the key the backend reads", async () => {
+ fetchMock.mockResolvedValue(respond(204));
+
+ await confirmPasswordReset({
+ email: "op@example.test",
+ code: "123456",
+ sessionNonce: "nonce-1",
+ newPassword: "a-long-enough-password",
+ });
+
+ // Named explicitly: a renamed or empty `session_nonce` is answered with the
+ // same opaque 401 as a wrong code, so nothing downstream could tell.
+ expect(sentBody()).toEqual({
+ email: "op@example.test",
+ code: "123456",
+ session_nonce: "nonce-1",
+ new_password: "a-long-enough-password",
+ });
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/api/v1/password/reset/confirm");
+ });
+});
+
+describe("the request timeout covers the body read, not just the head", () => {
+ // `fetch` resolves on the response HEAD, so a helper that cleared its timeout
+ // on return would leave a stalled BODY hanging forever — which is the case
+ // the timeout exists for. A refactor that did exactly this passed all 1424
+ // tests, because nothing covered it.
+ //
+ // No fake timers needed: the config floor is 100ms, and the assertion that
+ // matters is that the signal ABORTED. Without that line the test passes
+ // against the broken version, because the rejection then comes from shape
+ // validation rather than from the abort.
+ it("aborts a response whose body never arrives", async () => {
+ process.env.MAINTMODE_API_TIMEOUT_MS = "100";
+ let aborted = false;
+
+ fetchMock.mockImplementation(async (_url: string, init: { signal: AbortSignal }) => ({
+ ok: true,
+ status: 202,
+ statusText: "Accepted",
+ text: () =>
+ new Promise((_resolve, reject) => {
+ init.signal.addEventListener("abort", () => {
+ aborted = true;
+ reject(new Error("aborted"));
+ });
+ }),
+ }));
+
+ await expect(requestPasswordResetCode("op@example.test")).rejects.toThrow();
+ expect(aborted).toBe(true);
+
+ delete process.env.MAINTMODE_API_TIMEOUT_MS;
+ });
+});
+
+describe("requestPasswordResetCode", () => {
+ it("returns the session nonce from a 202", async () => {
+ fetchMock.mockResolvedValue(respond(202, '{"session_nonce":"nonce-1"}'));
+
+ await expect(requestPasswordResetCode("op@example.test")).resolves.toEqual({
+ session_nonce: "nonce-1",
+ });
+ expect(sentBody()).toEqual({ email: "op@example.test" });
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/api/v1/password/reset/request");
+ });
+
+ it("rejects a 202 that carries no nonce, rather than binding undefined", async () => {
+ fetchMock.mockResolvedValue(respond(202, "{}"));
+
+ await expect(requestPasswordResetCode("op@example.test")).rejects.toThrow();
+ });
+
+ it("throws with the status on a rate limit", async () => {
+ fetchMock.mockResolvedValue(respond(429, "slow down"));
+
+ await expect(requestPasswordResetCode("op@example.test")).rejects.toMatchObject({ status: 429 });
+ });
+});
diff --git a/src/server/auth/__tests__/built-in-sign-in-callback.test.ts b/src/server/auth/__tests__/built-in-sign-in-callback.test.ts
index 76658b4..e96ee27 100644
--- a/src/server/auth/__tests__/built-in-sign-in-callback.test.ts
+++ b/src/server/auth/__tests__/built-in-sign-in-callback.test.ts
@@ -31,7 +31,6 @@ vi.mock("@/server/auth/otp-nonce-cookie", () => ({
// The real implementation: normalization is part of the behaviour under test,
// so stubbing it would hide the case-variant bug this file now covers.
normalizeEmail: (email: string) => email.trim().toLowerCase(),
- OTP_NONCE_COOKIE: "__Host-mm.otp_nonce",
}));
const { runBuiltInSignIn } = await import("@/server/auth/built-in-sign-in");
diff --git a/src/server/auth/__tests__/otp-nonce-cookie.test.ts b/src/server/auth/__tests__/otp-nonce-cookie.test.ts
index c09d557..2e510a6 100644
--- a/src/server/auth/__tests__/otp-nonce-cookie.test.ts
+++ b/src/server/auth/__tests__/otp-nonce-cookie.test.ts
@@ -2,9 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
OTP_NONCE_COOKIE,
+ PWRESET_NONCE_COOKIE,
clearOtpBinding,
+ clearPasswordResetBinding,
readOtpBinding,
+ readPasswordResetBinding,
setOtpBinding,
+ setPasswordResetBinding,
} from "@/server/auth/otp-nonce-cookie";
const store = {
@@ -116,3 +120,67 @@ describe("otp-nonce-cookie — the encoding resists a hostile email address", ()
expect(read?.email).toBe(hostile);
});
});
+
+describe("two flows, two cookies (RUK-289)", () => {
+ beforeEach(() => {
+ store.set.mockReset();
+ store.get.mockReset();
+ store.delete.mockReset();
+ });
+
+ // Asserted as literal strings against the real exported constants, NOT
+ // re-derived from whatever produces them and NOT mocked. Both names are
+ // deployed artifacts: renaming the sign-in one invalidates every binding a
+ // user is holding mid-flow at deploy time, and a test that recomputes the
+ // name would pass through exactly that rename.
+ it("pins both cookie names as deployed artifacts", () => {
+ expect(OTP_NONCE_COOKIE).toBe("__Host-mm.otp_nonce");
+ expect(PWRESET_NONCE_COOKIE).toBe("__Host-mm.pwreset_nonce");
+ });
+
+ // The bug this split exists for: with ONE cookie, requesting a password-reset
+ // code overwrites the sign-in binding, and the email equality check PASSES
+ // because the address is the same. The sign-in code is then verified against
+ // the reset nonce and the user is told a correct code is wrong.
+ //
+ // Note what this does NOT claim: the sign-in CODE does not survive either,
+ // because the backend keeps one OTP record per user and issuing a reset code
+ // consumes the live one (SPEC §1.5). Only the local binding is separated.
+ it("writing a reset binding leaves the sign-in binding untouched", async () => {
+ await setOtpBinding({ nonce: "signin-nonce", email: "op@example.test" });
+ await setPasswordResetBinding({ nonce: "reset-nonce", email: "op@example.test" });
+
+ const written = store.set.mock.calls.map(([name]) => name);
+ expect(written).toEqual(["__Host-mm.otp_nonce", "__Host-mm.pwreset_nonce"]);
+ });
+
+ it("reads the reset binding from its own cookie", async () => {
+ store.get.mockImplementation((name: string) =>
+ name === "__Host-mm.pwreset_nonce"
+ ? { value: encode({ nonce: "reset-nonce", email: "op@example.test" }) }
+ : undefined,
+ );
+
+ await expect(readPasswordResetBinding()).resolves.toEqual({
+ nonce: "reset-nonce",
+ email: "op@example.test",
+ });
+ // The sign-in reader must not fall back to the reset cookie.
+ await expect(readOtpBinding()).resolves.toBeUndefined();
+ });
+
+ it("clearing one flow's binding does not clear the other's", async () => {
+ await clearPasswordResetBinding();
+
+ expect(store.delete).toHaveBeenCalledTimes(1);
+ expect(store.delete).toHaveBeenCalledWith("__Host-mm.pwreset_nonce");
+ });
+
+ it("keeps the reset cookie's flags and TTL identical to sign-in's", async () => {
+ await setPasswordResetBinding({ nonce: "n", email: "op@example.test" });
+
+ const [, , opts] = store.set.mock.calls[0];
+ expect(opts).toMatchObject({ httpOnly: true, sameSite: "lax", secure: true, path: "/" });
+ expect(opts.maxAge).toBe(300);
+ });
+});
diff --git a/src/server/auth/__tests__/password-reset-actions.test.ts b/src/server/auth/__tests__/password-reset-actions.test.ts
new file mode 100644
index 0000000..c0856ff
--- /dev/null
+++ b/src/server/auth/__tests__/password-reset-actions.test.ts
@@ -0,0 +1,334 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ abandonPasswordResetAction,
+ confirmPasswordResetAction,
+ requestPasswordResetAction,
+} from "@/server/auth/password-reset-actions";
+
+const requestPasswordResetCode = vi.fn();
+const confirmPasswordReset = vi.fn();
+const setPasswordResetBinding = vi.fn();
+const readPasswordResetBinding = vi.fn();
+const clearPasswordResetBinding = vi.fn();
+const signOut = vi.fn();
+const clearActiveSession = vi.fn();
+
+vi.mock("@/server/auth/backend-token-exchange", () => ({
+ requestPasswordResetCode: (...args: unknown[]) => requestPasswordResetCode(...args),
+ confirmPasswordReset: (...args: unknown[]) => confirmPasswordReset(...args),
+}));
+
+vi.mock("@/server/auth/otp-nonce-cookie", async (importOriginal) => {
+ // `normalizeEmail` is the real one: the binding check compares against it, and
+ // a stubbed version would make the mismatch tests agree with themselves.
+ const actual = await importOriginal();
+ return {
+ normalizeEmail: actual.normalizeEmail,
+ setPasswordResetBinding: (...args: unknown[]) => setPasswordResetBinding(...args),
+ readPasswordResetBinding: () => readPasswordResetBinding(),
+ clearPasswordResetBinding: () => clearPasswordResetBinding(),
+ };
+});
+
+vi.mock("@/server/auth/auth-config", () => ({ signOut: (...args: unknown[]) => signOut(...args) }));
+vi.mock("@/server/auth/session-token", () => ({ clearActiveSession: () => clearActiveSession() }));
+
+/** A backend failure as `postBackendJson` throws it. */
+function backendError(status: number, body = "") {
+ return Object.assign(new Error(`backend ${status}`), { status, responseBody: body });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ console.error = vi.fn();
+});
+
+describe("requesting a reset code cannot become an account-existence oracle", () => {
+ // AC-1. The backend answers 202 for a registered address, an unknown one and
+ // a blocked one alike; this asserts the ACTION collapses them too, which is
+ // the only half of that promise the frontend owns.
+ it("answers identically for every address the backend accepts", async () => {
+ requestPasswordResetCode.mockResolvedValue({ session_nonce: "n" });
+
+ const registered = await requestPasswordResetAction("known@example.test");
+ const unknown = await requestPasswordResetAction("nobody@example.test");
+ const blocked = await requestPasswordResetAction("blocked@example.test");
+
+ expect(registered).toEqual({});
+ expect(unknown).toEqual(registered);
+ expect(blocked).toEqual(registered);
+ });
+
+ it("binds the nonce to this browser", async () => {
+ requestPasswordResetCode.mockResolvedValue({ session_nonce: "nonce-1" });
+
+ await requestPasswordResetAction("op@example.test");
+
+ expect(setPasswordResetBinding).toHaveBeenCalledWith({ nonce: "nonce-1", email: "op@example.test" });
+ });
+
+ it("rejects an empty address without calling the backend", async () => {
+ await expect(requestPasswordResetAction(" ")).resolves.toEqual({ error: "invalid_email" });
+ expect(requestPasswordResetCode).not.toHaveBeenCalled();
+ });
+
+ // AC-12. A rate limit is not a verdict on the address, and an outage is a
+ // fact about the service — neither may wear the uniform copy, which would
+ // tell every user their input was wrong.
+ it("separates a rate limit and an outage from the uniform answer", async () => {
+ requestPasswordResetCode.mockRejectedValueOnce(backendError(429));
+ await expect(requestPasswordResetAction("op@example.test")).resolves.toEqual({
+ error: "otp_rate_limited",
+ });
+
+ requestPasswordResetCode.mockRejectedValueOnce(backendError(503));
+ await expect(requestPasswordResetAction("op@example.test")).resolves.toEqual({
+ error: "password_reset_unavailable",
+ });
+
+ requestPasswordResetCode.mockRejectedValueOnce(backendError(404));
+ await expect(requestPasswordResetAction("op@example.test")).resolves.toEqual({
+ error: "password_reset_unavailable",
+ });
+ });
+
+ // A rejected input is not an outage. Today this endpoint answers only 202 and
+ // 429, but reporting a future 4xx as "the service is down" would send a user
+ // whose address was refused off to wait for a recovery that never comes.
+ it("does not report a rejected request as an outage", async () => {
+ requestPasswordResetCode.mockRejectedValueOnce(backendError(400));
+
+ await expect(requestPasswordResetAction("op@example.test")).resolves.toEqual({
+ error: "invalid_email",
+ });
+ });
+});
+
+describe("confirming a reset", () => {
+ beforeEach(() => {
+ readPasswordResetBinding.mockResolvedValue({ nonce: "nonce-1", email: "op@example.test" });
+ });
+
+ // AC-3, at the action boundary. The backend hides a policy violation inside
+ // the same 401 as a wrong code, so a password that fails the policy must
+ // never reach it — the user would be told their correct code was wrong.
+ it("refuses a short password without spending an attempt", async () => {
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "short",
+ });
+
+ expect(result).toEqual({ error: "password_policy_violation" });
+ expect(confirmPasswordReset).not.toHaveBeenCalled();
+ // The binding survives: nothing was spent, and the code is still good.
+ expect(clearPasswordResetBinding).not.toHaveBeenCalled();
+ });
+
+ // AC-4. Past the 204 the password IS changed and every session is dead.
+ it("tears the local session down on success", async () => {
+ confirmPasswordReset.mockResolvedValue(undefined);
+
+ await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(signOut).toHaveBeenCalledWith({ redirect: false });
+ expect(clearActiveSession).toHaveBeenCalled();
+ });
+
+ // AC-4, the half that matters more. The backend has already committed the
+ // change; a teardown that throws must not swallow the confirmation, or the
+ // user is left believing their password is unchanged when it is not.
+ it("still confirms when the teardown throws", async () => {
+ confirmPasswordReset.mockResolvedValue(undefined);
+ signOut.mockRejectedValueOnce(new Error("cookie store unavailable"));
+
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(result).toEqual({ done: true });
+ });
+
+ it("sends the bound nonce, not anything the caller supplied", async () => {
+ confirmPasswordReset.mockResolvedValue(undefined);
+
+ await confirmPasswordResetAction({
+ email: "OP@Example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(confirmPasswordReset).toHaveBeenCalledWith({
+ email: "op@example.test",
+ code: "123456",
+ sessionNonce: "nonce-1",
+ newPassword: "a-long-enough-password",
+ });
+ });
+
+ it("reports a lost binding with the reset flow's own code, not sign-in's", async () => {
+ readPasswordResetBinding.mockResolvedValue(undefined);
+
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ // Not `otp_session_mismatch`: its copy returns the user to sign-in, which
+ // is not where someone mid-reset is trying to go.
+ expect(result).toEqual({ error: "password_reset_session_mismatch" });
+ expect(confirmPasswordReset).not.toHaveBeenCalled();
+ });
+
+ it("refuses when the bound address is not the one being confirmed", async () => {
+ readPasswordResetBinding.mockResolvedValue({ nonce: "n", email: "someone-else@example.test" });
+
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(result).toEqual({ error: "password_reset_session_mismatch" });
+ expect(confirmPasswordReset).not.toHaveBeenCalled();
+ });
+
+ // The clear policy, which is the difference between a recoverable mistype and
+ // a destroyed code.
+ it("keeps the binding on a wrong code, so remaining attempts survive", async () => {
+ confirmPasswordReset.mockRejectedValueOnce(
+ backendError(401, '{"code":"unauthorized","message":"authentication failed"}'),
+ );
+
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "000000",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(result).toEqual({ error: "password_reset_failed" });
+ expect(clearPasswordResetBinding).not.toHaveBeenCalled();
+ });
+
+ it("clears the binding when the backend reports a session mismatch", async () => {
+ // Spaced exactly as a pretty-printing encoder would emit it: a substring
+ // match on `"code":"..."` would miss this and fail open into the generic
+ // collapse, showing sign-in recovery copy on a reset screen.
+ confirmPasswordReset.mockRejectedValueOnce(
+ backendError(401, '{ "code": "otp_session_mismatch", "message": "authentication failed" }'),
+ );
+
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(result).toEqual({ error: "password_reset_session_mismatch" });
+ expect(clearPasswordResetBinding).toHaveBeenCalled();
+ });
+
+ // §3.6 requires the rate limit to keep its own copy on BOTH endpoints. The
+ // request path already had this; the confirm path did not, and folding a 429
+ // into "unavailable" tells a throttled user the service is broken.
+ it("keeps a rate limit apart from an outage, and keeps the binding", async () => {
+ confirmPasswordReset.mockRejectedValueOnce(backendError(429));
+
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(result).toEqual({ error: "otp_rate_limited" });
+ expect(clearPasswordResetBinding).not.toHaveBeenCalled();
+ });
+
+ // §3.4's rule, made executable. A substring implementation would match the
+ // code wherever it appears — including inside `message` — and would then
+ // clear a binding that still had attempts left, destroying a usable code.
+ // Every other fixture here has field and substring agreeing, so only this
+ // one separates the two implementations.
+ it("reads the code FIELD, not the message text", async () => {
+ confirmPasswordReset.mockRejectedValueOnce(
+ backendError(
+ 401,
+ '{"code":"unauthorized","message":"authentication failed for otp_session_mismatch reasons"}',
+ ),
+ );
+
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(result).toEqual({ error: "password_reset_failed" });
+ expect(clearPasswordResetBinding).not.toHaveBeenCalled();
+ });
+
+ it("keeps an outage apart from a wrong code, and keeps the binding", async () => {
+ confirmPasswordReset.mockRejectedValueOnce(backendError(502));
+
+ const result = await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ expect(result).toEqual({ error: "password_reset_unavailable" });
+ expect(clearPasswordResetBinding).not.toHaveBeenCalled();
+ });
+
+ // The teardown error can be a BackendAuthError whose `responseBody` is the
+ // raw refresh response — a token pair. Logging the object would serialize it.
+ it("never logs credential material when the teardown fails", async () => {
+ confirmPasswordReset.mockResolvedValue(undefined);
+ signOut.mockRejectedValueOnce(
+ Object.assign(new Error("refresh failed"), {
+ status: 401,
+ responseBody: '{"access_token":"at_SECRET","refresh_token":"rt_SECRET"}',
+ }),
+ );
+
+ await confirmPasswordResetAction({
+ email: "op@example.test",
+ code: "123456",
+ newPassword: "a-long-enough-password",
+ });
+
+ const logged = JSON.stringify((console.error as unknown as ReturnType).mock.calls);
+ expect(logged).not.toContain("rt_SECRET");
+ expect(logged).not.toContain("access_token");
+ });
+
+ it("never logs the address or the code", async () => {
+ confirmPasswordReset.mockRejectedValueOnce(backendError(401, "{}"));
+
+ await confirmPasswordResetAction({
+ email: "secret@example.test",
+ code: "424242",
+ newPassword: "a-long-enough-password",
+ });
+
+ const logged = JSON.stringify((console.error as unknown as ReturnType).mock.calls);
+ expect(logged).not.toContain("secret@example.test");
+ expect(logged).not.toContain("424242");
+ });
+});
+
+describe("abandoning the flow", () => {
+ it("clears only the reset binding", async () => {
+ await abandonPasswordResetAction();
+
+ expect(clearPasswordResetBinding).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/server/auth/backend-token-exchange.ts b/src/server/auth/backend-token-exchange.ts
index 2d42c6f..2bfd421 100644
--- a/src/server/auth/backend-token-exchange.ts
+++ b/src/server/auth/backend-token-exchange.ts
@@ -12,6 +12,9 @@ const ME_PATH = "/api/v1/me";
const OTP_REQUEST_PATH = "/api/v1/login/otp/request";
const OTP_VERIFY_PATH = "/api/v1/login/otp/verify";
const PASSWORD_LOGIN_PATH = "/api/v1/login/password";
+const PASSWORD_RESET_REQUEST_PATH = "/api/v1/password/reset/request";
+const PASSWORD_RESET_CONFIRM_PATH = "/api/v1/password/reset/confirm";
+const CHANGE_PASSWORD_PATH = "/api/v1/me/password";
/**
* BFF-owned OAuth.
@@ -149,6 +152,166 @@ export async function loginWithPassword(args: {
);
}
+/**
+ * Password reset, step one: ask the backend to mail a code.
+ *
+ * Shaped exactly like `requestOtpCode` because it IS the same mechanism — the
+ * backend routes both through one OTP issuer. It answers 202 with a session
+ * nonce for every outcome, including an address with no account and a malformed
+ * body (a placeholder nonce), so nothing here may branch on the result.
+ *
+ * Note the consequence the caller must not paper over: because the two flows
+ * share one OTP record per user, asking for a reset code consumes any live
+ * sign-in code. Separate cookies keep the two BINDINGS apart; they cannot keep
+ * the codes apart.
+ */
+export async function requestPasswordResetCode(email: string): Promise<{ session_nonce: string }> {
+ return postBackendJson<{ session_nonce: string }>(
+ PASSWORD_RESET_REQUEST_PATH,
+ { email },
+ (value): value is { session_nonce: string } =>
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as { session_nonce?: unknown }).session_nonce === "string",
+ );
+}
+
+/**
+ * Password reset, step two: redeem the code and install the new password.
+ *
+ * Answers **204 with an empty body** — no token pair — and revokes every
+ * session, so the caller signs in again afterwards. It cannot go through
+ * `postBackendJson`, which requires a JSON payload it can shape-check.
+ *
+ * Failures collapse into one 401 "authentication failed", with
+ * `otp_session_mismatch` as the sole distinguishable case. A password that
+ * breaks the length policy is INSIDE that collapse and is reported as a wrong
+ * code — which is why the client checks the length before calling this.
+ */
+export async function confirmPasswordReset(args: {
+ email: string;
+ code: string;
+ sessionNonce: string;
+ newPassword: string;
+}): Promise {
+ return backendFetch(
+ PASSWORD_RESET_CONFIRM_PATH,
+ {
+ method: "POST",
+ headers: { accept: "application/json", "content-type": "application/json" },
+ body: JSON.stringify({
+ email: args.email,
+ code: args.code,
+ session_nonce: args.sessionNonce,
+ new_password: args.newPassword,
+ }),
+ },
+ async (response) => {
+ if (!response.ok) {
+ throw new BackendAuthError(response.status, (await response.text()) || response.statusText);
+ }
+ },
+ );
+}
+
+/**
+ * The outcome of a change-password call, classified WITHOUT reading any prose.
+ *
+ * The backend's three 400s all carry `code: "invalid request"` and differ only
+ * in free text, so branching on the message would be a contract that breaks on
+ * a reword. What the caller knows instead is what it sent, which is enough.
+ */
+export type ChangePasswordOutcome =
+ | { ok: true }
+ /** Wrong `current_password` — the caller sent one and the backend refused. */
+ | { ok: false; kind: "wrong-current-password" }
+ /** The `refresh_token` was stale or foreign; NOTHING was changed. */
+ | { ok: false; kind: "session-stale" }
+ /** A 400: the wrong shape for this account's state, or a policy violation. */
+ | { ok: false; kind: "rejected"; message: string }
+ | { ok: false; kind: "unavailable" };
+
+/**
+ * Sets the caller's own password.
+ *
+ * Deliberately NOT routed through `authenticatedBackendRequest`, for two
+ * reasons that both end in "the user is signed out and their password is
+ * unchanged":
+ *
+ * - it retries the mutation after refreshing on a 401, and this endpoint takes
+ * a `refresh_token` in the BODY, so the retry would carry a token the
+ * refresh just superseded;
+ * - it collapses every 401 into one error type, losing the difference between
+ * a wrong current password and a dead session — which is the difference
+ * between a message in the form and a redirect to /login.
+ *
+ * `refreshToken` names the session to keep alive. Omitting it revokes every
+ * session including the caller's, which is the honest fallback when the BFF has
+ * no live refresh token to offer.
+ */
+export async function changeBackendPassword(args: {
+ accessToken: string;
+ currentPassword?: string;
+ newPassword: string;
+ refreshToken?: string;
+}): Promise {
+ try {
+ return await backendFetch(
+ CHANGE_PASSWORD_PATH,
+ {
+ method: "POST",
+ headers: {
+ accept: "application/json",
+ "content-type": "application/json",
+ authorization: `Bearer ${args.accessToken}`,
+ },
+ body: JSON.stringify({
+ // Sent only when there is one. The backend rejects the field outright
+ // for an account with no password, so an empty string is not the same
+ // as absent.
+ ...(args.currentPassword ? { current_password: args.currentPassword } : {}),
+ new_password: args.newPassword,
+ ...(args.refreshToken ? { refresh_token: args.refreshToken } : {}),
+ }),
+ },
+ async (response): Promise => {
+ if (response.status === 204) {
+ return { ok: true };
+ }
+
+ const body = await response.text();
+
+ if (response.status === 401) {
+ // The one classification, and it reads what the REQUEST carried rather
+ // than what the response says. A 401 when a current password was sent is
+ // that password being wrong; a 401 when none was sent is the
+ // `refresh_token` having been superseded, and nothing the user typed.
+ return { ok: false, kind: args.currentPassword ? "wrong-current-password" : "session-stale" };
+ }
+ if (response.status === 400) {
+ // The `message` FIELD, not the raw envelope. `body` is the whole
+ // response text, so passing it straight through put
+ // `{"code":"invalid request","message":…}` verbatim into the
+ // operator's form — and would paste a proxy's HTML error page in
+ // whole. Read for DISPLAY only: §3.4 forbids branching on backend
+ // prose, and the only behavioural branch remains the card's flip flag.
+ const parsed = safeJsonParse<{ message?: string }>(body);
+ return {
+ ok: false,
+ kind: "rejected",
+ message: parsed?.message ?? "That password wasn't accepted.",
+ };
+ }
+ return { ok: false, kind: "unavailable" };
+ },
+ );
+ } catch {
+ // Network failure, a malformed URL, or the abort above. This function
+ // reports rather than throws, so every one of them is `unavailable`.
+ return { ok: false, kind: "unavailable" };
+ }
+}
+
/**
* Rotates the refresh token via `POST /api/v1/refresh`. Returns the new
* `TokenPairResponse`.
@@ -164,13 +327,9 @@ export async function refreshBackendToken(refreshToken: string): Promise {
- const config = readMaintmodeBackendConfig();
- const target = resolveBackendUrl(config.authApiBaseUrl, LOGOUT_PATH);
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
-
- try {
- const response = await fetch(target, {
+ return backendFetch(
+ LOGOUT_PATH,
+ {
method: "POST",
headers: {
accept: "application/json",
@@ -178,15 +337,14 @@ export async function revokeBackendSession(accessToken: string, refreshToken: st
authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ refresh_token: refreshToken }),
- signal: controller.signal,
- });
- if (!response.ok && response.status !== 204) {
- const body = await response.text();
- throw new BackendAuthError(response.status, body || response.statusText);
- }
- } finally {
- clearTimeout(timeout);
- }
+ },
+ async (response) => {
+ if (!response.ok && response.status !== 204) {
+ const body = await response.text();
+ throw new BackendAuthError(response.status, body || response.statusText);
+ }
+ },
+ );
}
/**
@@ -195,56 +353,80 @@ export async function revokeBackendSession(accessToken: string, refreshToken: st
* (`Authorization: Bearer`); there is no body.
*/
export async function revokeAllBackendSessions(accessToken: string): Promise {
- const config = readMaintmodeBackendConfig();
- const target = resolveBackendUrl(config.authApiBaseUrl, LOGOUT_ALL_PATH);
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
-
- try {
- const response = await fetch(target, {
+ return backendFetch(
+ LOGOUT_ALL_PATH,
+ {
method: "POST",
headers: {
accept: "application/json",
authorization: `Bearer ${accessToken}`,
},
- signal: controller.signal,
- });
- if (!response.ok && response.status !== 204) {
- const body = await response.text();
- throw new BackendAuthError(response.status, body || response.statusText);
- }
- } finally {
- clearTimeout(timeout);
- }
+ },
+ async (response) => {
+ if (!response.ok && response.status !== 204) {
+ const body = await response.text();
+ throw new BackendAuthError(response.status, body || response.statusText);
+ }
+ },
+ );
}
/**
* Loads the current user's profile via `GET /api/v1/me`.
*/
export async function fetchBackendMe(accessToken: string): Promise {
- const config = readMaintmodeBackendConfig();
- const target = resolveBackendUrl(config.authApiBaseUrl, ME_PATH);
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
-
- try {
- const response = await fetch(target, {
+ return backendFetch(
+ ME_PATH,
+ {
method: "GET",
headers: {
accept: "application/json",
authorization: `Bearer ${accessToken}`,
},
- signal: controller.signal,
- });
- const body = await response.text();
- if (!response.ok) {
- throw new BackendAuthError(response.status, body || response.statusText);
- }
- const parsed = safeJsonParse(body);
- if (!parsed?.id || !parsed.email) {
- throw new BackendAuthError(response.status, body, "Backend /me returned an unexpected payload");
- }
- return parsed;
+ },
+ async (response) => {
+ const body = await response.text();
+ if (!response.ok) {
+ throw new BackendAuthError(response.status, body || response.statusText);
+ }
+ const parsed = safeJsonParse(body);
+ if (!parsed?.id || !parsed.email) {
+ throw new BackendAuthError(response.status, body, "Backend /me returned an unexpected payload");
+ }
+ return parsed;
+ },
+ );
+}
+
+/**
+ * Resolves `path` against the auth base URL and runs one `fetch` under the
+ * configured timeout.
+ *
+ * Owns the request scaffold ONLY — the URL and the abort timer, which were
+ * identical at six call sites. It deliberately does not touch the response:
+ * these endpoints disagree about what a response even is (a shape-checked JSON
+ * body, a bare 204, a 401 classified by what the REQUEST carried), and some
+ * throw where others return a discriminated result. Folding that in would erase
+ * the distinctions their callers exist to act on.
+ *
+ * `handleResponse` receives the response, and the timeout is cleared only once
+ * it settles. That is why this takes a callback rather than returning the
+ * `Response`: the timer has to outlive the body read. `fetch` resolves on the
+ * response HEAD, so clearing it at that point would leave a stalled `.text()`
+ * hanging forever — exactly the case the timeout exists for.
+ */
+async function backendFetch(
+ path: string,
+ init: Omit,
+ handleResponse: (response: Response) => Promise,
+): Promise {
+ const config = readMaintmodeBackendConfig();
+ const target = resolveBackendUrl(config.authApiBaseUrl, path);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
+
+ try {
+ return await handleResponse(await fetch(target, { ...init, signal: controller.signal }));
} finally {
clearTimeout(timeout);
}
@@ -256,13 +438,9 @@ async function postBackendJson(
isShapeValid: (parsed: TResponse | undefined) => boolean,
extraHeaders?: Record,
): Promise {
- const config = readMaintmodeBackendConfig();
- const target = resolveBackendUrl(config.authApiBaseUrl, path);
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
-
- try {
- const response = await fetch(target, {
+ return backendFetch(
+ path,
+ {
method: "POST",
headers: {
// Spread extra headers first so the fixed accept/content-type below
@@ -272,20 +450,19 @@ async function postBackendJson(
"content-type": "application/json",
},
body: JSON.stringify(body),
- signal: controller.signal,
- });
- const text = await response.text();
- if (!response.ok) {
- throw new BackendAuthError(response.status, text || response.statusText);
- }
- const parsed = safeJsonParse(text);
- if (!isShapeValid(parsed)) {
- throw new BackendAuthError(response.status, text, `Backend ${path} returned an unexpected payload`);
- }
- return parsed as TResponse;
- } finally {
- clearTimeout(timeout);
- }
+ },
+ async (response) => {
+ const text = await response.text();
+ if (!response.ok) {
+ throw new BackendAuthError(response.status, text || response.statusText);
+ }
+ const parsed = safeJsonParse(text);
+ if (!isShapeValid(parsed)) {
+ throw new BackendAuthError(response.status, text, `Backend ${path} returned an unexpected payload`);
+ }
+ return parsed as TResponse;
+ },
+ );
}
function safeJsonParse(text: string): T | undefined {
diff --git a/src/server/auth/contracts.ts b/src/server/auth/contracts.ts
index 1d1158a..eb96a52 100644
--- a/src/server/auth/contracts.ts
+++ b/src/server/auth/contracts.ts
@@ -90,5 +90,25 @@ export const AUTH_ERROR_CODES = {
// separate so the copy does not tell someone to re-check a correct code and
// send more requests into the limiter that is already refusing them.
otpRateLimited: "otp_rate_limited",
+ // Password reset (RUK-289). The reset flow's OWN mismatch code, deliberately
+ // not a reuse of `otpSessionMismatch`: that one's copy sends the user back to
+ // sign-in, which is the wrong destination when they are mid-reset and have
+ // not chosen a new password yet. Leaks nothing, for the same reason its
+ // sign-in twin leaks nothing — it names a fact about this browser.
+ passwordResetSessionMismatch: "password_reset_session_mismatch",
+ // Every other confirm failure: wrong code, expired, attempts exhausted, and —
+ // because the backend deliberately hides it inside the same collapse — a
+ // password that breaks the length policy. The client checks length before
+ // sending precisely so a user never sees this code for that reason.
+ passwordResetFailed: "password_reset_failed",
+ // The new password failed the client-side policy check, so nothing was sent.
+ // Distinct from every server answer: no attempt was spent and the binding is
+ // still good, so the copy must not tell the user to request a new code.
+ passwordPolicyViolation: "password_policy_violation",
+ // The endpoint itself is unreachable or broken (404/5xx), which is a fact
+ // about the SERVICE and not about an account — so saying so plainly leaks
+ // nothing. Kept apart from the anti-enumeration collapse because folding it
+ // in tells every user their input was wrong during an outage.
+ passwordResetUnavailable: "password_reset_unavailable",
} as const;
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[keyof typeof AUTH_ERROR_CODES];
diff --git a/src/server/auth/otp-nonce-cookie.ts b/src/server/auth/otp-nonce-cookie.ts
index f3aa578..1076613 100644
--- a/src/server/auth/otp-nonce-cookie.ts
+++ b/src/server/auth/otp-nonce-cookie.ts
@@ -29,11 +29,28 @@ import { cookies } from "next/headers";
* (the backend answers 202 for any well-formed one), so `${nonce}:${email}`
* would let an address containing the delimiter control the parsed nonce.
*
- * One cookie, not one per flow: a second tab overwrites the first, and the
- * first tab's code then fails with `otp_session_mismatch`, which renders the
+ * ONE COOKIE PER FLOW, not one overall (revised in RUK-289). Within a single
+ * flow a second tab still overwrites the first, and that remains deliberate:
+ * the first tab then fails with `otp_session_mismatch`, which renders the
* honest "request a new code" state that exists for exactly this situation.
+ *
+ * Across flows it was a defect. Sign-in and password-reset both bind a nonce,
+ * and with one cookie a reset request overwrote the sign-in binding while the
+ * email equality check still PASSED — same address — so a sign-in code was
+ * verified against the reset nonce and the user was told a correct code was
+ * wrong.
+ *
+ * Separating the names fixes the binding, and only the binding. The backend
+ * keeps one OTP record per user, so requesting a reset code still consumes any
+ * live sign-in code server-side (SPEC §1.5): the sign-in tab fails either way,
+ * and the point of the split is that it now fails as "request a new code"
+ * rather than as "that code is wrong".
+ *
+ * Both names are deployed artifacts. Renaming either one invalidates every
+ * binding users hold mid-flow at deploy time.
*/
export const OTP_NONCE_COOKIE = "__Host-mm.otp_nonce";
+export const PWRESET_NONCE_COOKIE = "__Host-mm.pwreset_nonce";
/**
* Equal to the backend's `otp_ttl` (5 min), deliberately not longer. A margin
@@ -67,13 +84,13 @@ export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
-export async function setOtpBinding(binding: OtpBinding): Promise {
+async function setBinding(name: string, binding: OtpBinding): Promise {
const encoded = Buffer.from(
JSON.stringify({ nonce: binding.nonce, email: normalizeEmail(binding.email) }),
"utf8",
).toString("base64url");
const store = await cookies();
- store.set(OTP_NONCE_COOKIE, encoded, {
+ store.set(name, encoded, {
httpOnly: true,
sameSite: "lax",
secure: true,
@@ -82,6 +99,14 @@ export async function setOtpBinding(binding: OtpBinding): Promise {
});
}
+export function setOtpBinding(binding: OtpBinding): Promise {
+ return setBinding(OTP_NONCE_COOKIE, binding);
+}
+
+export function setPasswordResetBinding(binding: OtpBinding): Promise {
+ return setBinding(PWRESET_NONCE_COOKIE, binding);
+}
+
/**
* Reads the binding, or `undefined` when there is none.
*
@@ -91,9 +116,9 @@ export async function setOtpBinding(binding: OtpBinding): Promise {
* state — so a corrupted cookie can never surface as "wrong code" to someone
* holding a correct one, and can never crash the sign-in callback.
*/
-export async function readOtpBinding(): Promise {
+async function readBinding(name: string): Promise {
const store = await cookies();
- const raw = store.get(OTP_NONCE_COOKIE)?.value;
+ const raw = store.get(name)?.value;
if (!raw) {
return undefined;
}
@@ -106,7 +131,23 @@ export async function readOtpBinding(): Promise {
}
}
-export async function clearOtpBinding(): Promise {
+export function readOtpBinding(): Promise {
+ return readBinding(OTP_NONCE_COOKIE);
+}
+
+export function readPasswordResetBinding(): Promise {
+ return readBinding(PWRESET_NONCE_COOKIE);
+}
+
+async function clearBinding(name: string): Promise {
const store = await cookies();
- store.delete(OTP_NONCE_COOKIE);
+ store.delete(name);
+}
+
+export function clearOtpBinding(): Promise {
+ return clearBinding(OTP_NONCE_COOKIE);
+}
+
+export function clearPasswordResetBinding(): Promise {
+ return clearBinding(PWRESET_NONCE_COOKIE);
}
diff --git a/src/server/auth/password-reset-actions.ts b/src/server/auth/password-reset-actions.ts
new file mode 100644
index 0000000..98b738c
--- /dev/null
+++ b/src/server/auth/password-reset-actions.ts
@@ -0,0 +1,189 @@
+"use server";
+
+import { signOut } from "@/server/auth/auth-config";
+import { confirmPasswordReset, requestPasswordResetCode } from "@/server/auth/backend-token-exchange";
+import { AUTH_ERROR_CODES } from "@/server/auth/contracts";
+import {
+ clearPasswordResetBinding,
+ normalizeEmail,
+ readPasswordResetBinding,
+ setPasswordResetBinding,
+} from "@/server/auth/otp-nonce-cookie";
+import { clearActiveSession } from "@/server/auth/session-token";
+import { isPasswordWithinPolicy } from "@/domain/auth/sign-in-method";
+
+/**
+ * Server actions behind the "forgot password" flow (RUK-289).
+ *
+ * Actions rather than BFF routes for the reason RUK-288 established: these
+ * write a cookie, and `/login` sits under `(public)`, which mounts neither
+ * React Query nor `sonner` — a `useMutation` there throws at runtime while
+ * `tsc` and the unit tests stay green.
+ */
+
+export interface PasswordResetActionResult {
+ /** An `AUTH_ERROR_CODES` value the client renders in place, or undefined. */
+ error?: string;
+ /** Set once the password is changed, so the sign-in form can confirm it. */
+ done?: boolean;
+}
+
+/** A backend failure's HTTP status, when it carried one. */
+function statusOf(error: unknown): number | undefined {
+ const status = (error as { status?: unknown } | null)?.status;
+ return typeof status === "number" ? status : undefined;
+}
+
+/**
+ * Whether the backend's body named a specific code.
+ *
+ * Reads the `code` FIELD rather than substring-matching the raw text: a
+ * whitespace difference in the encoder (`"code": "..."`) would defeat a
+ * substring match, and it would fail open into the generic collapse — showing
+ * sign-in recovery copy on a reset screen. Never reads `message`, which is
+ * prose and is not a contract.
+ */
+function codeIs(error: unknown, code: string): boolean {
+ const body = (error as { responseBody?: unknown } | null)?.responseBody;
+ if (typeof body !== "string") return false;
+ try {
+ return (JSON.parse(body) as { code?: unknown }).code === code;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Step one: ask the backend to mail a reset code, and bind it to this browser.
+ *
+ * The backend answers 202 for every outcome — unknown address, blocked user,
+ * even a malformed body — so this reports success identically in all of them.
+ * Anything else would turn the screen into an account-existence oracle.
+ *
+ * What it cannot promise, and the copy must not either: if the user has a live
+ * code with attempts left, the backend issues no new one and still answers 202.
+ * "We sent a code if that address is registered" stays true; "check your inbox
+ * for a new code" would not.
+ */
+export async function requestPasswordResetAction(email: string): Promise {
+ const trimmed = email.trim();
+ if (!trimmed) {
+ return { error: "invalid_email" };
+ }
+
+ try {
+ const { session_nonce: nonce } = await requestPasswordResetCode(trimmed);
+ await setPasswordResetBinding({ nonce, email: trimmed });
+ return {};
+ } catch (error) {
+ const status = statusOf(error);
+ // Logged because the user is shown one uniform state by design, which
+ // leaves an operator with nothing to diagnose from. Status only — never the
+ // address, which is the thing the uniform answer exists to protect.
+ console.error("[password-reset] request failed", { status });
+
+ if (status === 429) {
+ return { error: AUTH_ERROR_CODES.otpRateLimited };
+ }
+ // A dead or missing endpoint is a fact about the service, not about an
+ // account, so saying so plainly leaks nothing — and folding it into the
+ // anti-enumeration copy would tell every user their input was wrong during
+ // an outage.
+ //
+ // Discriminated the same way the confirm path does it rather than treating
+ // every non-429 as an outage: today this endpoint answers 202 or 429 and
+ // nothing else, but a future 4xx would otherwise be reported as "the
+ // service is down" to a user whose input was simply rejected.
+ if (status === undefined || status >= 500 || status === 404) {
+ return { error: AUTH_ERROR_CODES.passwordResetUnavailable };
+ }
+ return { error: "invalid_email" };
+ }
+}
+
+/**
+ * Step two: redeem the code, install the new password, and end the session.
+ *
+ * The length check runs BEFORE the request and is not optional politeness: the
+ * backend hides a policy violation inside the same 401 as a wrong code, so
+ * without it a user with a short password is told the code they just typed is
+ * wrong. It also spends no attempt, which is why its result must not clear the
+ * binding.
+ */
+export async function confirmPasswordResetAction(args: {
+ email: string;
+ code: string;
+ newPassword: string;
+}): Promise {
+ if (!isPasswordWithinPolicy(args.newPassword)) {
+ return { error: AUTH_ERROR_CODES.passwordPolicyViolation };
+ }
+
+ const binding = await readPasswordResetBinding();
+ if (!binding || binding.email !== normalizeEmail(args.email)) {
+ // No binding means this browser cannot prove it asked for the code, so the
+ // request would be refused anyway. Answered locally with the reset flow's
+ // own mismatch code — not sign-in's, whose copy sends the user somewhere
+ // they are not trying to go.
+ await clearPasswordResetBinding();
+ return { error: AUTH_ERROR_CODES.passwordResetSessionMismatch };
+ }
+
+ try {
+ await confirmPasswordReset({
+ email: binding.email,
+ code: args.code,
+ sessionNonce: binding.nonce,
+ newPassword: args.newPassword,
+ });
+ } catch (error) {
+ const status = statusOf(error);
+ // Status only. A binding was necessarily present — the function returns
+ // early without one — so logging that fact would be a constant dressed as a
+ // variable. The address and the code are never logged: they are what the
+ // uniform 202 exists to protect.
+ console.error("[password-reset] confirm failed", { status });
+
+ if (status === 429) {
+ return { error: AUTH_ERROR_CODES.otpRateLimited };
+ }
+ if (status === undefined || status >= 500 || status === 404) {
+ return { error: AUTH_ERROR_CODES.passwordResetUnavailable };
+ }
+ if (codeIs(error, "otp_session_mismatch")) {
+ await clearPasswordResetBinding();
+ return { error: AUTH_ERROR_CODES.passwordResetSessionMismatch };
+ }
+ // Everything else is the deliberate collapse: wrong code, expired,
+ // attempts exhausted. The binding is KEPT — attempts may remain, and
+ // discarding a still-usable code is worse than a retry.
+ return { error: AUTH_ERROR_CODES.passwordResetFailed };
+ }
+
+ // Past this point the password IS changed and every session is revoked. The
+ // teardown below can fail; the confirmation cannot be conditional on it.
+ await clearPasswordResetBinding();
+
+ try {
+ // Without this the NextAuth cookie outlives the backend session: `proxy.ts`
+ // still reads a session, `/login` bounces the user to `/`, and every
+ // request 401s on a dead token.
+ await signOut({ redirect: false });
+ await clearActiveSession();
+ } catch (error) {
+ // Status only, like the two log lines above. Logging the error OBJECT here
+ // would render `BackendAuthError.responseBody` — the backend's raw response
+ // text — and the refresh path's body is a token pair, so a failed teardown
+ // would write a live refresh token into the application log.
+ console.error("[password-reset] post-reset session teardown failed", {
+ status: statusOf(error),
+ });
+ }
+
+ return { done: true };
+}
+
+/** Abandons the reset flow so the user can start again with another address. */
+export async function abandonPasswordResetAction(): Promise {
+ await clearPasswordResetBinding();
+}
diff --git a/tests/contracts/contract-gaps.test.ts b/tests/contracts/contract-gaps.test.ts
index f245b10..96b0e3a 100644
--- a/tests/contracts/contract-gaps.test.ts
+++ b/tests/contracts/contract-gaps.test.ts
@@ -353,6 +353,23 @@ describe("registry — refuted claims stay refuted", () => {
// `null` is a VALUE — "not set". Absence would be the gap, and it is not.
expect("timezone" in fixture("me.json")).toBe(true);
});
+
+ it("`password_set` is NOT yet a key on /me (RUK-289 gap, still open)", () => {
+ // The mirror of the assertion above, and the only executable check the
+ // `password_set` registry row has.
+ //
+ // It is green today because the backend that serves this field is on an
+ // unmerged branch, so the recorded fixture predates it. It goes RED on the
+ // day someone re-records `me.json` against a backend that sends the field —
+ // which is exactly the day the gap closes, the registry row is owed
+ // deletion, and this assertion is owed deletion with it.
+ //
+ // Written this way round deliberately. The pass-through assertion in
+ // `me.contract.test.ts` cannot do this job: it compares the route's echo
+ // against the same fixture that fed the mock, so it is a tautology on key
+ // sets and stays green whatever the fixture contains.
+ expect("password_set" in fixture("me.json")).toBe(false);
+ });
});
describe("the registry file itself", () => {
diff --git a/tests/contracts/me-password.contract.test.ts b/tests/contracts/me-password.contract.test.ts
new file mode 100644
index 0000000..54bac27
--- /dev/null
+++ b/tests/contracts/me-password.contract.test.ts
@@ -0,0 +1,182 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Contract test — `POST /api/me/password` → backend `POST /api/v1/me/password`.
+ * RUK-289, SPEC §1.3, §3.3, §3.4.
+ *
+ * **Why there is no recorded fixture here, and why that does not excuse the
+ * test.** `scripts/refresh-fixtures.mjs` issues GET only and refuses any
+ * non-2xx, while this seam is a POST whose success is `204` with an empty body
+ * and whose interesting shapes are the error envelopes the recorder rejects
+ * (SPEC §7.3). So the contract policy's four questions are answered against a
+ * stubbed `fetch` rather than a fixture:
+ *
+ * 1. are the fields forwarded — including the two whose ABSENCE is meaningful?
+ * 2. does the response reach the client unchanged (204 stays 204)?
+ * 3. does a backend error stay an error, rather than degrading into success?
+ * 4. is the expectation independent of the thing it checks — the assertions
+ * read the request off the `fetch` stub, not off the arguments passed in.
+ *
+ * The seam matters more than most: SPEC §7.3 lists "a wrong `current_password`
+ * moving off 401" as a drift no automated check would catch, and this is the
+ * check that catches it. It is here rather than only in the unit suite because
+ * `npm run test:contracts` is what a reviewer reads as "the contracts are
+ * green", and a route absent from it is a route that claim does not cover.
+ */
+
+const readActiveSession = vi.fn();
+vi.mock("@/server/auth/session-token", () => ({
+ readActiveSession: () => readActiveSession(),
+}));
+vi.mock("@/server/backend/security/csrf", () => ({ isSameOriginRequest: () => true }));
+
+const { POST } = await import("@/app/api/me/password/route");
+
+const fetchMock = vi.fn();
+
+beforeEach(() => {
+ vi.stubGlobal("fetch", fetchMock);
+ fetchMock.mockReset();
+ process.env.MAINTMODE_API_BASE_URL = "http://backend.test/maintmode";
+ readActiveSession.mockResolvedValue({
+ accessToken: "access-1",
+ refreshToken: "refresh-1",
+ accessTokenExpiresAt: Date.now() + 600_000,
+ });
+});
+
+afterEach(() => vi.unstubAllGlobals());
+
+function backendAnswers(status: number, body = "") {
+ fetchMock.mockResolvedValue({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: `status ${status}`,
+ text: async () => body,
+ });
+}
+
+function post(body: unknown) {
+ return new Request("https://app.test/api/me/password", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+/** What actually went on the wire, read from the stub rather than the input. */
+function wireRequest() {
+ const [url, init] = fetchMock.mock.calls[0];
+ return { url: String(url), init, body: JSON.parse(init.body) as Record };
+}
+
+describe("me/password — request forwarding", () => {
+ it("posts to the auth base's password path with a bearer credential", async () => {
+ backendAnswers(204);
+
+ await POST(post({ current_password: "old", new_password: "a-long-enough-password" }));
+
+ const { url, init } = wireRequest();
+ expect(url).toContain("/api/v1/me/password");
+ expect(init.method).toBe("POST");
+ expect(init.headers.authorization).toBe("Bearer access-1");
+ });
+
+ it("forwards refresh_token, which is what keeps the caller signed in", async () => {
+ backendAnswers(204);
+
+ await POST(post({ current_password: "old", new_password: "a-long-enough-password" }));
+
+ // Dropping it makes the backend revoke every session including this one, so
+ // the operator is signed out by their own success (SPEC §1.3).
+ expect(wireRequest().body).toEqual({
+ current_password: "old",
+ new_password: "a-long-enough-password",
+ refresh_token: "refresh-1",
+ });
+ });
+
+ it("omits current_password as a KEY when the caller sent none", async () => {
+ backendAnswers(204);
+
+ await POST(post({ new_password: "a-long-enough-password" }));
+
+ // Absent and empty are different requests: the backend rejects the field
+ // outright for an account that has no password.
+ expect("current_password" in wireRequest().body).toBe(false);
+ });
+});
+
+describe("me/password — response pass-through", () => {
+ it("hands 204 to the client as 204 with no body", async () => {
+ backendAnswers(204);
+
+ const response = await POST(post({ new_password: "a-long-enough-password" }));
+
+ expect(response.status).toBe(204);
+ await expect(response.text()).resolves.toBe("");
+ });
+});
+
+describe("me/password — a backend error stays an error", () => {
+ // The degradation this guards against is not an empty list but a false
+ // success: reporting 2xx for a password the backend never changed.
+ it("does not turn a backend failure into a success", async () => {
+ for (const status of [400, 401, 403, 500, 503]) {
+ fetchMock.mockReset();
+ backendAnswers(status, '{"code":"unauthorized"}');
+
+ const response = await POST(post({ new_password: "a-long-enough-password" }));
+
+ expect(response.status).not.toBe(204);
+ expect(response.status).toBeGreaterThanOrEqual(400);
+ }
+ });
+
+ // SPEC §7.3's drift #2, pinned. If the backend moves a wrong current password
+ // off 401, this fails — and without it the change would reach the generic
+ // mapper, which answers AUTH_REQUIRED and signs the operator out with no
+ // message on their most common mistake.
+ it("keeps a wrong current password renderable rather than signing the user out", async () => {
+ backendAnswers(401, '{"code":"unauthorized","message":"invalid credentials"}');
+
+ const response = await POST(post({ current_password: "wrong", new_password: "a-long-enough-password" }));
+
+ expect(response.status).toBe(422);
+ await expect(response.json()).resolves.toMatchObject({ code: "WRONG_CURRENT_PASSWORD" });
+ });
+
+ it("distinguishes a stale refresh token, which changed nothing", async () => {
+ backendAnswers(401, '{"code":"unauthorized"}');
+
+ const response = await POST(post({ new_password: "a-long-enough-password" }));
+
+ expect(response.status).toBe(409);
+ await expect(response.json()).resolves.toMatchObject({ code: "SESSION_STALE" });
+ });
+
+ it("surfaces the backend's message field on a 400, not its envelope", async () => {
+ backendAnswers(
+ 400,
+ '{"code":"invalid request","message":"validation error: password does not meet the length policy"}',
+ );
+
+ const response = await POST(post({ new_password: "a-long-enough-password" }));
+
+ expect(response.status).toBe(400);
+ // The message, not the whole body: echoing the envelope would put raw JSON
+ // in the operator's form, and would paste a proxy's HTML page in whole.
+ // Read for display only — nothing branches on it.
+ await expect(response.json()).resolves.toMatchObject({
+ error: "validation error: password does not meet the length policy",
+ });
+ });
+
+ it("answers an unreachable backend as an outage", async () => {
+ fetchMock.mockRejectedValue(new Error("connect ECONNREFUSED"));
+
+ const response = await POST(post({ new_password: "a-long-enough-password" }));
+
+ expect(response.status).toBe(503);
+ });
+});