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

@@ -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) {