UNPKG

mentoss

Version:

A utility to mock fetch requests and responses.

332 lines (331 loc) 11.7 kB
/** * @fileoverview A class that represents cookie-based credentials. * @author Nicholas C. Zakas */ /* global Headers */ //----------------------------------------------------------------------------- // Imports //----------------------------------------------------------------------------- import { parseUrl } from "./util.js"; //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** @typedef {import("./types.js").Credentials} Credentials */ /** * @typedef {"strict"|"lax"|"none"} SameSiteType */ /** * @typedef {Object} CookieInfo * @property {string} name The name of the cookie. * @property {string} value The value of the cookie. * @property {string} [domain] The domain of the cookie. * @property {string} [path] The path of the cookie. * @property {boolean} [secure] The secure flag of the cookie. * @property {SameSiteType} [sameSite] The SameSite attribute of the cookie. */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- const sameSiteValues = new Set(["strict", "lax", "none"]); /** * Asserts that a string is a valid domain that does not include a protocol or path. * @param {string|undefined} domain The domain string to verify. * @throws {Error} If the domain is not valid. */ function assertValidDomain(domain) { if (!domain) { throw new TypeError("Domain is required."); } const domainPattern = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)+[a-zA-Z]{2,}$/; if (!domainPattern.test(domain)) { throw new TypeError(`Invalid domain: ${domain}`); } } /** * Asserts that a string is a valid SameSite value and that the security requirements are met. * @param {SameSiteType|undefined} sameSite The SameSite value to verify. * @param {boolean} secure The secure flag of the cookie. * @throws {TypeError} If the SameSite value is not valid or if SameSite=None without Secure. */ function assertValidSameSite(sameSite, secure) { if (sameSite && !sameSiteValues.has(sameSite)) { throw new TypeError(`Invalid sameSite value: ${sameSite}`); } // If sameSite is "none", secure must be true if (sameSite === "none" && !secure) { throw new TypeError(`SameSite=None requires Secure flag to be true`); } } /** * Represents a cookie. * @implements {CookieInfo} */ class Cookie { /** * The name of the cookie. * @type {string} */ name; /** * The value of the cookie. * @type {string} */ value; /** * The domain of the cookie. * @type {string} */ domain; /** * The path of the cookie. * @type {string} */ path; /** * The secure flag of the cookie. * @type {boolean} */ secure; /** * The SameSite attribute of the cookie. * @type {SameSiteType} */ sameSite; /** * Creates a new CookieData instance. * @param {Object} options The options for the cookie. * @param {string} options.name The name of the cookie. * @param {string} options.value The value of the cookie. * @param {string|undefined} options.domain The domain of the cookie. * @param {string} [options.path="/"] The path of the cookie. * @param {boolean} [options.secure=false] The secure flag of the cookie. * @param {SameSiteType} [options.sameSite="lax"] The SameSite attribute of the cookie. */ constructor({ name, value, domain, path = "/", secure = false, sameSite = "lax", }) { assertValidDomain(domain); if (!name) { throw new TypeError("Cookie name is required."); } if (!value) { throw new TypeError("Cookie value is required."); } assertValidSameSite(sameSite, secure); this.name = name; this.value = value; this.domain = /** @type {string} */ (domain); this.path = path; this.secure = secure; this.sameSite = sameSite; } /** * Gets a unique key for this cookie. This is used to store the cookie * in the credentials map to uniquely identify cookies based on their * properties. * @returns {string} */ get key() { return Cookie.getKey(this.name, this.domain, this.path, this.secure); } /** * Checks if this cookie is a credential for the given request. * @param {Request} request The request to check. * @return {boolean} True if this cookie is a credential for the request. */ isCredentialForRequest(request) { const url = parseUrl(request.url); // Basic checks for domain, path, and secure flag const basicChecks = url.hostname.endsWith(this.domain) && url.pathname.startsWith(this.path) && (this.secure ? url.protocol === "https:" : true); if (!basicChecks) { return false; } // Check SameSite attribute if (this.sameSite) { const requestOrigin = request.headers?.get("Origin"); switch (this.sameSite) { case "strict": // Only send cookie if the request came from the same origin if (requestOrigin && requestOrigin !== url.origin) { return false; } break; case "lax": // Permit cookies for navigation to top-level document via "safe" methods // For simplicity, we'll only block cross-origin non-GET requests in Lax mode if (requestOrigin && requestOrigin !== url.origin && request.method !== "GET") { return false; } break; case "none": // Allow cross-origin requests, but cookie must be Secure // We already validated secure flag in the constructor break; default: // Default to Lax behavior if (requestOrigin && requestOrigin !== url.origin && request.method !== "GET") { return false; } } } return true; } /** * Converts this cookie to a cookie header string. * @return {string} The cookie header string. */ toCookieHeaderString() { return `${encodeURIComponent(this.name)}=${encodeURIComponent(this.value)}`; } /** * Returns a string representation of the cookie. * @return {string} The string representation of the cookie. */ toString() { let cookieString = `🍪 [Cookie: ${this.name}=${this.value}`; if (this.domain) { cookieString += `; Domain=${this.domain}`; } if (this.path) { cookieString += `; Path=${this.path}`; } if (this.sameSite) { cookieString += `; SameSite=${this.sameSite}`; } if (this.secure) { cookieString += `; Secure`; } return cookieString + "]"; } /** * Returns a unique key for a cookie based on its properties. * @param {string} name The name of the cookie. * @param {string} domain The domain of the cookie. * @param {string} [path="/"] The path of the cookie. * @param {boolean} [secure=false] The secure flag of the cookie. * @returns {string} The unique key for the cookie. */ static getKey(name, domain, path = "/", secure = false) { return JSON.stringify([name, domain, path, secure]); } } //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** * A class that represents cookie-based credentials. * @implements {Credentials} */ export class CookieCredentials { /** * The domain for the cookie credentials. * @type {string|undefined} */ #domain; /** * The cookies for the cookie credentials. * @type {Map<string,Cookie>} */ #cookies = new Map(); /** * The base URL for the cookie credentials. This will be overwritten * by the fetch mocker when in use. * @type {string} */ #basePath = "/"; /** * Creates a new CookieCredentials instance. * @param {string|URL} [baseUrl] The base URL for the credentials */ constructor(baseUrl) { if (baseUrl) { const url = parseUrl(baseUrl); this.#domain = url.hostname; this.#basePath = url.pathname; } } /** * Gets the domain for the cookie credentials. * @returns {string|undefined} The domain for the cookie credentials. */ get domain() { return this.#domain; } /** * Gets the base path for the cookie credentials. * @return {string} The base path for the cookie credentials. */ get basePath() { return this.#basePath; } /** * Sets a cookie for the cookie credentials. * @param {CookieInfo} cookieInfo The cookie to set. * @returns {void} * @throws {TypeError} If the cookie already exists. * @throws {TypeError} If the cookie domain does not match the credentials domain. */ setCookie(cookieInfo) { const cookie = new Cookie({ domain: this.#domain, path: this.#basePath, ...cookieInfo, }); const cookieKey = cookie.key; if (this.#cookies.has(cookieKey)) { throw new TypeError(`Cookie already exists: ${cookie.toString()}`); } if (this.#domain && !cookie.domain.endsWith(this.#domain)) { throw new TypeError(`Cookie domain must end with ${this.#domain}: ${cookie.toString()}`); } this.#cookies.set(cookie.key, cookie); } /** * Deletes a cookie from the cookie credentials. * @param {Omit<CookieInfo, "value">} cookieInfo The cookie to delete. * @returns {void} * @throws {TypeError} If the cookie does not exist. */ deleteCookie(cookieInfo) { if (!cookieInfo.name) { throw new TypeError("Cookie name is required."); } if (!cookieInfo.domain && !this.#domain) { throw new TypeError("Domain is required to delete a cookie."); } const cookieKey = Cookie.getKey(cookieInfo.name, String(cookieInfo.domain ?? this.#domain), cookieInfo.path, cookieInfo.secure); if (!this.#cookies.has(cookieKey)) { throw new TypeError(`Cookie does not exist: ${cookieInfo.toString()}`); } this.#cookies.delete(cookieKey); } /** * Gets the credentials headers for the given request. * @param {Request} request The request to get the credentials for. * @return {Headers} The credentials headers for the request. */ getHeadersForRequest(request) { const headers = new Headers(); const cookies = []; for (const cookie of this.#cookies.values()) { if (cookie.isCredentialForRequest(request)) { cookies.push(cookie.toCookieHeaderString()); } } if (cookies.length > 0) { headers.append("Cookie", cookies.join("; ")); } return headers; } /** * Clears all cookies from the cookie credentials. * @returns {void} */ clear() { this.#cookies.clear(); } }