better-auth
Version:
The most comprehensive authentication framework for TypeScript.
200 lines (198 loc) • 6.61 kB
JavaScript
import { symmetricDecodeJWT, symmetricEncodeJWT } from "../crypto/jwt.mjs";
import "../crypto/index.mjs";
import { safeJSONParse } from "@better-auth/core/utils";
import * as z from "zod";
//#region src/cookies/session-store.ts
const ALLOWED_COOKIE_SIZE = 4096;
const ESTIMATED_EMPTY_COOKIE_SIZE = 200;
const CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE;
/**
* Parse cookies from the request headers
*/
function parseCookiesFromContext(ctx) {
const cookieHeader = ctx.headers?.get("cookie");
if (!cookieHeader) return {};
const cookies = {};
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) {
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, ctx) {
const 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) {
return Object.keys(chunks).sort((a, b) => {
return getChunkIndex(a) - getChunkIndex(b);
}).map((key) => chunks[key]).join("");
}
/**
* Split a cookie value into chunks if needed
*/
function chunkCookie(storeName, cookie, chunks, logger) {
const chunkCount = Math.ceil(cookie.value.length / CHUNK_SIZE);
if (chunkCount === 1) {
chunks[cookie.name] = cookie.value;
return [cookie];
}
const cookies = [];
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_${storeName.toUpperCase()}_COOKIE`, {
message: `${storeName} 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, cookieOptions) {
const cleanedChunks = {};
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
*/
const storeFactory = (storeName) => (cookieName, cookieOptions, ctx) => {
const chunks = readExistingChunks(cookieName, ctx);
const logger = ctx.context.logger;
return {
getValue() {
return joinChunks(chunks);
},
hasChunks() {
return Object.keys(chunks).length > 0;
},
chunk(value, options) {
const cleanedChunks = getCleanCookies(chunks, cookieOptions);
for (const name in chunks) delete chunks[name];
const cookies = cleanedChunks;
const chunked = chunkCookie(storeName, {
name: cookieName,
value,
options: {
...cookieOptions,
...options
}
}, chunks, logger);
for (const chunk of chunked) cookies[chunk.name] = chunk;
return Object.values(cookies);
},
clean() {
const cleanedChunks = getCleanCookies(chunks, cookieOptions);
for (const name in chunks) delete chunks[name];
return Object.values(cleanedChunks);
},
setCookies(cookies) {
for (const cookie of cookies) ctx.setCookie(cookie.name, cookie.value, cookie.options);
}
};
};
const createSessionStore = storeFactory("Session");
const createAccountStore = storeFactory("Account");
function getChunkedCookie(ctx, cookieName) {
const value = ctx.getCookie(cookieName);
if (value) return value;
const chunks = [];
const cookieHeader = ctx.headers?.get("cookie");
if (!cookieHeader) return null;
const cookies = {};
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 indexStr = name.split(".").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;
}
async function setAccountCookie(c, accountData) {
const accountDataCookie = c.context.authCookies.accountData;
const options = {
maxAge: 300,
...accountDataCookie.options
};
const data = await symmetricEncodeJWT(accountData, c.context.secret, "better-auth-account", options.maxAge);
if (data.length > ALLOWED_COOKIE_SIZE) {
const accountStore = createAccountStore(accountDataCookie.name, options, c);
const cookies = accountStore.chunk(data, options);
accountStore.setCookies(cookies);
} else {
const accountStore = createAccountStore(accountDataCookie.name, options, c);
if (accountStore.hasChunks()) {
const cleanCookies = accountStore.clean();
accountStore.setCookies(cleanCookies);
}
c.setCookie(accountDataCookie.name, data, options);
}
}
async function getAccountCookie(c) {
const accountCookie = getChunkedCookie(c, c.context.authCookies.accountData.name);
if (accountCookie) {
const accountData = safeJSONParse(await symmetricDecodeJWT(accountCookie, c.context.secret, "better-auth-account"));
if (accountData) return accountData;
}
return null;
}
const getSessionQuerySchema = z.optional(z.object({
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()
}));
//#endregion
export { createAccountStore, createSessionStore, getAccountCookie, getChunkedCookie, getSessionQuerySchema, setAccountCookie };
//# sourceMappingURL=session-store.mjs.map