mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
d3257a1b6b
commit
5a875d2840
19 files changed
+348
-88
No files matched your search
+6
-3
@@ -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`,
|
||||
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user