Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/contract-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,36 @@ case") is exactly the defect RUK-258 removed.

---

### `password_set` on /me — read by the frontend, not yet on the wire

| Field | Where it is needed | What is on the wire | Ticket | Stub |
| -------------- | --------------------------------------------------- | --------------------------------------- | ------- | ---------------- |
| `password_set` | profile password card, `/set-password` (RUK-289 UI) | the key is absent from the recorded 200 | RUK-289 | none — see below |

The backend that serves this field is written but sits on an unmerged branch, so
the recorded fixture predates it and the field arrives as `undefined` until that
branch ships. The frontend types it optional and treats `undefined` as "unknown"
rather than as `false`: the two mean different things, and collapsing them would
draw a set-password form for every operator, whose every save is then a 400.

**There is no mapper stub, because there is no mapper.** `/api/me` is an
unnarrowed pass-through (`NextResponse.json(data)`), so nothing substitutes a
placeholder value the way `notify-channel-mapper.ts` does for `updated_at` — the
key is simply not there. The Stub column has nothing to point at.

**This row is prose, in the sense the `updated_at` row above means it** — the
Class-B stub-scanner is pinned to `maintenance-mapper.ts` and cannot cover it.
Unlike that row, however, this one is not unprotected: `contract-gaps.test.ts`
asserts that `password_set` is **absent** from `me.json`, which is green today
and goes red on the day the fixture is re-recorded against a backend that sends
it. That is the day this row is owed deletion, along with the assertion itself.

The pass-through assertion in `me.contract.test.ts` deliberately does NOT serve
this purpose: it compares the route's echo against the same fixture that fed its
mock, so it is a tautology on key sets and stays green whatever the fixture
holds. It proves the route narrows nothing; it says nothing about which fields
exist.

## Class B′ — the backend sends it, the frontend does not read it

The opposite direction. It does not break a screen, but it means data the
Expand Down
11 changes: 11 additions & 0 deletions src/app/(app)/set-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SetPasswordPage } from "@/features/settings/set-password-page";

/**
* Deliberately NOT wrapped in `AppShell`: this is a single-purpose page reached
* from the profile, and the app chrome around one form is noise. It sits under
* `(app)` for the session and the provider tree, which the form's mutation
* needs and which `(public)` does not mount.
*/
export default function Page() {
return <SetPasswordPage />;
}
20 changes: 20 additions & 0 deletions src/app/(public)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import {
credentialsSignInAction,
requestOtpAction,
} from "@/server/auth/built-in-sign-in-actions";
import {
abandonPasswordResetAction,
confirmPasswordResetAction,
requestPasswordResetAction,
} from "@/server/auth/password-reset-actions";
import { readPasswordResetBinding } from "@/server/auth/otp-nonce-cookie";
import { signIn } from "@/server/auth/auth-config";
import { safeNext } from "@/server/auth/safe-next";

