UNPKG

@slack/bolt

Version:

A framework for building Slack apps, fast.

435 lines 19.8 kB
"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