mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The cookie was the Session row's cuid. cuid is built for collision- resistant identity across distributed writers, not for unguessability — it encodes a millisecond timestamp and a process-local counter and never touches a CSPRNG. A session cookie is a bearer credential and has to be unguessable, so it now carries 32 random bytes and only the HMAC of that value is stored, matching how api_tokens and refresh_tokens already hold their secrets. Compatibility is passive, and deliberately so. A backfill cannot help: the cookie already sitting in a browser is the cuid, and nothing we generate server-side can be pushed into it — backfilling would sign everyone out at deploy. Upgrading a row in place on first use loses to the parallel requests a single page load fires: each mints its own secret, one write wins, and the losers cannot re-emit the winner's secret because only a hash is stored, so the browser can end up holding a value the row does not honour. Instead, rows minted before the column keep a NULL token_hash and stay resolvable by id, and are denied the sliding-expiry extension so they drain within the 30-day session lifetime. Every login from this release forward is on a secret at once, and an id stops authenticating the moment its row holds one. Note for operators: API_TOKEN_HMAC_KEY now also keys session cookies, so rotating it signs browser sessions out as well as invalidating tokens. It is already Core-required, so no deployment gains a new prerequisite.
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const cookieState = vi.hoisted(() => ({
|
|
sessionId: undefined as string | undefined,
|
|
delete: vi.fn(),
|
|
set: vi.fn(),
|
|
}));
|
|
|
|
interface SessionRow {
|
|
id: string;
|
|
userId: string;
|
|
tokenHash: string | null;
|
|
expiresAt: Date;
|
|
lastActiveAt: Date | null;
|
|
user: { id: string; locale?: string | null };
|
|
}
|
|
|
|
const store = vi.hoisted(() => ({ sessions: [] as unknown[] }));
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
session: {
|
|
// Dispatches on the `where` shape so the two resolution paths — by
|
|
// secret hash and by primary key — stay distinguishable in assertions.
|
|
findUnique: vi.fn(
|
|
async ({ where }: { where: { id?: string; tokenHash?: string } }) => {
|
|
const rows = store.sessions as SessionRow[];
|
|
if (where.tokenHash !== undefined) {
|
|
return rows.find((r) => r.tokenHash === where.tokenHash) ?? null;
|
|
}
|
|
return rows.find((r) => r.id === where.id) ?? null;
|
|
},
|
|
),
|
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
const row = {
|
|
id: `sess_cuid_${(store.sessions as SessionRow[]).length + 1}`,
|
|
lastActiveAt: null,
|
|
user: { id: data.userId as string },
|
|
...data,
|
|
} as unknown as SessionRow;
|
|
(store.sessions as SessionRow[]).push(row);
|
|
return row;
|
|
}),
|
|
delete: vi.fn(),
|
|
deleteMany: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
apiToken: { updateMany: vi.fn() },
|
|
refreshToken: { updateMany: vi.fn() },
|
|
trustedDevice: { deleteMany: vi.fn() },
|
|
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
getEvent: () => undefined,
|
|
}));
|
|
|
|
// Deterministic stand-in for the HMAC so tests assert the *shape* of the
|
|
// stored value rather than re-deriving a keyed digest.
|
|
vi.mock("@/lib/auth/hmac", () => ({
|
|
hashToken: (raw: string) => `hash:${raw}`,
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) =>
|
|
name === "healthlog_session" && cookieState.sessionId
|
|
? { value: cookieState.sessionId }
|
|
: undefined,
|
|
set: cookieState.set,
|
|
delete: cookieState.delete,
|
|
})),
|
|
}));
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
getSession,
|
|
createSession,
|
|
destroyAllSessions,
|
|
destroySession,
|
|
} from "../session";
|
|
|
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
function seed(row: Partial<SessionRow> & { id: string }): SessionRow {
|
|
const full: SessionRow = {
|
|
userId: "user-1",
|
|
tokenHash: null,
|
|
expiresAt: new Date(Date.now() + THIRTY_DAYS_MS),
|
|
lastActiveAt: new Date(),
|
|
user: { id: row.userId ?? "user-1" },
|
|
...row,
|
|
} as SessionRow;
|
|
(store.sessions as SessionRow[]).push(full);
|
|
return full;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
store.sessions = [];
|
|
cookieState.sessionId = "sess-expired";
|
|
// `clearAllMocks` drops recorded calls but keeps implementations, so a
|
|
// rejection staged by one test would otherwise leak into the next. Re-arm
|
|
// the write mocks with a benign default each time.
|
|
vi.mocked(prisma.session.delete).mockResolvedValue({} as never);
|
|
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 1 } as never);
|
|
vi.mocked(prisma.session.update).mockResolvedValue({} as never);
|
|
});
|
|
|
|
describe("createSession", () => {
|
|
it("puts a CSPRNG secret in the cookie, never the row's primary key", async () => {
|
|
cookieState.sessionId = undefined;
|
|
|
|
await createSession("user-1", false);
|
|
|
|
const row = (store.sessions as SessionRow[])[0];
|
|
const cookieValue = cookieState.set.mock.calls.find(
|
|
(c) => c[0] === "healthlog_session",
|
|
)![1] as string;
|
|
|
|
// A cuid is collision-resistant, not unguessable. The cookie must not be
|
|
// the primary key under any encoding.
|
|
expect(cookieValue).not.toBe(row.id);
|
|
expect(cookieValue).not.toContain(row.id);
|
|
// 32 bytes of randomness, hex-encoded, behind a greppable prefix.
|
|
expect(cookieValue).toMatch(/^hls_[0-9a-f]{64}$/);
|
|
// Only the hash is persisted — a table dump yields no usable cookie.
|
|
expect(row.tokenHash).toBe(`hash:${cookieValue}`);
|
|
expect(row.tokenHash).not.toContain(row.id);
|
|
});
|
|
|
|
it("mints a distinct secret per session", async () => {
|
|
cookieState.sessionId = undefined;
|
|
await createSession("user-1", false);
|
|
await createSession("user-1", false);
|
|
|
|
const [first, second] = cookieState.set.mock.calls
|
|
.filter((c) => c[0] === "healthlog_session")
|
|
.map((c) => c[1] as string);
|
|
|
|
expect(first).not.toBe(second);
|
|
});
|
|
});
|
|
|
|
describe("getSession — cookie resolution", () => {
|
|
it("resolves a modern cookie by its hash, not by primary key", async () => {
|
|
const row = seed({ id: "sess_cuid_1", tokenHash: "hash:hls_abc" });
|
|
cookieState.sessionId = "hls_abc";
|
|
|
|
const result = await getSession();
|
|
|
|
expect(result?.session.id).toBe(row.id);
|
|
// The lookup went through the hash column.
|
|
expect(prisma.session.findUnique).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { tokenHash: "hash:hls_abc" } }),
|
|
);
|
|
});
|
|
|
|
it("refuses a modern-shaped cookie whose hash matches nothing", async () => {
|
|
seed({ id: "sess_cuid_1", tokenHash: "hash:hls_real" });
|
|
cookieState.sessionId = "hls_forged";
|
|
|
|
await expect(getSession()).resolves.toBeNull();
|
|
});
|
|
|
|
it("still accepts a pre-upgrade cookie carrying the row id (no forced logout)", async () => {
|
|
// The compatibility path. A session minted before the secret existed has
|
|
// a NULL token_hash and the browser holds its cuid; the deploy must not
|
|
// sign that user out.
|
|
const row = seed({ id: "sess_legacy", tokenHash: null });
|
|
cookieState.sessionId = "sess_legacy";
|
|
|
|
const result = await getSession();
|
|
|
|
expect(result?.session.id).toBe(row.id);
|
|
expect(result?.user.id).toBe("user-1");
|
|
});
|
|
|
|
it("retires the row id once that row holds a secret", async () => {
|
|
// Same cuid, but the row has been issued a secret. The id is an internal
|
|
// identifier again and must not authenticate anything — otherwise the
|
|
// guessable-credential surface never actually closes.
|
|
seed({ id: "sess_upgraded", tokenHash: "hash:hls_xyz" });
|
|
cookieState.sessionId = "sess_upgraded";
|
|
|
|
await expect(getSession()).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("getSession — sliding expiry", () => {
|
|
it("extends a modern session and re-emits the same secret", async () => {
|
|
seed({
|
|
id: "sess_cuid_1",
|
|
tokenHash: "hash:hls_abc",
|
|
expiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
|
|
});
|
|
cookieState.sessionId = "hls_abc";
|
|
|
|
await getSession();
|
|
|
|
expect(prisma.session.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { id: "sess_cuid_1" } }),
|
|
);
|
|
const cookieValue = cookieState.set.mock.calls.find(
|
|
(c) => c[0] === "healthlog_session",
|
|
)![1];
|
|
expect(cookieValue).toBe("hls_abc");
|
|
});
|
|
|
|
it("withholds the extension from a legacy session so the id path drains", async () => {
|
|
// A legacy row must not renew itself indefinitely, or an active user could
|
|
// ride a cuid-as-credential session forever. Capped at the expiry it
|
|
// already carries, the path is gone within the session lifetime.
|
|
seed({
|
|
id: "sess_legacy",
|
|
tokenHash: null,
|
|
expiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
|
|
});
|
|
cookieState.sessionId = "sess_legacy";
|
|
|
|
const result = await getSession();
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(prisma.session.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getSession", () => {
|
|
it("swallows expired-session delete races and clears cookies", async () => {
|
|
seed({
|
|
id: "sess-expired",
|
|
tokenHash: null,
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
});
|
|
vi.mocked(prisma.session.deleteMany).mockRejectedValue(
|
|
new Error("already deleted") as never,
|
|
);
|
|
|
|
await expect(getSession()).resolves.toBeNull();
|
|
|
|
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
|
where: { id: "sess-expired" },
|
|
});
|
|
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
|
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
|
});
|
|
});
|
|
|
|
describe("destroySession", () => {
|
|
it("deletes the row the secret resolves to, not the cookie value", async () => {
|
|
// The cookie is no longer the primary key, so a logout that deletes by
|
|
// cookie value would no-op and leave the session alive.
|
|
seed({ id: "sess_cuid_1", tokenHash: "hash:hls_abc" });
|
|
cookieState.sessionId = "hls_abc";
|
|
|
|
await destroySession();
|
|
|
|
expect(prisma.session.delete).toHaveBeenCalledWith({
|
|
where: { id: "sess_cuid_1" },
|
|
});
|
|
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
|
});
|
|
|
|
it("logs a legacy cookie out too", async () => {
|
|
seed({ id: "sess_legacy", tokenHash: null });
|
|
cookieState.sessionId = "sess_legacy";
|
|
|
|
await destroySession();
|
|
|
|
expect(prisma.session.delete).toHaveBeenCalledWith({
|
|
where: { id: "sess_legacy" },
|
|
});
|
|
});
|
|
|
|
it("treats an already-deleted session row (P2025) as an idempotent logout", async () => {
|
|
seed({ id: "sess-gone", tokenHash: null });
|
|
cookieState.sessionId = "sess-gone";
|
|
vi.mocked(prisma.session.delete).mockRejectedValue(
|
|
Object.assign(new Error("record not found"), { code: "P2025" }) as never,
|
|
);
|
|
|
|
await expect(destroySession()).resolves.toBeUndefined();
|
|
|
|
expect(prisma.session.delete).toHaveBeenCalledWith({
|
|
where: { id: "sess-gone" },
|
|
});
|
|
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
|
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
|
});
|
|
|
|
it("clears the cookie even when the row delete fails on a transient fault", async () => {
|
|
seed({ id: "sess-live", tokenHash: null });
|
|
cookieState.sessionId = "sess-live";
|
|
vi.mocked(prisma.session.delete).mockRejectedValue(
|
|
Object.assign(new Error("connection reset"), { code: "P1001" }) as never,
|
|
);
|
|
|
|
// A non-P2025 delete failure is recorded on the wide event, not thrown,
|
|
// so logout never leaves the client authenticated with the cookie intact.
|
|
await expect(destroySession()).resolves.toBeUndefined();
|
|
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
|
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
|
});
|
|
});
|
|
|
|
describe("destroyAllSessions", () => {
|
|
it("revokes web sessions, API tokens, and refresh tokens for the user", async () => {
|
|
vi.mocked(prisma.session.deleteMany).mockResolvedValue({
|
|
count: 2,
|
|
} as never);
|
|
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({
|
|
count: 3,
|
|
} as never);
|
|
|
|
await destroyAllSessions("user-rotated");
|
|
|
|
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
|
where: { userId: "user-rotated" },
|
|
});
|
|
expect(prisma.apiToken.updateMany).toHaveBeenCalledWith({
|
|
where: { userId: "user-rotated", revoked: false },
|
|
data: { revoked: true },
|
|
});
|
|
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
|
|
where: { userId: "user-rotated", revokedAt: null },
|
|
data: { revokedAt: expect.any(Date) },
|
|
});
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|