fix(auth): self-hoster opt-out for session-cookie Secure flag (v1.5.1)

v1.5.1 patch. Fixes the silent login failure self-hosters hit on
plain-HTTP origins that aren't localhost — the bundled Docker image
runs NODE_ENV=production, so every cookie carried Secure, and modern
browsers drop those on http://10.x.x.x style origins. The session
cookie never made it back to the browser, /api/auth/me returned 401,
and the login page reloaded.

New SESSION_COOKIE_SECURE env var with three values:
- unset (default) — flag follows NODE_ENV === "production" (unchanged)
- "false" — never set (LAN / VPN / Tailscale-only opt-out)
- "true"  — always set (pnpm dev fronted by HTTPS)

Shared helper at src/lib/auth/secure-cookie.ts replaces four scattered
literals across session, codex device-start, and withings connect.
Six unit cases pin the decision matrix.

OpenAPI spec generator now reads info.version from package.json so
the drift gate stays meaningful instead of catching every release bump.

Default behaviour unchanged for HTTPS-fronted deployments.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-26 19:16:44 +02:00
1 parent 1cd28acebc
commit c6d2b212f2
11 files changed
+161 -8

No files matched your search

+2 -1
View File
@@ -6,6 +6,7 @@ import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { apiSuccess } from "@/lib/api-response";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* v1.4.7.1: Device-code flow for ChatGPT-OAuth.
@@ -42,7 +43,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
),
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
sameSite: "lax",
path: "/",
maxAge: 15 * 60,
+2 -1
View File
@@ -11,6 +11,7 @@ import {
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { NextRequest, NextResponse } from "next/server";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirects the user to Withings OAuth authorization page.
@@ -88,7 +89,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
const response = NextResponse.redirect(url);
response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
path: "/",
@@ -0,0 +1,49 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { shouldEmitSecureCookie } from "../secure-cookie";
describe("shouldEmitSecureCookie", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("emits Secure under NODE_ENV=production when no override is set", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
expect(shouldEmitSecureCookie()).toBe(true);
});
it("omits Secure under NODE_ENV=development when no override is set", () => {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("SESSION_COOKIE_SECURE", "");
expect(shouldEmitSecureCookie()).toBe(false);
});
it("omits Secure when SESSION_COOKIE_SECURE=false even under NODE_ENV=production (LAN / VPN self-host opt-out)", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
expect(shouldEmitSecureCookie()).toBe(false);
});
it("emits Secure when SESSION_COOKIE_SECURE=true even under NODE_ENV=development (dev box behind HTTPS)", () => {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
expect(shouldEmitSecureCookie()).toBe(true);
});
it("trims whitespace and accepts case-insensitive `False` / `TRUE`", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", " False ");
expect(shouldEmitSecureCookie()).toBe(false);
vi.stubEnv("SESSION_COOKIE_SECURE", "TRUE");
expect(shouldEmitSecureCookie()).toBe(true);
});
it("falls back to the NODE_ENV default for unrecognised override values", () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("SESSION_COOKIE_SECURE", "yes");
expect(shouldEmitSecureCookie()).toBe(true);
vi.stubEnv("NODE_ENV", "development");
expect(shouldEmitSecureCookie()).toBe(false);
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Should the `Secure` flag be set on session-class cookies?
*
* Default — the flag is on whenever `NODE_ENV === "production"`, which
* is the safe choice for any deployment terminated by HTTPS (the
* documented self-hosting path: Caddy / Traefik / Nginx in front).
*
* Opt-out — operators running plain HTTP on a LAN or private VPN can
* set `SESSION_COOKIE_SECURE=false` to drop the flag. Without the flag
* a browser will send the cookie back over HTTP, so login round-trips
* complete on `http://10.x.x.x:3000` and friends. This is intentional
* for evaluation, NAS / homelab deployments, and Tailscale-only
* surfaces; it is NOT appropriate for a deployment that ever serves
* plain HTTP to the open internet, because the session cookie then
* crosses the wire in cleartext.
*
* Opt-in — `SESSION_COOKIE_SECURE=true` forces the flag on even under
* `NODE_ENV !== "production"`, useful when a developer fronts their
* `pnpm dev` server with HTTPS for testing the production cookie path.
*
* The function is intentionally stateless / re-evaluated per call so a
* runtime env change (e.g. via `compose.yml` edit + `docker compose up
* -d`) takes effect on the next request without an app restart.
*/
export function shouldEmitSecureCookie(): boolean {
const override = process.env.SESSION_COOKIE_SECURE?.trim().toLowerCase();
if (override === "false") return false;
if (override === "true") return true;
return process.env.NODE_ENV === "production";
}
+4 -3
View File
@@ -3,6 +3,7 @@ import { cookies } from "next/headers";
import type { User } from "@/generated/prisma/client";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { getEvent } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
const SESSION_COOKIE = "healthlog_session";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -28,7 +29,7 @@ export async function setOnboardingPendingCookie(
if (pending) {
cookieStore.set(ONBOARDING_COOKIE, "pending", {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
// v1.4.22 W5 reconcile (Sec-MED-1) — Strict (not Lax) because
// no cross-site redirect flow ever depends on this cookie. The
// sibling `healthlog_session` cookie stays Lax because the
@@ -70,7 +71,7 @@ export async function createSession(
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
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",
@@ -133,7 +134,7 @@ export async function getSession(): Promise<{
});
cookieStore.set(SESSION_COOKIE, session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: SESSION_MAX_AGE_MS / 1000,
path: "/",
+6 -1
View File
@@ -25,6 +25,7 @@
import { createDocument, type ZodOpenApiObject } from "zod-openapi";
import { openApiPaths, openApiComponents } from "./routes";
import { version as packageVersion } from "../../../package.json";
const openApiBase: Pick<
ZodOpenApiObject,
@@ -33,7 +34,11 @@ const openApiBase: Pick<
openapi: "3.1.0",
info: {
title: "HealthLog API",
version: "1.4.23",
// v1.5.1 — pulled from package.json so the spec's info.version
// tracks the release line automatically. The CI drift gate
// (`pnpm openapi:check`) catches the case where the registry
// and the committed spec disagree.
version: packageVersion,
description:
"Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.\n\n" +
"## Date-time contract\n\n" +