diff --git a/docs/api/README.md b/docs/api/README.md index 507e31faf..ad6ddfaaf 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -82,9 +82,12 @@ before treating a warning as a regression. v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`) every token above, whichever surface issued it. - - `sessionCookie` — HttpOnly `healthlog_session` cookie set by the - password and passkey login flows. Used by the web app and any iOS - client running inside a web view. + - `sessionCookie` — HttpOnly session cookie set by the password and + passkey login flows. Used by the web app and any iOS client running + inside a web view. It is named `__Host-healthlog_session` wherever it is + `Secure` (every HTTPS install), so no other host under the same domain + can set it; a plain-HTTP install keeps `healthlog_session`. A cookie + under the old name is still read and is re-issued under the new one. - **`components.schemas`** — DTOs for the documented routes plus the underlying Prisma models (e.g. `Measurement`, `Medication`, `MedicationIntakeEvent`, `MoodEntry`, `User`, `DashboardLayout`, diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 780128da3..a0c7d6a89 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -94543,4 +94543,6 @@ components: type: apiKey in: cookie name: healthlog_session - description: Browser session cookie — set by /api/auth/login. + description: Browser session cookie — set by /api/auth/login. Named `__Host-healthlog_session` wherever the cookie is + `Secure` (every HTTPS install); a plain-HTTP install, which cannot hold a `__Host-` cookie, keeps + `healthlog_session`. diff --git a/docs/ops/measurement-maintenance.md b/docs/ops/measurement-maintenance.md index c43caa52d..e0bce3094 100644 --- a/docs/ops/measurement-maintenance.md +++ b/docs/ops/measurement-maintenance.md @@ -107,10 +107,12 @@ In the admin area, or with a signed-in admin session: ```sh curl -X POST https:///api/admin/maintenance/measurements \ -H 'content-type: application/json' \ - --cookie 'healthlog_session=' \ + --cookie '__Host-healthlog_session=' \ -d '{"vacuum": true, "reindex": true}' ``` +On a plain-HTTP install the cookie is named `healthlog_session`. + The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a run is already queued or running. A Bearer token is refused: the route is cookie-only, like every admin route. Without a background worker the route diff --git a/src/__tests__/proxy-onboarding-redirect.test.ts b/src/__tests__/proxy-onboarding-redirect.test.ts index 7047d1a4c..ee609e893 100644 --- a/src/__tests__/proxy-onboarding-redirect.test.ts +++ b/src/__tests__/proxy-onboarding-redirect.test.ts @@ -75,6 +75,16 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => { expect(res.headers.get("location")).toBeNull(); }); + it("counts the __Host- session cookie as a session at the page gate", () => { + const res = proxy( + makeRequest("/insights", { "__Host-healthlog_session": "sess-1" }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + const anonymous = proxy(makeRequest("/insights")); + expect(anonymous.headers.get("location")).toMatch(/\/auth\/login$/); + }); + it("passes through when hl_onboarding cookie is absent (completed users)", () => { const res = proxy(makeRequest("/", { healthlog_session: "sess-1" })); expect(res.status).toBe(200); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index c97358181..7467f1473 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -190,10 +190,13 @@ describe("S1 — the direct session-resolution set is frozen", () => { /\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt|loadOnboardingFlowState)\s*\(/; /** - * Reaching for the cookie by name is the way around the helpers. Both the - * exported constant and the raw literal count. + * Reaching for the cookie by name is the way around the helpers. The + * reader (`readSessionCookie`, which tries both names), the exported names + * and either raw literal all count; the `__Host-` name joined the old one + * when a `Secure` session cookie started being issued under it. */ - const RAW_SESSION_COOKIE = /\bSESSION_COOKIE_NAME\b|"healthlog_session"/; + const RAW_SESSION_COOKIE = + /\b(readSessionCookie|SESSION_COOKIE_NAMES?|HOST_SESSION_COOKIE|LEGACY_SESSION_COOKIE)\b|"(__Host-)?healthlog_session"/; it("no file under src/app derives a session outside requireAuth unlisted", () => { const resolvers = appFilesMatching(SESSION_DERIVING_HELPERS); diff --git a/src/app/api/auth/native/complete/__tests__/route.test.ts b/src/app/api/auth/native/complete/__tests__/route.test.ts index e82eeed96..2fb1e6066 100644 --- a/src/app/api/auth/native/complete/__tests__/route.test.ts +++ b/src/app/api/auth/native/complete/__tests__/route.test.ts @@ -20,7 +20,6 @@ vi.mock("@/lib/rate-limit", () => ({ const validateSessionWithCreatedAt = vi.fn(); vi.mock("@/lib/auth/session", () => ({ validateSessionWithCreatedAt: (v: unknown) => validateSessionWithCreatedAt(v), - SESSION_COOKIE_NAME: "healthlog_session", })); // Keep the real shared core (buildHandoffCallbackUrl is used by diff --git a/src/app/api/auth/native/complete/route.ts b/src/app/api/auth/native/complete/route.ts index d2f19d5ce..8f1b40633 100644 --- a/src/app/api/auth/native/complete/route.ts +++ b/src/app/api/auth/native/complete/route.ts @@ -29,11 +29,11 @@ import { annotate } from "@/lib/logging/context"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { validateSessionWithCreatedAt } from "@/lib/auth/session"; import { - validateSessionWithCreatedAt, - SESSION_COOKIE_NAME, -} from "@/lib/auth/session"; + expireSessionCookies, + readSessionCookie, +} from "@/lib/auth/session-cookie"; import { mintNativeHandoff } from "@/lib/auth/native-handoff"; import { NATIVE_HANDOFF_STATE_COOKIE, @@ -95,7 +95,7 @@ export const GET = apiHandler(async (req: NextRequest) => { // (row + expiry), never "a cookie is present". Projects `createdAt` so the // freshness comparison below is structurally always available. const authed = await validateSessionWithCreatedAt( - req.cookies.get(SESSION_COOKIE_NAME)?.value, + readSessionCookie(req.cookies)?.value, ); if (!authed) { annotate({ meta: { reason: "no_session" } }); @@ -140,13 +140,7 @@ export const GET = apiHandler(async (req: NextRequest) => { await prisma.session .delete({ where: { id: authed.session.id } }) .catch(() => {}); - response.cookies.set(SESSION_COOKIE_NAME, "", { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: 0, - path: "/", - }); + expireSessionCookies(response.cookies); return response; }); diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts index a01fa9212..161f0e687 100644 --- a/src/app/api/auth/oidc/callback/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts @@ -42,7 +42,6 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn(), validateSessionFromCookieValue: vi.fn(), - SESSION_COOKIE_NAME: "healthlog_session", })); vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() })); vi.mock("@/lib/auth/mfa/challenge", () => ({ diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index 4b7b83ac8..bfe808080 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -3,10 +3,8 @@ import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; -import { - validateSessionFromCookieValue, - SESSION_COOKIE_NAME, -} from "@/lib/auth/session"; +import { validateSessionFromCookieValue } from "@/lib/auth/session"; +import { readSessionCookie } from "@/lib/auth/session-cookie"; import { discoverOidcMetadata, exchangeCodeForTokens, @@ -225,7 +223,7 @@ export const GET = apiHandler(async (req: NextRequest) => { // session gains nothing here but a redirect to a page they could already // reach — no code redemption, no session mint, no identity change. const existingSession = await validateSessionFromCookieValue( - req.cookies.get(SESSION_COOKIE_NAME)?.value, + readSessionCookie(req.cookies)?.value, ); if (existingSession) { annotate({ meta: { reason: "duplicate_callback_authenticated" } }); @@ -258,7 +256,7 @@ export const GET = apiHandler(async (req: NextRequest) => { : null; if (isClaim) { const liveSession = await validateSessionFromCookieValue( - req.cookies.get(SESSION_COOKIE_NAME)?.value, + readSessionCookie(req.cookies)?.value, ); if (liveSession) { annotate({ meta: { reason: "claim_signed_in" } }); diff --git a/src/lib/auth/__tests__/native-client.test.ts b/src/lib/auth/__tests__/native-client.test.ts index 2accfde8b..c503bf9f7 100644 --- a/src/lib/auth/__tests__/native-client.test.ts +++ b/src/lib/auth/__tests__/native-client.test.ts @@ -124,6 +124,17 @@ describe("isCookielessNativeCaller (M-3 refresh-token issuance gate)", () => { ).toBe(false); }); + it("false when the session cookie arrives under its __Host- name", () => { + expect( + isCookielessNativeCaller( + h({ + "x-client-type": "native", + cookie: "foo=bar; __Host-healthlog_session=abc123", + }), + ), + ).toBe(false); + }); + it("true for a native caller whose cookie header lacks the session cookie", () => { expect( isCookielessNativeCaller( diff --git a/src/lib/auth/__tests__/session.test.ts b/src/lib/auth/__tests__/session.test.ts index 65d80fd7f..ecf516f9a 100644 --- a/src/lib/auth/__tests__/session.test.ts +++ b/src/lib/auth/__tests__/session.test.ts @@ -1,7 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const cookieState = vi.hoisted(() => ({ sessionId: undefined as string | undefined, + /** The session cookie under the `__Host-` name, when the browser holds one. */ + hostSessionId: undefined as string | undefined, delete: vi.fn(), set: vi.fn(), })); @@ -71,10 +73,15 @@ vi.mock("@/lib/auth/hmac", () => ({ vi.mock("next/headers", () => ({ cookies: vi.fn(async () => ({ - get: (name: string) => - name === "healthlog_session" && cookieState.sessionId - ? { value: cookieState.sessionId } - : undefined, + get: (name: string) => { + if (name === "healthlog_session" && cookieState.sessionId) { + return { value: cookieState.sessionId }; + } + if (name === "__Host-healthlog_session" && cookieState.hostSessionId) { + return { value: cookieState.hostSessionId }; + } + return undefined; + }, set: cookieState.set, delete: cookieState.delete, })), @@ -98,6 +105,16 @@ const UNKNOWN_SECRET = `hls_${"c".repeat(64)}`; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; +/** Both session cookie names were expired, the `__Host-` one as `Secure`. */ +function expectSessionCookiesExpired() { + expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session"); + expect(cookieState.set).toHaveBeenCalledWith( + "__Host-healthlog_session", + "", + expect.objectContaining({ maxAge: 0, path: "/", secure: true }), + ); +} + function seed(row: Partial & { id: string }): SessionRow { const full: SessionRow = { userId: "user-1", @@ -115,6 +132,7 @@ beforeEach(() => { vi.clearAllMocks(); store.sessions = []; cookieState.sessionId = "sess-expired"; + cookieState.hostSessionId = undefined; // `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. @@ -203,6 +221,97 @@ describe("getSession — cookie resolution", () => { }); }); +describe("the session cookie's name on a Secure install", () => { + // A cookie named `healthlog_session` can be planted by any host under the + // same registrable domain; one named `__Host-…` only by this host, and only + // when Secure, on Path=/, without Domain. + beforeEach(() => { + vi.stubEnv("SESSION_COOKIE_SECURE", "true"); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("issues a new session as __Host-healthlog_session, Secure, on /, without Domain", async () => { + cookieState.sessionId = undefined; + await createSession("user-1", false); + const issued = cookieState.set.mock.calls.find( + (c) => c[0] === "__Host-healthlog_session", + ); + expect(issued?.[1]).toMatch(/^hls_[0-9a-f]{64}$/); + expect(issued?.[2]).toMatchObject({ secure: true, path: "/" }); + expect(issued?.[2]).not.toHaveProperty("domain"); + expect( + cookieState.set.mock.calls.some((c) => c[0] === "healthlog_session"), + ).toBe(false); + }); + + it("keeps a session found under the old name, and re-issues it under the new one", async () => { + seed({ id: "sess_cuid_1", tokenHash: `hash:${SECRET}` }); + cookieState.sessionId = SECRET; + + const result = await getSession(); + + expect(result?.session.id).toBe("sess_cuid_1"); + expect(cookieState.set).toHaveBeenCalledWith( + "__Host-healthlog_session", + SECRET, + expect.objectContaining({ secure: true, path: "/" }), + ); + expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session"); + }); + + it("answers from the __Host- cookie when a planted old-name cookie rides along", async () => { + seed({ id: "sess_mine", tokenHash: `hash:${SECRET}` }); + seed({ + id: "sess_planted", + userId: "attacker", + tokenHash: `hash:${FORGED_SECRET}`, + }); + cookieState.hostSessionId = SECRET; + cookieState.sessionId = FORGED_SECRET; + + const result = await getSession(); + + expect(result?.session.id).toBe("sess_mine"); + }); + + it("logs out both names", async () => { + seed({ id: "sess_cuid_1", tokenHash: `hash:${SECRET}` }); + cookieState.hostSessionId = SECRET; + cookieState.sessionId = undefined; + await destroySession(); + expect(prisma.session.delete).toHaveBeenCalledWith({ + where: { id: "sess_cuid_1" }, + }); + expectSessionCookiesExpired(); + }); +}); + +describe("the session cookie's name on a plain-HTTP install", () => { + beforeEach(() => { + vi.stubEnv("SESSION_COOKIE_SECURE", "false"); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("keeps the old name, which a browser accepts without Secure", async () => { + cookieState.sessionId = undefined; + await createSession("user-1", false); + expect( + cookieState.set.mock.calls.some( + (c) => c[0] === "healthlog_session" && c[2]?.secure === false, + ), + ).toBe(true); + expect( + cookieState.set.mock.calls.some( + (c) => c[0] === "__Host-healthlog_session", + ), + ).toBe(false); + }); +}); + describe("getSession — sliding expiry", () => { it("extends a modern session and re-emits the same secret", async () => { seed({ @@ -257,7 +366,7 @@ describe("getSession", () => { expect(prisma.session.deleteMany).toHaveBeenCalledWith({ where: { id: "sess-expired" }, }); - expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session"); + expectSessionCookiesExpired(); expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding"); }); }); @@ -385,7 +494,7 @@ describe("a session lookup that fails is not a missing session", () => { cookieState.sessionId = SECRET; failLookup(); await expect(destroySession()).resolves.toBeUndefined(); - expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session"); + expectSessionCookiesExpired(); }); }); @@ -401,7 +510,7 @@ describe("destroySession", () => { expect(prisma.session.delete).toHaveBeenCalledWith({ where: { id: "sess_cuid_1" }, }); - expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session"); + expectSessionCookiesExpired(); }); it("logs a legacy cookie out too", async () => { @@ -427,7 +536,7 @@ describe("destroySession", () => { expect(prisma.session.delete).toHaveBeenCalledWith({ where: { id: "sess-gone" }, }); - expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session"); + expectSessionCookiesExpired(); expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding"); }); @@ -441,7 +550,7 @@ describe("destroySession", () => { // 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"); + expectSessionCookiesExpired(); expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding"); }); }); diff --git a/src/lib/auth/native-client.ts b/src/lib/auth/native-client.ts index c1df65ffc..9953a4cb2 100644 --- a/src/lib/auth/native-client.ts +++ b/src/lib/auth/native-client.ts @@ -16,6 +16,7 @@ * - `X-Client-Type: native` always forces native policy. * - `X-Client-Type: web` always forces web policy. */ +import { cookieHeaderCarriesSession } from "./session-cookie"; export type ClientPolicy = "web" | "native"; @@ -95,13 +96,6 @@ export function resolveTokenPolicy(headers: Headers): TokenPolicyDecision { }; } -/** - * Name of the browser session cookie (mirrors `SESSION_COOKIE` in - * `src/lib/auth/session.ts`). A request that round-trips this cookie is a - * browser, not a genuine cookie-less native caller. - */ -const SESSION_COOKIE_NAME = "healthlog_session"; - /** * True only for a genuinely cookie-less native caller — the gate for * *issuing* a 60-day refresh token (M-3 hardening). @@ -110,7 +104,7 @@ const SESSION_COOKIE_NAME = "healthlog_session"; * a browser context (DOM/XSS-reachable), even when that browser spoofs * `X-Client-Type: native`. Two browser tells suppress issuance: * - a `Mozilla/`-bearing User-Agent (every real browser sends one), and - * - an inbound `healthlog_session` cookie (a browser round-trips its + * - an inbound session cookie, under either of its names (a browser round-trips its * session cookie; the native client authenticates by Bearer and never * holds one). * @@ -127,9 +121,7 @@ export function isCookielessNativeCaller(headers: Headers): boolean { // Belt-and-braces: a request that presents the session cookie is a // browser round-trip regardless of UA. Native callers never hold one. - const cookie = headers.get("cookie") ?? ""; - if (cookie.split(/;\s*/).some((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`))) - return false; + if (cookieHeaderCarriesSession(headers.get("cookie"))) return false; return true; } diff --git a/src/lib/auth/session-cookie.ts b/src/lib/auth/session-cookie.ts new file mode 100644 index 000000000..49a648b0b --- /dev/null +++ b/src/lib/auth/session-cookie.ts @@ -0,0 +1,139 @@ +/** + * The browser session cookie's name, and the one way to read and clear it. + * + * A cookie named `healthlog_session` can be set by any host under the same + * registrable domain: a page on `other.example.com` may write one with + * `Domain=example.com`, and the browser sends it to `health.example.com` + * alongside, or instead of, the server's own. That lets a sibling subdomain + * plant a session of its choosing in a visitor's browser. A cookie whose name + * starts with `__Host-` is accepted by the browser only when it is `Secure`, + * has `Path=/` and carries no `Domain`, so only this exact host can have set + * it. + * + * So wherever the session cookie is `Secure` (`shouldEmitSecureCookie()`), + * it is issued as `__Host-healthlog_session`. A plain-HTTP install cannot + * hold a `__Host-` cookie and keeps the old name. + * + * The transition signs nobody out: the old name is still read, after the new + * one, and `getSession()` re-issues a session found under the old name under + * the new one and expires the old cookie. A browser that holds the new + * cookie is never answered from the old one, so once a person has been seen + * once after the upgrade, a planted old-name cookie no longer reaches them. + * + * Nothing here imports the database, so the proxy can use it. + */ +import { shouldEmitSecureCookie } from "./secure-cookie"; + +/** The name every release before this one used, and plain-HTTP installs keep. */ +export const LEGACY_SESSION_COOKIE = "healthlog_session"; + +/** The name a `Secure` session cookie is issued under. */ +export const HOST_SESSION_COOKIE = "__Host-healthlog_session"; + +/** The names a session cookie is read under, in the order they are tried. */ +export const SESSION_COOKIE_NAMES = [ + HOST_SESSION_COOKIE, + LEGACY_SESSION_COOKIE, +] as const; + +export type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number]; + +/** The name a session cookie issued now is set under. */ +export function sessionCookieName(): SessionCookieName { + return shouldEmitSecureCookie() ? HOST_SESSION_COOKIE : LEGACY_SESSION_COOKIE; +} + +interface CookieReader { + get(name: string): { value: string } | undefined; +} + +interface CookieStore extends CookieReader, CookieWriter { + delete(name: string): unknown; +} + +interface CookieWriter { + set( + name: string, + value: string, + options: { + httpOnly: boolean; + secure: boolean; + sameSite: "lax"; + maxAge: number; + path: string; + }, + ): unknown; +} + +/** The session cookie a request carries, under whichever name it came. */ +export function readSessionCookie( + jar: CookieReader, +): { name: SessionCookieName; value: string } | null { + for (const name of SESSION_COOKIE_NAMES) { + const value = jar.get(name)?.value; + if (value) return { name, value }; + } + return null; +} + +/** Whether a raw `Cookie` header carries a session cookie under either name. */ +export function cookieHeaderCarriesSession(header: string | null): boolean { + if (!header) return false; + return header + .split(/;\s*/) + .some((pair) => + SESSION_COOKIE_NAMES.some((name) => pair.startsWith(`${name}=`)), + ); +} + +/** The attributes the session cookie is issued with. */ +export function sessionCookieOptions(maxAgeSeconds: number) { + return { + httpOnly: true, + // A `__Host-` cookie is refused by the browser without `Secure`; the + // name and the flag come from the same switch. + secure: shouldEmitSecureCookie(), + // OAuth callbacks (e.g. Withings) arrive via top-level cross-site + // redirect. Lax keeps CSRF protection for unsafe methods while allowing + // this flow. + sameSite: "lax" as const, + maxAge: maxAgeSeconds, + path: "/", + }; +} + +/** + * Set the session cookie under the name this install issues, and expire a + * cookie under the old name the request still carried, so a browser holds + * one session cookie, not two. + */ +export function issueSessionCookie( + store: CookieStore, + value: string, + maxAgeSeconds: number, +): void { + const name = sessionCookieName(); + store.set(name, value, sessionCookieOptions(maxAgeSeconds)); + if (name !== LEGACY_SESSION_COOKIE && store.get(LEGACY_SESSION_COOKIE)) { + store.delete(LEGACY_SESSION_COOKIE); + } +} + +/** + * Expire the session cookie under both names. The `__Host-` one is + * overwritten with `Secure` and `Path=/`, without which the browser ignores + * the overwrite and the cookie stays; the old name is deleted as it always + * was. + */ +export function expireSessionCookies( + jar: CookieWriter & { delete(name: string): unknown }, +): void { + jar.set(HOST_SESSION_COOKIE, "", { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 0, + path: "/", + }); + jar.delete(LEGACY_SESSION_COOKIE); +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 7224dee51..3350c5ea8 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -11,14 +11,15 @@ import { SessionStoreUnavailableError } from "@/lib/api-errors"; import { credentialRevocations } from "@/lib/auth/credential-revocations"; import { locales, type Locale } from "@/lib/i18n/config"; import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie"; +import { + expireSessionCookies, + issueSessionCookie, + readSessionCookie, + sessionCookieName, +} from "@/lib/auth/session-cookie"; -const SESSION_COOKIE = "healthlog_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days -/** Public name of the browser session cookie, for routes that read it off a - * `NextRequest` directly (rather than through `getSession()`'s `cookies()`). */ -export const SESSION_COOKIE_NAME = SESSION_COOKIE; - /** * 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 @@ -244,7 +245,7 @@ export async function setOnboardingPendingCookie( secure: shouldEmitSecureCookie(), // v1.4.22 — Strict (not Lax) because // no cross-site redirect flow ever depends on this cookie. The - // sibling `healthlog_session` cookie stays Lax because the + // sibling session cookie stays Lax because the // Withings OAuth callback arrives via top-level cross-site // redirect; the onboarding hint has no equivalent flow. sameSite: "strict", @@ -291,15 +292,7 @@ export async function createSession( }); const cookieStore = await cookies(); - cookieStore.set(SESSION_COOKIE, secret, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - // OAuth callbacks (e.g. Withings) arrive via top-level cross-site redirect. - // Lax keeps CSRF protection for unsafe methods while allowing this flow. - sameSite: "lax", - maxAge: SESSION_MAX_AGE_MS / 1000, - path: "/", - }); + issueSessionCookie(cookieStore, secret, SESSION_MAX_AGE_MS / 1000); // Anchor the onboarding cookie alongside the session cookie so a // future auth surface added without remembering the helper can never @@ -329,8 +322,9 @@ export async function getSession(): Promise<{ } const cookieStore = await cookies(); - const cookieValue = cookieStore.get(SESSION_COOKIE)?.value; - if (!cookieValue) return null; + const carried = readSessionCookie(cookieStore); + if (!carried) return null; + const cookieValue = carried.value; const session = await findSessionByCookie(cookieValue); @@ -340,7 +334,7 @@ export async function getSession(): Promise<{ .deleteMany({ where: { id: session.id } }) .catch(() => {}); } - cookieStore.delete(SESSION_COOKIE); + expireSessionCookies(cookieStore); cookieStore.delete(ONBOARDING_COOKIE); return null; } @@ -364,13 +358,16 @@ export async function getSession(): Promise<{ where: { id: session.id }, data: { expiresAt: newExpiry }, }); - cookieStore.set(SESSION_COOKIE, cookieValue, { - httpOnly: true, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: SESSION_MAX_AGE_MS / 1000, - path: "/", - }); + issueSessionCookie(cookieStore, cookieValue, SESSION_MAX_AGE_MS / 1000); + } else if (carried.name !== sessionCookieName()) { + // Issued before the `__Host-` name: re-issued under it, so the upgrade + // signs nobody out. A server-component render cannot write cookies; the + // next route request does. + try { + issueSessionCookie(cookieStore, cookieValue, SESSION_MAX_AGE_MS / 1000); + } catch { + // Read-only cookie context. + } } // v1.23 — sliding "last seen" stamp for the active-session list. Throttled @@ -501,7 +498,7 @@ export async function getSession(): Promise<{ */ export async function getSessionUserLocale(): Promise { const cookieStore = await cookies(); - const cookieValue = cookieStore.get(SESSION_COOKIE)?.value; + const cookieValue = readSessionCookie(cookieStore)?.value; if (!cookieValue) return null; // Same two-path resolution as `getSession`, read-only: this runs in the root @@ -514,7 +511,7 @@ export async function getSessionUserLocale(): Promise { export async function destroySession(): Promise { const cookieStore = await cookies(); - const cookieValue = cookieStore.get(SESSION_COOKIE)?.value; + const cookieValue = readSessionCookie(cookieStore)?.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 @@ -547,7 +544,7 @@ export async function destroySession(): Promise { }); } } - cookieStore.delete(SESSION_COOKIE); + expireSessionCookies(cookieStore); cookieStore.delete(ONBOARDING_COOKIE); } diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 31da5dec6..cea0de0a0 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -288,7 +288,8 @@ export const openApiComponents: NonNullable = { type: "apiKey", in: "cookie", name: "healthlog_session", - description: "Browser session cookie — set by /api/auth/login.", + description: + "Browser session cookie — set by /api/auth/login. Named `__Host-healthlog_session` wherever the cookie is `Secure` (every HTTPS install); a plain-HTTP install, which cannot hold a `__Host-` cookie, keeps `healthlog_session`.", }, }, }; diff --git a/src/proxy.ts b/src/proxy.ts index d5227364c..8cc2b7f8f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { shouldRunWeb } from "@/lib/process-type"; +import { readSessionCookie } from "@/lib/auth/session-cookie"; import { findRetiredRoute, retiredRouteEnvelope, @@ -560,8 +561,7 @@ export function proxy(request: NextRequest) { // can never be session-gated without also being uncacheable. const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic; if (isSessionGatedPage) { - const hasSession = request.cookies.has("healthlog_session"); - if (!hasSession) { + if (!readSessionCookie(request.cookies)) { return applyBaselineSecurityHeaders( NextResponse.redirect(new URL("/auth/login", request.url)), ); diff --git a/tests/integration/routes/parity/oidc-callback.parity.test.ts b/tests/integration/routes/parity/oidc-callback.parity.test.ts index 3f4145bbf..661d78770 100644 --- a/tests/integration/routes/parity/oidc-callback.parity.test.ts +++ b/tests/integration/routes/parity/oidc-callback.parity.test.ts @@ -22,7 +22,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { GET } from "@/app/api/auth/oidc/callback/route"; import { encrypt } from "@/lib/crypto"; import { OIDC_STATE_COOKIE } from "@/lib/auth/oidc-cookie"; -import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; +import { sessionCookieName } from "@/lib/auth/session-cookie"; import { anonymous, callRoute } from "../../../kit/call-route"; import { makeSession, makeUser } from "../../../kit/factories"; @@ -312,7 +312,7 @@ describe("GET /api/auth/oidc/callback parity", () => { const session = await makeSession(first); await step("12-duplicate-with-session", { state: null, - cookies: { [SESSION_COOKIE_NAME]: session.cookie }, + cookies: { [sessionCookieName()]: session.cookie }, }); }); @@ -382,7 +382,7 @@ describe("GET /api/auth/oidc/callback parity", () => { identity("sub-claimer", "claimer@example.test", true); await step("23-claim-signed-in", { state: { intent: "claim", claimBinding: "binding-1" }, - cookies: { [SESSION_COOKIE_NAME]: session.cookie }, + cookies: { [sessionCookieName()]: session.cookie }, }); await step("24-claim-no-cookie", { state: { intent: "claim", claimBinding: "binding-1" }, diff --git a/tests/kit/call-route.ts b/tests/kit/call-route.ts index b81e2bd74..26c180ec1 100644 --- a/tests/kit/call-route.ts +++ b/tests/kit/call-route.ts @@ -25,7 +25,7 @@ import { NextRequest } from "next/server"; import type { z } from "zod"; import { openApiComponents, openApiPaths } from "@/lib/openapi/routes"; -import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; +import { sessionCookieName } from "@/lib/auth/session-cookie"; import { cookieJar, headerJar } from "../integration/mock-next-headers"; import type { BearerFixture, SessionFixture } from "./factories"; @@ -122,7 +122,7 @@ export async function callRoute( headers.set("authorization", `Bearer ${options.auth.token}`); } if (options.auth.kind === "session") { - headers.set("cookie", `${SESSION_COOKIE_NAME}=${options.auth.cookie}`); + headers.set("cookie", `${sessionCookieName()}=${options.auth.cookie}`); } // `next/headers` is served from the shared jars; fill them for this call @@ -133,7 +133,7 @@ export async function callRoute( const savedHeaders = new Map(headerJar); cookieJar.clear(); if (options.auth.kind === "session") { - cookieJar.set(SESSION_COOKIE_NAME, options.auth.cookie); + cookieJar.set(sessionCookieName(), options.auth.cookie); } headerJar.delete("authorization"); headers.forEach((value, name) => headerJar.set(name.toLowerCase(), value)); diff --git a/tests/kit/factories.ts b/tests/kit/factories.ts index 1510314f6..ff7bd564f 100644 --- a/tests/kit/factories.ts +++ b/tests/kit/factories.ts @@ -27,7 +27,8 @@ import type { Workout, } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { createSession, SESSION_COOKIE_NAME } from "@/lib/auth/session"; +import { createSession } from "@/lib/auth/session"; +import { sessionCookieName } from "@/lib/auth/session-cookie"; import { issueApiToken } from "@/lib/auth/issue-token"; import { cookieJar } from "../integration/mock-next-headers"; @@ -91,7 +92,7 @@ export async function makeSession( "healthlog-kit", options.mfaVerifiedAt ?? null, ); - const cookie = cookieJar.get(SESSION_COOKIE_NAME); + const cookie = cookieJar.get(sessionCookieName()); cookieJar.clear(); for (const [name, value] of before) cookieJar.set(name, value); if (!cookie) {