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}`);
}