fix(auth): issue a Secure session cookie under the __Host- prefix

A cookie named healthlog_session can be set by any host under the same
registrable domain, so a page on a sibling subdomain could plant a session of
its choosing in a visitor's browser. Wherever the session cookie is Secure it
is now issued as __Host-healthlog_session, which a browser accepts only from
this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie,
keep the old name.

Nobody is signed out: the old name is still read after the new one, a
session found under it is re-issued under the new name and the old cookie
expired, and a browser holding the new cookie is never answered from the old
one. Every reader and writer goes through one module, which the proxy, the
native-caller check, the native handoff and the OIDC callback use; the
session-surface guard follows the new reader.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 17:44:31 +01:00
1 parent d3257a1b6b
commit 5a875d2840
19 files changed
+348 -88

No files matched your search

+6 -3
View File
@@ -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`,
+3 -1
View File
@@ -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`.
+3 -1
View File
@@ -107,10 +107,12 @@ In the admin area, or with a signed-in admin session:
```sh
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
-H 'content-type: application/json' \
--cookie 'healthlog_session=<session>' \
--cookie '__Host-healthlog_session=<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
@@ -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);
+6 -3
View File
@@ -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);
@@ -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
+6 -12
View File
@@ -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;
});
@@ -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", () => ({
+4 -6
View File
@@ -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" } });
@@ -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(
+119 -10
View File
@@ -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<SessionRow> & { 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");
});
});
+3 -11
View File
@@ -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;
}
+139
View File
@@ -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);
}
+25 -28
View File
@@ -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<string | null> {
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<string | null> {
export async function destroySession(): Promise<void> {
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<void> {
});
}
}
cookieStore.delete(SESSION_COOKIE);
expireSessionCookies(cookieStore);
cookieStore.delete(ONBOARDING_COOKIE);
}
+2 -1
View File
@@ -288,7 +288,8 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["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`.",
},
},
};
+2 -2
View File
@@ -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)),
);
@@ -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" },
+3 -3
View File
@@ -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<T = unknown>(
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<T = unknown>(
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));
+3 -2
View File
@@ -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) {