diff --git a/.changeset/fix-bearer-cookie-parse-mutate-serialize.md b/.changeset/fix-bearer-cookie-parse-mutate-serialize.md new file mode 100644 index 0000000000..a9967418d5 --- /dev/null +++ b/.changeset/fix-bearer-cookie-parse-mutate-serialize.md @@ -0,0 +1,9 @@ +--- +"better-auth": patch +--- + +The bearer plugin now produces a single entry per cookie name when merging +its session token into the request `Cookie` header. Previously the merged +header could carry two entries for the same name if the request already +had a stale session cookie, which would surface to downstream code that +picks the first occurrence. diff --git a/packages/better-auth/src/cookies/cookie-utils.ts b/packages/better-auth/src/cookies/cookie-utils.ts index d404842cc7..5d6f6a0e7b 100644 --- a/packages/better-auth/src/cookies/cookie-utils.ts +++ b/packages/better-auth/src/cookies/cookie-utils.ts @@ -173,6 +173,34 @@ export function toCookieOptions( }; } +/** + * Add or replace a cookie in the request `Cookie` header. + * + * Cookie pairs are joined with `; `, but `headers.append("cookie", ...)` + * joins with `, ` in some runtimes (e.g. Deno, Cloudflare Workers) and + * breaks downstream cookie parsing. This builds the header value via + * parse-mutate-serialize. + */ +export function setRequestCookie( + headers: Headers, + name: string, + value: string, +): void { + const cookieMap = new Map(); + for (const pair of (headers.get("cookie") || "").split(";")) { + const trimmed = pair.trim(); + const eq = trimmed.indexOf("="); + if (eq > 0) cookieMap.set(trimmed.slice(0, eq), trimmed.slice(eq + 1)); + } + + cookieMap.set(name, value); + + headers.set( + "cookie", + Array.from(cookieMap, ([k, v]) => `${k}=${v}`).join("; "), + ); +} + export function setCookieToHeader(headers: Headers) { return (context: { response: Response }) => { const setCookieHeader = context.response.headers.get("set-cookie"); diff --git a/packages/better-auth/src/cookies/cookies.test.ts b/packages/better-auth/src/cookies/cookies.test.ts index 2eed1335e7..5e65273b17 100644 --- a/packages/better-auth/src/cookies/cookies.test.ts +++ b/packages/better-auth/src/cookies/cookies.test.ts @@ -12,6 +12,7 @@ import { HOST_COOKIE_PREFIX, parseSetCookieHeader, SECURE_COOKIE_PREFIX, + setRequestCookie, stripSecureCookiePrefix, toCookieOptions, } from "./cookie-utils"; @@ -372,6 +373,44 @@ describe("cookie-utils stripSecureCookiePrefix", () => { }); }); +/** + * @see https://github.com/better-auth/better-call/issues/54 + * @see https://github.com/better-auth/better-auth/pull/8089 + */ +describe("cookie-utils setRequestCookie", () => { + it("writes a cookie when the header is empty", () => { + const headers = new Headers(); + setRequestCookie(headers, "better-auth.session_token", "abc"); + expect(headers.get("cookie")).toBe("better-auth.session_token=abc"); + }); + + it("preserves existing cookies and joins with `; ` per RFC 6265", () => { + const headers = new Headers({ cookie: "preference=dark; locale=en" }); + setRequestCookie(headers, "better-auth.session_token", "abc"); + expect(headers.get("cookie")).toBe( + "preference=dark; locale=en; better-auth.session_token=abc", + ); + }); + + it("replaces an existing cookie of the same name rather than duplicating it", () => { + const headers = new Headers({ + cookie: "better-auth.session_token=stale; locale=en", + }); + setRequestCookie(headers, "better-auth.session_token", "fresh"); + expect(headers.get("cookie")).toBe( + "better-auth.session_token=fresh; locale=en", + ); + }); + + it("ignores malformed pairs in the existing header", () => { + const headers = new Headers({ cookie: "valid=1; ; =orphan; locale=en" }); + setRequestCookie(headers, "better-auth.session_token", "abc"); + expect(headers.get("cookie")).toBe( + "valid=1; locale=en; better-auth.session_token=abc", + ); + }); +}); + describe("getSessionCookie", async () => { it("should return the correct session cookie", async () => { const { signInWithTestUser } = await getTestInstance(); diff --git a/packages/better-auth/src/plugins/bearer/index.ts b/packages/better-auth/src/plugins/bearer/index.ts index 18a69ab5ea..d345301245 100644 --- a/packages/better-auth/src/plugins/bearer/index.ts +++ b/packages/better-auth/src/plugins/bearer/index.ts @@ -3,6 +3,7 @@ import { createAuthMiddleware } from "@better-auth/core/api"; import { createHMAC } from "@better-auth/utils/hmac"; import { serializeSignedCookie } from "better-call"; import { parseSetCookieHeader } from "../../cookies"; +import { setRequestCookie } from "../../cookies/cookie-utils"; import { PACKAGE_VERSION } from "../../version"; declare module "@better-auth/core" { @@ -105,14 +106,10 @@ export const bearer = (options?: BearerOptions | undefined) => { const headers = new Headers({ ...Object.fromEntries(existingHeaders?.entries()), }); - // Use headers.set() with "; " separator per RFC 6265. - // headers.append("cookie") joins with ", " in some runtimes - // (e.g. Deno, Cloudflare Workers), which breaks cookie parsing. - const existingCookie = headers.get("cookie"); - const newCookie = `${c.context.authCookies.sessionToken.name}=${signedToken}`; - headers.set( - "cookie", - existingCookie ? `${existingCookie}; ${newCookie}` : newCookie, + setRequestCookie( + headers, + c.context.authCookies.sessionToken.name, + signedToken, ); return { context: {