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

@@ -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" } });