Files
HealthLog/tests/integration/auth-password-change.test.ts
T
Marc-André Bombeck 7b5bc130d8 fix(auth): require a fresh proof before a second factor is added
A browser session on its own could start TOTP enrollment or register a
security key. Combined with a pending secret counting as a factor, that let
a stolen session give itself a second factor, register a passkey with it,
and keep access after the owner changed the password.

TOTP setup and security-key registration now pass on the cookie path only
when the session signed in or completed a second factor in the last five
minutes, or when the body carries a proof in the step-up shape (password,
a confirmed authenticator code, or a passkey or security-key assertion).
The settings cards ask for that proof when the server answers
auth.reproof.required and retry the same call with it. The Bearer path is
unchanged: its elevation already is the proof.

Every route that checks a password or a factor as a re-proof now uses one
verifier and one per-account budget of five attempts per fifteen minutes,
shared with the step-up mint, and records each refused proof as
auth.mfa.failed. Passkey enrollment and password change had neither the
limit (passkey enrollment) nor the audit row (both). A structural test
freezes the set of credential-verifying routes and requires a throttle and
an audit call in each.
2026-09-26 15:55:57 +02:00

307 lines
10 KiB
TypeScript

/**
* v1.4.35 — integration coverage for `POST /api/auth/password`.
*
* The v1.4.34.3 token-wipe directive routed every password rotation
* through `destroyAllSessions(userId)`, which now revokes web sessions,
* API tokens, and refresh tokens in a single transaction. The route had
* zero route-level coverage despite that change — F-1 in the test
* coverage audit. This file pins the contract end-to-end against real
* Postgres:
*
* - happy path with a strong new password returns 200, rotates the
* session cookie, and wipes every sibling credential on the user
* - wrong current password returns 401 and leaves credentials intact
* - identical current + new password returns 422
* - weak new password returns 422 and does not touch the hash
* - a stale session cookie carried by a second request returns 401
* on a session-protected endpoint after the rotation
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { hashToken } from "@/lib/auth/hmac";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const STRONG_CURRENT = "OldStrongP@ssw0rd!123abc";
const STRONG_NEW = "NewStrongP@ssw0rd!456xyz";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedUserWithSession(): Promise<{
userId: string;
sessionId: string;
}> {
const { hashPassword } = await import("@/lib/auth/password");
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "pw-change",
email: "pw-change@example.test",
role: "USER",
passwordHash: await hashPassword(STRONG_CURRENT),
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
return { userId: user.id, sessionId: session.id };
}
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/auth/password (real Postgres)", () => {
it("rotates the session cookie and wipes every sibling credential", async () => {
const { userId, sessionId } = await seedUserWithSession();
const prisma = getPrismaClient();
// Seed a sibling web session, a long-lived API token, and a refresh
// token — the rotation must invalidate all three.
const siblingSession = await prisma.session.create({
data: {
userId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const apiToken = await prisma.apiToken.create({
data: {
userId,
name: "iOS app",
tokenHash: "hashed-token-1",
revoked: false,
},
});
const refreshToken = await prisma.refreshToken.create({
data: {
userId,
tokenHash: "hashed-refresh-1",
expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000),
revokedAt: null,
},
});
const { POST } = await import("@/app/api/auth/password/route");
const res = await POST(
makeRequest({
currentPassword: STRONG_CURRENT,
newPassword: STRONG_NEW,
confirmPassword: STRONG_NEW,
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { changed: boolean } };
expect(body.data.changed).toBe(true);
// Every prior web session is gone — including the one we logged in
// with — and a brand-new session row exists with the cookie pointing
// at it (the route calls createSession after destroyAllSessions).
const remainingSessions = await prisma.session.findMany({
where: { userId },
});
expect(remainingSessions).toHaveLength(1);
expect(remainingSessions[0].id).not.toBe(sessionId);
expect(remainingSessions[0].id).not.toBe(siblingSession.id);
// v1.30.32 — the cookie carries the session secret, not the row id, so
// bind them through the stored hash instead. Strictly stronger than the
// old identity check: it proves the cookie actually authenticates the
// surviving row, and that the row id never leaves the server.
const rotatedCookie = cookieJar.get("healthlog_session")!;
expect(rotatedCookie).not.toBe(remainingSessions[0].id);
expect(remainingSessions[0].tokenHash).toBe(hashToken(rotatedCookie));
// The API token is flipped to revoked rather than deleted so the
// audit trail survives.
const refreshedToken = await prisma.apiToken.findUnique({
where: { id: apiToken.id },
});
expect(refreshedToken?.revoked).toBe(true);
const refreshedRefresh = await prisma.refreshToken.findUnique({
where: { id: refreshToken.id },
});
expect(refreshedRefresh?.revokedAt).not.toBeNull();
// The password hash on disk is actually a fresh hash for the new
// value — verifyPassword roundtrips.
const { verifyPassword } = await import("@/lib/auth/password");
const dbUser = await prisma.user.findUnique({ where: { id: userId } });
expect(dbUser?.passwordHash).toBeTruthy();
expect(await verifyPassword(dbUser!.passwordHash!, STRONG_NEW)).toBe(true);
// An audit row was written for the change action.
const audit = await prisma.auditLog.findFirst({
where: { userId, action: "auth.password.change" },
});
expect(audit).not.toBeNull();
});
it("returns 401 and leaves credentials intact on a wrong current password", async () => {
const { userId } = await seedUserWithSession();
const prisma = getPrismaClient();
const apiToken = await prisma.apiToken.create({
data: {
userId,
name: "iOS app",
tokenHash: "hashed-token-2",
revoked: false,
},
});
const { POST } = await import("@/app/api/auth/password/route");
const res = await POST(
makeRequest({
currentPassword: "WrongPasswordValue!1",
newPassword: STRONG_NEW,
confirmPassword: STRONG_NEW,
}),
);
expect(res.status).toBe(401);
// Sibling credentials are intact.
const stillActive = await prisma.apiToken.findUnique({
where: { id: apiToken.id },
});
expect(stillActive?.revoked).toBe(false);
// The password hash is unchanged — the old password still verifies.
const { verifyPassword } = await import("@/lib/auth/password");
const dbUser = await prisma.user.findUnique({ where: { id: userId } });
expect(await verifyPassword(dbUser!.passwordHash!, STRONG_CURRENT)).toBe(
true,
);
});
it("audits a wrong current password and shares the re-proof ceiling", async () => {
const { userId } = await seedUserWithSession();
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/auth/password/route");
const statuses: number[] = [];
for (let i = 0; i < 6; i++) {
const res = await POST(
makeRequest({
currentPassword: `WrongPasswordValue!${i}`,
newPassword: STRONG_NEW,
confirmPassword: STRONG_NEW,
}),
);
statuses.push(res.status);
}
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
expect(
await prisma.auditLog.count({
where: { userId, action: "auth.mfa.failed" },
}),
).toBe(5);
});
it("returns 422 when the new password matches the current password", async () => {
await seedUserWithSession();
const { POST } = await import("@/app/api/auth/password/route");
const res = await POST(
makeRequest({
currentPassword: STRONG_CURRENT,
newPassword: STRONG_CURRENT,
confirmPassword: STRONG_CURRENT,
}),
);
expect(res.status).toBe(422);
});
it("returns 422 on a weak new password and does not touch the hash", async () => {
const { userId } = await seedUserWithSession();
const prisma = getPrismaClient();
const before = await prisma.user.findUnique({ where: { id: userId } });
const beforeHash = before!.passwordHash;
const { POST } = await import("@/app/api/auth/password/route");
// 12 chars but trivially weak — zxcvbn scores 0/1.
const res = await POST(
makeRequest({
currentPassword: STRONG_CURRENT,
newPassword: "password1234",
confirmPassword: "password1234",
}),
);
expect(res.status).toBe(422);
const after = await prisma.user.findUnique({ where: { id: userId } });
expect(after!.passwordHash).toBe(beforeHash);
});
it("stale cookie carried in a second request lands unauthenticated on a protected endpoint", async () => {
const { sessionId } = await seedUserWithSession();
const { POST } = await import("@/app/api/auth/password/route");
const rotateRes = await POST(
makeRequest({
currentPassword: STRONG_CURRENT,
newPassword: STRONG_NEW,
confirmPassword: STRONG_NEW,
}),
);
expect(rotateRes.status).toBe(200);
// Reset the cookie jar to the stolen / stale cookie value the
// attacker would still hold after the legitimate user changed
// their password. The route handler must see no session.
cookieJar.clear();
cookieJar.set("healthlog_session", sessionId);
// Hit a session-protected endpoint — `apiHandler` raises HttpError(401)
// which the wrapper converts to a 401 JSON response.
const { GET } = await import("@/app/api/auth/me/route");
const meRes = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me"),
);
expect(meRes.status).toBe(401);
});
});