From 0fca5ca207f82d28be48f383b7b0feb71cd8f2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 10:59:38 +0200 Subject: [PATCH 1/5] fix(auth): bind a live refresh token to its issuing device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rotation refused a replayed token under a spoofed device id (M-4) but still rotated a *live* one presented from a different device, and then inherited the caller's id onto the new row — putting the resulting family outside the victim device's containment scope. Refuse the mismatch outright when both ids are present; an absent id on either side stays unattributable and rotates as before, so a client that sends no X-Device-Id is not locked out. The expiry refusal had no coverage at all: the standing test presented a token that was never issued, so it passed on not_found and stayed green with the expiry branch deleted. Replace it with the real secret plus a just-inside-the-window companion, and cover the device binding. --- src/lib/auth/__tests__/refresh-token.test.ts | 84 ++++++++++++++++++-- src/lib/auth/refresh-token.ts | 22 ++++- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/lib/auth/__tests__/refresh-token.test.ts b/src/lib/auth/__tests__/refresh-token.test.ts index 1ee9835cc..b19d98a4b 100644 --- a/src/lib/auth/__tests__/refresh-token.test.ts +++ b/src/lib/auth/__tests__/refresh-token.test.ts @@ -394,21 +394,95 @@ describe("rotateRefreshToken", () => { expect(liveDev1).toHaveLength(0); }); - it("rejects expired refresh tokens", async () => { - await issueAccessAndRefresh({ + it("rejects an expired refresh token by its own secret", async () => { + // The token presented here is the REAL one, so the row is found and the + // expiry branch is what refuses it. Presenting an unknown string instead + // would pass on `not_found` and prove nothing about expiry at all. + const initial = await issueAccessAndRefresh({ userId: "u1", policy: NATIVE_POLICY, source: "login.password", }); - // Manually expire dbState.refreshTokens[0].expiresAt = new Date(Date.now() - 1000); + const result = await rotateRefreshToken({ - refreshToken: "hlr_doesnt_matter", + refreshToken: initial.refreshToken, policy: NATIVE_POLICY, }); + expect(result.ok).toBe(false); if (result.ok) return; - expect(result.reason).toBe("not_found"); + expect(result.reason).toBe("expired"); + // An expired token must not mint a replacement pair. + expect(dbState.refreshTokens).toHaveLength(1); + }); + + it("accepts the same token one millisecond before it expires", async () => { + // Boundary companion: proves the expiry refusal above is driven by the + // clock and not by the rotation path refusing everything. + const initial = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + }); + dbState.refreshTokens[0].expiresAt = new Date(Date.now() + 60_000); + + const result = await rotateRefreshToken({ + refreshToken: initial.refreshToken, + policy: NATIVE_POLICY, + }); + + expect(result.ok).toBe(true); + }); + + it("refuses a live token presented under a different device id", async () => { + // Device A's token replayed from device B. The token is valid and unused, + // so only the device binding can refuse it. + const dev1 = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + deviceId: "dev-1", + }); + + const result = await rotateRefreshToken({ + refreshToken: dev1.refreshToken, + policy: NATIVE_POLICY, + deviceId: "dev-2", + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("device_mismatch"); + // Refused, not consumed: the legitimate device can still rotate it. + expect(dbState.refreshTokens[0].usedAt).toBeNull(); + expect(dbState.refreshTokens).toHaveLength(1); + + const legitimate = await rotateRefreshToken({ + refreshToken: dev1.refreshToken, + policy: NATIVE_POLICY, + deviceId: "dev-1", + }); + expect(legitimate.ok).toBe(true); + }); + + it("still rotates when the caller sends no device id at all", async () => { + // An older client that never sends `X-Device-Id` must not be locked out + // by the device binding — a presented null is unattributable, not a + // mismatch. + const dev1 = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + deviceId: "dev-1", + }); + + const result = await rotateRefreshToken({ + refreshToken: dev1.refreshToken, + policy: NATIVE_POLICY, + }); + + expect(result.ok).toBe(true); }); it("rejects revoked refresh tokens", async () => { diff --git a/src/lib/auth/refresh-token.ts b/src/lib/auth/refresh-token.ts index 812588f57..37997964f 100644 --- a/src/lib/auth/refresh-token.ts +++ b/src/lib/auth/refresh-token.ts @@ -83,7 +83,7 @@ export async function issueAccessAndRefresh( } export type RotationFailureReason = - "not_found" | "expired" | "already_used" | "revoked"; + "not_found" | "expired" | "already_used" | "revoked" | "device_mismatch"; export type RotationResult = | { ok: true; bundle: IssuedRefreshBundle } @@ -158,6 +158,26 @@ export async function rotateRefreshToken(input: { return { ok: false, reason: "already_used" }; } + // A live token belongs to the device it was issued to. The M-4 escalation + // above already treats a mismatched deviceId on a REPLAY as a spoofing + // signal; the same mismatch on a still-live token is refused outright + // rather than silently rotated. Without this the reuse-detection scope is + // only as honest as the caller: a thief could rotate a stolen token under + // their own fabricated device id, and the resulting family — keyed on the + // attacker's id, because `issueAccessAndRefresh` below inherits + // `row.deviceId ?? input.deviceId` — would sit outside the victim device's + // containment blast radius. + // + // Both ids must be present to compare. A stored null means the row predates + // per-device issuance and cannot be attributed; a presented null means the + // caller sent no `X-Device-Id` header. Neither is treated as a mismatch, so + // this cannot lock out an older client that never sends the header. + const presentedId = input.deviceId ?? null; + const storedId = row.deviceId ?? null; + if (presentedId !== null && storedId !== null && presentedId !== storedId) { + return { ok: false, reason: "device_mismatch" }; + } + // Issue the new pair first, THEN mark old consumed in a transaction. // (Race: if two concurrent refreshes hit the same row, both will try to // mark `usedAt`. We use updateMany with a `usedAt: null` guard so only From fd6a80a921746ce2d2a9d66ce4b9e4df54bd38c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:01:03 +0200 Subject: [PATCH 2/5] fix(nightscout): assert the public-host floor before persisting the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Egress was already correct — the client fetches through safeFetch with requirePublicHost pinned off the user's opt-in, manual redirects and a timeout — but the check on the value we STORE was only a side effect of the live probe the connect route happens to run first. That makes the guard contingent on the probe's placement rather than on the input. Run isPublicUrl at input time, gated on the same self-hoster opt-in, so the URL that gets encrypted onto the row is the URL that was validated. Cover the alt-notation classes the helper knows about; each case asserts the probe was never reached, which is what distinguishes the input-time refusal from the fetch-time one. --- .../connect/__tests__/route.test.ts | 57 +++++++++++++++++++ src/app/api/nightscout/connect/route.ts | 20 +++++++ src/lib/validations/nightscout.ts | 16 ++++-- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts index 811a09f72..115bfb8a3 100644 --- a/src/app/api/nightscout/connect/__tests__/route.test.ts +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -140,6 +140,63 @@ describe("POST /api/nightscout/connect", () => { expect(userUpdate).not.toHaveBeenCalled(); }); + // Input-time SSRF floor. Every case below leaves the default happy-path + // `fetchSgvEntries` mock in place — it RESOLVES — so the only thing that + // can produce a 422 is the check that runs before the probe. Asserting the + // probe was never called is what separates this from the fetch-time guard: + // a URL that never reaches the egress layer cannot have been refused by it. + describe("refuses private / non-public hosts before the probe runs", () => { + const cases: Array<[string, string]> = [ + ["loopback", "http://127.0.0.1:1337"], + ["loopback by name", "http://localhost:1337"], + ["link-local", "http://169.254.1.1"], + ["cloud metadata endpoint", "http://169.254.169.254/latest/meta-data/"], + ["RFC1918 class A", "http://10.0.0.5"], + ["RFC1918 class C", "http://192.168.1.5"], + ["CGNAT", "http://100.64.0.1"], + ["IPv4-mapped IPv6 loopback", "http://[::ffff:127.0.0.1]"], + ["IPv6 loopback", "http://[::1]:1337"], + ["IPv6 unique-local", "http://[fd00::1]"], + ["octal IPv4 loopback", "http://0177.0.0.1"], + ["hex IPv4 loopback", "http://0x7f000001"], + ["decimal IPv4 loopback", "http://2130706433"], + [".internal suffix", "http://cgm.internal"], + [".local suffix", "http://cgm.local"], + ]; + + it.each(cases)("refuses %s", async (_label, url) => { + const res = await post(req({ url, token: "tok" })); + + expect(res.status).toBe(422); + expect(String(res.error)).toMatch(/private network/i); + expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("still lets the explicit self-hoster opt-in through to the probe", async () => { + // The floor is gated, not absolute: a LAN instance stays reachable for + // the operator who asked for it. Without this the opt-in is dead code. + const res = await post( + req({ + url: "http://192.168.1.5:1337", + token: "tok", + allowPrivateHost: true, + }), + ); + + expect(res.status).toBe(200); + expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1); + expect(userUpdate).toHaveBeenCalledTimes(1); + }); + + it("lets an ordinary public instance through", async () => { + const res = await post(req({ url: "https://ns.example.com" })); + + expect(res.status).toBe(200); + expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1); + }); + }); + it("rate-limits the connect surface", async () => { rateLimitMock.mockResolvedValue({ allowed: false, diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 39af2550a..50c7ad821 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -12,6 +12,7 @@ import { markReconnected } from "@/lib/integrations/status"; import { SafeFetchError } from "@/lib/safe-fetch"; import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client"; import { nightscoutConnectSchema } from "@/lib/validations/nightscout"; +import { isPublicUrl } from "@/lib/validations/notifications"; /** * Connect (or update) the user's Nightscout instance (v1.17.0). @@ -49,6 +50,25 @@ export const POST = apiHandler(async (request: NextRequest) => { const { url, token, allowPrivateHost } = result.data; + // SSRF floor at INPUT time, not merely as a side effect of the live probe + // below. `fetchSgvEntries` does route through `safeFetch` with the same + // public-host pin, so today both paths refuse the same URLs — but that + // makes the guard on the value we PERSIST contingent on the probe staying + // exactly where it is. Reorder it, make it best-effort, or add a "save + // without testing" affordance and the stored URL is suddenly unchecked. + // Assert it here so the thing that gets encrypted onto the row is the + // thing that was validated. + // + // Gated on the same explicit opt-in the fetch path uses: a self-hoster who + // deliberately points at a LAN instance has asked for the private host and + // must still be able to connect. + if (!allowPrivateHost && !isPublicUrl(url)) { + return apiError( + "That instance is on a private network. Enable the private-network option to connect to it.", + 422, + ); + } + // Live validation: pull a single SGV entry. A connection that can't even // fetch one row is not worth storing. try { diff --git a/src/lib/validations/nightscout.ts b/src/lib/validations/nightscout.ts index 8b0e5a77d..010f2d753 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -7,11 +7,17 @@ import { z } from "zod/v4"; * on `User` (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`); the * private-host opt-in maps to `nightscoutAllowPrivateHost`. * - * The URL is validated to be a parseable http(s) origin at input time. The - * SSRF floor (public-host requirement, unless `allowPrivateHost` is set) is - * enforced at fetch time by the client through `safeFetch` — input-shape - * validation here only rejects garbage early so the connect route can return - * a clear 422 instead of letting a malformed string reach the egress layer. + * The URL is validated to be a parseable http(s) origin here. The SSRF floor + * (public-host requirement, unless `allowPrivateHost` is set) is asserted + * twice on purpose: the connect route runs `isPublicUrl` before it persists + * anything, and `safeFetch` re-checks plus pins the resolved IP at connect + * time. The shape check here only rejects garbage early so the route can + * return a clear 422 instead of letting a malformed string reach either. + * + * The floor is NOT expressed as a Zod refinement because it depends on the + * sibling `allowPrivateHost` field and needs its own error message; folding + * it in here would collapse "malformed URL" and "private host, opt in to + * reach it" into one opaque 422. */ export const nightscoutConnectSchema = z.object({ // Trimmed: a trailing space / newline from a copy button reaches the From a8b0365ca5b53257b71e73becfa13b017c5e4049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:21:01 +0200 Subject: [PATCH 3/5] fix(auth): carry a generated secret in the session cookie, not the row id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/self-hosting/getting-started.md | 10 +- .../0254_session_token_hash/migration.sql | 21 ++ prisma/schema.prisma | 10 + scripts/env-manifest.json | 2 +- src/lib/auth/__tests__/session.test.ts | 233 ++++++++++++++++-- src/lib/auth/session.ts | 146 +++++++++-- tests/integration/auth-flow.test.ts | 132 ++++++++++ tests/integration/setup.ts | 10 + 8 files changed, 516 insertions(+), 48 deletions(-) create mode 100644 prisma/migrations/0254_session_token_hash/migration.sql diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index c24150fd4..644d3ed8b 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -47,11 +47,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env ``` -| Variable | Purpose | Rotation cost | -| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update | -| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | -| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token | +| Variable | Purpose | Rotation cost | +| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update | +| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | +| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session | You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml` stack: compose constructs the connection string inside the `app` diff --git a/prisma/migrations/0254_session_token_hash/migration.sql b/prisma/migrations/0254_session_token_hash/migration.sql new file mode 100644 index 000000000..30ea82b9d --- /dev/null +++ b/prisma/migrations/0254_session_token_hash/migration.sql @@ -0,0 +1,21 @@ +-- Session cookies carry a purpose-generated secret rather than the row's cuid +-- primary key. +-- +-- A cuid is built for collision-resistant identity, not for unguessability: it +-- embeds a timestamp and a monotonic counter and does not come from a CSPRNG. +-- A session cookie is a bearer credential and must. The cookie now carries a +-- 32-byte random token (`hls_`) and only its HMAC-SHA256 hash is stored, +-- matching how api_tokens and refresh_tokens already hold their secrets. +-- +-- Additive and nullable ON PURPOSE: every session row that exists at deploy +-- time keeps NULL here and stays resolvable by its id, so the release signs +-- nobody out. Those rows cannot have their sliding expiry extended, so the +-- id-as-credential path drains itself within the 30-day session lifetime with +-- no operator action. Postgres permits many NULLs under a unique index, so the +-- constraint below does not conflict with the backfill-free transition. + +-- AlterTable +ALTER TABLE "sessions" ADD COLUMN "token_hash" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f525464f7..ffaaff10a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1023,6 +1023,16 @@ model Passkey { model Session { id String @id @default(cuid()) userId String @map("user_id") + // v1.30.32 — HMAC-SHA256 of the session cookie's secret. The cookie carries + // a 32-byte CSPRNG token (`hls_`), never this row's cuid: a cuid is + // collision-resistant, not unguessable (embedded timestamp + counter), and a + // session identifier is a bearer credential. Hash-at-rest mirrors the + // ApiToken / RefreshToken posture — a database read yields no usable cookie. + // + // Nullable for the transition only: rows minted before this column existed + // carry NULL and are still resolvable by id until they expire. See the + // resolution comment in `src/lib/auth/session.ts`. + tokenHash String? @unique @map("token_hash") expiresAt DateTime @map("expires_at") createdAt DateTime @default(now()) @map("created_at") ipAddress String? @map("ip_address") diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index f65d9e554..dd45eee0f 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -31,7 +31,7 @@ }, { "name": "API_TOKEN_HMAC_KEY", - "purpose": "HMAC-SHA256 key for hashing external Bearer tokens. 64 hex chars." + "purpose": "HMAC-SHA256 key for hashing external Bearer tokens AND web session cookies. 64 hex chars. Rotating it signs every browser session out on top of invalidating every issued token." } ] }, diff --git a/src/lib/auth/__tests__/session.test.ts b/src/lib/auth/__tests__/session.test.ts index ee27a18aa..b7b169611 100644 --- a/src/lib/auth/__tests__/session.test.ts +++ b/src/lib/auth/__tests__/session.test.ts @@ -6,23 +6,48 @@ const cookieState = vi.hoisted(() => ({ 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: { - findUnique: vi.fn(), + // 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 }) => { + 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(), - }, + apiToken: { updateMany: vi.fn() }, + refreshToken: { updateMany: vi.fn() }, + trustedDevice: { deleteMany: vi.fn() }, $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), }, })); @@ -35,6 +60,12 @@ 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) => @@ -47,20 +78,165 @@ vi.mock("next/headers", () => ({ })); import { prisma } from "@/lib/db"; -import { getSession, destroyAllSessions, destroySession } from "../session"; +import { + getSession, + createSession, + destroyAllSessions, + destroySession, +} from "../session"; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +function seed(row: Partial & { 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.resetAllMocks(); + 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 () => { - vi.mocked(prisma.session.findUnique).mockResolvedValue({ + seed({ id: "sess-expired", + tokenHash: null, expiresAt: new Date(Date.now() - 1000), - user: { id: "user-1" }, - } as never); + }); vi.mocked(prisma.session.deleteMany).mockRejectedValue( new Error("already deleted") as never, ); @@ -76,7 +252,33 @@ describe("getSession", () => { }); 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, @@ -92,6 +294,7 @@ describe("destroySession", () => { }); 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, diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index bcddcc777..88820489b 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -1,6 +1,8 @@ +import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/db"; import { cookies } from "next/headers"; import type { User } from "@/generated/prisma/client"; +import { hashToken } from "@/lib/auth/hmac"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { getEvent } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; @@ -11,6 +13,77 @@ import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie"; const SESSION_COOKIE = "healthlog_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +/** + * Marks a cookie value as a v1.30.32 session secret. Doubles as the branch + * between the two resolution paths below, so a cookie is only ever looked up + * one way and a raw cuid can never be tried as a secret (or vice versa). + */ +const SESSION_TOKEN_PREFIX = "hls_"; +const SESSION_SECRET_BYTES = 32; + +/** + * Mint a session cookie secret: 32 CSPRNG bytes, hex-encoded. + * + * This exists because the cookie used to carry `Session.id` — a `cuid()`. + * cuid targets collision-resistant identity across distributed writers, not + * unguessability: it encodes a millisecond timestamp and a process-local + * counter and never touches a CSPRNG. That is a fine primary key and an unfit + * bearer credential. Only `hashToken(secret)` reaches the database, so a + * leaked table dump yields no usable cookie — the same posture ApiToken and + * RefreshToken already hold. + */ +function mintSessionSecret(): string { + return `${SESSION_TOKEN_PREFIX}${randomBytes(SESSION_SECRET_BYTES).toString("hex")}`; +} + +/** + * Resolve the session row a cookie value refers to, or null. + * + * COMPATIBILITY DECISION (v1.30.32). Two paths, and the transition is + * deliberately passive: + * + * 1. `hls_…` — the modern secret. Looked up by `hashToken(value)`. + * 2. anything else — a pre-upgrade cookie carrying the row's cuid. Accepted + * ONLY while that row's `tokenHash` is still NULL. + * + * A migration-time backfill cannot work: the cookie already in the user's + * browser is the cuid, and no token we generate server-side can be pushed into + * it. Backfilling would sign out every logged-in user at deploy — the one + * outcome this change must not cause. + * + * Upgrading a legacy row in place on next use was the other candidate and was + * rejected as unsafe. A browser fires several requests in parallel, all + * carrying the same legacy cookie; each would mint its own secret and race to + * claim the row. Exactly one write can win, and the losers cannot learn the + * winner's secret to re-emit it — the row stores only a hash. Whichever + * response reached the browser last would decide the cookie, and if that was a + * loser's, the user is signed out. A guarded update narrows the write but not + * the outcome: a loser that declines to re-cookie leaves the browser holding a + * legacy id the row no longer honours. The race has no correct resolution, and + * its failure mode is precisely the forced logout we are avoiding. + * + * So legacy rows are left alone and simply live out the expiry they already + * have — `getSession` withholds the sliding-expiry extension from them (see + * there). That bounds the id-as-credential path to the 30-day session lifetime + * from the deploy, self-draining, with no operator step and no backfill. Every + * login from the deploy onward is on a secret immediately. + */ +async function findSessionByCookie(cookieValue: string) { + if (cookieValue.startsWith(SESSION_TOKEN_PREFIX)) { + return prisma.session.findUnique({ + where: { tokenHash: hashToken(cookieValue) }, + include: { user: true }, + }); + } + + const legacy = await prisma.session.findUnique({ + where: { id: cookieValue }, + include: { user: true }, + }); + // A row that already holds a secret has retired its id as a credential. + return legacy && legacy.tokenHash === null ? legacy : null; +} + // v1.23 — throttle window for the user-facing active-session list's "last // seen" stamp. A write only happens when the prior stamp is older than this, // so the high-churn `getSession` path costs at most one extra UPDATE per @@ -73,9 +146,13 @@ export async function createSession( // "single-factor session" and never satisfies step-up. mfaVerifiedAt?: Date | null, ): Promise { + // The secret is the cookie; the row id stays an internal identifier and is + // never handed to the client. + const secret = mintSessionSecret(); const session = await prisma.session.create({ data: { userId, + tokenHash: hashToken(secret), expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS), ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, @@ -84,7 +161,7 @@ export async function createSession( }); const cookieStore = await cookies(); - cookieStore.set(SESSION_COOKIE, session.id, { + cookieStore.set(SESSION_COOKIE, secret, { httpOnly: true, secure: shouldEmitSecureCookie(), // OAuth callbacks (e.g. Withings) arrive via top-level cross-site redirect. @@ -117,18 +194,15 @@ export async function getSession(): Promise<{ } const cookieStore = await cookies(); - const sessionId = cookieStore.get(SESSION_COOKIE)?.value; - if (!sessionId) return null; + const cookieValue = cookieStore.get(SESSION_COOKIE)?.value; + if (!cookieValue) return null; - const session = await prisma.session.findUnique({ - where: { id: sessionId }, - include: { user: true }, - }); + const session = await findSessionByCookie(cookieValue); if (!session || session.expiresAt < new Date()) { if (session) { await prisma.session - .deleteMany({ where: { id: sessionId } }) + .deleteMany({ where: { id: session.id } }) .catch(() => {}); } cookieStore.delete(SESSION_COOKIE); @@ -136,18 +210,26 @@ export async function getSession(): Promise<{ return null; } - // Sliding expiry: refresh if more than 1 day old + // Sliding expiry: refresh if more than 1 day old. + // + // Withheld from legacy (`tokenHash === null`) rows on purpose. Extending + // those would let a cuid-as-credential session renew itself indefinitely for + // an active user, and the whole point of the passive transition is that the + // id-resolvable path drains on its own. Capping them at the expiry they + // already carry bounds it to at most 30 days past the deploy; the user then + // logs in once and lands on a secret. Re-emitting the cookie is likewise + // pointless for a legacy row — its value cannot be improved in place. const oneDayMs = 24 * 60 * 60 * 1000; if ( - session.expiresAt.getTime() - Date.now() < - SESSION_MAX_AGE_MS - oneDayMs + session.tokenHash !== null && + session.expiresAt.getTime() - Date.now() < SESSION_MAX_AGE_MS - oneDayMs ) { const newExpiry = new Date(Date.now() + SESSION_MAX_AGE_MS); await prisma.session.update({ where: { id: session.id }, data: { expiresAt: newExpiry }, }); - cookieStore.set(SESSION_COOKIE, session.id, { + cookieStore.set(SESSION_COOKIE, cookieValue, { httpOnly: true, secure: shouldEmitSecureCookie(), sameSite: "lax", @@ -211,33 +293,43 @@ export async function getSession(): Promise<{ */ export async function getSessionUserLocale(): Promise { const cookieStore = await cookies(); - const sessionId = cookieStore.get(SESSION_COOKIE)?.value; - if (!sessionId) return null; + const cookieValue = cookieStore.get(SESSION_COOKIE)?.value; + if (!cookieValue) return null; - const session = await prisma.session.findUnique({ - where: { id: sessionId }, - select: { expiresAt: true, user: { select: { locale: true } } }, - }); + // Same two-path resolution as `getSession`, read-only: this runs in the root + // layout where the cookie store cannot be written, so there is nothing to + // refresh or clear here. + const session = await findSessionByCookie(cookieValue); if (!session || session.expiresAt < new Date()) return null; return session.user.locale; } export async function destroySession(): Promise { const cookieStore = await cookies(); - const sessionId = cookieStore.get(SESSION_COOKIE)?.value; - if (sessionId) { + const cookieValue = cookieStore.get(SESSION_COOKIE)?.value; + if (cookieValue) { + // Resolve the cookie to its row before deleting — the cookie is a secret, + // not the primary key, so `delete({ where: { id: cookieValue } })` would + // silently no-op on every modern session and leave the row alive after a + // logout. + // // Logout is idempotent: a session row that no longer exists (P2025) is a // no-op. Any other failure is a real delete error — record it on the wide // event rather than swallowing it silently, but never block the cookie // clear below: a transient DB fault must not leave the client logged in // with the cookie intact. - await prisma.session.delete({ where: { id: sessionId } }).catch((err) => { - if (!isP2025(err)) { - getEvent()?.addWarning( - `destroySession delete failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }); + const session = await findSessionByCookie(cookieValue).catch(() => null); + if (session) { + await prisma.session + .delete({ where: { id: session.id } }) + .catch((err) => { + if (!isP2025(err)) { + getEvent()?.addWarning( + `destroySession delete failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }); + } } cookieStore.delete(SESSION_COOKIE); cookieStore.delete(ONBOARDING_COOKIE); diff --git a/tests/integration/auth-flow.test.ts b/tests/integration/auth-flow.test.ts index 5f269a6d9..e496fe0ea 100644 --- a/tests/integration/auth-flow.test.ts +++ b/tests/integration/auth-flow.test.ts @@ -132,6 +132,138 @@ describe("session lifecycle (real Postgres)", () => { expect(cookieJar.get("hl_onboarding")).toBeUndefined(); }); + /** + * v1.30.32 — the cookie carries a CSPRNG secret, not the row's cuid. + * These run against real Postgres because the thing under test is the + * migration plus the unique index plus the two lookup paths; a mocked + * client can assert the shape but not that the column and constraint + * actually exist on a migrated database. + */ + describe("session cookie secret (migration 0254)", () => { + it("issues a secret cookie and stores only its hash", async () => { + const { createSession } = await import("@/lib/auth/session"); + const { hashToken } = await import("@/lib/auth/hmac"); + + const user = await getPrismaClient().user.create({ + data: { username: "secret-cookie", email: "secret@example.test" }, + }); + const sessionId = await createSession(user.id, false); + + const cookie = cookieJar.get("healthlog_session")!; + expect(cookie).toMatch(/^hls_[0-9a-f]{64}$/); + expect(cookie).not.toBe(sessionId); + + const row = await getPrismaClient().session.findUnique({ + where: { id: sessionId }, + }); + // The raw secret is nowhere on the row. + expect(row?.tokenHash).toBe(hashToken(cookie)); + expect(row?.tokenHash).not.toBe(cookie); + }); + + it("authenticates by the secret and refuses the row id", async () => { + const { createSession, getSession } = await import("@/lib/auth/session"); + + const user = await getPrismaClient().user.create({ + data: { username: "id-retired", email: "retired@example.test" }, + }); + const sessionId = await createSession(user.id, false); + + // The secret works. + expect((await getSession())?.session.id).toBe(sessionId); + + // The primary key does not. This is the whole point: knowing or + // guessing a cuid must no longer authenticate anything. + cookieJar.set("healthlog_session", sessionId); + expect(await getSession()).toBeNull(); + }); + + it("keeps a pre-upgrade session alive on its row id", async () => { + // The compatibility contract. This row is exactly what the migration + // leaves behind: a real session with a NULL token_hash whose cookie in + // the user's browser is the cuid. The deploy must not sign them out. + const { getSession } = await import("@/lib/auth/session"); + + const user = await getPrismaClient().user.create({ + data: { username: "legacy-session", email: "legacy@example.test" }, + }); + const legacy = await getPrismaClient().session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000), + }, + }); + expect(legacy.tokenHash).toBeNull(); + + cookieJar.set("healthlog_session", legacy.id); + const result = await getSession(); + + expect(result).not.toBeNull(); + expect(result?.session.id).toBe(legacy.id); + expect(result?.user.id).toBe(user.id); + }); + + it("does not extend a pre-upgrade session's expiry", async () => { + // Withholding the sliding refresh is what bounds the transition: the + // id-resolvable path drains within the session lifetime instead of + // renewing forever under an active user. + const { getSession } = await import("@/lib/auth/session"); + + const user = await getPrismaClient().user.create({ + data: { username: "legacy-drain", email: "drain@example.test" }, + }); + const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); + const legacy = await getPrismaClient().session.create({ + data: { userId: user.id, expiresAt }, + }); + + cookieJar.set("healthlog_session", legacy.id); + expect(await getSession()).not.toBeNull(); + + const after = await getPrismaClient().session.findUnique({ + where: { id: legacy.id }, + }); + expect(after?.expiresAt.getTime()).toBe(expiresAt.getTime()); + }); + + it("gives concurrent sessions distinct secrets under the unique index", async () => { + const { createSession } = await import("@/lib/auth/session"); + + const user = await getPrismaClient().user.create({ + data: { username: "many-secrets", email: "many@example.test" }, + }); + await createSession(user.id, false); + const first = cookieJar.get("healthlog_session")!; + await createSession(user.id, false); + const second = cookieJar.get("healthlog_session")!; + + expect(first).not.toBe(second); + const rows = await getPrismaClient().session.findMany({ + where: { userId: user.id }, + }); + expect(rows).toHaveLength(2); + expect(new Set(rows.map((r) => r.tokenHash)).size).toBe(2); + }); + + it("logs out by resolving the secret to its row", async () => { + const { createSession, destroySession } = + await import("@/lib/auth/session"); + + const user = await getPrismaClient().user.create({ + data: { username: "secret-logout", email: "logout@example.test" }, + }); + const sessionId = await createSession(user.id, false); + + await destroySession(); + + // A logout that deleted by cookie value would leave this row behind. + const row = await getPrismaClient().session.findUnique({ + where: { id: sessionId }, + }); + expect(row).toBeNull(); + }); + }); + it("destroySession clears the hl_onboarding cookie alongside the session cookie", async () => { const { createSession, destroySession } = await import("@/lib/auth/session"); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 0b4240f3d..56fdd8094 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -24,6 +24,16 @@ import type { PrismaClient } from "@/generated/prisma/client"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +// v1.30.32 — session cookies now carry a CSPRNG secret whose HMAC is what +// lands in `sessions.token_hash`, so `createSession` needs this key exactly +// like token issuance already did. It is a Core-required variable in +// `scripts/env-manifest.json` (the app refuses to boot without it), so +// defaulting it here matches production rather than papering over a gap. +// Same `??=` treatment as the encryption key above: files that set their own +// still win. +process.env.API_TOKEN_HMAC_KEY ??= + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + /** * The application's Prisma singleton. Tests use this so any code * imported via `await import("@/lib/...")` shares the exact same client From 12844a92067117c3836d903bc27cc631b56f6369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:37:15 +0200 Subject: [PATCH 4/5] test(auth): bind the rotated cookie through its hash after a password change The assertion compared the cookie to the new session row's id, which only held while the id WAS the cookie. Check the stored hash instead: it proves the cookie authenticates the surviving row and that the row id never reaches the client. --- tests/integration/auth-password-change.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/integration/auth-password-change.test.ts b/tests/integration/auth-password-change.test.ts index d3000ff69..44bc9f7bb 100644 --- a/tests/integration/auth-password-change.test.ts +++ b/tests/integration/auth-password-change.test.ts @@ -21,6 +21,7 @@ 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"); @@ -140,7 +141,14 @@ describe("POST /api/auth/password (real Postgres)", () => { expect(remainingSessions).toHaveLength(1); expect(remainingSessions[0].id).not.toBe(sessionId); expect(remainingSessions[0].id).not.toBe(siblingSession.id); - expect(cookieJar.get("healthlog_session")).toBe(remainingSessions[0].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. From 8bc1d75373ef74dee357a151a4c47c0b7ee5cbcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:41:12 +0200 Subject: [PATCH 5/5] chore(db): renumber the session-token migration to 0255 0254 was taken by the document-summary state migration that landed on the trunk first. --- .../migration.sql | 0 tests/integration/auth-flow.test.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename prisma/migrations/{0254_session_token_hash => 0255_session_token_hash}/migration.sql (100%) diff --git a/prisma/migrations/0254_session_token_hash/migration.sql b/prisma/migrations/0255_session_token_hash/migration.sql similarity index 100% rename from prisma/migrations/0254_session_token_hash/migration.sql rename to prisma/migrations/0255_session_token_hash/migration.sql diff --git a/tests/integration/auth-flow.test.ts b/tests/integration/auth-flow.test.ts index e496fe0ea..eb878779e 100644 --- a/tests/integration/auth-flow.test.ts +++ b/tests/integration/auth-flow.test.ts @@ -139,7 +139,7 @@ describe("session lifecycle (real Postgres)", () => { * client can assert the shape but not that the column and constraint * actually exist on a migrated database. */ - describe("session cookie secret (migration 0254)", () => { + describe("session cookie secret (migration 0255)", () => { it("issues a secret cookie and stores only its hash", async () => { const { createSession } = await import("@/lib/auth/session"); const { hashToken } = await import("@/lib/auth/hmac");