amancevice/slackend

View on GitHub
index.js

Summary

Maintainability
A
1 hr
Test Coverage
A
100%
"use strict";

// stdlib
const crypto = require("crypto");
const qs = require("querystring");

// node_modules
const bodyParser = require("body-parser");
const debug = require("debug");
const express = require("express");
const { WebClient } = require("@slack/web-api");

const logger = {
  debug: debug(process.env.SLACKEND_DEBUG || "slackend:debug"),
  info: debug(process.env.SLACKEND_INFO || "slackend:info"),
  warn: debug(process.env.SLACKEND_WARN || "slackend:warn"),
  error: debug(process.env.SLACKEND_ERROR || "slackend:error"),
};

function calculateSignature(req, options = {}) {
  const ts = req.headers["x-slack-request-timestamp"];
  const given = req.headers["x-slack-signature"];
  const hmac = crypto.createHmac("sha256", options.signing_secret);
  const data = `${options.signing_version}:${ts}:${req.body}`;
  const signature = hmac.update(data).digest("hex");
  const computed = `${options.signing_version}=${signature}`;
  const delta = Math.abs(new Date() / 1000 - ts);
  const res = { given: given, computed: computed, delta: delta };
  logger.debug(`SIGNING DATA ${data}`);
  logger.debug(`SIGNATURES ${JSON.stringify(res)}`);
  return res;
}

function getOptions(options = {}) {
  return {
    client_id: process.env.SLACK_CLIENT_ID,
    client_secret: process.env.SLACK_CLIENT_SECRET,
    disable_verification: process.env.SLACK_DISABLE_VERIFICATION,
    oauth_install_uri: process.env.SLACK_OAUTH_INSTALL_URI,
    oauth_error_uri: process.env.SLACK_OAUTH_ERROR_URI,
    oauth_redirect_uri: process.env.SLACK_OAUTH_REDIRECT_URI,
    oauth_success_uri: process.env.SLACK_OAUTH_SUCCESS_URI,
    signing_secret: process.env.SLACK_SIGNING_SECRET,
    signing_version: process.env.SLACK_SIGNING_VERSION,
    token: process.env.SLACK_TOKEN,
    ...options,
  };
}

function handleInstall(options = {}) {
  return (req, res) => {
    logger.info(`RESPONSE [302] ${options.oauth_install_uri}`);
    res.redirect(options.oauth_install_uri);
  };
}

function handleOAuth(options = {}, version = null) {
  return (req, res, next) => {
    // Handle denials
    if (req.query.error) {
      logger.error(req.query.error);
      logger.warn(`RESPONSE [302] ${options.oauth_error_uri}`);
      return res.redirect(options.oauth_error_uri);
    }

    // Set up OAuth
    const slack = options.slack || new WebClient(options.token);
    const oauth = version ? slack.oauth[version] : slack.oauth;
    const payload = {
      code: req.query.code,
      client_id: options.client_id,
      client_secret: options.client_secret,
      redirect_uri: options.redirect_uri,
    };
    const finish = (ret) => {
      res.locals.slack = {
        type: `oauth${version ? `/${version}` : ""}`,
        body: ret,
      };
      next();
    };
    const error = (err) => {
      logger.error(err);
      if (options.oauth_error_uri) {
        logger.warn(`RESPONSE [302] ${options.oauth_error_uri}`);
        res.redirect(options.oauth_error_uri);
      } else {
        logger.error("RESPONSE [403]");
        res.status(403).json({ error: err });
      }
    };

    // Fetch token and finish (or error)
    oauth.access(payload).then(finish).catch(error);
  };
}

function handleCallback(options = {}) {
  return (req, res, next) => {
    res.locals.slack = {
      type: "callback",
      body: JSON.parse(qs.parse(req.body).payload),
    };
    if (res.locals.slack.body.type === "block_actions") {
      res.locals.slack.body.action_ids = res.locals.slack.body.actions.map(
        (x) => x.action_id
      );
    }
    next();
  };
}

function handleEvent(options = {}) {
  return (req, res, next) => {
    res.locals.slack = { type: "event", body: JSON.parse(req.body) };
    if (res.locals.slack.body.type === "url_verification") {
      logger.info(`RESPONSE [200] ${res.locals.slack.body.challenge}`);
      res.json({ challenge: res.locals.slack.body.challenge });
    } else {
      next();
    }
  };
}

function handleSlashCmd(options = {}) {
  return (req, res, next) => {
    res.locals.slack = { type: "slash", body: qs.parse(req.body) };
    next();
  };
}

function logSlackMsg(req, res, next) {
  logger.debug(`SLACK MESSAGE ${JSON.stringify(res.locals.slack)}`);
  next();
}

function verifyRequest(options = {}) {
  return (req, res, next) => {
    logger.debug(`HEADERS ${JSON.stringify(req.headers)}`);
    logger.debug(`BODY ${JSON.stringify(req.body)}`);
    if (options.disable_verification) {
      logger.warn("VERIFICATION DISABLED - ENV");
      next();
    } else if (options.signing_secret === undefined) {
      logger.warn("VERIFICATION DISABLED - NO SIGNING SECRET");
      next();
    } else {
      const sign = calculateSignature(req, options);
      if (sign.delta > 60 * 5) {
        logger.error("RESPONSE [403] Request too old");
        res.status(403).json({ error: "Request too old" });
      } else if (sign.given !== sign.computed) {
        logger.error("RESPONSE [403] Signatures do not match");
        res.status(403).json({ error: "Signatures do not match" });
      } else {
        next();
      }
    }
  };
}

const app = (options = {}) => {
  // Set opts with defaults
  const opts = getOptions(options);

  // Create express router & callbacks
  const app = express(),
    doCallback = handleCallback(opts),
    doEvent = handleEvent(opts),
    doInstall = handleInstall(opts),
    doOAuth = handleOAuth(opts),
    doOAuthV2 = handleOAuth(opts, "v2"),
    doSlash = handleSlashCmd(opts),
    doVerify = verifyRequest(opts);

  // Configure routes
  app.use(bodyParser.text({ type: "*/*" }));
  app.get("/health", (req, res) => res.json({ ok: true }));
  app.get("/install", doInstall);
  app.get("/oauth", doOAuth, logSlackMsg);
  app.get("/oauth/v2", doOAuthV2, logSlackMsg);
  app.post("/callbacks", doVerify, doCallback, logSlackMsg);
  app.post("/events", doVerify, doEvent, logSlackMsg);
  app.post("/slash/:cmd", doVerify, doSlash, logSlackMsg);

  // Return express app
  return app;
};

module.exports = app;
module.exports.logger = logger;