mentoss
Version: 
A utility to mock fetch requests and responses.
332 lines (331 loc) • 11.7 kB
JavaScript
/**
 * @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();
    }
}