Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added oauth package to bolt #479

Merged
merged 7 commits into from
May 22, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
stevengill marked this conversation as resolved.
Show resolved Hide resolved

```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'];
stevengill marked this conversation as resolved.
Show resolved Hide resolved
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
stevengill marked this conversation as resolved.
Show resolved Hide resolved
) {
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;
stevengill marked this conversation as resolved.
Show resolved Hide resolved
} else if (authorize !== undefined && !usingBuiltinOauth) {
this.authorize = authorize;
} else {
this.logger.error('Never should have reached this point, please report to the team');
assertNever();
}
stevengill marked this conversation as resolved.
Show resolved Hide resolved

// 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