Expand Down Expand Up @@ -41,6 +47,16 @@ export default async function Page({
// break-glass fallback rather than a 500.
const providers = await resolveAuthProviders();

// A password reset spans an email round-trip, so the user leaves this tab and
// comes back — reload is the flow's primary re-entry, not an edge case. The
// cookie is httpOnly and readable only here, so the step has to be resolved
// server-side and handed down.
//
// ONLY the address crosses. The nonce stays in the cookie: it is httpOnly
// precisely so browser JavaScript cannot read the binding, and passing the
// whole binding to a client component would give that away for nothing.
const resetBinding = await readPasswordResetBinding();

/**
* The built-in methods post through server actions rather than a client
* `fetch`: NextAuth attaches its CSRF token only when `signIn` runs on the
Expand All @@ -66,6 +82,10 @@ export default async function Page({
otpSignInAction={otpSignInAction}
passwordSignInAction={passwordSignInAction}
changeEmailAction={changeEmailAction}
requestPasswordResetAction={requestPasswordResetAction}
confirmPasswordResetAction={confirmPasswordResetAction}
abandonPasswordResetAction={abandonPasswordResetAction}
resetInProgressEmail={resetBinding?.email}
/>
);
}
184 changes: 184 additions & 0 deletions src/app/api/me/password/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { POST } from "@/app/api/me/password/route";

const readActiveSession = vi.fn();
const changeBackendPassword = vi.fn();
const isSameOriginRequest = vi.fn(() => true);

vi.mock("@/server/auth/session-token", () => ({
readActiveSession: () => readActiveSession(),
}));
vi.mock("@/server/auth/backend-token-exchange", () => ({
changeBackendPassword: (...args: unknown[]) => changeBackendPassword(...args),
}));
vi.mock("@/server/backend/security/csrf", () => ({
isSameOriginRequest: () => isSameOriginRequest(),
}));

function post(body: unknown) {
return new Request("https://app.test/api/me/password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}

beforeEach(() => {
vi.clearAllMocks();
isSameOriginRequest.mockReturnValue(true);
readActiveSession.mockResolvedValue({
accessToken: "access-1",
refreshToken: "refresh-1",
accessTokenExpiresAt: Date.now() + 600_000,
});
});

describe("the session is read once and sent verbatim", () => {
// AC-6. `readActiveSession` rotates the refresh token when the access token
// is near expiry, so a second read can hand back a different token — and the
// backend answers a superseded `refresh_token` with a 401 and NO change.
it("reads the session exactly once per request", async () => {
changeBackendPassword.mockResolvedValue({ ok: true });

await POST(post({ current_password: "old-password", new_password: "new-password-here" }));

expect(readActiveSession).toHaveBeenCalledTimes(1);
});

// Without this the backend revokes EVERY session including the caller's, and
// the user is signed out by their own success.
it("sends the refresh token so the caller's own session survives", async () => {
changeBackendPassword.mockResolvedValue({ ok: true });

await POST(post({ current_password: "old-password", new_password: "new-password-here" }));

expect(changeBackendPassword).toHaveBeenCalledWith(
expect.objectContaining({ accessToken: "access-1", refreshToken: "refresh-1" }),
);
});

it("never retries the mutation", async () => {
changeBackendPassword.mockResolvedValue({ ok: false, kind: "session-stale" });

await POST(post({ new_password: "new-password-here" }));

// One call, however it failed. A retry would send a token the refresh had
// already replaced, and the password would silently stay unchanged.
expect(changeBackendPassword).toHaveBeenCalledTimes(1);
});

it("omits current_password entirely when the account has none", async () => {
changeBackendPassword.mockResolvedValue({ ok: true });

await POST(post({ new_password: "new-password-here" }));

// Absent, not empty: the backend rejects the field outright for an account
// with no password, so "" and undefined are different requests.
expect(changeBackendPassword).toHaveBeenCalledWith(
expect.objectContaining({ currentPassword: undefined }),
);
});
});

describe("no failure may answer 401", () => {
// AC-7, and the reason this route exists at all. `bffFetch` navigates to
// /login on any 401 carrying AUTH_REQUIRED, behind a never-resolving promise
// — so answering a wrong current password with a 401 signs the operator out
// on their most common mistake, with no message.
it("answers a wrong current password with a renderable status", async () => {
changeBackendPassword.mockResolvedValue({ ok: false, kind: "wrong-current-password" });

const response = await POST(post({ current_password: "wrong", new_password: "new-password-here" }));

expect(response.status).toBe(422);
expect(response.status).not.toBe(401);
await expect(response.json()).resolves.toMatchObject({ code: "WRONG_CURRENT_PASSWORD" });
});

it("distinguishes a stale session from a wrong password", async () => {
changeBackendPassword.mockResolvedValue({ ok: false, kind: "session-stale" });

const response = await POST(post({ new_password: "new-password-here" }));

// Different meaning, different copy: nothing the user typed was wrong and
// the password was NOT changed.
expect(response.status).toBe(409);
await expect(response.json()).resolves.toMatchObject({ code: "SESSION_STALE" });
});

it("passes a backend 400 through without parsing its prose", async () => {
changeBackendPassword.mockResolvedValue({
ok: false,
kind: "rejected",
message: "validation error: the current password is required",
});

const response = await POST(post({ new_password: "new-password-here" }));

expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ code: "INVALID_REQUEST" });
});

it("answers an outage as an outage", async () => {
changeBackendPassword.mockResolvedValue({ ok: false, kind: "unavailable" });

const response = await POST(post({ new_password: "new-password-here" }));

expect(response.status).toBe(503);
});
});

describe("the guards every mutating route here carries", () => {
it("refuses a cross-origin request", async () => {
isSameOriginRequest.mockReturnValue(false);

const response = await POST(post({ new_password: "new-password-here" }));

expect(response.status).toBe(403);
expect(changeBackendPassword).not.toHaveBeenCalled();
});

it("answers 401 when there is genuinely no session", async () => {
// The one legitimate 401: no session at all, which IS the redirect case.
readActiveSession.mockResolvedValue(null);

const response = await POST(post({ new_password: "new-password-here" }));

expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({ code: "AUTH_REQUIRED" });
});

it("answers a malformed body with a 400, not a 500", async () => {
const malformed = new Request("https://app.test/api/me/password", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not json",
});

const response = await POST(malformed);

// An uncaught throw here would be a Next 500 whose body is not the envelope
// `bffFetch` parses, so the card would surface framework noise.
expect(response.status).toBe(400);
expect(changeBackendPassword).not.toHaveBeenCalled();
});

it("rejects a request with no new password", async () => {
const response = await POST(post({ current_password: "old-password" }));

expect(response.status).toBe(400);
expect(changeBackendPassword).not.toHaveBeenCalled();
});
});

describe("success", () => {
it("answers 204 with no body", async () => {
changeBackendPassword.mockResolvedValue({ ok: true });

const response = await POST(post({ new_password: "new-password-here" }));

expect(response.status).toBe(204);
await expect(response.text()).resolves.toBe("");
});
});
112 changes: 112 additions & 0 deletions src/app/api/me/password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { NextResponse } from "next/server";

import { changeBackendPassword } from "@/server/auth/backend-token-exchange";
import { readActiveSession } from "@/server/auth/session-token";
import { routeErrorResponse } from "@/server/backend/errors/bff-error";
import { readJsonBody } from "@/server/backend/http/read-json-body";
import { isSameOriginRequest } from "@/server/backend/security/csrf";

interface ChangePasswordBody {
/** Omitted when the account has no password yet — the backend rejects it then. */
current_password?: string;
new_password?: string;
}

/**
* POST /api/me/password — proxy to backend `POST /api/v1/me/password`.
*
* This route deliberately does NOT use `authenticatedBackendRequest`, and does
* not let a backend 401 reach `routeErrorResponse`. Both defaults are wrong
* here in the same direction:
*
* - the wrapper refreshes and RETRIES the mutation on a 401, but this endpoint
* takes a `refresh_token` in its body, so the retry would send the token the
* refresh just replaced — the backend answers 401 again and the password is
* never changed;
* - `routeErrorResponse` maps every `BackendUnauthorizedError` to
* `AUTH_REQUIRED`, which `bffFetch` answers by navigating to `/login` behind
* a never-resolving promise. A wrong current password is a 401, so the most
* common mistake on this form would sign the operator out with no message,
* indistinguishable from an expired session.
*
* So: read the session ONCE, send that exact token, never retry, and answer
* every failure with a non-401 status the card can render in place.
*/
export async function POST(request: Request) {
if (!isSameOriginRequest(request)) {
return NextResponse.json(
{ error: "Cross-origin requests are not allowed", code: "FORBIDDEN" },
{ status: 403 },
);
}

// Read once. `readActiveSession` rotates the refresh token when the access
// token is close to expiry, so calling it twice — or letting anything else
// refresh between the read and the send — puts a superseded token in the body.
const session = await readActiveSession();
if (!session) {
return NextResponse.json({ error: "Sign-in is required", code: "AUTH_REQUIRED" }, { status: 401 });
}

// Caught rather than left to propagate: `readJsonBody` throws a
// `BffValidationError` on malformed JSON, and an uncaught throw out of a route
// handler is a Next 500 whose body is not the `{error, code}` envelope
// `bffFetch` parses — the card would print framework noise into the form. The
// catch is scoped to the parse alone, so the deliberate hand-rolled handling
// of the backend's own failures below still bypasses the generic mapper.
let body: ChangePasswordBody | undefined;
try {
body = await readJsonBody<ChangePasswordBody>(request);
} catch (error) {
return routeErrorResponse(error);
}

const newPassword = body?.new_password;
if (typeof newPassword !== "string" || !newPassword) {
return NextResponse.json(
{ error: "A new password is required", code: "INVALID_REQUEST" },
{ status: 400 },
);
}

const outcome = await changeBackendPassword({
accessToken: session.accessToken,
currentPassword: body?.current_password || undefined,
newPassword,
// Keeps THIS session alive while the backend revokes the others. Without
// it the backend revokes every session including the caller's, and the user
// is signed out by their own success.
refreshToken: session.refreshToken,
});

if (outcome.ok) {
return new NextResponse(null, { status: 204 });
}

switch (outcome.kind) {
case "wrong-current-password":
// 422, not 401: the status is what decides whether `bffFetch` navigates
// away, and this is a field the user can correct in place.
return NextResponse.json(
{ error: "That current password isn't right", code: "WRONG_CURRENT_PASSWORD" },
{ status: 422 },
);
case "session-stale":
// Also not a 401, for the same mechanical reason — but the meaning is
// different and so is the copy: nothing the user typed was wrong, and
// the password was NOT changed.
return NextResponse.json(
{ error: "Your session expired before the change was saved", code: "SESSION_STALE" },
{ status: 409 },
);
case "rejected":
// The backend's own message, passed through as an opaque string. Never
// parsed: its three 400s share one code and differ only in prose.
return NextResponse.json({ error: outcome.message, code: "INVALID_REQUEST" }, { status: 400 });
default:
return NextResponse.json(
{ error: "Password change is unavailable right now", code: "BACKEND_UNAVAILABLE" },
{ status: 503 },
);
}
}
Loading
Loading