From 7661f336aa5b38c1f24399878ea74eb86d11d0c0 Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Mon, 20 Apr 2020 13:17:30 -0700 Subject: [PATCH 1/7] added oauth package to bolt --- package.json | 1 + src/App.ts | 74 ++++++++++++++++++++++++++++++++---------- src/ExpressReceiver.ts | 68 +++++++++++++++++++++++++++++++++++--- 3 files changed, 122 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index d55af274f..016451596 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@slack/logger": "^2.0.0", + "@slack/oauth": "file:../node-slack-sdk/packages/oauth", "@slack/types": "^1.5.0", "@slack/web-api": "^5.8.0", "@types/express": "^4.16.1", diff --git a/src/App.ts b/src/App.ts index d6a2c37c0..0e2754369 100644 --- a/src/App.ts +++ b/src/App.ts @@ -63,6 +63,14 @@ export interface AppOptions { signingSecret?: ExpressReceiverOptions['signingSecret']; endpoints?: ExpressReceiverOptions['endpoints']; processBeforeResponse?: ExpressReceiverOptions['processBeforeResponse']; + clientId?: ExpressReceiverOptions['clientId']; + clientSecret?: ExpressReceiverOptions['clientSecret']; + stateStore?: ExpressReceiverOptions['stateStore']; // default ClearStateStore + stateSecret?: ExpressReceiverOptions['stateSecret']; // required when using default stateStore + installationStore?: ExpressReceiverOptions['installationStore']; // default MemoryInstallationStore + authVersion?: ExpressReceiverOptions['authVersion']; // default 'v2' + scopes?: ExpressReceiverOptions['scopes']; + metadata?: ExpressReceiverOptions['metadata']; agent?: Agent; clientTls?: Pick; convoStore?: ConversationStore | false; @@ -83,7 +91,7 @@ export { LogLevel, Logger } from '@slack/logger'; export interface Authorize { ( source: AuthorizeSourceData, - body: AnyMiddlewareArgs['body'], + body?: AnyMiddlewareArgs['body'], ): Promise; } @@ -158,7 +166,7 @@ export default class App { private logger: Logger; /** Authorize */ - private authorize: Authorize; + private authorize!: Authorize; /** Global middleware chain */ private middleware: Middleware[]; @@ -186,6 +194,14 @@ export default class App { ignoreSelf = true, clientOptions = undefined, processBeforeResponse = false, + clientId = undefined, + clientSecret = undefined, + stateStore = undefined, + stateSecret = undefined, + installationStore = undefined, + authVersion = 'v2', + scopes = undefined, + metadata = undefined, }: AppOptions = {}) { if (typeof logger === 'undefined') { @@ -218,21 +234,6 @@ export default class App { clientTls, )); - if (token !== undefined) { - if (authorize !== undefined) { - throw new AppInitializationError( - `Both token and authorize options provided. ${tokenUsage}`, - ); - } - this.authorize = singleTeamAuthorization(this.client, { botId, botUserId, botToken: token }); - } else if (authorize === undefined) { - throw new AppInitializationError( - `No token and no authorize options provided. ${tokenUsage}`, - ); - } else { - this.authorize = authorize; - } - this.middleware = []; this.listeners = []; @@ -252,11 +253,50 @@ export default class App { signingSecret, endpoints, processBeforeResponse, + clientId, + clientSecret, + stateStore, + stateSecret, + installationStore, + authVersion, + metadata, + scopes, logger: this.logger, }); } } + let usingBuiltinOauth = undefined; + if ( + clientId !== undefined + && clientSecret !== undefined + && (stateSecret !== undefined || stateStore !== undefined) + && this.receiver instanceof ExpressReceiver + ) { + usingBuiltinOauth = true; + } + + if (token !== undefined) { + if (authorize !== undefined || usingBuiltinOauth !== undefined) { + throw new AppInitializationError( + `token as well as authorize options or installer options were provided. ${tokenUsage}`, + ); + } + this.authorize = singleTeamAuthorization(this.client, { botId, botUserId, botToken: token }); + } else if (authorize === undefined && usingBuiltinOauth === undefined) { + throw new AppInitializationError( + `No token, no authorize options, and no installer options provided. ${tokenUsage}`, + ); + } else if (authorize !== undefined && usingBuiltinOauth !== undefined) { + throw new AppInitializationError( + `Both authorize options and installer options provided. ${tokenUsage}`, + ); + } else if (authorize === undefined && usingBuiltinOauth !== undefined) { + this.authorize = (this.receiver as ExpressReceiver).oauthAuthorize as Authorize; + } else if (authorize !== undefined && usingBuiltinOauth === undefined) { + this.authorize = authorize; + } + // Conditionally use a global middleware that ignores events (including messages) that are sent from this app if (ignoreSelf) { this.use(ignoreSelfMiddleware()); diff --git a/src/ExpressReceiver.ts b/src/ExpressReceiver.ts index 9cd87395f..19f3fd4ab 100644 --- a/src/ExpressReceiver.ts +++ b/src/ExpressReceiver.ts @@ -1,6 +1,6 @@ import { AnyMiddlewareArgs, Receiver, ReceiverEvent } from './types'; import { createServer, Server } from 'http'; -import express, { Request, Response, Application, RequestHandler } from 'express'; +import express, { Request, Response, Application, RequestHandler, Router } from 'express'; import rawBody from 'raw-body'; import querystring from 'querystring'; import crypto from 'crypto'; @@ -8,6 +8,7 @@ import tsscmp from 'tsscmp'; import App from './App'; import { ReceiverAuthenticityError, ReceiverMultipleAckError } from './errors'; import { Logger, ConsoleLogger } from '@slack/logger'; +import { InstallProvider, StateStore, InstallationStore, OAuthInterface, CallbackOptions } from '@slack/oauth'; // 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. @@ -18,6 +19,15 @@ export interface ExpressReceiverOptions { [endpointType: string]: string; }; processBeforeResponse?: boolean; + clientId?: string; + clientSecret?: string; + stateStore?: StateStore; // default ClearStateStore + stateSecret?: string; // ClearStateStoreOptions['secret']; // required when using default stateStore + installationStore?: InstallationStore; // default MemoryInstallationStore + authVersion?: 'v1' | 'v2'; // default 'v2' + scopes?: string | string[]; + metadata?: string; + oAuthCallbackOptions?: CallbackOptions; } /** @@ -32,18 +42,28 @@ export default class ExpressReceiver implements Receiver { private bolt: App | undefined; private logger: Logger; private processBeforeResponse: boolean; + public router: Router; + public oauthAuthorize: OAuthInterface['authorize'] | undefined; constructor({ signingSecret = '', logger = new ConsoleLogger(), endpoints = { events: '/slack/events' }, processBeforeResponse = false, + clientId = undefined, + clientSecret = undefined, + stateStore = undefined, + stateSecret = undefined, + installationStore = undefined, + authVersion = 'v2', + metadata = undefined, + scopes = undefined, }: ExpressReceiverOptions) { this.app = express(); // TODO: what about starting an https server instead of http? what about other options to create the server? this.server = createServer(this.app); - const expressMiddleware: RequestHandler[] = [ + const expressMiddleware = [ verifySignatureAndParseRawBody(logger, signingSecret), respondToSslCheck, respondToUrlVerification, @@ -52,9 +72,49 @@ export default class ExpressReceiver implements Receiver { this.processBeforeResponse = processBeforeResponse; this.logger = logger; - const endpointList: string[] = typeof endpoints === 'string' ? [endpoints] : Object.values(endpoints); + const endpointList = typeof endpoints === 'string' ? [endpoints] : Object.values(endpoints); + this.router = Router(); for (const endpoint of endpointList) { - this.app.post(endpoint, ...expressMiddleware); + this.router.post(endpoint, ...expressMiddleware); + } + this.app.use(this.router); + this.oauthAuthorize = undefined; + + let oauthInstaller: OAuthInterface | undefined = undefined; + if ( + clientId !== undefined + && clientSecret !== undefined + && (stateSecret !== undefined || stateStore !== undefined) + ) { + + oauthInstaller = new InstallProvider({ + clientId, + clientSecret, + stateSecret, + stateStore, + installationStore, + authVersion, + }); + } + + // Add OAuth routes to receiver + if (oauthInstaller !== undefined) { + this.app.use('/slack/install', (req, res) => { + return oauthInstaller!.handleCallback(req, res, {}); + }); + this.app.get('/slack/begin_auth', (_req, res, next) => { + // TODO, take in arguments or function for + oauthInstaller!.generateInstallUrl({ + metadata, + scopes: scopes!, + }).then((url: string) => { + res.send(``); + }).catch(next); + }); + this.oauthAuthorize = oauthInstaller.authorize; } } From e545f1988fcd8a4f69dc1b316a7ad8f4cc35e761 Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Wed, 29 Apr 2020 20:58:59 -0700 Subject: [PATCH 2/7] updated oauth integration based on feedback --- package.json | 2 +- src/App.ts | 39 +++++++++++------------ src/ExpressReceiver.ts | 70 ++++++++++++++++++++++++------------------ src/helpers.ts | 2 +- 4 files changed, 60 insertions(+), 53 deletions(-) diff --git a/package.json b/package.json index 016451596..0c4947dc8 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "dependencies": { "@slack/logger": "^2.0.0", - "@slack/oauth": "file:../node-slack-sdk/packages/oauth", + "@slack/oauth": "^1.0.0", "@slack/types": "^1.5.0", "@slack/web-api": "^5.8.0", "@types/express": "^4.16.1", diff --git a/src/App.ts b/src/App.ts index 0e2754369..100918919 100644 --- a/src/App.ts +++ b/src/App.ts @@ -65,12 +65,10 @@ export interface AppOptions { processBeforeResponse?: ExpressReceiverOptions['processBeforeResponse']; clientId?: ExpressReceiverOptions['clientId']; clientSecret?: ExpressReceiverOptions['clientSecret']; - stateStore?: ExpressReceiverOptions['stateStore']; // default ClearStateStore stateSecret?: ExpressReceiverOptions['stateSecret']; // required when using default stateStore installationStore?: ExpressReceiverOptions['installationStore']; // default MemoryInstallationStore - authVersion?: ExpressReceiverOptions['authVersion']; // default 'v2' scopes?: ExpressReceiverOptions['scopes']; - metadata?: ExpressReceiverOptions['metadata']; + installerOptions?: ExpressReceiverOptions['installerOptions']; agent?: Agent; clientTls?: Pick; convoStore?: ConversationStore | false; @@ -196,12 +194,10 @@ export default class App { processBeforeResponse = false, clientId = undefined, clientSecret = undefined, - stateStore = undefined, stateSecret = undefined, installationStore = undefined, - authVersion = 'v2', scopes = undefined, - metadata = undefined, + installerOptions = undefined, }: AppOptions = {}) { if (typeof logger === 'undefined') { @@ -255,46 +251,47 @@ export default class App { processBeforeResponse, clientId, clientSecret, - stateStore, stateSecret, installationStore, - authVersion, - metadata, + installerOptions, scopes, logger: this.logger, }); } } - let usingBuiltinOauth = undefined; + let usingBuiltinOauth = false; if ( clientId !== undefined && clientSecret !== undefined - && (stateSecret !== undefined || stateStore !== undefined) + && (stateSecret !== undefined || (installerOptions !== undefined && installerOptions.stateStore !== undefined)) && this.receiver instanceof ExpressReceiver ) { usingBuiltinOauth = true; } if (token !== undefined) { - if (authorize !== undefined || usingBuiltinOauth !== undefined) { + if (authorize !== undefined || usingBuiltinOauth) { throw new AppInitializationError( - `token as well as authorize options or installer options were provided. ${tokenUsage}`, + `token as well as authorize options or oauth installer options were provided. ${tokenUsage}`, ); } this.authorize = singleTeamAuthorization(this.client, { botId, botUserId, botToken: token }); - } else if (authorize === undefined && usingBuiltinOauth === undefined) { + } else if (authorize === undefined && !usingBuiltinOauth) { throw new AppInitializationError( - `No token, no authorize options, and no installer options provided. ${tokenUsage}`, + `No token, no authorize options, and no oauth installer options provided. ${tokenUsage}`, ); - } else if (authorize !== undefined && usingBuiltinOauth !== undefined) { + } else if (authorize !== undefined && usingBuiltinOauth) { throw new AppInitializationError( - `Both authorize options and installer options provided. ${tokenUsage}`, + `Both authorize options and oauth installer options provided. ${tokenUsage}`, ); - } else if (authorize === undefined && usingBuiltinOauth !== undefined) { - this.authorize = (this.receiver as ExpressReceiver).oauthAuthorize as Authorize; - } else if (authorize !== undefined && usingBuiltinOauth === undefined) { + } else if (authorize === undefined && usingBuiltinOauth) { + this.authorize = (this.receiver as ExpressReceiver).installer?.authorize as Authorize; + } else if (authorize !== undefined && !usingBuiltinOauth) { this.authorize = authorize; + } else { + this.logger.error('Never should have reached this point, please report to the team'); + assertNever(); } // Conditionally use a global middleware that ignores events (including messages) that are sent from this app @@ -692,7 +689,7 @@ export default class App { } const tokenUsage = 'Apps used in one workspace should be initialized with a token. Apps used in many workspaces ' + - 'should be initialized with a authorize.'; + 'should be initialized with oauth installer or authorize.'; const validViewTypes = ['view_closed', 'view_submission']; diff --git a/src/ExpressReceiver.ts b/src/ExpressReceiver.ts index 19f3fd4ab..1ab92e2c1 100644 --- a/src/ExpressReceiver.ts +++ b/src/ExpressReceiver.ts @@ -8,7 +8,7 @@ import tsscmp from 'tsscmp'; import App from './App'; import { ReceiverAuthenticityError, ReceiverMultipleAckError } from './errors'; import { Logger, ConsoleLogger } from '@slack/logger'; -import { InstallProvider, StateStore, InstallationStore, OAuthInterface, CallbackOptions } from '@slack/oauth'; +import { InstallProvider, StateStore, InstallationStore, CallbackOptions } from '@slack/oauth'; // 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. @@ -21,13 +21,20 @@ export interface ExpressReceiverOptions { processBeforeResponse?: boolean; clientId?: string; clientSecret?: string; - stateStore?: StateStore; // default ClearStateStore stateSecret?: string; // ClearStateStoreOptions['secret']; // required when using default stateStore installationStore?: InstallationStore; // default MemoryInstallationStore - authVersion?: 'v1' | 'v2'; // default 'v2' scopes?: string | string[]; + installerOptions?: InstallerOptions; +} + +// Additional Installer Options +interface InstallerOptions { + stateStore?: StateStore; // default ClearStateStore + authVersion?: 'v1' | 'v2'; // default 'v2' metadata?: string; - oAuthCallbackOptions?: CallbackOptions; + installPath?: string; + redirectUriPath?: string; + callbackOptions?: CallbackOptions; } /** @@ -43,7 +50,7 @@ export default class ExpressReceiver implements Receiver { private logger: Logger; private processBeforeResponse: boolean; public router: Router; - public oauthAuthorize: OAuthInterface['authorize'] | undefined; + public installer: InstallProvider | undefined = undefined; constructor({ signingSecret = '', @@ -52,18 +59,16 @@ export default class ExpressReceiver implements Receiver { processBeforeResponse = false, clientId = undefined, clientSecret = undefined, - stateStore = undefined, stateSecret = undefined, installationStore = undefined, - authVersion = 'v2', - metadata = undefined, scopes = undefined, + installerOptions = {}, }: ExpressReceiverOptions) { this.app = express(); // TODO: what about starting an https server instead of http? what about other options to create the server? this.server = createServer(this.app); - const expressMiddleware = [ + const expressMiddleware: RequestHandler[] = [ verifySignatureAndParseRawBody(logger, signingSecret), respondToSslCheck, respondToUrlVerification, @@ -77,45 +82,50 @@ export default class ExpressReceiver implements Receiver { for (const endpoint of endpointList) { this.router.post(endpoint, ...expressMiddleware); } - this.app.use(this.router); - this.oauthAuthorize = undefined; - let oauthInstaller: OAuthInterface | undefined = undefined; if ( clientId !== undefined && clientSecret !== undefined - && (stateSecret !== undefined || stateStore !== undefined) + && (stateSecret !== undefined || installerOptions.stateStore !== undefined) ) { - oauthInstaller = new InstallProvider({ + this.installer = new InstallProvider({ clientId, clientSecret, stateSecret, - stateStore, installationStore, - authVersion, + stateStore: installerOptions.stateStore, + authVersion: installerOptions.authVersion!, }); } // Add OAuth routes to receiver - if (oauthInstaller !== undefined) { - this.app.use('/slack/install', (req, res) => { - return oauthInstaller!.handleCallback(req, res, {}); + if (this.installer !== undefined) { + const redirectUriPath = installerOptions.redirectUriPath === undefined ? + '/slack/oauth_redirect' : installerOptions.redirectUriPath; + this.router.use(redirectUriPath, async (req, res) => { + await this.installer!.handleCallback(req, res, installerOptions.callbackOptions); }); - this.app.get('/slack/begin_auth', (_req, res, next) => { - // TODO, take in arguments or function for - oauthInstaller!.generateInstallUrl({ - metadata, - scopes: scopes!, - }).then((url: string) => { + + const installPath = installerOptions.installPath === undefined ? + '/slack/install' : installerOptions.installPath; + this.router.get(installPath, async (_req, res, next) => { + try { + const url = await this.installer!.generateInstallUrl({ + metadata: installerOptions.metadata, + scopes: scopes!, + }); res.send(``); - }).catch(next); + src="https://platform.slack-edge.com/img/add_to_slack.png" + srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, + https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" />`); + } catch (error) { + next(error); + } }); - this.oauthAuthorize = oauthInstaller.authorize; } + + this.app.use(this.router); } private async requestHandler(req: Request, res: Response): Promise { diff --git a/src/helpers.ts b/src/helpers.ts index 454c1f9b0..81c1786e2 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -80,6 +80,6 @@ export function getTypeAndConversation(body: any): { type?: IncomingEventType, c /* istanbul ignore next */ /** Helper that should never be called, but is useful for exhaustiveness checking in conditional branches */ -export function assertNever(x: never): never { +export function assertNever(x?: never): never { throw new Error(`Unexpected object: ${x}`); } From ffd45904a5bf179b07613b562f850e84cfb03f86 Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Mon, 11 May 2020 14:47:32 -0700 Subject: [PATCH 3/7] added oauth docs --- docs/_basic/authenticating_oauth.md | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/_basic/authenticating_oauth.md diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md new file mode 100644 index 000000000..1c28b1066 --- /dev/null +++ b/docs/_basic/authenticating_oauth.md @@ -0,0 +1,99 @@ +--- +title: Authenticating with OAuth +lang: en +slug: authenticating-oauth +order: 14 +--- + +
+Slack apps that are installed in multiple workspaces, like in the App Directory or in an Enterprise Grid, will need to implement OAuth and store information about each of those installations (such as access tokens). By providing `clientId`, `clientSecret`, `stateSecret` and `scopes` when initializing `App`, Bolt for JavaScript will handle the work of setting up OAuth routes and verifying state. Bolt for JavaScript's OAuth support is built on top of Slack's [OAuth library](https://slack.dev/node-slack-sdk/oauth#slack-oauth). Currently, OAuth support is only available when using Bolt for JavaScript's built-in `ExpressReceiver`. + +Bolt for JavaScript will create a **Redirect URL** `slack/oauth_redirect`, which is used by Slack to redirect users after they grant access for you app to be installed in their workspace. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. + +Bolt for JavaScript will also create a `slack/install` route, where you can find an `Add to Slack` button for your app to do direct installs of the app. If you need an additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can generate it via `app.installer.generateInstallUrl()`. Read more about `generateInstallUrl()` in the [OAuth docs](https://slack.dev/node-slack-sdk/oauth#generating-an-installation-url). +
+ +```javascript +const app = new App({ + signingSecret: process.env.SLACK_SIGNING_SECRET, + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET + stateSecret: 'my-state-secret', + scopes: ['channels:read', 'groups:read', 'channels:manage', 'chat:write', 'incoming-webhook'], + installationStore: { + storeInstallation: (installation) => { + // change the line below so it saves to your database + return database.set(installation.team.id, installation); + }, + fetchInstallation: (InstallQuery) => { + // change the line below so it fetches from your database + return database.get(InstallQuery.teamId); + }, + }, +}); +``` + +
+ +

Customizing OAuth defaults

+
+ +
+The OAuth support in Bolt comes with a variety of defaults. We do provide a way to override these defaults with the `installerOptions` object which can be passed in during the initialization of `App`. You can override the following: + +- `authVersion`: Used to toggle between new Slack Apps and Classic Slack Apps +- `metadata`: Used to pass around session related information +- `installPath`: Override default path for "Add to Slack" button +- `redirectUriPath`: Override default redirect url path +- `callbackOptions`: Provide custom success and failure pages at the end of the OAuth flow +- `stateStore`: Provide a custom state store instead of using the built in `ClearStatStore` + +
+ +```javascript +const app = new App({ + signingSecret: process.env.SLACK_SIGNING_SECRET, + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET + scopes: ['channels:read', 'groups:read', 'channels:manage', 'chat:write', 'incoming-webhook'], + installerOptions: { + authVersion: 'v1', // default is 'v2', 'v1' is used for classic slack apps + metadata: 'some session data', + installPath: 'slack/installApp', + redirectUriPath: 'slack/redirect', + callbackOptions: { + success: (installation, installOptions, req, res) => { + // Do custom success logic here + res.send('successful!'); + }, + failure: (error, installOptions , req, res) => { + // Do custom failure logic here + res.send('failure'); + } + }, + stateStore: { + // Do not need to provide a `stateSecret` when passing in a stateStore + // generateStateParam's first argument is the entire InstallUrlOptions object which was passed into generateInstallUrl method + // the second argument is a date object + // the method is expected to return a string representing the state + generateStateParam: (installUrlOptions, date) => { + // generate a random string to use as state in the URL + const randomState = randomStringGenerator(); + // save installOptions to cache/db + myDB.set(randomState, installUrlOptions); + // return a state string that references saved options in DB + return randomState; + }, + // verifyStateParam's first argument is a date object and the second argument is a string representing the state + // verifyStateParam is expected to return an object representing installUrlOptions + verifyStateParam: (date, state) => { + // fetch saved installOptions from DB using state reference + const installUrlOptions = myDB.get(randomState); + return installUrlOptions; + } + }, + } +}); +``` + +
From f0a22ab0c49c39e3014ea73ba8481f150e8954d0 Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Wed, 13 May 2020 16:27:50 -0700 Subject: [PATCH 4/7] implemented oauth feedback --- docs/_basic/authenticating_oauth.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md index 1c28b1066..b90f317e6 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/_basic/authenticating_oauth.md @@ -6,11 +6,13 @@ order: 14 ---
-Slack apps that are installed in multiple workspaces, like in the App Directory or in an Enterprise Grid, will need to implement OAuth and store information about each of those installations (such as access tokens). By providing `clientId`, `clientSecret`, `stateSecret` and `scopes` when initializing `App`, Bolt for JavaScript will handle the work of setting up OAuth routes and verifying state. Bolt for JavaScript's OAuth support is built on top of Slack's [OAuth library](https://slack.dev/node-slack-sdk/oauth#slack-oauth). Currently, OAuth support is only available when using Bolt for JavaScript's built-in `ExpressReceiver`. +Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `clientId`, `clientSecret`, `stateSecret` and `scopes` when initializing `App`, Bolt for JavaScript will handle the work of setting up OAuth routes and verifying state. Your app only has built-in OAuth support when using the built-in ExpressReceiver. If you're implementing a custom receiver, you can make use of our [OAuth library](https://slack.dev/node-slack-sdk/oauth#slack-oauth), which is what Bolt for JavaScript uses under the hood. -Bolt for JavaScript will create a **Redirect URL** `slack/oauth_redirect`, which is used by Slack to redirect users after they grant access for you app to be installed in their workspace. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. +Bolt for JavaScript will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app's installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `installerOptions` argument described below. -Bolt for JavaScript will also create a `slack/install` route, where you can find an `Add to Slack` button for your app to do direct installs of the app. If you need an additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can generate it via `app.installer.generateInstallUrl()`. Read more about `generateInstallUrl()` in the [OAuth docs](https://slack.dev/node-slack-sdk/oauth#generating-an-installation-url). +Bolt for JavaScript will also create a `slack/install` route, where you can find an `Add to Slack` button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can generate it via `app.installer.generateInstallUrl()`. Read more about `generateInstallUrl()` in the [OAuth docs](https://slack.dev/node-slack-sdk/oauth#generating-an-installation-url). + +To learn more about the OAuth installation flow with Slack, [read the API documentation](https://api.slack.com/authentication/oauth-v2).
```javascript @@ -39,7 +41,7 @@ const app = new App({
-The OAuth support in Bolt comes with a variety of defaults. We do provide a way to override these defaults with the `installerOptions` object which can be passed in during the initialization of `App`. You can override the following: +You can override the default OAuth using the `installOptions` object, which can be passed in during the initialization of `App`. You can override the following: - `authVersion`: Used to toggle between new Slack Apps and Classic Slack Apps - `metadata`: Used to pass around session related information From e8bd599c130eddb1f2d1e1cc9392291b1ca6fcab Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Thu, 21 May 2020 13:49:18 -0700 Subject: [PATCH 5/7] updated oauth version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0c4947dc8..14e128980 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "dependencies": { "@slack/logger": "^2.0.0", - "@slack/oauth": "^1.0.0", + "@slack/oauth": "^1.1.0", "@slack/types": "^1.5.0", "@slack/web-api": "^5.8.0", "@types/express": "^4.16.1", From ebd5e114f47251676d22b09f8e15b1e5f967a9d7 Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Thu, 21 May 2020 16:31:37 -0700 Subject: [PATCH 6/7] added oauth tests --- src/App.spec.ts | 41 +++++++++++++++++++++++++++++++++---- src/ExpressReceiver.spec.ts | 7 +++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/App.spec.ts b/src/App.spec.ts index aee109e35..cdf960258 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -66,20 +66,37 @@ describe('App', () => { assert(authorizeCallback.notCalled, 'Should not call the authorize callback on instantiation'); assert.instanceOf(app, App); }); - it('should fail without a token for single team authorization or authorize callback', async () => { + it('should fail without a token for single team authorization or authorize callback or oauth installer', + async () => { + // Arrange + const App = await importApp(); // tslint:disable-line:variable-name + + // Act + try { + new App({ signingSecret: '' }); // tslint:disable-line:no-unused-expression + assert.fail(); + } catch (error) { + // Assert + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should fail when both a token and authorize callback are specified', async () => { // Arrange + const authorizeCallback = sinon.fake(); const App = await importApp(); // tslint:disable-line:variable-name // Act try { - new App({ signingSecret: '' }); // tslint:disable-line:no-unused-expression + // tslint:disable-next-line:no-unused-expression + new App({ token: '', authorize: authorizeCallback, signingSecret: '' }); assert.fail(); } catch (error) { // Assert assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + assert(authorizeCallback.notCalled); } }); - it('should fail when both a token and authorize callback are specified', async () => { + it('should fail when both a token is specified and OAuthInstaller is initialized', async () => { // Arrange const authorizeCallback = sinon.fake(); const App = await importApp(); // tslint:disable-line:variable-name @@ -87,7 +104,23 @@ describe('App', () => { // Act try { // tslint:disable-next-line:no-unused-expression - new App({ token: '', authorize: authorizeCallback, signingSecret: '' }); + new App({ token: '', clientId: '', clientSecret: '', stateSecret: '', signingSecret: '' }); + assert.fail(); + } catch (error) { + // Assert + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + assert(authorizeCallback.notCalled); + } + }); + it('should fail when both a authorize callback is specified and OAuthInstaller is initialized', async () => { + // Arrange + const authorizeCallback = sinon.fake(); + const App = await importApp(); // tslint:disable-line:variable-name + + // Act + try { + // tslint:disable-next-line:no-unused-expression + new App({ authorize: authorizeCallback, clientId: '', clientSecret: '', stateSecret: '', signingSecret: '' }); assert.fail(); } catch (error) { // Assert diff --git a/src/ExpressReceiver.spec.ts b/src/ExpressReceiver.spec.ts index 327474fcf..b0cc53210 100644 --- a/src/ExpressReceiver.spec.ts +++ b/src/ExpressReceiver.spec.ts @@ -41,6 +41,13 @@ describe('ExpressReceiver', () => { logger: noopLogger, endpoints: { events: '/custom-endpoint' }, processBeforeResponse: true, + clientId: 'my-clientId', + clientSecret: 'my-client-secret', + stateSecret: 'state-secret', + scopes: ['channels:read'], + installerOptions: { + authVersion: 'v2', + }, }); assert.isNotNull(receiver); }); From 47428bfb7d68bdeb7da59a31fc82f755ed671212 Mon Sep 17 00:00:00 2001 From: Steve Gill Date: Thu, 21 May 2020 21:02:59 -0700 Subject: [PATCH 7/7] Update src/App.ts --- src/App.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.ts b/src/App.ts index 100918919..cffe7af25 100644 --- a/src/App.ts +++ b/src/App.ts @@ -286,7 +286,7 @@ export default class App { `Both authorize options and oauth installer options provided. ${tokenUsage}`, ); } else if (authorize === undefined && usingBuiltinOauth) { - this.authorize = (this.receiver as ExpressReceiver).installer?.authorize as Authorize; + this.authorize = (this.receiver as ExpressReceiver).installer!.authorize as Authorize; } else if (authorize !== undefined && !usingBuiltinOauth) { this.authorize = authorize; } else {