@slack/bolt
Version:
A framework for building Slack apps, fast.
435 lines • 19.8 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildBodyParserMiddleware = exports.verifySignatureAndParseBody = exports.verifySignatureAndParseRawBody = exports.respondToUrlVerification = exports.respondToSslCheck = void 0;
const node_crypto_1 = __importDefault(require("node:crypto"));
const node_http_1 = require("node:http");
const node_https_1 = require("node:https");
const node_querystring_1 = __importDefault(require("node:querystring"));
const logger_1 = require("@slack/logger");
const oauth_1 = require("@slack/oauth");
const express_1 = __importStar(require("express"));
const raw_body_1 = __importDefault(require("raw-body"));
const tsscmp_1 = __importDefault(require("tsscmp"));
const errors_1 = require("../errors");
const httpFunc = __importStar(require("./HTTPModuleFunctions"));
const HTTPResponseAck_1 = require("./HTTPResponseAck");
const verify_redirect_opts_1 = require("./verify-redirect-opts");
// Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer()
const httpsOptionKeys = [
'ALPNProtocols',
'clientCertEngine',
'enableTrace',
'handshakeTimeout',
'rejectUnauthorized',
'requestCert',
'sessionTimeout',
'SNICallback',
'ticketKeys',
'pskCallback',
'pskIdentityHint',
'ca',
'cert',
'sigalgs',
'ciphers',
'clientCertEngine',
'crl',
'dhparam',
'ecdhCurve',
'honorCipherOrder',
'key',
'privateKeyEngine',
'privateKeyIdentifier',
'maxVersion',
'minVersion',
'passphrase',
'pfx',
'secureOptions',
'secureProtocol',
'sessionIdContext',
];
const missingServerErrorDescription = 'The receiver cannot be started because private state was mutated. Please report this to the maintainers.';
const respondToSslCheck = (req, res, next) => {
if (req.body?.ssl_check) {
res.send();
return;
}
next();
};
exports.respondToSslCheck = respondToSslCheck;
const respondToUrlVerification = (req, res, next) => {
if (req.body?.type && req.body.type === 'url_verification') {
res.json({ challenge: req.body.challenge });
return;
}
next();
};
exports.respondToUrlVerification = respondToUrlVerification;
/**
* Receives HTTP requests with Events, Slash Commands, and Actions
*/
class ExpressReceiver {
/* Express app */
app;
server;
bolt;
logger;
processBeforeResponse;
signatureVerification;
router;
installer = undefined;
installerOptions;
customPropertiesExtractor;
dispatchErrorHandler;
processEventErrorHandler;
unhandledRequestHandler;
unhandledRequestTimeoutMillis;
constructor({ signingSecret = '', logger = undefined, logLevel = logger_1.LogLevel.INFO, endpoints = { events: '/slack/events' }, processBeforeResponse = false, signatureVerification = true, clientId = undefined, clientSecret = undefined, stateSecret = undefined, redirectUri = undefined, installationStore = undefined, scopes = undefined, installerOptions = {}, app = undefined, router = undefined, customPropertiesExtractor = (_req) => ({}), dispatchErrorHandler = httpFunc.defaultAsyncDispatchErrorHandler, processEventErrorHandler = httpFunc.defaultProcessEventErrorHandler, unhandledRequestHandler = httpFunc.defaultUnhandledRequestHandler, unhandledRequestTimeoutMillis = 3001, }) {
this.app = app !== undefined ? app : (0, express_1.default)();
if (typeof logger !== 'undefined') {
this.logger = logger;
}
else {
this.logger = new logger_1.ConsoleLogger();
this.logger.setLevel(logLevel);
}
this.signatureVerification = signatureVerification;
const bodyParser = this.signatureVerification
? buildVerificationBodyParserMiddleware(this.logger, signingSecret)
: buildBodyParserMiddleware(this.logger);
const expressMiddleware = [
bodyParser,
exports.respondToSslCheck,
exports.respondToUrlVerification,
this.requestHandler.bind(this),
];
this.processBeforeResponse = processBeforeResponse;
const endpointList = typeof endpoints === 'string' ? [endpoints] : Object.values(endpoints);
this.router = router !== undefined ? router : (0, express_1.Router)();
for (const endpoint of endpointList) {
this.router.post(endpoint, ...expressMiddleware);
}
this.customPropertiesExtractor = customPropertiesExtractor;
this.dispatchErrorHandler = dispatchErrorHandler;
this.processEventErrorHandler = processEventErrorHandler;
this.unhandledRequestHandler = unhandledRequestHandler;
this.unhandledRequestTimeoutMillis = unhandledRequestTimeoutMillis;
// Verify redirect options if supplied, throws coded error if invalid
(0, verify_redirect_opts_1.verifyRedirectOpts)({ redirectUri, redirectUriPath: installerOptions.redirectUriPath });
if (clientId !== undefined &&
clientSecret !== undefined &&
(installerOptions.stateVerification === false || // state store not needed
stateSecret !== undefined ||
installerOptions.stateStore !== undefined) // user provided state store
) {
this.installer = new oauth_1.InstallProvider({
clientId,
clientSecret,
stateSecret,
installationStore,
logLevel,
logger, // pass logger that was passed in constructor, not one created locally
directInstall: installerOptions.directInstall,
stateStore: installerOptions.stateStore,
stateVerification: installerOptions.stateVerification,
legacyStateVerification: installerOptions.legacyStateVerification,
stateCookieName: installerOptions.stateCookieName,
stateCookieExpirationSeconds: installerOptions.stateCookieExpirationSeconds,
renderHtmlForInstallPath: installerOptions.renderHtmlForInstallPath,
authVersion: installerOptions.authVersion ?? 'v2',
clientOptions: installerOptions.clientOptions,
authorizationUrl: installerOptions.authorizationUrl,
});
}
// create install url options
const installUrlOptions = {
metadata: installerOptions.metadata,
scopes: scopes ?? [],
userScopes: installerOptions.userScopes,
redirectUri,
};
// Add OAuth routes to receiver
if (this.installer !== undefined) {
const { installer } = this;
const redirectUriPath = installerOptions.redirectUriPath === undefined ? '/slack/oauth_redirect' : installerOptions.redirectUriPath;
const { callbackOptions, stateVerification } = installerOptions;
this.router.use(redirectUriPath, async (req, res) => {
try {
if (stateVerification === false) {
// when stateVerification is disabled pass install options directly to handler
// since they won't be encoded in the state param of the generated url
await installer.handleCallback(req, res, callbackOptions, installUrlOptions);
}
else {
await installer.handleCallback(req, res, callbackOptions);
}
}
catch (e) {
await this.dispatchErrorHandler({
error: e,
logger: this.logger,
request: req,
response: res,
});
}
});
const installPath = installerOptions.installPath === undefined ? '/slack/install' : installerOptions.installPath;
const { installPathOptions } = installerOptions;
this.router.get(installPath, async (req, res, next) => {
try {
try {
await installer.handleInstallPath(req, res, installPathOptions, installUrlOptions);
}
catch (error) {
next(error);
}
}
catch (e) {
await this.dispatchErrorHandler({
error: e,
logger: this.logger,
request: req,
response: res,
});
}
});
}
this.app.use(this.router);
}
async requestHandler(req, res) {
const ack = new HTTPResponseAck_1.HTTPResponseAck({
logger: this.logger,
processBeforeResponse: this.processBeforeResponse,
unhandledRequestHandler: this.unhandledRequestHandler,
unhandledRequestTimeoutMillis: this.unhandledRequestTimeoutMillis,
httpRequest: req,
httpResponse: res,
});
const event = {
body: req.body,
ack: ack.bind(),
retryNum: httpFunc.extractRetryNumFromHTTPRequest(req),
retryReason: httpFunc.extractRetryReasonFromHTTPRequest(req),
customProperties: this.customPropertiesExtractor(req),
};
try {
await this.bolt?.processEvent(event);
if (ack.storedResponse !== undefined) {
httpFunc.buildContentResponse(res, ack.storedResponse);
this.logger.debug('stored response sent');
}
}
catch (err) {
const acknowledgedByHandler = await this.processEventErrorHandler({
error: err,
logger: this.logger,
request: req,
response: res,
storedResponse: ack.storedResponse,
});
if (acknowledgedByHandler) {
// If the value is false, we don't touch the value as a race condition
// with ack() call may occur especially when processBeforeResponse: false
ack.ack();
}
}
}
init(bolt) {
this.bolt = bolt;
}
start(portOrListenOptions, serverOptions = {}) {
let createServerFn = node_http_1.createServer;
// Look for HTTPS-specific serverOptions to determine which factory function to use
if (Object.keys(serverOptions).filter((k) => httpsOptionKeys.includes(k)).length > 0) {
createServerFn = node_https_1.createServer;
}
if (this.server !== undefined) {
return Promise.reject(new errors_1.ReceiverInconsistentStateError('The receiver cannot be started because it was already started.'));
}
this.server = createServerFn(serverOptions, this.app);
return new Promise((resolve, reject) => {
if (this.server === undefined) {
throw new errors_1.ReceiverInconsistentStateError(missingServerErrorDescription);
}
this.server.on('error', (error) => {
if (this.server === undefined) {
throw new errors_1.ReceiverInconsistentStateError(missingServerErrorDescription);
}
this.server.close();
// If the error event occurs before listening completes (like EADDRINUSE), this works well. However, if the
// error event happens some after the Promise is already resolved, the error would be silently swallowed up.
// The documentation doesn't describe any specific errors that can occur after listening has started, so this
// feels safe.
reject(error);
});
this.server.on('close', () => {
// Not removing all listeners because consumers could have added their own `close` event listener, and those
// should be called. If the consumer doesn't dispose of any references to the server properly, this would be
// a memory leak.
// this.server?.removeAllListeners();
this.server = undefined;
});
this.server.listen(portOrListenOptions, () => {
if (this.server === undefined) {
return reject(new errors_1.ReceiverInconsistentStateError(missingServerErrorDescription));
}
return resolve(this.server);
});
});
}
// TODO: the arguments should be defined as the arguments to close() (which happen to be none), but for sake of
// generic types
stop() {
if (this.server === undefined) {
return Promise.reject(new errors_1.ReceiverInconsistentStateError('The receiver cannot be stopped because it was not started.'));
}
return new Promise((resolve, reject) => {
this.server?.close((error) => {
if (error !== undefined) {
return reject(error);
}
this.server = undefined;
return resolve();
});
});
}
}
exports.default = ExpressReceiver;
function verifySignatureAndParseRawBody(logger, signingSecret) {
return buildVerificationBodyParserMiddleware(logger, signingSecret);
}
exports.verifySignatureAndParseRawBody = verifySignatureAndParseRawBody;
/**
* This request handler has two responsibilities:
* - Verify the request signature
* - Parse request.body and assign the successfully parsed object to it.
*/
function buildVerificationBodyParserMiddleware(logger, signingSecret) {
return async (req, res, next) => {
// *** Parsing body ***
// As the verification passed, parse the body as an object and assign it to req.body
const stringBody = await parseExpressRequestRawBody(req);
// Following middlewares can expect `req.body` is already parsed.
try {
// This handler parses `req.body` or `req.rawBody`(on Google Could Platform)
// and overwrites `req.body` with the parsed JS object.
req.body = verifySignatureAndParseBody(typeof signingSecret === 'string' ? signingSecret : await signingSecret(), stringBody, req.headers);
}
catch (error) {
if (error) {
if (error instanceof errors_1.ReceiverAuthenticityError) {
logError(logger, 'Request verification failed', error);
res.status(401).send();
return;
}
logError(logger, 'Parsing request body failed', error);
res.status(400).send();
return;
}
}
next();
};
}
// biome-ignore lint/suspicious/noExplicitAny: errors can be anything
function logError(logger, message, error) {
const logMessage = 'code' in error ? `${message} (code: ${error.code}, message: ${error.message})` : `${message} (error: ${error})`;
logger.warn(logMessage);
}
function verifyRequestSignature(signingSecret, body, signature, requestTimestamp) {
if (signature === undefined || requestTimestamp === undefined) {
throw new errors_1.ReceiverAuthenticityError('Slack request signing verification failed. Some headers are missing.');
}
const ts = Number(requestTimestamp);
if (Number.isNaN(ts)) {
throw new errors_1.ReceiverAuthenticityError('Slack request signing verification failed. Timestamp is invalid.');
}
// Divide current date to match Slack ts format
// Subtract 5 minutes from current time
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5;
if (ts < fiveMinutesAgo) {
throw new errors_1.ReceiverAuthenticityError('Slack request signing verification failed. Timestamp is too old.');
}
const hmac = node_crypto_1.default.createHmac('sha256', signingSecret);
const [version, hash] = signature.split('=');
hmac.update(`${version}:${ts}:${body}`);
if (!(0, tsscmp_1.default)(hash, hmac.digest('hex'))) {
throw new errors_1.ReceiverAuthenticityError('Slack request signing verification failed. Signature mismatch.');
}
}
/**
* This request handler has two responsibilities:
* - Verify the request signature
* - Parse `request.body` and assign the successfully parsed object to it.
*/
function verifySignatureAndParseBody(signingSecret, body,
// biome-ignore lint/suspicious/noExplicitAny: TODO: headers should only be of a certain type, but some other functions here expect a more complicated type. revisit to type properly later.
headers) {
// *** Request verification ***
const { 'x-slack-signature': signature, 'x-slack-request-timestamp': requestTimestamp, 'content-type': contentType, } = headers;
verifyRequestSignature(signingSecret, body, signature, requestTimestamp);
return parseRequestBody(body, contentType);
}
exports.verifySignatureAndParseBody = verifySignatureAndParseBody;
function buildBodyParserMiddleware(logger) {
return async (req, res, next) => {
const stringBody = await parseExpressRequestRawBody(req);
try {
const { 'content-type': contentType } = req.headers;
req.body = parseRequestBody(stringBody, contentType);
}
catch (error) {
if (error) {
logError(logger, 'Parsing request body failed', error);
res.status(400).send();
return;
}
}
next();
};
}
exports.buildBodyParserMiddleware = buildBodyParserMiddleware;
// biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything
function parseRequestBody(stringBody, contentType) {
if (contentType === 'application/x-www-form-urlencoded') {
const parsedBody = node_querystring_1.default.parse(stringBody);
if (typeof parsedBody.payload === 'string') {
return JSON.parse(parsedBody.payload);
}
return parsedBody;
}
return JSON.parse(stringBody);
}
// On some environments like GCP (Google Cloud Platform),
// req.body can be pre-parsed and be passed as req.rawBody
async function parseExpressRequestRawBody(req) {
if ('rawBody' in req && req.rawBody) {
return Promise.resolve(req.rawBody.toString());
}
return (await (0, raw_body_1.default)(req)).toString();
}
//# sourceMappingURL=ExpressReceiver.js.map