fix(session): skip cache cookie when disabled (#11120)

Co-authored-by: Taesu <bytaesu@gmail.com>
This commit is contained in:
Max
2026-09-06 01:55:30 +00:00
committed by GitHub
co-authored by Taesu
parent c47b76517d
commit 7ec71461f1
5 changed files with 206 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Prevent `getSession` from failing when cookie caching is disabled while clients still have cached session cookies.
@@ -1,7 +1,141 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseCookies } from "../../cookies";
import { parseCookies, parseSetCookieHeader } from "../../cookies";
import { symmetricEncodeJWT } from "../../crypto";
import { getTestInstance } from "../../test-utils/test-instance";
/**
* @see https://github.com/better-auth/better-auth/issues/11119
*/
describe("disabled cookie cache fallback", () => {
const secret = "better-auth-retired-cookie-cache-test-secret";
const cacheMaxAge = 5 * 60;
it.each([
{
mode: "in configuration",
cacheEnabled: false,
query: undefined,
expectedSessionDataMaxAge: 0,
},
{
mode: "for the request",
cacheEnabled: true,
query: { disableCookieCache: true },
expectedSessionDataMaxAge: cacheMaxAge,
},
])("ignores retired session_data when caching is disabled $mode", async ({
cacheEnabled,
query,
expectedSessionDataMaxAge,
}) => {
const { client, testUser, cookieSetter } = await getTestInstance({
secret,
advanced: {
cookies: {
session_token: { name: "custom.session_token" },
session_data: { name: "custom.session" },
},
},
session: {
cookieCache: {
enabled: cacheEnabled,
strategy: "compact",
maxAge: cacheMaxAge,
},
},
});
const headers = new Headers();
await client.signIn.email(
{ email: testUser.email, password: testUser.password },
{ onSuccess: cookieSetter(headers) },
);
const retiredCache = await symmetricEncodeJWT(
{ retired: true },
secret,
"better-auth-session",
);
headers.set(
"cookie",
`${headers.get("cookie")}; custom.session=${retiredCache}; custom.session.0=stale; custom.session.1=chunks`,
);
let setCookieHeader = "";
const session = await client.getSession({
query,
fetchOptions: {
headers,
onSuccess(context) {
setCookieHeader = context.response.headers.get("set-cookie") || "";
},
},
});
expect(session.data?.user.email).toBe(testUser.email);
const responseCookies = parseSetCookieHeader(setCookieHeader);
expect(responseCookies.get("custom.session")?.["max-age"]).toBe(
expectedSessionDataMaxAge,
);
for (const name of ["custom.session.0", "custom.session.1"]) {
expect(responseCookies.get(name)?.["max-age"]).toBe(0);
}
expect(responseCookies.has("custom.session_token")).toBe(false);
});
it.each([
{ state: "missing", sessionTokenCookie: "" },
{
state: "invalid",
sessionTokenCookie: "; custom.session_token=invalid",
},
])("clears retired session_data when session_token is $state", async ({
sessionTokenCookie,
}) => {
const { client } = await getTestInstance({
secret,
advanced: {
cookies: {
session_token: { name: "custom.session_token" },
session_data: { name: "custom.session" },
},
},
session: {
cookieCache: { enabled: false },
},
});
const retiredCache = await symmetricEncodeJWT(
{ retired: true },
secret,
"better-auth-session",
);
const headers = new Headers({
cookie:
`custom.session=${retiredCache}; custom.session.0=stale; custom.session.1=chunks` +
sessionTokenCookie,
});
let setCookieHeader = "";
const session = await client.getSession({
fetchOptions: {
headers,
onSuccess(context) {
setCookieHeader = context.response.headers.get("set-cookie") || "";
},
},
});
expect(session.data).toBeNull();
const responseCookies = parseSetCookieHeader(setCookieHeader);
for (const name of [
"custom.session",
"custom.session.0",
"custom.session.1",
]) {
expect(responseCookies.get(name)?.["max-age"]).toBe(0);
}
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/9233
*
@@ -19,6 +153,7 @@ describe("cookieCache HMAC verification failure fallback", async () => {
afterEach(() => {
vi.useRealTimers();
});
it("should fall through to session_token DB validation when session_data HMAC fails", async () => {
const { client, testUser, cookieSetter } = await getTestInstance({
session: {
+23 -10
View File
@@ -11,6 +11,7 @@ import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import * as z from "zod";
import { hasServerSessionStore } from "../../context/store-capabilities";
import {
createSessionStore,
decodeCookieCache,
deleteSessionCookie,
expireCookie,
@@ -83,12 +84,14 @@ export const getSession = <Option extends BetterAuthOptions>() =>
}
try {
const cookieCacheEnabled =
ctx.context.options.session?.cookieCache?.enabled === true;
const sessionCookieToken = await ctx.getSignedCookie(
ctx.context.authCookies.sessionToken.name,
ctx.context.secret,
);
if (!sessionCookieToken) {
if (!sessionCookieToken && cookieCacheEnabled) {
return null;
}
@@ -96,11 +99,25 @@ export const getSession = <Option extends BetterAuthOptions>() =>
ctx,
ctx.context.authCookies.sessionData.name,
);
if (sessionDataCookie && !cookieCacheEnabled) {
const sessionStore = createSessionStore(
ctx.context.authCookies.sessionData.name,
ctx.context.authCookies.sessionData.attributes,
ctx,
);
sessionStore.setCookies(sessionStore.clean());
}
if (!sessionCookieToken) {
return null;
}
const sessionDataPayload = sessionDataCookie
? await decodeCookieCache(ctx, sessionDataCookie)
: null;
if (sessionDataCookie && !sessionDataPayload) {
const shouldUseCookieCache =
cookieCacheEnabled && !ctx.query?.disableCookieCache;
const sessionDataPayload =
shouldUseCookieCache && sessionDataCookie
? await decodeCookieCache(ctx, sessionDataCookie)
: null;
if (shouldUseCookieCache && sessionDataCookie && !sessionDataPayload) {
expireCookie(ctx, ctx.context.authCookies.sessionData);
}
@@ -112,11 +129,7 @@ export const getSession = <Option extends BetterAuthOptions>() =>
/**
* If session data is present in the cookie, check if it should be used or refreshed
*/
if (
sessionDataPayload?.session &&
ctx.context.options.session?.cookieCache?.enabled &&
!ctx.query?.disableCookieCache
) {
if (sessionDataPayload?.session) {
const session = sessionDataPayload.session;
let shouldExpireCookieCache =
@@ -2077,6 +2077,26 @@ describe("Cookie header without whitespace after semicolon", () => {
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/11119
*/
describe("getChunkedCookie", () => {
it.each([
"better-auth.session_data.0junk",
"better-auth.session_data.nested.0",
"better-auth.session_data.01",
"better-auth.session_data.-1",
])("ignores non-canonical chunk name %s", (name) => {
const headers = new Headers({ cookie: `${name}=chunk` });
const ctx = {
getCookie: () => undefined,
headers,
} as unknown as Parameters<typeof getChunkedCookie>[0];
expect(getChunkedCookie(ctx, "better-auth.session_data")).toBeNull();
});
});
describe("parseCookies validation", () => {
it("returns empty map for empty header", () => {
expect(parseCookies("").size).toBe(0);
@@ -43,6 +43,22 @@ interface Cookie {
type Chunks = Record<string, string>;
function parseCookieChunkIndex(
cookieName: string,
name: string,
): number | null {
const prefix = `${cookieName}.`;
if (!name.startsWith(prefix)) return null;
const suffix = name.slice(prefix.length);
const index = Number(suffix);
if (!Number.isSafeInteger(index) || index < 0 || String(index) !== suffix) {
return null;
}
return index;
}
/**
* Read all existing chunks from cookies
*/
@@ -54,9 +70,9 @@ function readExistingChunks(
const cookies = parseCookies(ctx.headers?.get("cookie") || "");
for (const [name, value] of cookies) {
if (name.startsWith(cookieName)) {
chunks[name] = value;
}
if (name !== cookieName && parseCookieChunkIndex(cookieName, name) === null)
continue;
chunks[name] = value;
}
return chunks;
@@ -208,14 +224,9 @@ export function getChunkedCookie(
}
for (const [name, val] of parseCookies(cookieHeader)) {
if (name.startsWith(cookieName + ".")) {
const parts = name.split(".");
const indexStr = parts.at(-1);
const index = parseInt(indexStr || "0", 10);
if (!isNaN(index)) {
chunks.push({ index, value: val });
}
}
const index = parseCookieChunkIndex(cookieName, name);
if (index === null) continue;
chunks.push({ index, value: val });
}
if (chunks.length > 0) {