fix: centralize cookie parsing and handle Expires dates correctly (#7556)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: taesu <taesu@better-auth.com>
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
Taesu
2026-01-28 17:06:03 -08:00
committed by Alex Yang
co-authored by Cursor Agent taesu Alex Yang
parent d2ca08bc48
commit d598bd03f7
3 changed files with 68 additions and 80 deletions
@@ -26,11 +26,55 @@ export function stripSecureCookiePrefix(cookieName: string): string {
return cookieName;
}
/**
* Split `Set-Cookie` header, handling commas in `Expires` dates.
*/
export function splitSetCookieHeader(setCookie: string): string[] {
if (!setCookie) return [];
const result: string[] = [];
let current = "";
let i = 0;
while (i < setCookie.length) {
const c = setCookie[i];
if (c === ",") {
const lower = current.toLowerCase();
if (lower.includes("expires=") && !lower.includes("gmt")) {
current += c;
i++;
} else {
const trimmed = current.trim();
if (trimmed) {
result.push(trimmed);
}
current = "";
i++;
if (i < setCookie.length && setCookie[i] === " ") {
i++;
}
}
continue;
}
current += c;
i++;
}
const trimmed = current.trim();
if (trimmed) {
result.push(trimmed);
}
return result;
}
export function parseSetCookieHeader(
setCookie: string,
): Map<string, CookieAttributes> {
const cookies = new Map<string, CookieAttributes>();
const cookieArray = setCookie.split(", ");
const cookieArray = splitSetCookieHeader(setCookie);
cookieArray.forEach((cookieString) => {
const parts = cookieString.split(";").map((part) => part.trim());
@@ -107,12 +151,9 @@ export function setCookieToHeader(headers: Headers) {
}
});
const setCookieHeaders = setCookieHeader.split(",");
setCookieHeaders.forEach((header) => {
const cookies = parseSetCookieHeader(header);
cookies.forEach((value, name) => {
cookieMap.set(name, value.value);
});
const cookies = parseSetCookieHeader(setCookieHeader);
cookies.forEach((value, name) => {
cookieMap.set(name, value.value);
});
const updatedCookies = Array.from(cookieMap.entries())
@@ -219,6 +219,24 @@ describe("cookie-utils parseSetCookieHeader", () => {
const map = parseSetCookieHeader(header);
expect(map.get("a")?.value).toBe("1");
expect(map.get("b")?.value).toBe("2");
expect(map.get("a")?.expires).toEqual(
new Date("Wed, 21 Oct 2015 07:28:00 GMT"),
);
expect(map.get("b")?.expires).toEqual(
new Date("Thu, 22 Oct 2015 07:28:00 GMT"),
);
});
it("handles cookie with Expires followed by cookie without Expires", () => {
const map = parseSetCookieHeader(
"session=xyz; Expires=Mon, 01 Jan 2026 00:00:00 GMT, token=abc",
);
expect(map.get("session")?.value).toBe("xyz");
expect(map.get("session")?.expires).toEqual(
new Date("Mon, 01 Jan 2026 00:00:00 GMT"),
);
expect(map.get("token")?.value).toBe("abc");
expect(map.get("token")?.expires).toBeUndefined();
});
});
+2 -73
View File
@@ -5,6 +5,7 @@ import type {
} from "@better-auth/core";
import { safeJSONParse } from "@better-auth/core/utils";
import {
parseSetCookieHeader,
SECURE_COOKIE_PREFIX,
stripSecureCookiePrefix,
} from "better-auth/cookies";
@@ -19,79 +20,6 @@ if (Platform.OS !== "web") {
setupExpoOnlineManager();
}
interface CookieAttributes {
value: string;
expires?: Date | undefined;
"max-age"?: number | undefined;
domain?: string | undefined;
path?: string | undefined;
secure?: boolean | undefined;
httpOnly?: boolean | undefined;
sameSite?: ("Strict" | "Lax" | "None") | undefined;
}
export function parseSetCookieHeader(
header: string,
): Map<string, CookieAttributes> {
const cookieMap = new Map<string, CookieAttributes>();
const cookies = splitSetCookieHeader(header);
cookies.forEach((cookie) => {
const parts = cookie.split(";").map((p) => p.trim());
const [nameValue, ...attributes] = parts;
const [name, ...valueParts] = (nameValue || "").split("=");
const value = valueParts.join("=");
if (!name || value === undefined) {
return;
}
const attrObj: CookieAttributes = { value };
attributes.forEach((attr) => {
const [attrName, ...attrValueParts] = attr.split("=");
if (!attrName?.trim()) {
return;
}
const attrValue = attrValueParts.join("=");
const normalizedAttrName = attrName.trim().toLowerCase();
attrObj[normalizedAttrName as "value"] = attrValue;
});
cookieMap.set(name, attrObj);
});
return cookieMap;
}
function splitSetCookieHeader(setCookie: string): string[] {
const parts: string[] = [];
let buffer = "";
let i = 0;
while (i < setCookie.length) {
const char = setCookie[i];
if (char === ",") {
const recent = buffer.toLowerCase();
const hasExpires = recent.includes("expires=");
const hasGmt = /gmt/i.test(recent);
if (hasExpires && !hasGmt) {
buffer += char;
i += 1;
continue;
}
if (buffer.trim().length > 0) {
parts.push(buffer.trim());
buffer = "";
}
i += 1;
if (setCookie[i] === " ") i += 1;
continue;
}
buffer += char;
i += 1;
}
if (buffer.trim().length > 0) {
parts.push(buffer.trim());
}
return parts;
}
interface ExpoClientOptions {
scheme?: string | undefined;
storage: {
@@ -538,5 +466,6 @@ export const expoClient = (opts: ExpoClientOptions) => {
} satisfies BetterAuthClientPlugin;
};
export { parseSetCookieHeader } from "better-auth/cookies";
export * from "./focus-manager";
export * from "./online-manager";