api/session.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

/**
  * @module api/session
  */

const lib = require(__dirname + '/../lib');
const api = require(__dirname + '/../api');
const logger = require(__dirname + '/../logger');
const cache = require(__dirname + '/../cache');

const mod =

/**
 * Session cookies support
 */


module.exports = {
    name: "api.session",
    args: [
        { name: "disabled", type: "bool", descr: "Disable cookie session support, all requests must be signed for Web clients" },
        { name: "cache", descr: "Cache name for session control" },
        { name: "age", type: "int", min: 0, descr: "Session age in milliseconds, for cookie based authentication" },
        { name: "same-site", descr: "Session SameSite option, for cookie based authentication" },
        { name: "secure", type: "bool", descr: "Set cookie Secure flag" },
        { name: "cookie-(.+)", obj: "session-cookie", type: "map", nocamel: 1, descr: "Cookie values for requests that match beginning of the path", example: "-api-session-cookie-/testing secure:false,sameSite:None" },
    ],

    // Web session age
    age: 86400 * 14 * 1000,
    sameSite: "strict",
    secure: true,
    cookie: {},
};


/**
 * Find a closest cookie by host/domain/path, longest takes precedence, returns found cookie merged with the options
 * @param {Request} req
 * @param {Object} [options]
 * @returns {Object}
 * @memberof module:api/session
 * @method makeCookie
 */
mod.makeCookie = function(req, options)
{
    if (!req._sessionCookie) {
        var path = "", host = "";
        for (const p in this.cookie) {
            if (p[0] == "/") {
                if (req.options.path.startsWith(p) && p.length > path.length) {
                    path = p;
                }
            } else
            if ((p === req.options.host || p === req.options.domain) && p.length > host.length) {
                host = p;
            }
        }
        if (path) req._sessionCookie = Object.assign({}, this.cookie[path]);
        if (host) req._sessionCookie = Object.assign(req._sessionCookie || {}, this.cookie[host]);
    }
    return Object.assign(options || {}, req._sessionCookie);
}

/**
 * Return named encrypted signature cookie, uses {@link module:api/signature.header}
 * @param {Request} req
 * @memberof module:api/session
 * @method getCookie
 */
mod.getCookie = function(req)
{
    var value = req.cookies && req.cookies[api.signature.header];
    return value && lib.base64ToJson(value, api.accessTokenSecret);
}

/**
 * Set a cookie by name and domain, the value is always encrypted
 * @param {Request} req
 * @param {string} name
 * @param {string|Object} value
 * @memberof module:api/session
 * @method setCookie
 */
mod.setCookie = function(req, name, value)
{
    if (!req?.res || !name) return "";
    value = value ? lib.jsonToBase64(value, api.accessTokenSecret) : "";
    var opts = this.makeCookie(req, {
        path: "/",
        httpOnly: true,
        secure: this.secure,
        sameSite: this.sameSite,
    });
    if (value) {
        opts.maxAge = this.age;
    } else {
        opts.expires = new Date(1);
    }
    req.res.cookie(name, value, opts);
}

/**
 * Setup session cookies or access token for automatic authentication without signing, req must be complete with all required
 * properties after successful authorization.
 * @param {Request} req
 * @param {function} [callback]
 * @memberof module:api/session
 * @method setup
 */
mod.setup = function(req, callback)
{
    req.options.session = req.user?.login && req.user?.secret && req.headers ? true : false;
    var hooks = api.hooks.find('sig', req.method, req.path);
    logger.debug("setup:", mod.name, hooks.length, "hooks", req.options);

    if (!hooks.length) {
        if (req.options.session) this.create(req, req.options);
        return lib.tryCall(callback);
    }

    lib.forEachSeries(hooks, (hook, next) => {
        hook.callback.call(api, req, req.user, null, next);
    }, (sig) => {
        if (!sig) {
            if (req.options.session) this.create(req, req.options);
        }
        lib.tryCall(callback);
    }, true);
}

/**
 * Create a session cookie for the request
 * @param {Request} req
 * @param {Object} [options]
 * @memberof module:api/session
 * @method create
 */
mod.create = function(req, options)
{
    var sig = api.signature.create(req.user?.login, req.user?.secret, { host: req.headers.host, version: 2, expires: options?.sessionAge || this.age });
    if (!this.disabled) this.setCookie(req, sig.header, sig.value);
    return sig;
}

/**
 * Clear session cookie for the request
 * @param {Request} req
 * @memberof module:api/session
 * @method clear
 */
mod.clear = function(req)
{
    this.save(req.signature, -Date.now());

    if (!this.disabled) {
        this.setCookie(req, api.signature.header, "");
    }
}

/**
 * Return saved signature from the cache
 * @param {Object} sig
 * @param {function} callback
 * @memberof module:api/session
 * @method check
 */
mod.check = function(sig, callback)
{
    if (this.disabled || !this.age || !sig?.signature) return lib.tryCall(callback);
    cache.get(`SIG:${sig.login}:${sig.signature}`, { cacheName: this.cache }, (err, val) => {
        logger.debug("check:", mod.name, sig, "VAL:", val);
        lib.tryCall(callback, err, val);
    });
}

/**
 * Save given signature and value in the cache, to handle expired or revoked signatures
 * @param {Object} sig
 * @param {any} val
 * @param {function} [callback]
 * @memberof module:api/session
 * @method save
 */
mod.save = function(sig, val, callback)
{
    if (typeof val == "function") callback = val, val = 0;
    if (this.disabled || !this.age || !sig?.signature) return lib.tryCall(callback);
    logger.debug("save:", mod.name, sig, "VAL:", val);
    cache.put(`SIG:${sig.login}:${sig.signature}`, val || Date.now(), { cacheName: this.cache, ttl: this.age }, callback);
}