From 7ada220a157705b9576b7878fdef4cec7f56023e Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Mon, 20 Sep 2021 19:09:03 -0700 Subject: [PATCH] Add Support for Custom HTTP Routes (#1114) --- src/App.ts | 4 + src/errors.ts | 6 ++ src/receivers/HTTPReceiver.spec.ts | 56 +++++++++-- src/receivers/HTTPReceiver.ts | 18 +++- src/receivers/SocketModeReceiver.spec.ts | 62 +++++++++++- src/receivers/SocketModeReceiver.ts | 123 ++++++++++++++--------- src/receivers/custom-routes.ts | 48 +++++++++ 7 files changed, 262 insertions(+), 55 deletions(-) create mode 100644 src/receivers/custom-routes.ts diff --git a/src/App.ts b/src/App.ts index 88e740f5b..03c0b9cb9 100644 --- a/src/App.ts +++ b/src/App.ts @@ -76,6 +76,7 @@ const tokenUsage = 'Apps used in one workspace should be initialized with a toke export interface AppOptions { signingSecret?: HTTPReceiverOptions['signingSecret']; endpoints?: HTTPReceiverOptions['endpoints']; + customRoutes?: HTTPReceiverOptions['customRoutes']; processBeforeResponse?: HTTPReceiverOptions['processBeforeResponse']; signatureVerification?: HTTPReceiverOptions['signatureVerification']; clientId?: HTTPReceiverOptions['clientId']; @@ -211,6 +212,7 @@ export default class App { public constructor({ signingSecret = undefined, endpoints = undefined, + customRoutes = undefined, agent = undefined, clientTls = undefined, receiver = undefined, @@ -341,6 +343,7 @@ export default class App { logger, logLevel: this.logLevel, installerOptions: this.installerOptions, + customRoutes, }); } else if (signatureVerification && signingSecret === undefined) { // No custom receiver @@ -354,6 +357,7 @@ export default class App { this.receiver = new HTTPReceiver({ signingSecret: signingSecret || '', endpoints, + customRoutes, processBeforeResponse, signatureVerification, clientId, diff --git a/src/errors.ts b/src/errors.ts index 96792d25f..c5a7a75a7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -16,6 +16,8 @@ export enum ErrorCode { ContextMissingPropertyError = 'slack_bolt_context_missing_property_error', + CustomRouteInitializationError = 'slack_bolt_custom_route_initialization_error', + ReceiverMultipleAckError = 'slack_bolt_receiver_ack_multiple_error', ReceiverAuthenticityError = 'slack_bolt_receiver_authenticity_error', ReceiverInconsistentStateError = 'slack_bolt_receiver_inconsistent_state_error', @@ -80,6 +82,10 @@ export class ContextMissingPropertyError extends Error implements CodedError { } } +export class CustomRouteInitializationError extends Error implements CodedError { + public code = ErrorCode.CustomRouteInitializationError; +} + export class ReceiverMultipleAckError extends Error implements CodedError { public code = ErrorCode.ReceiverMultipleAckError; diff --git a/src/receivers/HTTPReceiver.spec.ts b/src/receivers/HTTPReceiver.spec.ts index 661d1ee29..fdd3ba48d 100644 --- a/src/receivers/HTTPReceiver.spec.ts +++ b/src/receivers/HTTPReceiver.spec.ts @@ -7,7 +7,7 @@ import { EventEmitter } from 'events'; import { InstallProvider } from '@slack/oauth'; import { IncomingMessage, ServerResponse } from 'http'; import { Override, mergeOverrides } from '../test-helpers'; -import { HTTPReceiverDeferredRequestError } from '../errors'; +import { CustomRouteInitializationError, HTTPReceiverDeferredRequestError } from '../errors'; /* Testing Harness */ @@ -291,7 +291,46 @@ describe('HTTPReceiver', function () { await receiver.requestListener(fakeReq, fakeRes); assert(installProviderStub.handleCallback.calledWith(fakeReq, fakeRes, callbackOptions)); }); - it('should throw if a request comes into neither the install path nor the redirect URI path', async function () { + + it('should call custom route handler only if request matches route path and method', async function () { + const HTTPReceiver = await importHTTPReceiver(); + const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; + const receiver = new HTTPReceiver({ + clientSecret: 'my-client-secret', + signingSecret: 'secret', + customRoutes, + }); + + const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + + fakeReq.url = '/test'; + fakeReq.headers = { host: 'localhost' }; + + fakeReq.method = 'GET'; + receiver.requestListener(fakeReq, fakeRes); + assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes)); + + fakeReq.method = 'POST'; + receiver.requestListener(fakeReq, fakeRes); + assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes)); + + fakeReq.method = 'UNHANDLED_METHOD'; + assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); + }); + + it("should throw an error if customRoutes don't have the required keys", async function () { + const HTTPReceiver = await importHTTPReceiver(); + const customRoutes = [{ path: '/test' }] as any; + + assert.throws(() => new HTTPReceiver({ + clientSecret: 'my-client-secret', + signingSecret: 'secret', + customRoutes, + }), CustomRouteInitializationError); + }); + + it("should throw if request doesn't match install path, redirect URI path, or custom routes", async function () { // Arrange const installProviderStub = sinon.createStubInstance(InstallProvider); const overrides = mergeOverrides( @@ -303,6 +342,8 @@ describe('HTTPReceiver', function () { const metadata = 'this is bat country'; const scopes = ['channels:read']; const userScopes = ['chat:write']; + const customRoutes = [{ path: '/nope', method: 'POST', handler: sinon.fake() }]; + const receiver = new HTTPReceiver({ logger: noopLogger, clientId: 'my-clientId', @@ -317,18 +358,21 @@ describe('HTTPReceiver', function () { metadata, userScopes, }, + customRoutes, }); + assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; + const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/nope'; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; + const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; + fakeRes.writeHead = sinon.fake(); + fakeRes.end = sinon.fake(); + assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); }); diff --git a/src/receivers/HTTPReceiver.ts b/src/receivers/HTTPReceiver.ts index 80dbf31fb..1a882250f 100644 --- a/src/receivers/HTTPReceiver.ts +++ b/src/receivers/HTTPReceiver.ts @@ -18,6 +18,7 @@ import { ErrorCode, CodedError, } from '../errors'; +import { CustomRoute, prepareRoutes, ReceiverRoutes } from './custom-routes'; // Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer() const httpsOptionKeys = [ @@ -58,6 +59,7 @@ const missingServerErrorDescription = 'The receiver cannot be started because pr export interface HTTPReceiverOptions { signingSecret: string; endpoints?: string | string[]; + customRoutes?: CustomRoute[]; logger?: Logger; logLevel?: LogLevel; processBeforeResponse?: boolean; @@ -69,7 +71,6 @@ export interface HTTPReceiverOptions { scopes?: InstallURLOptions['scopes']; installerOptions?: HTTPReceiverInstallerOptions; } - export interface HTTPReceiverInstallerOptions { installPath?: string; directInstall?: boolean; // see https://api.slack.com/start/distributing/directory#direct_install @@ -90,6 +91,8 @@ export interface HTTPReceiverInstallerOptions { export default class HTTPReceiver implements Receiver { private endpoints: string[]; + private routes: ReceiverRoutes; + private signingSecret: string; private processBeforeResponse: boolean; @@ -121,6 +124,7 @@ export default class HTTPReceiver implements Receiver { public constructor({ signingSecret = '', endpoints = ['/slack/events'], + customRoutes = [], logger = undefined, logLevel = LogLevel.INFO, processBeforeResponse = false, @@ -143,6 +147,7 @@ export default class HTTPReceiver implements Receiver { return defaultLogger; })(); this.endpoints = Array.isArray(endpoints) ? endpoints : [endpoints]; + this.routes = prepareRoutes(customRoutes); // Initialize InstallProvider when it's required options are provided if ( @@ -302,19 +307,28 @@ export default class HTTPReceiver implements Receiver { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const [installPath, installRedirectUriPath] = [this.installPath!, this.installRedirectUriPath!]; + // Visiting the installation endpoint if (path === installPath) { // Render installation path (containing Add to Slack button) return this.handleInstallPathRequest(res); } + + // Installation has been initiated if (path === installRedirectUriPath) { // Handle OAuth callback request (to exchange authorization grant for a new access token) return this.handleInstallRedirectRequest(req, res); } } + // Handle custom routes + if (Object.keys(this.routes).length) { + const match = this.routes[path] && this.routes[path][method] !== undefined; + if (match) { return this.routes[path][method](req, res); } + } + // If the request did not match the previous conditions, an error is thrown. The error can be caught by the // the caller in order to defer to other routing logic (similar to calling `next()` in connect middleware). - throw new HTTPReceiverDeferredRequestError('Unhandled HTTP request', req, res); + throw new HTTPReceiverDeferredRequestError(`Unhandled HTTP request (${method}) made to ${path}`, req, res); } private handleIncomingEvent(req: IncomingMessage, res: ServerResponse) { diff --git a/src/receivers/SocketModeReceiver.spec.ts b/src/receivers/SocketModeReceiver.spec.ts index 9d82daaea..84ddf147a 100644 --- a/src/receivers/SocketModeReceiver.spec.ts +++ b/src/receivers/SocketModeReceiver.spec.ts @@ -8,6 +8,7 @@ import { IncomingMessage, ServerResponse } from 'http'; import { InstallProvider } from '@slack/oauth'; import { SocketModeClient } from '@slack/socket-mode'; import { Override, mergeOverrides } from '../test-helpers'; +import { CustomRouteInitializationError } from '../errors'; // Fakes class FakeServer extends EventEmitter { @@ -150,6 +151,7 @@ describe('SocketModeReceiver', function () { receiver.installer = installProviderStub as unknown as InstallProvider; const fakeReq = { url: '/heyo', + method: 'GET', }; const fakeRes = null; await this.listener(fakeReq, fakeRes); @@ -191,6 +193,7 @@ describe('SocketModeReceiver', function () { receiver.installer = installProviderStub as unknown as InstallProvider; const fakeReq = { url: '/hiya', + method: 'GET', }; const fakeRes = { writeHead: sinon.fake(), @@ -232,6 +235,7 @@ describe('SocketModeReceiver', function () { receiver.installer = installProviderStub as unknown as InstallProvider; const fakeReq = { url: '/hiya', + method: 'GET', }; const fakeRes = { writeHead: sinon.fake(), @@ -275,6 +279,7 @@ describe('SocketModeReceiver', function () { receiver.installer = installProviderStub as unknown as InstallProvider; const fakeReq = { url: '/hiya', + method: 'GET', }; const fakeRes = { writeHead: sinon.fake(), @@ -286,7 +291,57 @@ describe('SocketModeReceiver', function () { assert(fakeRes.writeHead.calledWith(302, sinon.match.object)); assert(fakeRes.end.called); }); - it('should return a 404 if a request comes into neither the install path nor the redirect URI path', async function () { + + it('should call custom route handler only if request matches route path and method', async function () { + // Arrange + const installProviderStub = sinon.createStubInstance(InstallProvider); + const overrides = mergeOverrides( + withHttpCreateServer(this.fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); + const SocketModeReceiver = await importSocketModeReceiver(overrides); + const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; + + const receiver = new SocketModeReceiver({ + appToken: 'my-secret', + customRoutes, + }); + assert.isNotNull(receiver); + receiver.installer = installProviderStub as unknown as InstallProvider; + + const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + + fakeReq.url = '/test'; + fakeReq.headers = { host: 'localhost' }; + + fakeReq.method = 'GET'; + await this.listener(fakeReq, fakeRes); + assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes)); + + fakeReq.method = 'POST'; + await this.listener(fakeReq, fakeRes); + assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes)); + + fakeReq.method = 'UNHANDLED_METHOD'; + await this.listener(fakeReq, fakeRes); + assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); + assert(fakeRes.end.called); + }); + + it("should throw an error if customRoutes don't have the required keys", async function () { + // Arrange + const overrides = mergeOverrides( + withHttpCreateServer(this.fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); + const SocketModeReceiver = await importSocketModeReceiver(overrides); + const customRoutes = [{ handler: sinon.fake() }] as any; + + assert.throws(() => new SocketModeReceiver({ appToken: 'my-secret', customRoutes }), CustomRouteInitializationError); + }); + + it('should return a 404 if a request passes the install path, redirect URI path and custom routes', async function () { // Arrange const installProviderStub = sinon.createStubInstance(InstallProvider); const overrides = mergeOverrides( @@ -298,6 +353,7 @@ describe('SocketModeReceiver', function () { const metadata = 'this is bat country'; const scopes = ['channels:read']; const userScopes = ['chat:write']; + const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const receiver = new SocketModeReceiver({ appToken: 'my-secret', logger: noopLogger, @@ -305,6 +361,7 @@ describe('SocketModeReceiver', function () { clientSecret: 'my-client-secret', stateSecret: 'state-secret', scopes, + customRoutes, installerOptions: { authVersion: 'v2', installPath: '/hiya', @@ -317,6 +374,7 @@ describe('SocketModeReceiver', function () { receiver.installer = installProviderStub as unknown as InstallProvider; const fakeReq = { url: '/nope', + method: 'GET', }; const fakeRes = { writeHead: sinon.fake(), @@ -324,7 +382,7 @@ describe('SocketModeReceiver', function () { }; await this.listener(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); - assert(fakeRes.end.calledWith(sinon.match("route /nope doesn't exist!"))); + assert(fakeRes.end.calledOnce); }); }); describe('#start()', function () { diff --git a/src/receivers/SocketModeReceiver.ts b/src/receivers/SocketModeReceiver.ts index 59be06119..d29eaf06e 100644 --- a/src/receivers/SocketModeReceiver.ts +++ b/src/receivers/SocketModeReceiver.ts @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { SocketModeClient } from '@slack/socket-mode'; -import { createServer } from 'http'; +import { createServer, IncomingMessage, ServerResponse } from 'http'; import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOptions } from '@slack/oauth'; import { AppsConnectionsOpenResponse } from '@slack/web-api'; import App from '../App'; import { Receiver, ReceiverEvent } from '../types'; import defaultRenderHtmlForInstallPath from './render-html-for-install-path'; +import { prepareRoutes, ReceiverRoutes } from './custom-routes'; // TODO: we throw away the key names for endpoints, so maybe we should use this interface. is it better for migrations? // if that's the reason, let's document that with a comment. @@ -20,6 +21,13 @@ export interface SocketModeReceiverOptions { scopes?: InstallURLOptions['scopes']; installerOptions?: InstallerOptions; appToken: string; // App Level Token + customRoutes?: CustomRoute[]; +} + +export interface CustomRoute { + path: string; + method: string | string[]; + handler: (req: IncomingMessage, res: ServerResponse) => void; } // Additional Installer Options @@ -51,6 +59,8 @@ export default class SocketModeReceiver implements Receiver { public installer: InstallProvider | undefined = undefined; + private routes: ReceiverRoutes; + public constructor({ appToken, logger = undefined, @@ -61,6 +71,7 @@ export default class SocketModeReceiver implements Receiver { installationStore = undefined, scopes = undefined, installerOptions = {}, + customRoutes = [], }: SocketModeReceiverOptions) { this.client = new SocketModeClient({ appToken, @@ -69,18 +80,16 @@ export default class SocketModeReceiver implements Receiver { clientOptions: installerOptions.clientOptions, }); - if (typeof logger !== 'undefined') { - this.logger = logger; - } else { - this.logger = new ConsoleLogger(); - this.logger.setLevel(logLevel); - } + this.logger = logger ?? (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(logLevel); + return defaultLogger; + })(); + this.routes = prepareRoutes(customRoutes); - if ( - clientId !== undefined && - clientSecret !== undefined && - (stateSecret !== undefined || installerOptions.stateStore !== undefined) - ) { + // Initialize InstallProvider + if (clientId !== undefined && clientSecret !== undefined && + (stateSecret !== undefined || installerOptions.stateStore !== undefined)) { this.installer = new InstallProvider({ clientId, clientSecret, @@ -95,53 +104,77 @@ export default class SocketModeReceiver implements Receiver { }); } - // Add OAuth routes to receiver - if (this.installer !== undefined) { + // Add OAuth and/or custom routes to receiver + if (this.installer !== undefined || customRoutes.length) { // use default or passed in redirect path const redirectUriPath = installerOptions.redirectUriPath === undefined ? '/slack/oauth_redirect' : installerOptions.redirectUriPath; // use default or passed in installPath const installPath = installerOptions.installPath === undefined ? '/slack/install' : installerOptions.installPath; const directInstallEnabled = installerOptions.directInstall !== undefined && installerOptions.directInstall; + const port = installerOptions.port === undefined ? 3000 : installerOptions.port; const server = createServer(async (req, res) => { - if (req.url !== undefined && req.url.startsWith(redirectUriPath)) { - // call installer.handleCallback to wrap up the install flow - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await this.installer!.handleCallback(req, res, installerOptions.callbackOptions); - } else if (req.url !== undefined && req.url.startsWith(installPath)) { - try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const method = req.method!.toUpperCase(); + + // Handle OAuth-related requests + if (this.installer) { + // Installation has been initiated + if (req.url && req.url.startsWith(redirectUriPath)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const url = await this.installer!.generateInstallUrl({ - metadata: installerOptions.metadata, - scopes: scopes ?? [], - userScopes: installerOptions.userScopes, - }); - if (directInstallEnabled) { - res.writeHead(302, { Location: url }); - res.end(''); - } else { - res.writeHead(200, {}); - const renderHtml = installerOptions.renderHtmlForInstallPath !== undefined ? - installerOptions.renderHtmlForInstallPath : - defaultRenderHtmlForInstallPath; - res.end(renderHtml(url)); + await this.installer!.handleCallback(req, res, installerOptions.callbackOptions); + return; + } + + // Visiting the installation endpoint + if (req.url && req.url.startsWith(installPath)) { + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const url = await this.installer!.generateInstallUrl({ + metadata: installerOptions.metadata, + scopes: scopes ?? [], + userScopes: installerOptions.userScopes, + }); + if (directInstallEnabled) { + res.writeHead(302, { Location: url }); + res.end(''); + } else { + res.writeHead(200, {}); + const renderHtml = installerOptions.renderHtmlForInstallPath !== undefined ? + installerOptions.renderHtmlForInstallPath : + defaultRenderHtmlForInstallPath; + res.end(renderHtml(url)); + return; + } + } catch (err) { + const e = err as any; + throw new Error(e); } - } catch (err) { - const e = err as any; - throw new Error(e); } - } else { - this.logger.error(`Tried to reach ${req.url} which isn't a valid route.`); - // Return 404 because we don't support route - res.writeHead(404, {}); - res.end(`route ${req.url} doesn't exist!`); } + + // Handle request for custom routes + if (customRoutes.length && req.url) { + const match = this.routes[req.url] && this.routes[req.url][method] !== undefined; + + if (match) { + this.routes[req.url][method](req, res); + return; + } + } + + this.logger.info(`An unhandled HTTP request (${req.method}) made to ${req.url} was ignored`); + res.writeHead(404, {}); + res.end(); }); - const port = installerOptions.port === undefined ? 3000 : installerOptions.port; - this.logger.debug(`listening on port ${port} for OAuth`); - this.logger.debug(`Go to http://localhost:${port}${installPath} to initiate OAuth flow`); + this.logger.debug(`Listening for HTTP requests on port ${port}`); + + if (this.installer) { + this.logger.debug(`Go to http://localhost:${port}${installPath} to initiate OAuth flow`); + } + // use port 3000 by default server.listen(port); } diff --git a/src/receivers/custom-routes.ts b/src/receivers/custom-routes.ts new file mode 100644 index 000000000..0284aa576 --- /dev/null +++ b/src/receivers/custom-routes.ts @@ -0,0 +1,48 @@ +import { IncomingMessage, ServerResponse } from 'http'; +import { CustomRouteInitializationError } from '../errors'; + +export interface CustomRoute { + path: string; + method: string | string[]; + handler: (req: IncomingMessage, res: ServerResponse) => void; +} + +export interface ReceiverRoutes { + [url: string]: { + [method: string]: (req: IncomingMessage, res: ServerResponse) => void; + }; +} + +export function prepareRoutes(customRoutes: CustomRoute[]): ReceiverRoutes { + const routes: ReceiverRoutes = {}; + + validateCustomRoutes(customRoutes); + + customRoutes.forEach((r) => { + const methodObj = Array.isArray(r.method) ? + r.method.reduce((o, key) => ({ ...o, [key.toUpperCase()]: r.handler }), {}) : + { [r.method.toUpperCase()]: r.handler }; + routes[r.path] = routes[r.path] ? { ...routes[r.path], ...methodObj } : methodObj; + }); + + return routes; +} + +function validateCustomRoutes(customRoutes: CustomRoute[]): void { + const requiredKeys: (keyof CustomRoute)[] = ['path', 'method', 'handler']; + const missingKeys: (keyof CustomRoute)[] = []; + + // Check for missing required keys + customRoutes.forEach((route) => { + requiredKeys.forEach((key) => { + if (route[key] === undefined && !missingKeys.includes(key)) { + missingKeys.push(key); + } + }); + }); + + if (missingKeys.length > 0) { + const errorMsg = `One or more routes in customRoutes are missing required keys: ${missingKeys.join(', ')}`; + throw new CustomRouteInitializationError(errorMsg); + } +}