Skip to content

Commit

Permalink
Merge pull request #479 from stevengill/oauth
Browse files Browse the repository at this point in the history
added oauth package to bolt
  • Loading branch information
stevengill authored May 22, 2020
2 parents a86a30a + 47428bf commit b65c74f
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 26 deletions.
101 changes: 101 additions & 0 deletions docs/_basic/authenticating_oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
title: Authenticating with OAuth
lang: en
slug: authenticating-oauth
order: 14
---

<div class="section-content">
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).
</div>

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

<details class="secondary-wrapper">
<summary class="section-head" markdown="0">
<h4 class="section-head">Customizing OAuth defaults</h4>
</summary>

<div class="secondary-content" markdown="0">
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`

</div>

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

</details>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 37 additions & 4 deletions src/App.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,28 +66,61 @@ 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

// 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
Expand Down
73 changes: 55 additions & 18 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SecureContextOptions, 'pfx' | 'key' | 'passphrase' | 'cert' | 'ca'>;
convoStore?: ConversationStore | false;
Expand All @@ -83,7 +89,7 @@ export { LogLevel, Logger } from '@slack/logger';
export interface Authorize {
(
source: AuthorizeSourceData,
body: AnyMiddlewareArgs['body'],
body?: AnyMiddlewareArgs['body'],
): Promise<AuthorizeResult>;
}

Expand Down Expand Up @@ -158,7 +164,7 @@ export default class App {
private logger: Logger;

/** Authorize */
private authorize: Authorize;
private authorize!: Authorize;

/** Global middleware chain */
private middleware: Middleware<AnyMiddlewareArgs>[];
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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 = [];

Expand All @@ -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());
Expand Down Expand Up @@ -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'];

Expand Down
7 changes: 7 additions & 0 deletions src/ExpressReceiver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading

0 comments on commit b65c74f

Please sign in to comment.