mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
fix(session): skip cache cookie when disabled (#11120)
Co-authored-by: Taesu <bytaesu@gmail.com>
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user