feat: session store chunking (#5645)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Yang
2025-10-28 17:11:52 -07:00
committed by GitHub
parent 7ff7e6a79c
commit 0aa6dff678
4 changed files with 638 additions and 57 deletions

View File

@@ -14,37 +14,17 @@ import { APIError } from "better-call";
import * as z from "zod";
import {
deleteSessionCookie,
getChunkedCookie,
setCookieCache,
setSessionCookie,
} from "../../cookies";
import { symmetricDecodeJWT } from "../../crypto/jwt";
import { getSessionQuerySchema } from "../../cookies/session-store";
import { symmetricDecodeJWT } from "../../crypto";
import type { InferSession, InferUser, Session, User } from "../../types";
import type { Prettify } from "../../types/helper";
import { getDate } from "../../utils/date";
import { safeJSONParse } from "../../utils/json";
export const getSessionQuerySchema = z.optional(
z.object({
/**
* If cookie cache is enabled, it will disable the cache
* and fetch the session from the database
*/
disableCookieCache: z.coerce
.boolean()
.meta({
description: "Disable cookie cache and fetch session from database",
})
.optional(),
disableRefresh: z.coerce
.boolean()
.meta({
description:
"Disable session refresh. Useful for checking session status, without updating the session",
})
.optional(),
}),
);
export const getSession = <Option extends BetterAuthOptions>() =>
createAuthEndpoint(
"/get-session",
@@ -95,7 +75,8 @@ export const getSession = <Option extends BetterAuthOptions>() =>
return null;
}
const sessionDataCookie = ctx.getCookie(
const sessionDataCookie = getChunkedCookie(
ctx,
ctx.context.authCookies.sessionData.name,
);

View File

@@ -369,17 +369,8 @@ describe("getSessionCookie", async () => {
await expect(getCookieCache(request)).rejects.toThrow();
});
it("should log error and skip setting cookie when data exceeds size limit", async () => {
const loggerErrors: string[] = [];
const mockLogger = {
log: (level: string, message: string) => {
if (level === "error") {
loggerErrors.push(message);
}
},
};
const { auth } = await getTestInstance({
it("should chunk large cookies instead of logging error", async () => {
const { client, testUser } = await getTestInstance({
secret: "better-auth.secret",
user: {
additionalFields: {
@@ -402,35 +393,46 @@ describe("getSessionCookie", async () => {
enabled: true,
},
},
logger: mockLogger,
});
// Create a very large string that will exceed the cookie size limit when combined with session data
// The limit is 4093 bytes, so we create data that will definitely exceed it
const largeString = "x".repeat(2000);
const headers = new Headers();
let hasCookieChunks = false;
// Sign up with large user data using the server API
const result = await auth.api.signUpEmail({
body: {
await client.signUp.email(
{
name: "Test User",
email: "large-data-test@example.com",
password: "password123",
customField1: largeString,
customField2: largeString,
customField3: largeString,
} as any,
{
onSuccess(context) {
const setCookie = context.response.headers.get("set-cookie");
if (setCookie) {
const parsed = parseSetCookieHeader(setCookie);
parsed.forEach((value, name) => {
if (
name.includes("session_data.0") ||
name.includes("session_data.1")
) {
hasCookieChunks = true;
}
headers.append("cookie", `${name}=${value.value}`);
});
}
},
},
});
// Check that logger recorded an error about exceeding size limit
const sizeError = loggerErrors.find((msg) =>
msg.includes("Session data exceeds cookie size limit"),
);
expect(sizeError).toBeDefined();
expect(sizeError).toContain("4093 bytes");
// The sign up should still succeed
expect(result).toBeDefined();
expect(result?.user).toBeDefined();
// Verify that chunking happened (instead of logging an error and not caching)
expect(hasCookieChunks).toBe(true);
});
});
@@ -827,3 +829,265 @@ describe("Cookie Cache Field Filtering", () => {
expect(cache?.session?.token).toEqual(expect.any(String));
});
});
describe("Cookie Chunking", () => {
it("should chunk cookies when they exceed 4KB", async () => {
// Create a large string that will exceed the cookie size limit
const largeString = "x".repeat(2000);
const { client, cookieSetter } = await getTestInstance({
secret: "better-auth.secret",
user: {
additionalFields: {
field1: {
type: "string",
defaultValue: "",
},
field2: {
type: "string",
defaultValue: "",
},
},
},
session: {
cookieCache: {
enabled: true,
},
},
});
const headers = new Headers();
// Sign up with large user data
await client.signUp.email(
{
name: "Test User",
email: "chunk-test@example.com",
password: "password123",
field1: largeString,
field2: largeString,
} as any,
{
onSuccess(context) {
const setCookie = context.response.headers.get("set-cookie");
expect(setCookie).toBeDefined();
// Parse set-cookie header to check for chunks
const parsed = parseSetCookieHeader(setCookie!);
let hasChunks = false;
// Check if we have chunked cookies
parsed.forEach((value, name) => {
if (
name.includes("session_data.0") ||
name.includes("session_data.1")
) {
hasChunks = true;
}
});
expect(hasChunks).toBe(true);
// Set cookies in headers for next request
parsed.forEach((value, name) => {
headers.append("cookie", `${name}=${value.value}`);
});
},
},
);
// Now verify we can read it back
const request = new Request("https://example.com/api/auth/session", {
headers,
});
const cache = await getCookieCache(request, {
secret: "better-auth.secret",
});
expect(cache).not.toBeNull();
expect(cache?.user?.email).toEqual("chunk-test@example.com");
expect(cache?.session?.token).toEqual(expect.any(String));
});
it("should reconstruct chunked cookies correctly", async () => {
const largeString = "y".repeat(2500);
const { client, cookieSetter } = await getTestInstance({
secret: "better-auth.secret",
user: {
additionalFields: {
largeField: {
type: "string",
defaultValue: "",
},
},
},
session: {
cookieCache: {
enabled: true,
},
},
});
const headers = new Headers();
await client.signUp.email(
{
name: "Large Data User",
email: "large-chunk-test@example.com",
password: "password123",
largeField: largeString,
} as any,
{
onSuccess: cookieSetter(headers),
},
);
const request = new Request("https://example.com/api/auth/session", {
headers,
});
const cache = await getCookieCache(request, {
secret: "better-auth.secret",
});
expect(cache).not.toBeNull();
expect(cache?.user?.email).toEqual("large-chunk-test@example.com");
expect(cache?.user?.largeField).toEqual(largeString);
});
it("should clean up all chunks when deleting session", async () => {
const largeString = "z".repeat(2000);
const { client } = await getTestInstance({
secret: "better-auth.secret",
user: {
additionalFields: {
field1: {
type: "string",
defaultValue: "",
},
field2: {
type: "string",
defaultValue: "",
},
},
},
session: {
cookieCache: {
enabled: true,
},
},
});
const headers = new Headers();
// Sign up with large data to create chunks
await client.signUp.email(
{
name: "Delete Test User",
email: "delete-chunk-test@example.com",
password: "password123",
field1: largeString,
field2: largeString,
} as any,
{
onSuccess(context) {
const setCookie = context.response.headers.get("set-cookie");
if (setCookie) {
const parsed = parseSetCookieHeader(setCookie);
parsed.forEach((value, name) => {
headers.append("cookie", `${name}=${value.value}`);
});
}
},
},
);
// Sign out
await client.signOut({
fetchOptions: {
headers,
onSuccess(context) {
const setCookie = context.response.headers.get("set-cookie");
expect(setCookie).toBeDefined();
// Should have maxAge=0 for all chunks
const parsed = parseSetCookieHeader(setCookie!);
let hasCleanupChunks = false;
parsed.forEach((value, name) => {
if (name.includes("session_data")) {
expect(value["max-age"]).toBe(0);
hasCleanupChunks = true;
}
});
expect(hasCleanupChunks).toBe(true);
},
},
});
});
it("should NOT chunk cookies when they are under 4KB", async () => {
const { client, testUser, cookieSetter } = await getTestInstance({
secret: "better-auth.secret",
session: {
cookieCache: {
enabled: true,
},
},
});
const headers = new Headers();
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess(context) {
const setCookie = context.response.headers.get("set-cookie");
expect(setCookie).toBeDefined();
const parsed = parseSetCookieHeader(setCookie!);
let hasChunks = false;
let hasSingleSessionData = false;
parsed.forEach((value, name) => {
if (
name.includes("session_data.0") ||
name.includes("session_data.1")
) {
hasChunks = true;
}
if (name.endsWith("session_data")) {
hasSingleSessionData = true;
}
});
expect(hasChunks).toBe(false);
expect(hasSingleSessionData).toBe(true);
parsed.forEach((value, name) => {
headers.append("cookie", `${name}=${value.value}`);
});
},
},
);
// Verify we can read it back
const request = new Request("https://example.com/api/auth/session", {
headers,
});
const cache = await getCookieCache(request, {
secret: "better-auth.secret",
});
expect(cache).not.toBeNull();
expect(cache?.user?.email).toEqual(testUser.email);
});
});

View File

@@ -16,6 +16,7 @@ import type { Session, User } from "../types";
import { getDate } from "../utils/date";
import { safeJSONParse } from "../utils/json";
import { getBaseURL } from "../utils/url";
import { createSessionStore } from "./session-store";
export function createCookieGetter(options: BetterAuthOptions) {
const secure =
@@ -170,13 +171,30 @@ export async function setCookieCache(
);
}
// Check if we need to chunk the cookie (only if it exceeds 4093 bytes)
if (data.length > 4093) {
ctx.context?.logger?.error(
`Session data exceeds cookie size limit (${data.length} bytes > 4093 bytes). Consider reducing session data size or disabling cookie cache. Session will not be cached in cookie.`,
const sessionStore = createSessionStore(
ctx.context.authCookies.sessionData.name,
options,
ctx,
);
return;
const cookies = sessionStore.chunk(data, options);
sessionStore.setCookies(cookies);
} else {
const sessionStore = createSessionStore(
ctx.context.authCookies.sessionData.name,
options,
ctx,
);
if (sessionStore.hasChunks()) {
const cleanCookies = sessionStore.clean();
sessionStore.setCookies(cleanCookies);
}
ctx.setCookie(ctx.context.authCookies.sessionData.name, data, options);
}
ctx.setCookie(ctx.context.authCookies.sessionData.name, data, options);
}
}
@@ -249,10 +267,16 @@ export function deleteSessionCookie(
...ctx.context.authCookies.sessionToken.options,
maxAge: 0,
});
ctx.setCookie(ctx.context.authCookies.sessionData.name, "", {
...ctx.context.authCookies.sessionData.options,
maxAge: 0,
});
// Use createSessionStore to clean up all session data chunks
const sessionStore = createSessionStore(
ctx.context.authCookies.sessionData.name,
ctx.context.authCookies.sessionData.options,
ctx,
);
const cleanCookies = sessionStore.clean();
sessionStore.setCookies(cleanCookies);
if (!skipDontRememberMe) {
ctx.setCookie(ctx.context.authCookies.dontRememberToken.name, "", {
...ctx.context.authCookies.dontRememberToken.options,
@@ -346,7 +370,30 @@ export const getCookieCache = async <
? `__Secure-${cookiePrefix}.${cookieName}`
: `${cookiePrefix}.${cookieName}`;
const parsedCookie = parseCookies(cookies);
const sessionData = parsedCookie.get(name);
// Check for chunked cookies
let sessionData = parsedCookie.get(name);
if (!sessionData) {
// Try to reconstruct from chunks
const chunks: Array<{ index: number; value: string }> = [];
for (const [cookieName, value] of parsedCookie.entries()) {
if (cookieName.startsWith(name + ".")) {
const parts = cookieName.split(".");
const indexStr = parts[parts.length - 1];
const index = parseInt(indexStr || "0", 10);
if (!isNaN(index)) {
chunks.push({ index, value });
}
}
}
if (chunks.length > 0) {
// Sort by index and join
chunks.sort((a, b) => a.index - b.index);
sessionData = chunks.map((c) => c.value).join("");
}
}
if (sessionData) {
const secret = config?.secret || env.BETTER_AUTH_SECRET;
if (!secret) {
@@ -397,3 +444,4 @@ export const getCookieCache = async <
};
export * from "./cookie-utils";
export { createSessionStore, getChunkedCookie } from "./session-store";

View File

@@ -0,0 +1,288 @@
import type { GenericEndpointContext } from "@better-auth/core";
import type { InternalLogger } from "@better-auth/core/env";
import type { CookieOptions } from "better-call";
import * as z from "zod";
// Cookie size constants based on browser limits
const ALLOWED_COOKIE_SIZE = 4096;
// Estimated size of an empty cookie with all attributes
// (name, path, domain, secure, httpOnly, sameSite, expires/maxAge)
const ESTIMATED_EMPTY_COOKIE_SIZE = 200;
const CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE;
interface Cookie {
name: string;
value: string;
options: CookieOptions;
}
type Chunks = Record<string, string>;
/**
* Parse cookies from the request headers
*/
function parseCookiesFromContext(
ctx: GenericEndpointContext,
): Record<string, string> {
const cookieHeader = ctx.headers?.get("cookie");
if (!cookieHeader) {
return {};
}
const cookies: Record<string, string> = {};
const pairs = cookieHeader.split("; ");
for (const pair of pairs) {
const [name, ...valueParts] = pair.split("=");
if (name && valueParts.length > 0) {
cookies[name] = valueParts.join("=");
}
}
return cookies;
}
/**
* Extract the chunk index from a cookie name
*/
function getChunkIndex(cookieName: string): number {
const parts = cookieName.split(".");
const lastPart = parts[parts.length - 1];
const index = parseInt(lastPart || "0", 10);
return isNaN(index) ? 0 : index;
}
/**
* Read all existing chunks from cookies
*/
function readExistingChunks(
cookieName: string,
ctx: GenericEndpointContext,
): Chunks {
const chunks: Chunks = {};
const cookies = parseCookiesFromContext(ctx);
for (const [name, value] of Object.entries(cookies)) {
if (name.startsWith(cookieName)) {
chunks[name] = value;
}
}
return chunks;
}
/**
* Get the full session data by joining all chunks
*/
function joinChunks(chunks: Chunks): string {
const sortedKeys = Object.keys(chunks).sort((a, b) => {
const aIndex = getChunkIndex(a);
const bIndex = getChunkIndex(b);
return aIndex - bIndex;
});
return sortedKeys.map((key) => chunks[key]).join("");
}
/**
* Split a cookie value into chunks if needed
*/
function chunkCookie(
cookie: Cookie,
chunks: Chunks,
logger: InternalLogger,
): Cookie[] {
const chunkCount = Math.ceil(cookie.value.length / CHUNK_SIZE);
if (chunkCount === 1) {
chunks[cookie.name] = cookie.value;
return [cookie];
}
const cookies: Cookie[] = [];
for (let i = 0; i < chunkCount; i++) {
const name = `${cookie.name}.${i}`;
const start = i * CHUNK_SIZE;
const value = cookie.value.substring(start, start + CHUNK_SIZE);
cookies.push({ ...cookie, name, value });
chunks[name] = value;
}
logger.debug("CHUNKING_SESSION_COOKIE", {
message: `Session cookie exceeds allowed ${ALLOWED_COOKIE_SIZE} bytes.`,
emptyCookieSize: ESTIMATED_EMPTY_COOKIE_SIZE,
valueSize: cookie.value.length,
chunkCount,
chunks: cookies.map((c) => c.value.length + ESTIMATED_EMPTY_COOKIE_SIZE),
});
return cookies;
}
/**
* Get all cookies that should be cleaned (removed)
*/
function getCleanCookies(
chunks: Chunks,
cookieOptions: CookieOptions,
): Record<string, Cookie> {
const cleanedChunks: Record<string, Cookie> = {};
for (const name in chunks) {
cleanedChunks[name] = {
name,
value: "",
options: { ...cookieOptions, maxAge: 0 },
};
}
return cleanedChunks;
}
/**
* Create a session store for handling cookie chunking.
* When session data exceeds 4KB, it automatically splits it into multiple cookies.
*
* Based on next-auth's SessionStore implementation.
* @see https://github.com/nextauthjs/next-auth/blob/27b2519b84b8eb9cf053775dea29d577d2aa0098/packages/next-auth/src/core/lib/cookie.ts
*/
export function createSessionStore(
cookieName: string,
cookieOptions: CookieOptions,
ctx: GenericEndpointContext,
) {
const chunks = readExistingChunks(cookieName, ctx);
const logger = ctx.context.logger;
return {
/**
* Get the full session data by joining all chunks
*/
getValue(): string {
return joinChunks(chunks);
},
/**
* Check if there are existing chunks
*/
hasChunks(): boolean {
return Object.keys(chunks).length > 0;
},
/**
* Chunk a cookie value and return all cookies to set (including cleanup cookies)
*/
chunk(value: string, options?: Partial<CookieOptions>): Cookie[] {
// Start by cleaning all existing chunks
const cleanedChunks = getCleanCookies(chunks, cookieOptions);
// Clear the chunks object
for (const name in chunks) {
delete chunks[name];
}
const cookies: Record<string, Cookie> = cleanedChunks;
// Create new chunks
const chunked = chunkCookie(
{
name: cookieName,
value,
options: { ...cookieOptions, ...options },
},
chunks,
logger,
);
// Update with new chunks
for (const chunk of chunked) {
cookies[chunk.name] = chunk;
}
return Object.values(cookies);
},
/**
* Get cookies to clean up all chunks
*/
clean(): Cookie[] {
const cleanedChunks = getCleanCookies(chunks, cookieOptions);
// Clear the chunks object
for (const name in chunks) {
delete chunks[name];
}
return Object.values(cleanedChunks);
},
/**
* Set all cookies in the context
*/
setCookies(cookies: Cookie[]): void {
for (const cookie of cookies) {
ctx.setCookie(cookie.name, cookie.value, cookie.options);
}
},
};
}
export function getChunkedCookie(
ctx: GenericEndpointContext,
cookieName: string,
): string | null {
const value = ctx.getCookie(cookieName);
if (value) {
return value;
}
const chunks: Array<{ index: number; value: string }> = [];
const cookieHeader = ctx.headers?.get("cookie");
if (!cookieHeader) {
return null;
}
const cookies: Record<string, string> = {};
const pairs = cookieHeader.split("; ");
for (const pair of pairs) {
const [name, ...valueParts] = pair.split("=");
if (name && valueParts.length > 0) {
cookies[name] = valueParts.join("=");
}
}
for (const [name, val] of Object.entries(cookies)) {
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 });
}
}
}
if (chunks.length > 0) {
chunks.sort((a, b) => a.index - b.index);
return chunks.map((c) => c.value).join("");
}
return null;
}
export const getSessionQuerySchema = z.optional(
z.object({
/**
* If cookie cache is enabled, it will disable the cache
* and fetch the session from the database
*/
disableCookieCache: z.coerce
.boolean()
.meta({
description: "Disable cookie cache and fetch session from database",
})
.optional(),
disableRefresh: z.coerce
.boolean()
.meta({
description:
"Disable session refresh. Useful for checking session status, without updating the session",
})
.optional(),
}),
);