diff --git a/.changeset/clean-rice-skip.md b/.changeset/clean-rice-skip.md new file mode 100644 index 0000000000..fb0fbef6ef --- /dev/null +++ b/.changeset/clean-rice-skip.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Prevent `getSession` from failing when cookie caching is disabled while clients still have cached session cookies. diff --git a/packages/better-auth/src/api/routes/cookie-cache-fallback.test.ts b/packages/better-auth/src/api/routes/cookie-cache-fallback.test.ts index 4d76c834dc..2f2af62f42 100644 --- a/packages/better-auth/src/api/routes/cookie-cache-fallback.test.ts +++ b/packages/better-auth/src/api/routes/cookie-cache-fallback.test.ts @@ -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: { diff --git a/packages/better-auth/src/api/routes/session.ts b/packages/better-auth/src/api/routes/session.ts index 2380d4ccc6..a8e3d091df 100644 --- a/packages/better-auth/src/api/routes/session.ts +++ b/packages/better-auth/src/api/routes/session.ts @@ -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 =