//=========================================== // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template //=========================================== import { isBrowserLike } from '@hexclave/shared/dist/utils/env'; import { HexclaveAssertionError } from '@hexclave/shared/dist/utils/errors'; import Cookies from "js-cookie"; import { calculatePKCECodeChallenge, generateRandomCodeVerifier, generateRandomState } from "oauth4webapi"; // INFO: This file is used to manage cookies. It also sets some cookie flags automatically, see this description. // // It provides asynchronous setCookie, getCookie, deleteCookie, etc. functions that can be used in various environments // (browser + Next.js for now). Under the hood, they just get a CookieHelper object and then set the cookies there. // // The CookieHelper object is a simple object that lets you set, get and delete cookies synchronously. Acquiring one // is asynchronous (except for browser environments, where they can be acquired synchronously), but once you have it, // you can use it synchronously. This function is useful if you cannot await in the calling code, but otherwise you // should prefer to await the functions directly. // // Some cookie flags are set automatically by the CookieHelper (and hence also the Cookie functions). // In particular: // - SameSite is set to `Lax` by default, which is already true in Chromium-based browsers, so this creates // compatibility with other browsers that use either Strict or None (particularly Safari and Firefox, and older // versions of Chrome). If Partitioned is automatically set (as described below), then this value is set to `None` // instead. // - Secure is set depending on whether we could successfully determine that the client is on HTTPS. For this, we use a // set of heuristics: // - In a browser environment, we check window.location.protocol which is always accurate // - In a Next.js server environment: // - First we check the `stack-is-https` cookie, which is set in various places on the // client with a Secure attribute. If that one is passed on to the server, we know that the client is on HTTPS // and we can set the Secure flag on the cookie. TODO: Should we also do this with a second cookie with a // __Host- prefix, so a malicious subdomain of the current domain cannot forcibly enable HTTPS mode and // therefore prevent new cookies from being set? // - Otherwise, we check the X-Forwarded-Proto header. If that one is `https`, we know that the client is // (pretending to be) on HTTPS and we can set the Secure flag on the cookie. Note that this header is // spoofable by malicious clients (so is the cookie actually), but since setting this value can only *increase* // security (and therefore prevent setting of a cookie), and requires a malicious client, this is still safe. // - If neither of the above is true, we don't set the Secure flag on the cookie. // - Partitioned is set depending on whether it is needed & supported. Unfortunately, the fact that Partitioned // cookies require SameSite=None, browsers that don't support it will still set them as normal third-party cookies, // which are fundamentally unsafe. Therefore, we need to take extra care that we only ever set Partitioned cookies // if we know for sure that the browser supports it. // - In a browser environment, we check: // - Whether `Secure` is set. If it's not, we don't set Partitioned. // - Whether we can set & retrieve cookies without Partitioned being set. If this is the case, we are likely in a // top-level context or a browser that partitions cookies by default (eg. Firefox). In this case, we don't need // Partitioned and can just proceed as normal. // - Whether CHIPS is supported. To prevent the case where CHIPS is not supported but third-party cookies are (in // which we would accidentally set SameSite=None without Partitioned as the latter requires the former), we // check this by running a simple test with document.cookie. // - Whether the browser supports Partitioned cookies. If yes, set Partitioned. Otherwise, don't set Partitioned. // Since there's no easy cross-compat way to do this (CookieStore and document.cookie do not return whether a // cookie is partitioned on some/all versions of Safari and Firefox), we use a heuristic; we run this test by // creating two cookies with the same name: One with Partitioned and one without. If there are two resulting // cookies, that means they were put into different jars, implying that the browser supports Partitioned cookies // (but doesn't partition cookies by default). If they result in just one cookie, that could mean that the // browser doesn't support Partitioned cookies, or that the browser doesn't put partitioned cookies into // different jars by default, in which case we still don't know. This heuristic works on Chrome, but may // incorrectly conclude that some other browsers don't support Partitioned. But from a security perspective, // that is better than accidentally setting SameSite=None without Partitioned. TODO: Find a better heuristic to // to determine whether the browser supports Partitioned cookies or not. // - In a Next.js server environment, right now we do nothing because of the complexity involved :( TODO: In the // future, we could improve this for example by setting hint cookies from the client, but we need to make sure that // no malicious actor (eg. on a malicious subdomain) can forcefully enable Partitioned cookies on a browser that // does not support it. type SetCookieOptions = { maxAge: number | "session", noOpIfServerComponent?: boolean, domain?: string, secure?: boolean }; type DeleteCookieOptions = { noOpIfServerComponent?: boolean, domain?: string }; function ensureClient() { if (!isBrowserLike()) { throw new Error("cookieClient functions can only be called in a browser environment, yet window is undefined"); } } export type CookieHelper = { get: (name: string) => string | null, getAll: () => Record, set: (name: string, value: string, options: SetCookieOptions) => void, setOrDelete: (name: string, value: string | null, options: SetCookieOptions & DeleteCookieOptions) => void, delete: (name: string, options: DeleteCookieOptions) => void, }; const placeholderCookieHelperIdentity = { "placeholder cookie helper identity": true }; export async function createPlaceholderCookieHelper(): Promise { function throwError(): never { throw new HexclaveAssertionError("Throwing cookie helper is just a placeholder. This should never be called"); } return { get: throwError, getAll: throwError, set: throwError, setOrDelete: throwError, delete: throwError, }; } function requiresSecureAttribute(name: string): boolean { return name.startsWith("__Host-"); } function validateCookieOptions(name: string, options: DeleteCookieOptions | SetCookieOptions) { if (requiresSecureAttribute(name) && options.domain !== undefined) { throw new HexclaveAssertionError("__Host- cookies must not specify a Domain attribute"); } } export async function createCookieHelper(): Promise { if (isBrowserLike()) { return createBrowserCookieHelper(); } else { return await createPlaceholderCookieHelper(); } } export function createCookieHelperSync(): CookieHelper { if (isBrowserLike()) { return createBrowserCookieHelper(); } function throwError(): never { throw new HexclaveAssertionError("Synchronous server cookie helpers are not available on this platform"); } return { get: throwError, getAll: throwError, set: throwError, setOrDelete: throwError, delete: throwError, }; } export function createBrowserCookieHelper(): CookieHelper { return { get: getCookieClient, getAll: getAllCookiesClient, set: setCookieClient, setOrDelete: setOrDeleteCookieClient, delete: deleteCookieClient, }; } function handleCookieError(e: unknown, options: DeleteCookieOptions | SetCookieOptions) { if (e instanceof Error && e.message.includes("Cookies can only be modified in")) { if (options.noOpIfServerComponent) { // ignore } else { throw new HexclaveAssertionError("Attempted to set cookie in server component. Pass { noOpIfServerComponent: true } in the options of Stack's cookie functions if this is intentional and you want to ignore this error. Read more: https://nextjs.org/docs/app/api-reference/functions/cookies#options"); } } else { throw e; } } export function getCookieClient(name: string): string | null { const all = getAllCookiesClient(); return all[name] ?? null; } export function getAllCookiesClient(): Record { ensureClient(); // set a helper cookie, see comment in `NextCookieHelper.set` above // Hexclave rebrand: dual-write the is-https hint cookie under both names Cookies.set("hexclave-is-https", "true", { secure: true, expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365) }); Cookies.set("stack-is-https", "true", { secure: true, expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365) }); return Cookies.get(); } export async function getCookie(name: string): Promise { const cookieHelper = await createCookieHelper(); return cookieHelper.get(name); } export async function isSecure(): Promise { if (isBrowserLike()) { return determineSecureFromClientContext(); } return false; } function determineSecureFromClientContext(): boolean { return typeof window !== "undefined" && window.location.protocol === "https:"; } let _shouldSetPartitionedClientCache: boolean | undefined = undefined; function shouldSetPartitionedClient() { return _shouldSetPartitionedClientCache ??= _internalShouldSetPartitionedClient(); } function _internalShouldSetPartitionedClient() { ensureClient(); if (!(determineSecureFromClientContext())) { return false; } // check whether we can set & retrieve normal cookies (either because we're on a top-level/same-origin context or the browser partitions cookies by default) const cookie1Name = "__Host-stack-temporary-chips-test-" + Math.random().toString(36).substring(2, 15); document.cookie = `${cookie1Name}=value1; Secure; path=/`; const cookies1 = document.cookie.split("; "); document.cookie = `${cookie1Name}=delete1; Secure; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;`; if (cookies1.some((c) => c.startsWith(cookie1Name + "="))) { return false; } // check whether Partitioned cookies are supported by the browser // TODO: See comment at the top. Feels like we should find a better way to do this const cookie2Name = "__Host-stack-temporary-chips-test-" + Math.random().toString(36).substring(2, 15); // just to be safe, delete the cookie first to avoid weird RNG-prediction attacks // I don't know what they look like (since this is a host cookie) but better safe than sorry // (this function should be 100% bulletproof so we don't accidentally fall back to non-partitioned third party cookies on unsupported browsers) document.cookie = `${cookie2Name}=delete1; Secure; SameSite=None; Partitioned; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; document.cookie = `${cookie2Name}=delete2; Secure; SameSite=None; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; // set the cookie, once partitioned and once not partitioned document.cookie = `${cookie2Name}=set1; Secure; SameSite=None; Partitioned; path=/`; document.cookie = `${cookie2Name}=set2; Secure; SameSite=None; path=/`; // check if there are two cookies const cookies2 = document.cookie.split("; "); const numberOfCookiesWithThisName = cookies2.filter((c) => c.startsWith(cookie2Name + "=")).length; // clean up document.cookie = `${cookie2Name}=delete3; Secure; SameSite=None; Partitioned; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; document.cookie = `${cookie2Name}=delete4; Secure; SameSite=None; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; return numberOfCookiesWithThisName === 2; } function setCookieClientInternal(name: string, value: string, options: SetCookieOptions) { validateCookieOptions(name, options); const secure = requiresSecureAttribute(name) || (options.secure ?? determineSecureFromClientContext()); const partitioned = shouldSetPartitionedClient(); Cookies.set(name, value, { expires: options.maxAge === "session" ? undefined : new Date(Date.now() + (options.maxAge) * 1000), domain: options.domain, secure, path: "/", sameSite: "Lax", ...(partitioned ? { partitioned, sameSite: "None", } : {}), }); } function deleteCookieClientInternal(name: string, options: DeleteCookieOptions) { validateCookieOptions(name, options); for (const partitioned of [true, false]) { if (options.domain !== undefined) { Cookies.remove(name, { domain: options.domain, secure: determineSecureFromClientContext(), partitioned, path: "/" }); } Cookies.remove(name, { secure: requiresSecureAttribute(name) || determineSecureFromClientContext(), partitioned, path: "/" }); } } export function setOrDeleteCookieClient(name: string, value: string | null, options: SetCookieOptions & DeleteCookieOptions) { ensureClient(); if (value === null) { deleteCookieClientInternal(name, options); } else { setCookieClientInternal(name, value, options); } } export async function setOrDeleteCookie(name: string, value: string | null, options: SetCookieOptions & DeleteCookieOptions) { const cookieHelper = await createCookieHelper(); cookieHelper.setOrDelete(name, value, options); } export function deleteCookieClient(name: string, options: DeleteCookieOptions) { ensureClient(); deleteCookieClientInternal(name, options); } export async function deleteCookie(name: string, options: DeleteCookieOptions) { const cookieHelper = await createCookieHelper(); cookieHelper.delete(name, options); } export function setCookieClient(name: string, value: string, options: SetCookieOptions) { ensureClient(); setCookieClientInternal(name, value, options); } export async function setCookie(name: string, value: string, options: SetCookieOptions) { const cookieHelper = await createCookieHelper(); cookieHelper.set(name, value, options); } export async function saveVerifierAndState() { const codeVerifier = generateRandomCodeVerifier(); const codeChallenge = await calculatePKCECodeChallenge(codeVerifier); const state = generateRandomState(); // Hexclave rebrand: dual-write the OAuth outer-state cookie under both names await setCookie("hexclave-oauth-outer-" + state, codeVerifier, { maxAge: 60 * 60 }); await setCookie("stack-oauth-outer-" + state, codeVerifier, { maxAge: 60 * 60 }); return { codeChallenge, state, }; } export function consumeVerifierAndStateCookie(state: string) { ensureClient(); // Hexclave rebrand: dual-read the OAuth outer-state cookie, preferring the new name; delete both. const hexclaveCookieName = "hexclave-oauth-outer-" + state; const stackCookieName = "stack-oauth-outer-" + state; const codeVerifier = getCookieClient(hexclaveCookieName) ?? getCookieClient(stackCookieName); if (!codeVerifier) { return null; } deleteCookieClient(hexclaveCookieName, {}); deleteCookieClient(stackCookieName, {}); return { codeVerifier, }; }