From 658f6f494a67bdd8730a13511a0748456f992295 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 26 Feb 2021 06:12:22 +0900 Subject: [PATCH] [#784] Add built-in AwsLambdaReceiver (PR #785) --- src/index.ts | 1 + src/receivers/AwsLambdaReceiver.spec.ts | 313 ++++++++++++++++++++++++ src/receivers/AwsLambdaReceiver.ts | 246 +++++++++++++++++++ 3 files changed, 560 insertions(+) create mode 100644 src/receivers/AwsLambdaReceiver.spec.ts create mode 100644 src/receivers/AwsLambdaReceiver.ts diff --git a/src/index.ts b/src/index.ts index dcb3499d9..f48142df1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export { export { default as ExpressReceiver, ExpressReceiverOptions } from './receivers/ExpressReceiver'; export { default as SocketModeReceiver, SocketModeReceiverOptions } from './receivers/SocketModeReceiver'; export { default as HTTPReceiver, HTTPReceiverOptions } from './receivers/HTTPReceiver'; +export { default as AwsLambdaReceiver, AwsLambdaReceiverOptions } from './receivers/AwsLambdaReceiver'; export * from './errors'; export * from './middleware/builtin'; diff --git a/src/receivers/AwsLambdaReceiver.spec.ts b/src/receivers/AwsLambdaReceiver.spec.ts new file mode 100644 index 000000000..26b3acb34 --- /dev/null +++ b/src/receivers/AwsLambdaReceiver.spec.ts @@ -0,0 +1,313 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/naming-convention */ + +import sinon from 'sinon'; +import { Logger, LogLevel } from '@slack/logger'; +import { assert } from 'chai'; +import 'mocha'; +import AwsLambdaReceiver from './AwsLambdaReceiver'; +import crypto from 'crypto'; +import rewiremock from 'rewiremock'; +import { WebClientOptions } from '@slack/web-api'; + +describe('AwsLambdaReceiver', function () { + beforeEach(function () {}); + + const noopLogger: Logger = { + debug(..._msg: any[]): void { + /* noop */ + }, + info(..._msg: any[]): void { + /* noop */ + }, + warn(..._msg: any[]): void { + /* noop */ + }, + error(..._msg: any[]): void { + /* noop */ + }, + setLevel(_level: LogLevel): void { + /* noop */ + }, + getLevel(): LogLevel { + return LogLevel.DEBUG; + }, + setName(_name: string): void { + /* noop */ + }, + }; + + describe('AwsLambdaReceiver', () => { + it('should instantiate with default logger', async (): Promise => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + }); + assert.isNotNull(awsReceiver); + }); + + it('should have start method', async (): Promise => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + }); + const handler = await awsReceiver.start(); + assert.isNotNull(handler); + }); + + it('should accept events', async (): Promise => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const body = JSON.stringify({ + token: 'fixed-value', + team_id: 'T111', + enterprise_id: 'E111', + api_app_id: 'A111', + event: { + client_msg_id: '977a7fa8-c9b3-4b51-a0b6-3b6c647e2165', + type: 'app_mention', + text: '<@U222> test', + user: 'W111', + ts: '1612879521.002100', + team: 'T111', + channel: 'C111', + event_ts: '1612879521.002100', + }, + type: 'event_callback', + event_id: 'Ev111', + event_time: 1612879521, + authorizations: [ + { + enterprise_id: 'E111', + team_id: 'T111', + user_id: 'W111', + is_bot: true, + is_enterprise_install: false, + }, + ], + is_ext_shared_channel: false, + event_context: '1-app_mention-T111-C111', + }); + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = { + resource: '/slack/events', + path: '/slack/events', + httpMethod: 'POST', + headers: { + Accept: 'application/json,*/*', + 'Content-Type': 'application/json', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}`, + }, + multiValueHeaders: {}, + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + requestContext: {}, + body: body, + isBase64Encoded: false, + }; + const response1 = await handler( + awsEvent, + {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_error, _result) => {}, + ); + assert.equal(response1.statusCode, 404); + const App = await importApp(); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.event('app_mention', async ({}) => {}); + const response2 = await handler( + awsEvent, + {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_error, _result) => {}, + ); + assert.equal(response2.statusCode, 200); + }); + + it('should accept interactivity requests', async (): Promise => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const body = + 'payload=%7B%22type%22%3A%22shortcut%22%2C%22token%22%3A%22fixed-value%22%2C%22action_ts%22%3A%221612879511.716075%22%2C%22team%22%3A%7B%22id%22%3A%22T111%22%2C%22domain%22%3A%22domain-value%22%2C%22enterprise_id%22%3A%22E111%22%2C%22enterprise_name%22%3A%22Sandbox+Org%22%7D%2C%22user%22%3A%7B%22id%22%3A%22W111%22%2C%22username%22%3A%22primary-owner%22%2C%22team_id%22%3A%22T111%22%7D%2C%22is_enterprise_install%22%3Afalse%2C%22enterprise%22%3A%7B%22id%22%3A%22E111%22%2C%22name%22%3A%22Kaz+SDK+Sandbox+Org%22%7D%2C%22callback_id%22%3A%22bolt-js-aws-lambda-shortcut%22%2C%22trigger_id%22%3A%22111.222.xxx%22%7D'; + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = { + resource: '/slack/events', + path: '/slack/events', + httpMethod: 'POST', + headers: { + Accept: 'application/json,*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}`, + }, + multiValueHeaders: {}, + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + requestContext: {}, + body: body, + isBase64Encoded: false, + }; + const response1 = await handler( + awsEvent, + {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_error, _result) => {}, + ); + assert.equal(response1.statusCode, 404); + const App = await importApp(); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.shortcut('bolt-js-aws-lambda-shortcut', async ({ ack }) => { + await ack(); + }); + const response2 = await handler( + awsEvent, + {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_error, _result) => {}, + ); + assert.equal(response2.statusCode, 200); + }); + + it('should accept slash commands', async (): Promise => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const body = + 'token=fixed-value&team_id=T111&team_domain=domain-value&channel_id=C111&channel_name=random&user_id=W111&user_name=primary-owner&command=%2Fhello-bolt-js&text=&api_app_id=A111&is_enterprise_install=false&enterprise_id=E111&enterprise_name=Sandbox+Org&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxx&trigger_id=111.222.xxx'; + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = { + resource: '/slack/events', + path: '/slack/events', + httpMethod: 'POST', + headers: { + Accept: 'application/json,*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}`, + }, + multiValueHeaders: {}, + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + requestContext: {}, + body: body, + isBase64Encoded: false, + }; + const response1 = await handler( + awsEvent, + {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_error, _result) => {}, + ); + assert.equal(response1.statusCode, 404); + const App = await importApp(); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.command('/hello-bolt-js', async ({ ack }) => { + await ack(); + }); + const response2 = await handler( + awsEvent, + {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_error, _result) => {}, + ); + assert.equal(response2.statusCode, 200); + }); + }); +}); + +export interface Override { + [packageName: string]: { + [exportName: string]: any; + }; +} + +export function mergeOverrides(...overrides: Override[]): Override { + let currentOverrides: Override = {}; + for (const override of overrides) { + currentOverrides = mergeObjProperties(currentOverrides, override); + } + return currentOverrides; +} + +function mergeObjProperties(first: Override, second: Override): Override { + const merged: Override = {}; + const props = Object.keys(first).concat(Object.keys(second)); + for (const prop of props) { + if (second[prop] === undefined && first[prop] !== undefined) { + merged[prop] = first[prop]; + } else if (first[prop] === undefined && second[prop] !== undefined) { + merged[prop] = second[prop]; + } else { + // second always overwrites the first + merged[prop] = { ...first[prop], ...second[prop] }; + } + } + return merged; +} + +// Composable overrides +function withNoopWebClient(): Override { + return { + '@slack/web-api': { + WebClient: class { + public token?: string; + constructor(token?: string, _options?: WebClientOptions) { + this.token = token; + } + public auth = { + test: sinon.fake.resolves({ + enterprise_id: 'E111', + team_id: 'T111', + bot_id: 'B111', + user_id: 'W111', + }), + }; + }, + }, + }; +} + +function withNoopAppMetadata(): Override { + return { + '@slack/web-api': { + addAppMetadata: sinon.fake(), + }, + }; +} + +// Loading the system under test using overrides +async function importApp( + overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), +): Promise { + return (await rewiremock.module(() => import('../App'), overrides)).default; +} diff --git a/src/receivers/AwsLambdaReceiver.ts b/src/receivers/AwsLambdaReceiver.ts new file mode 100644 index 000000000..d8eb7a2a5 --- /dev/null +++ b/src/receivers/AwsLambdaReceiver.ts @@ -0,0 +1,246 @@ +import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; +import querystring from 'querystring'; +import crypto from 'crypto'; +import tsscmp from 'tsscmp'; +import App from '../App'; +import { Receiver, ReceiverEvent } from '../types/receiver'; +import { ReceiverMultipleAckError } from '../errors'; + +export interface AwsEvent { + body: string | null; + headers: any; + multiValueHeaders: any; + httpMethod: string; + isBase64Encoded: boolean; + path: string; + pathParameters: any | null; + queryStringParameters: any | null; + multiValueQueryStringParameters: any | null; + stageVariables: any | null; + requestContext: any; + resource: string; +} + +export type AwsCallback = (error?: Error | string | null, result?: any) => void; + +export interface AwsResponse { + statusCode: number; + headers?: { + [header: string]: boolean | number | string; + }; + multiValueHeaders?: { + [header: string]: Array; + }; + body: string; + isBase64Encoded?: boolean; +} + +export type AwsHandler = (event: AwsEvent, context: any, callback: AwsCallback) => Promise; + +export interface AwsLambdaReceiverOptions { + signingSecret: string; + logger?: Logger; + logLevel?: LogLevel; +} + +/* + * Receiver implementation for AWS API Gateway + Lambda apps + * + * Note that this receiver does not support Slack OAuth flow. + * For OAuth flow endpoints, deploy another Lambda function built with ExpressReceiver. + */ +export default class AwsLambdaReceiver implements Receiver { + private signingSecret: string; + + private app?: App; + + private logger: Logger; + + constructor({ signingSecret, logger = undefined, logLevel = LogLevel.INFO }: AwsLambdaReceiverOptions) { + // Initialize instance variables, substituting defaults for each value + this.signingSecret = signingSecret; + this.logger = + logger ?? + (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(logLevel); + return defaultLogger; + })(); + } + + public init(app: App): void { + this.app = app; + } + + public start( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ..._args: any[] + ): Promise { + return new Promise((resolve, reject) => { + try { + const handler = this.toHandler(); + resolve(handler); + } catch (error) { + reject(error); + } + }); + } + + // eslint-disable-next-line class-methods-use-this + public stop( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ..._args: any[] + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return new Promise((resolve, _reject) => { + resolve(); + }); + } + + public toHandler(): AwsHandler { + return async ( + awsEvent: AwsEvent, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _awsContext: any, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _awsCallback: AwsCallback, + ): Promise => { + this.logger.debug(`AWS event: ${JSON.stringify(awsEvent, null, 2)}`); + + const rawBody: string = typeof awsEvent.body === 'undefined' || awsEvent.body == null ? '' : awsEvent.body; + + const body: any = this.parseRequestBody(rawBody, awsEvent.headers['Content-Type'], this.logger); + + // ssl_check (for Slash Commands) + if ( + typeof body !== 'undefined' && + body != null && + typeof body.ssl_check !== 'undefined' && + body.ssl_check != null + ) { + return Promise.resolve({ statusCode: 200, body: '' }); + } + + // request signature verification + const signature = awsEvent.headers['X-Slack-Signature'] as string; + const ts = Number(awsEvent.headers['X-Slack-Request-Timestamp']); + if (!this.isValidRequestSignature(this.signingSecret, rawBody, signature, ts)) { + return Promise.resolve({ statusCode: 401, body: '' }); + } + + // url_verification (Events API) + if ( + typeof body !== 'undefined' && + body != null && + typeof body.type !== 'undefined' && + body.type != null && + body.type === 'url_verification' + ) { + return Promise.resolve({ + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challenge: body.challenge }), + }); + } + + // Setup ack timeout warning + let isAcknowledged = false; + setTimeout(() => { + if (!isAcknowledged) { + this.logger.error( + 'An incoming event was not acknowledged within 3 seconds. ' + + 'Ensure that the ack() argument is called in a listener.', + ); + } + }, 3001); + + // Structure the ReceiverEvent + let storedResponse; + const event: ReceiverEvent = { + body, + ack: async (response) => { + if (isAcknowledged) { + throw new ReceiverMultipleAckError(); + } + isAcknowledged = true; + if (typeof response === 'undefined' || response == null) { + storedResponse = ''; + } else { + storedResponse = response; + } + }, + }; + + // Send the event to the app for processing + try { + await this.app?.processEvent(event); + if (storedResponse !== undefined) { + if (typeof storedResponse === 'string') { + return { statusCode: 200, body: storedResponse }; + } + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(storedResponse), + }; + } + } catch (err) { + this.logger.error('An unhandled error occurred while Bolt processed an event'); + this.logger.debug(`Error details: ${err}, storedResponse: ${storedResponse}`); + return { statusCode: 500, body: 'Internal server error' }; + } + return { statusCode: 404, body: '' }; + }; + } + + // eslint-disable-next-line class-methods-use-this + private parseRequestBody(stringBody: string, contentType: string | undefined, logger: Logger): any { + if (contentType === 'application/x-www-form-urlencoded') { + const parsedBody = querystring.parse(stringBody); + if (typeof parsedBody.payload === 'string') { + return JSON.parse(parsedBody.payload); + } + return parsedBody; + } + if (contentType === 'application/json') { + return JSON.parse(stringBody); + } + + logger.warn(`Unexpected content-type detected: ${contentType}`); + try { + // Parse this body anyway + return JSON.parse(stringBody); + } catch (e) { + logger.error(`Failed to parse body as JSON data for content-type: ${contentType}`); + throw e; + } + } + + // eslint-disable-next-line class-methods-use-this + private isValidRequestSignature( + signingSecret: string, + body: string, + signature: string, + requestTimestamp: number, + ): boolean { + if (!signature || !requestTimestamp) { + return false; + } + + // Divide current date to match Slack ts format + // Subtract 5 minutes from current time + const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5; + if (requestTimestamp < fiveMinutesAgo) { + return false; + } + + const hmac = crypto.createHmac('sha256', signingSecret); + const [version, hash] = signature.split('='); + hmac.update(`${version}:${requestTimestamp}:${body}`); + if (!tsscmp(hash, hmac.digest('hex'))) { + return false; + } + + return true; + } +}