biskviit.js 7.17 KB
'use strict';

var urllib = require('url');
var psl = require('psl');

const SESSION_TIMEOUT = 1800; // 30 min

module.exports = Biskviit;

/**
 * Creates a biskviit cookie jar for managing cookie values in memory
 *
 * @constructor
 * @param {Object} [options] Optional options object
 */
function Biskviit(options) {
    this.options = options || {};
    this.cookies = [];
}

/**
 * Stores a cookie string to the cookie storage
 *
 * @param {String} cookieStr Value from the 'Set-Cookie:' header
 * @param {String} url Current URL
 */
Biskviit.prototype.set = function(cookieStr, url) {
    var urlparts = urllib.parse(url || '');
    var cookie = this.parse(cookieStr);

    if (cookie.domain) {
        let domain = cookie.domain.replace(/^\./, '');

        // do not allow generic TLDs, except unlisted
        if (psl.parse(domain).listed && !psl.isValid(domain)) {
            cookie.domain = urlparts.hostname;
        }

        // do not allow cross origin cookies
        if (
            // can't be valid if the requested domain is shorter than current hostname
            urlparts.hostname.length < domain.length ||

            // prefix domains with dot to be sure that partial matches are not used
            ('.' + urlparts.hostname).substr(-domain.length + 1) !== ('.' + domain)) {
            cookie.domain = urlparts.hostname;
        }
    } else {
        cookie.domain = urlparts.hostname;
    }

    if (!cookie.path) {
        cookie.path = this.getPath(urlparts.pathname);
    }

    // if no expire date, then use sessionTimeout value
    if (!cookie.expires) {
        cookie.expires = new Date(Date.now() + (Number(this.options.sessionTimeout || SESSION_TIMEOUT) || SESSION_TIMEOUT) * 1000);
    }

    return this.add(cookie);
};

/**
 * Returns cookie string for the 'Cookie:' header.
 *
 * @param {String} url URL to check for
 * @returns {String} Cookie header or empty string if no matches were found
 */
Biskviit.prototype.get = function(url) {
    return this.list(url).map(function(cookie) {
        return cookie.name + '=' + cookie.value;
    }).join('; ');
};

/**
 * Lists all valied cookie objects for the specified URL
 *
 * @param {String} url URL to check for
 * @returns {Array} An array of cookie objects
 */
Biskviit.prototype.list = function(url) {
    var result = [];

    for (let i = this.cookies.length - 1; i >= 0; i--) {
        let cookie = this.cookies[i];

        if (this.isExpired(cookie)) {
            this.cookies.splice(i, i);
            continue;
        }

        if (this.match(cookie, url)) {
            result.unshift(cookie);
        }
    }

    return result;
};

/**
 * Parses cookie string from the 'Set-Cookie:' header
 *
 * @param {String} cookieStr String from the 'Set-Cookie:' header
 * @returns {Object} Cookie object
 */
Biskviit.prototype.parse = function(cookieStr) {
    var cookie = {};

    (cookieStr || '').toString().split(';').forEach(function(cookiePart) {
        var valueParts = cookiePart.split('=');
        var key = valueParts.shift().trim().toLowerCase();
        var value = valueParts.join('=').trim();

        if (!key) {
            // skip empty parts
            return;
        }

        switch (key) {

            case 'expires':
                value = new Date(value);
                // ignore date if can not parse it
                if (value.toString() !== 'Invalid Date') {
                    cookie.expires = value;
                }
                break;

            case 'path':
                cookie.path = value;
                break;

            case 'domain':
                let domain = value.toLowerCase();
                if (domain.length && domain.charAt(0) !== '.') {
                    domain = '.' + domain; // ensure preceeding dot for user set domains
                }
                cookie.domain = domain;
                break;

            case 'max-age':
                cookie.expires = new Date(Date.now() + (Number(value) || 0) * 1000);
                break;

            case 'secure':
                cookie.secure = true;
                break;

            case 'httponly':
                cookie.httponly = true;
                break;

            default:
                if (!cookie.name) {
                    cookie.name = key;
                    cookie.value = value;
                }
        }
    });

    return cookie;
};

/**
 * Checks if a cookie object is valid for a specified URL
 *
 * @param {Object} cookie Cookie object
 * @param {String} url URL to check for
 * @returns {Boolean} true if cookie is valid for specifiec URL
 */
Biskviit.prototype.match = function(cookie, url) {
    var urlparts = urllib.parse(url || '');

    // check if hostname matches
    // .foo.com also matches subdomains, foo.com does not
    if (urlparts.hostname !== cookie.domain && (cookie.domain.charAt(0) !== '.' || ('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)) {
        return false;
    }

    // check if path matches
    let path = this.getPath(urlparts.pathname);
    if (path.substr(0, cookie.path.length) !== cookie.path) {
        return false;
    }

    // check secure argument
    if (cookie.secure && urlparts.protocol !== 'https:') {
        return false;
    }

    return true;
};

/**
 * Adds (or updates/removes if needed) a cookie object to the cookie storage
 *
 * @param {Object} cookie Cookie value to be stored
 */
Biskviit.prototype.add = function(cookie) {
    // nothing to do here
    if (!cookie || !cookie.name) {
        return false;
    }

    // overwrite if has same params
    for (let i = 0, len = this.cookies.length; i < len; i++) {
        if (this.compare(this.cookies[i], cookie)) {

            // check if the cookie needs to be removed instead
            if (this.isExpired(cookie)) {
                this.cookies.splice(i, 1); // remove expired/unset cookie
                return false;
            }

            this.cookies[i] = cookie;
            return true;
        }
    }

    // add as new if not already expired
    if (!this.isExpired(cookie)) {
        this.cookies.push(cookie);
    }

    return true;
};

/**
 * Checks if two cookie objects are the same
 *
 * @param {Object} a Cookie to check against
 * @param {Object} b Cookie to check against
 * @returns {Boolean} True, if the cookies are the same
 */
Biskviit.prototype.compare = function(a, b) {
    return a.name === b.name && a.path === b.path && a.domain === b.domain && a.secure === b.secure && a.httponly === a.httponly;
};

/**
 * Checks if a cookie is expired
 *
 * @param {Object} cookie Cookie object to check against
 * @returns {Boolean} True, if the cookie is expired
 */
Biskviit.prototype.isExpired = function(cookie) {
    return (cookie.expires && cookie.expires < new Date()) || !cookie.value;
};

/**
 * Returns normalized cookie path for an URL path argument
 *
 * @param {String} pathname
 * @returns {String} Normalized path
 */
Biskviit.prototype.getPath = function(pathname) {
    var path = (pathname || '/').split('/');
    path.pop(); // remove filename part
    path = path.join('/').trim();

    // ensure path prefix /
    if (path.charAt(0) !== '/') {
        path = '/' + path;
    }

    // ensure path suffix /
    if (path.substr(-1) !== '/') {
        path += '/';
    }

    return path;
};