diff --git a/docs/_basic/authenticating_oauth.md b/docs/_basic/authenticating_oauth.md new file mode 100644 index 000000000..b90f317e6 --- /dev/null +++ b/docs/_basic/authenticating_oauth.md @@ -0,0 +1,101 @@ +--- +title: Authenticating with OAuth +lang: en +slug: authenticating-oauth +order: 14 +--- + +
+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 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 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 +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

+
+ +
+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 +- `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; + } + }, + } +}); +``` + +
diff --git a/package.json b/package.json index d55af274f..14e128980 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@slack/logger": "^2.0.0", + "@slack/oauth": "^1.1.0", "@slack/types": "^1.5.0", "@slack/web-api": "^5.8.0", "@types/express": "^4.16.1", 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/App.ts b/src/App.ts index d6a2c37c0..cffe7af25 100644 --- a/src/App.ts +++ b/src/App.ts @@ -63,6 +63,12 @@ export interface AppOptions { signingSecret?: ExpressReceiverOptions['signingSecret']; endpoints?: ExpressReceiverOptions['endpoints']; processBeforeResponse?: ExpressReceiverOptions['processBeforeResponse']; + clientId?: ExpressReceiverOptions['clientId']; + clientSecret?: ExpressReceiverOptions['clientSecret']; + stateSecret?: ExpressReceiverOptions['stateSecret']; // required when using default stateStore + installationStore?: ExpressReceiverOptions['installationStore']; // default MemoryInstallationStore + scopes?: ExpressReceiverOptions['scopes']; + installerOptions?: ExpressReceiverOptions['installerOptions']; agent?: Agent; clientTls?: Pick; convoStore?: ConversationStore | false; @@ -83,7 +89,7 @@ export { LogLevel, Logger } from '@slack/logger'; export interface Authorize { ( source: AuthorizeSourceData, - body: AnyMiddlewareArgs['body'], + body?: AnyMiddlewareArgs['body'], ): Promise; } @@ -158,7 +164,7 @@ export default class App { private logger: Logger; /** Authorize */ - private authorize: Authorize; + private authorize!: Authorize; /** Global middleware chain */ private middleware: Middleware[]; @@ -186,6 +192,12 @@ export default class App { ignoreSelf = true, clientOptions = undefined, processBeforeResponse = false, + clientId = undefined, + clientSecret = undefined, + stateSecret = undefined, + installationStore = undefined, + scopes = undefined, + installerOptions = undefined, }: AppOptions = {}) { if (typeof logger === 'undefined') { @@ -218,21 +230,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 +249,51 @@ export default class App { signingSecret, endpoints, processBeforeResponse, + clientId, + clientSecret, + stateSecret, + installationStore, + installerOptions, + scopes, logger: this.logger, }); } } + let usingBuiltinOauth = false; + if ( + clientId !== undefined + && clientSecret !== undefined + && (stateSecret !== undefined || (installerOptions !== undefined && installerOptions.stateStore !== undefined)) + && this.receiver instanceof ExpressReceiver + ) { + usingBuiltinOauth = true; + } + + if (token !== undefined) { + if (authorize !== undefined || usingBuiltinOauth) { + throw new AppInitializationError( + `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) { + throw new AppInitializationError( + `No token, no authorize options, and no oauth installer options provided. ${tokenUsage}`, + ); + } else if (authorize !== undefined && usingBuiltinOauth) { + throw new AppInitializationError( + `Both authorize options and oauth installer options provided. ${tokenUsage}`, + ); + } 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 if (ignoreSelf) { this.use(ignoreSelfMiddleware()); @@ -652,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.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); }); diff --git a/src/ExpressReceiver.ts b/src/ExpressReceiver.ts index 9cd87395f..1ab92e2c1 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, 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,22 @@ export interface ExpressReceiverOptions { [endpointType: string]: string; }; processBeforeResponse?: boolean; + clientId?: string; + clientSecret?: string; + stateSecret?: string; // ClearStateStoreOptions['secret']; // required when using default stateStore + installationStore?: InstallationStore; // default MemoryInstallationStore + scopes?: string | string[]; + installerOptions?: InstallerOptions; +} + +// Additional Installer Options +interface InstallerOptions { + stateStore?: StateStore; // default ClearStateStore + authVersion?: 'v1' | 'v2'; // default 'v2' + metadata?: string; + installPath?: string; + redirectUriPath?: string; + callbackOptions?: CallbackOptions; } /** @@ -32,12 +49,20 @@ export default class ExpressReceiver implements Receiver { private bolt: App | undefined; private logger: Logger; private processBeforeResponse: boolean; + public router: Router; + public installer: InstallProvider | undefined = undefined; constructor({ signingSecret = '', logger = new ConsoleLogger(), endpoints = { events: '/slack/events' }, processBeforeResponse = false, + clientId = undefined, + clientSecret = undefined, + stateSecret = undefined, + installationStore = 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? @@ -52,10 +77,55 @@ 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); + } + + if ( + clientId !== undefined + && clientSecret !== undefined + && (stateSecret !== undefined || installerOptions.stateStore !== undefined) + ) { + + this.installer = new InstallProvider({ + clientId, + clientSecret, + stateSecret, + installationStore, + stateStore: installerOptions.stateStore, + authVersion: installerOptions.authVersion!, + }); + } + + // Add OAuth routes to receiver + 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); + }); + + 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 (error) { + next(error); + } + }); } + + 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}`); }