diff --git a/packages/oauth/.gitignore b/packages/oauth/.gitignore index f507ff3b5..28891e45a 100644 --- a/packages/oauth/.gitignore +++ b/packages/oauth/.gitignore @@ -9,3 +9,4 @@ package-lock.json /.nyc_output /coverage *.lcov +tmp/ \ No newline at end of file diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 17e8f0f20..1f28bc81d 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -33,7 +33,7 @@ "build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output", "lint": "eslint --ext .ts src", "test": "npm run lint && npm run test:mocha", - "test:mocha": "nyc mocha --config .mocharc.json src/*.spec.js", + "test:mocha": "nyc mocha --config .mocharc.json src/*.spec.js src/**/*.spec.js src/*.spec.ts src/**/*.spec.ts", "coverage": "codecov -F oauthhelper --root=$PWD", "ref-docs:model": "api-extractor run", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" @@ -47,8 +47,10 @@ "lodash.isstring": "^4.0.1" }, "devDependencies": { + "@microsoft/api-extractor": "^7.19.4", "@types/chai": "^4.2.11", - "@microsoft/api-extractor": "^7.3.4", + "@types/mocha": "^9.1.0", + "@types/sinon": "^10.0.11", "@typescript-eslint/eslint-plugin": "^4.4.1", "@typescript-eslint/parser": "^4.4.0", "chai": "^4.2.0", @@ -59,9 +61,9 @@ "eslint-plugin-import": "^2.22.1", "eslint-plugin-jsdoc": "^30.6.1", "eslint-plugin-node": "^11.1.0", - "mocha": "^9.1.0", + "mocha": "^9.2.1", "nop": "^1.0.0", - "nyc": "^14.1.1", + "nyc": "^15.1.0", "rewiremock": "^3.13.9", "shx": "^0.3.2", "sinon": "^9.0.2", diff --git a/packages/oauth/src/authorize-result.ts b/packages/oauth/src/authorize-result.ts new file mode 100644 index 000000000..16c50a53c --- /dev/null +++ b/packages/oauth/src/authorize-result.ts @@ -0,0 +1,15 @@ +// This is intentionally structurally identical to AuthorizeResult from App +// It is redefined so that this class remains loosely coupled to the rest +// of Bolt. +export interface AuthorizeResult { + botToken?: string; + botRefreshToken?: string; + botTokenExpiresAt?: number; // utc, seconds + userToken?: string; + userRefreshToken?: string; + userTokenExpiresAt?: number; // utc, seconds + botId?: string; + botUserId?: string; + teamId?: string; + enterpriseId?: string; +} diff --git a/packages/oauth/src/callback-options.spec.ts b/packages/oauth/src/callback-options.spec.ts new file mode 100644 index 000000000..5e3ba7196 --- /dev/null +++ b/packages/oauth/src/callback-options.spec.ts @@ -0,0 +1,55 @@ +import { assert } from 'chai'; +import { describe, it } from 'mocha'; +import sinon from 'sinon'; +import { IncomingMessage, ServerResponse } from 'http'; + +import { CallbackOptions } from './callback-options'; +import { MissingStateError } from './errors'; + +describe('CallbackOptions', async () => { + it('should have success and failure', async () => { + const callbackOptions: CallbackOptions = { + success: async (installation, options, req, resp) => { + assert.isNotNull(installation); + assert.isNotNull(options); + assert.isNotNull(req); + assert.isNotNull(resp); + }, + failure: async (installation, options, req, resp) => { + assert.isNotNull(installation); + assert.isNotNull(options); + assert.isNotNull(req); + assert.isNotNull(resp); + }, + }; + assert.isNotNull(callbackOptions); + const installation = { + enterprise: undefined, + team: { + id: 'T111', + }, + bot: { + id: 'B111', + userId: 'W111', + scopes: ['commands'], + token: 'xoxb-', + }, + user: { + id: 'W222', + scopes: undefined, + token: undefined, + }, + }; + const options = { + scopes: ['commands', 'chat:write'], + }; + const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const resp = sinon.createStubInstance(ServerResponse) as ServerResponse; + callbackOptions.success!(installation, options, req, resp); + + const error = new MissingStateError(); + callbackOptions.failure!(error, options, req, resp); + }); + + // TODO: tests for default callbacks +}); diff --git a/packages/oauth/src/callback-options.ts b/packages/oauth/src/callback-options.ts new file mode 100644 index 000000000..ef1f66ffa --- /dev/null +++ b/packages/oauth/src/callback-options.ts @@ -0,0 +1,82 @@ +import { IncomingMessage, ServerResponse } from 'http'; +import { CodedError } from './errors'; +import { InstallURLOptions } from './install-url-options'; +import { Installation, OrgInstallation } from './installation'; + +export interface CallbackOptions { + // success is given control after handleCallback() has stored the + // installation. when provided, this function must complete the + // callbackRes. + success?: ( + installation: Installation | OrgInstallation, + options: InstallURLOptions, + callbackReq: IncomingMessage, + callbackRes: ServerResponse, + ) => void; + + // failure is given control when handleCallback() fails at any point. + // when provided, this function must complete the callbackRes. + // default: + // serve a generic "Error" web page (show detailed cause in development) + failure?: ( + error: CodedError, + options: InstallURLOptions, + callbackReq: IncomingMessage, + callbackRes: ServerResponse, + ) => void; +} + +// Default function to call when OAuth flow is successful +export function defaultCallbackSuccess( + installation: Installation, + _options: InstallURLOptions | undefined, + _req: IncomingMessage, + res: ServerResponse, +): void { + let redirectUrl: string; + + if (isNotOrgInstall(installation) && installation.appId !== undefined) { + // redirect back to Slack native app + // Changes to the workspace app was installed to, to the app home + redirectUrl = `slack://app?team=${installation.team.id}&id=${installation.appId}`; + } else if (isOrgInstall(installation)) { + // redirect to Slack app management dashboard + redirectUrl = `${installation.enterpriseUrl}manage/organization/apps/profile/${installation.appId}/workspaces/add`; + } else { + // redirect back to Slack native app + // does not change the workspace the slack client was last in + redirectUrl = 'slack://open'; + } + const htmlResponse = ` + + +

Success! Redirecting to the Slack App...

+ + `; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(htmlResponse); +} + +// Default function to call when OAuth flow is unsuccessful +export function defaultCallbackFailure( + _error: CodedError, + _options: InstallURLOptions, + _req: IncomingMessage, + res: ServerResponse, +): void { + res.writeHead(500, { 'Content-Type': 'text/html' }); + res.end('

Oops, Something Went Wrong! Please Try Again or Contact the App Owner

'); +} + +// ------------------------------------------ +// Internals +// ------------------------------------------ + +// Type guard to narrow Installation type to OrgInstallation +function isOrgInstall(installation: Installation): installation is OrgInstallation { + return installation.isEnterpriseInstall || false; +} + +function isNotOrgInstall(installation: Installation): installation is Installation<'v1' | 'v2', false> { + return !(isOrgInstall(installation)); +} diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 4818cde79..b37232cc6 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -1,906 +1,17 @@ -import { IncomingMessage, ServerResponse } from 'http'; -import { URLSearchParams, URL } from 'url'; -import { sign, verify } from 'jsonwebtoken'; -import { WebAPICallResult, WebClient, WebClientOptions } from '@slack/web-api'; -import { - CodedError, - InstallerInitializationError, - UnknownError, - MissingStateError, - MissingCodeError, - GenerateInstallUrlError, - AuthorizationError, -} from './errors'; -import { Logger, LogLevel, getLogger } from './logger'; -import { MemoryInstallationStore } from './stores'; - -// default implementation of StateStore -class ClearStateStore implements StateStore { - private stateSecret: string; - - public constructor(stateSecret: string) { - this.stateSecret = stateSecret; - } - - public async generateStateParam(installOptions: InstallURLOptions, now: Date): Promise { - return sign({ installOptions, now: now.toJSON() }, this.stateSecret); - } - - public async verifyStateParam(_now: Date, state: string): Promise { - // decode the state using the secret - const decoded: StateObj = verify(state, this.stateSecret) as StateObj; - - // return installOptions - return decoded.installOptions; - } -} - -/** - * InstallProvider Class. - * @param clientId - Your apps client ID - * @param clientSecret - Your apps client Secret - * @param stateSecret - Used to sign and verify the generated state when using the built-in `stateStore` - * @param stateStore - Replacement function for the built-in `stateStore` - * @param stateVerification - Pass in false to disable state parameter verification - * @param installationStore - Interface to store and retrieve installation data from the database - * @param authVersion - Can be either `v1` or `v2`. Determines which slack Oauth URL and method to use - * @param logger - Pass in your own Logger if you don't want to use the built-in one - * @param logLevel - Pass in the log level you want (ERROR, WARN, INFO, DEBUG). Default is INFO - */ -export class InstallProvider { - public stateStore?: StateStore; - - public installationStore: InstallationStore; - - private clientId: string; - - private clientSecret: string; - - private authVersion: string; - - private logger: Logger; - - private clientOptions: WebClientOptions; - - private authorizationUrl: string; - - private stateVerification: boolean; - - public constructor({ - clientId, - clientSecret, - stateSecret = undefined, - stateStore = undefined, - stateVerification = true, - installationStore = new MemoryInstallationStore(), - authVersion = 'v2', - logger = undefined, - logLevel = undefined, - clientOptions = {}, - authorizationUrl = 'https://slack.com/oauth/v2/authorize', - }: InstallProviderOptions) { - if (clientId === undefined || clientSecret === undefined) { - throw new InstallerInitializationError('You must provide a valid clientId and clientSecret'); - } - - // Setup the logger - if (typeof logger !== 'undefined') { - this.logger = logger; - if (typeof logLevel !== 'undefined') { - this.logger.debug('The logLevel given to OAuth was ignored as you also gave logger'); - } - } else { - this.logger = getLogger('OAuth:InstallProvider', logLevel ?? LogLevel.INFO, logger); - } - this.stateVerification = stateVerification; - if (!stateVerification) { - this.logger.warn("You've set InstallProvider#stateVerification to false. This flag is intended to enable org-wide app installations from admin pages. If this isn't your scenario, we recommend setting stateVerification to true and starting your OAuth flow from the provided `/slack/install` or your own starting endpoint."); - } - // Setup stateStore - if (stateStore !== undefined) { - this.stateStore = stateStore; - } else if (this.stateVerification) { - // if state verification is disabled, state store is not necessary - if (stateSecret !== undefined) { - this.stateStore = new ClearStateStore(stateSecret); - } else { - throw new InstallerInitializationError('To use the built-in state store you must provide a State Secret'); - } - } - - this.installationStore = installationStore; - this.clientId = clientId; - this.clientSecret = clientSecret; - this.handleCallback = this.handleCallback.bind(this); - this.authorize = this.authorize.bind(this); - this.authVersion = authVersion; - - this.authorizationUrl = authorizationUrl; - if (authorizationUrl !== 'https://slack.com/oauth/v2/authorize' && authVersion === 'v1') { - this.logger.info('You provided both an authorizationUrl and an authVersion! The authVersion will be ignored in favor of the authorizationUrl.'); - } else if (authVersion === 'v1') { - this.authorizationUrl = 'https://slack.com/oauth/authorize'; - } - - this.clientOptions = { - logger, - logLevel: this.logger.getLevel(), - ...clientOptions, - }; - } - - /** - * Fetches data from the installationStore - */ - public async authorize(source: InstallationQuery): Promise { - try { - // Note that `queryResult` may unexpectedly include null values for some properties. - // For example, MongoDB can often save properties as null for some reasons. - // Inside this method, we should alwayss check if a value is either undefined or null. - let queryResult; - if (source.isEnterpriseInstall) { - queryResult = await this.installationStore.fetchInstallation(source as InstallationQuery, this.logger); - } else { - queryResult = await this.installationStore.fetchInstallation(source as InstallationQuery, this.logger); - } - - if (queryResult === undefined || queryResult === null) { - throw new Error('Failed fetching data from the Installation Store'); - } - - const authResult: AuthorizeResult = {}; - - if (queryResult.user) { - authResult.userToken = queryResult.user.token; - } - - if (queryResult.team?.id) { - authResult.teamId = queryResult.team.id; - } else if (source?.teamId) { - /** - * Since queryResult is a org installation, it won't have team.id. - * If one was passed in via source, we should add it to the authResult. - */ - authResult.teamId = source.teamId; - } - - if (queryResult?.enterprise?.id || source?.enterpriseId) { - authResult.enterpriseId = queryResult?.enterprise?.id || source?.enterpriseId; - } - - if (queryResult.bot) { - authResult.botToken = queryResult.bot.token; - authResult.botId = queryResult.bot.id; - authResult.botUserId = queryResult.bot.userId; - - // Token Rotation Enabled (Bot Token) - if (queryResult.bot.refreshToken) { - authResult.botRefreshToken = queryResult.bot.refreshToken; - authResult.botTokenExpiresAt = queryResult.bot.expiresAt; // utc, seconds - } - } - - // Token Rotation Enabled (User Token) - if (queryResult.user?.refreshToken) { - authResult.userRefreshToken = queryResult.user.refreshToken; - authResult.userTokenExpiresAt = queryResult.user.expiresAt; // utc, seconds - } - - /* - * Token Rotation (Expiry Check + Refresh) - * The presence of `(bot|user)TokenExpiresAt` indicates having opted into token rotation. - * If the token has expired, or will expire within 2 hours, the token is refreshed and - * the `authResult` and `Installation` are updated with the new values. - */ - if (authResult.botRefreshToken || authResult.userRefreshToken) { - const currentUTCSec = Math.floor(Date.now() / 1000); // seconds - const tokensToRefresh: string[] = detectExpiredOrExpiringTokens(authResult, currentUTCSec); - - if (tokensToRefresh.length > 0) { - const installationUpdates: any = { ...queryResult }; // TODO :: TS - const refreshResponses = await this.refreshExpiringTokens(tokensToRefresh); - - // TODO: perhaps this for..of loop could introduce an async delay due to await'ing once for each refreshResp? - // Could we rewrite to be more performant and not trigger the eslint warning? Perhaps a concurrent async - // map/reduce? But will the return value be the same? Does order of this array matter? - // eslint-disable-next-line no-restricted-syntax - for (const refreshResp of refreshResponses) { - const tokenType = refreshResp.token_type; - - // Update Authorization - if (tokenType === 'bot') { - authResult.botToken = refreshResp.access_token; - authResult.botRefreshToken = refreshResp.refresh_token; - authResult.botTokenExpiresAt = currentUTCSec + refreshResp.expires_in; - } - - if (tokenType === 'user') { - authResult.userToken = refreshResp.access_token; - authResult.userRefreshToken = refreshResp.refresh_token; - authResult.userTokenExpiresAt = currentUTCSec + refreshResp.expires_in; - } - - // Update Installation - installationUpdates[tokenType].token = refreshResp.access_token; - installationUpdates[tokenType].refreshToken = refreshResp.refresh_token; - installationUpdates[tokenType].expiresAt = currentUTCSec + refreshResp.expires_in; - - const updatedInstallation = { - ...installationUpdates, - [tokenType]: { ...queryResult[tokenType], ...installationUpdates[tokenType] }, - }; - - // TODO: related to the above TODO comment as well - // eslint-disable-next-line no-await-in-loop - await this.installationStore.storeInstallation(updatedInstallation); - } - } - } - - return authResult; - } catch (error: any) { - throw new AuthorizationError(error.message); - } - } - - /** - * refreshExpiringTokens refreshes expired access tokens using the `oauth.v2.access` endpoint. - * - * The return value is an Array of Promises made up of the resolution of each token refresh attempt. - */ - private async refreshExpiringTokens(tokensToRefresh: string[]): Promise { - const client = new WebClient(undefined, this.clientOptions); - - const refreshPromises = tokensToRefresh.map(async (refreshToken) => await client.oauth.v2.access({ - client_id: this.clientId, - client_secret: this.clientSecret, - grant_type: 'refresh_token', - refresh_token: refreshToken, - }).catch((e) => e) as OAuthV2TokenRefreshResponse); - - return Promise.all(refreshPromises); - } - - /** - * Returns search params from a URL and ignores protocol / hostname as those - * aren't guaranteed to be accurate e.g. in x-forwarded- scenarios - */ - private static extractSearchParams(req: IncomingMessage): URLSearchParams { - const { searchParams } = new URL(req.url as string, `https://${req.headers.host}`); - return searchParams; - } - - /** - * Returns a URL that is suitable for including in an Add to Slack button - * Uses stateStore to generate a value for the state query param. - */ - public async generateInstallUrl(options: InstallURLOptions, stateVerification: boolean = true): Promise { - const slackURL = new URL(this.authorizationUrl); - - if (options.scopes === undefined || options.scopes === null) { - throw new GenerateInstallUrlError('You must provide a scope parameter when calling generateInstallUrl'); - } - - // scope - let scopes: string; - if (options.scopes instanceof Array) { - scopes = options.scopes.join(','); - } else { - scopes = options.scopes; - } - const params = new URLSearchParams(`scope=${scopes}`); - - // generate state - if (stateVerification && this.stateStore) { - const state = await this.stateStore.generateStateParam(options, new Date()); - params.append('state', state); - } - - // client id - params.append('client_id', this.clientId); - - // redirect uri - if (options.redirectUri !== undefined) { - params.append('redirect_uri', options.redirectUri); - } - - // team id - if (options.teamId !== undefined) { - params.append('team', options.teamId); - } - - // user scope, only available for OAuth v2 - if (options.userScopes !== undefined && this.authVersion === 'v2') { - let userScopes: string; - if (options.userScopes instanceof Array) { - userScopes = options.userScopes.join(','); - } else { - userScopes = options.userScopes; - } - params.append('user_scope', userScopes); - } - slackURL.search = params.toString(); - return slackURL.toString(); - } - - /** - * This method handles the incoming request to the callback URL. - * It can be used as a RequestListener in almost any HTTP server - * framework. - * - * Verifies the state using the stateStore, exchanges the grant in the - * query params for an access token, and stores token and associated data - * in the installationStore. - */ - public async handleCallback( - req: IncomingMessage, - res: ServerResponse, - options?: CallbackOptions, - installOptions?: InstallURLOptions, - ): Promise { - let code: string; - let flowError: string; - let state: string; - try { - if (req.url !== undefined) { - // Note: Protocol/ host of object are not necessarily accurate - // and shouldn't be relied on - // intended only for accessing searchParams only - const searchParams = InstallProvider.extractSearchParams(req); - flowError = searchParams.get('error') as string; - if (flowError === 'access_denied') { - throw new AuthorizationError('User cancelled the OAuth installation flow!'); - } - code = searchParams.get('code') as string; - state = searchParams.get('state') as string; - if (!code) { - throw new MissingCodeError('Redirect url is missing the required code query parameter'); - } - if (this.stateVerification && !state) { - throw new MissingStateError('Redirect url is missing the state query parameter. If this is intentional, see options for disabling default state verification.'); - } - } else { - throw new UnknownError('Something went wrong'); - } - // If state verification is enabled, attempt to verify, otherwise ignore - if (this.stateVerification && this.stateStore) { - // eslint-disable-next-line no-param-reassign - installOptions = await this.stateStore.verifyStateParam(new Date(), state); - } - if (!installOptions) { - const emptyInstallOptions: InstallURLOptions = { scopes: [] }; - // eslint-disable-next-line no-param-reassign - installOptions = emptyInstallOptions; - } - - const client = new WebClient(undefined, this.clientOptions); - - // Start: Build the installation object - let installation: Installation; - let resp: OAuthV1Response | OAuthV2Response; - - if (this.authVersion === 'v1') { - // convert response type from WebApiCallResult to OAuthResponse - const v1Resp = await client.oauth.access({ - code, - client_id: this.clientId, - client_secret: this.clientSecret, - redirect_uri: installOptions.redirectUri, - }) as OAuthV1Response; - - // resp obj for v1 - https://api.slack.com/methods/oauth.access#response - const v1Installation: Installation<'v1', false> = { - team: { id: v1Resp.team_id, name: v1Resp.team_name }, - enterprise: v1Resp.enterprise_id === null ? undefined : { id: v1Resp.enterprise_id }, - user: { - token: v1Resp.access_token, - scopes: v1Resp.scope.split(','), - id: v1Resp.user_id, - }, - - // synthesized properties: enterprise installation is unsupported in v1 auth - isEnterpriseInstall: false, - authVersion: 'v1', - }; - - // only can get botId if bot access token exists - // need to create a botUser + request bot scope to have this be part of resp - if (v1Resp.bot !== undefined) { - const authResult = await runAuthTest(v1Resp.bot.bot_access_token, this.clientOptions); - // We already tested that a bot user was in the response, so we know the following bot_id will be defined - const botId = authResult.bot_id as string; - - v1Installation.bot = { - id: botId, - scopes: ['bot'], - token: v1Resp.bot.bot_access_token, - userId: v1Resp.bot.bot_user_id, - }; - } - - resp = v1Resp; - installation = v1Installation; - } else { - // convert response type from WebApiCallResult to OAuthResponse - const v2Resp = await client.oauth.v2.access({ - code, - client_id: this.clientId, - client_secret: this.clientSecret, - redirect_uri: installOptions.redirectUri, - }) as OAuthV2Response; - - // resp obj for v2 - https://api.slack.com/methods/oauth.v2.access#response - const v2Installation: Installation<'v2', boolean> = { - team: v2Resp.team === null ? undefined : v2Resp.team, - enterprise: v2Resp.enterprise == null ? undefined : v2Resp.enterprise, - user: { - token: v2Resp.authed_user.access_token, - scopes: v2Resp.authed_user.scope?.split(','), - id: v2Resp.authed_user.id, - }, - tokenType: v2Resp.token_type, - isEnterpriseInstall: v2Resp.is_enterprise_install, - appId: v2Resp.app_id, - - // synthesized properties - authVersion: 'v2', - }; - - const currentUTC = Math.floor(Date.now() / 1000); // utc, seconds - - // Installation has Bot Token - if (v2Resp.access_token !== undefined && v2Resp.scope !== undefined && v2Resp.bot_user_id !== undefined) { - const authResult = await runAuthTest(v2Resp.access_token, this.clientOptions); - - v2Installation.bot = { - scopes: v2Resp.scope.split(','), - token: v2Resp.access_token, - userId: v2Resp.bot_user_id, - id: authResult.bot_id as string, - }; - - if (v2Resp.is_enterprise_install) { - v2Installation.enterpriseUrl = authResult.url; - } - - // Token Rotation is Enabled - if (v2Resp.refresh_token !== undefined && v2Resp.expires_in !== undefined) { - v2Installation.bot.refreshToken = v2Resp.refresh_token; - v2Installation.bot.expiresAt = currentUTC + v2Resp.expires_in; // utc, seconds - } - } - - // Installation has User Token - if (v2Resp.authed_user !== undefined && v2Resp.authed_user.access_token !== undefined) { - if (v2Resp.is_enterprise_install && v2Installation.enterpriseUrl === undefined) { - const authResult = await runAuthTest(v2Resp.authed_user.access_token, this.clientOptions); - v2Installation.enterpriseUrl = authResult.url; - } - - // Token Rotation is Enabled - if (v2Resp.authed_user.refresh_token !== undefined && v2Resp.authed_user.expires_in !== undefined) { - v2Installation.user.refreshToken = v2Resp.authed_user.refresh_token; - v2Installation.user.expiresAt = currentUTC + v2Resp.authed_user.expires_in; // utc, seconds - } - } - - resp = v2Resp; - installation = v2Installation; - } - - if (resp.incoming_webhook !== undefined) { - installation.incomingWebhook = { - url: resp.incoming_webhook.url, - channel: resp.incoming_webhook.channel, - channelId: resp.incoming_webhook.channel_id, - configurationUrl: resp.incoming_webhook.configuration_url, - }; - } - if (installOptions && installOptions.metadata !== undefined) { - // Pass the metadata in state parameter if exists. - // Developers can use the value for additional/custom data associated with the installation. - installation.metadata = installOptions.metadata; - } - // End: Build the installation object - - // Save installation object to installation store - if (installation.isEnterpriseInstall) { - await this.installationStore.storeInstallation(installation as OrgInstallation, this.logger); - } else { - await this.installationStore.storeInstallation(installation as Installation<'v1' | 'v2', false>, this.logger); - } - - // Call the success callback - if (options !== undefined && options.success !== undefined) { - this.logger.debug('calling passed in options.success'); - options.success(installation, installOptions, req, res); - } else { - this.logger.debug('run built-in success function'); - callbackSuccess(installation, installOptions, req, res); - } - } catch (error: any) { - this.logger.error(error); - - if (!installOptions) { - // To make the `installOptions` type compatible with `CallbackOptions#failure` signature - const emptyInstallOptions: InstallURLOptions = { scopes: [] }; - // eslint-disable-next-line no-param-reassign - installOptions = emptyInstallOptions; - } - - // Call the failure callback - if (options !== undefined && options.failure !== undefined) { - this.logger.debug('calling passed in options.failure'); - options.failure(error, installOptions, req, res); - } else { - this.logger.debug('run built-in failure function'); - callbackFailure(error, installOptions, req, res); - } - } - } -} - -export interface InstallProviderOptions { - clientId: string; - clientSecret: string; - stateStore?: StateStore; // default ClearStateStore - stateSecret?: string; // required with default ClearStateStore - stateVerification?: boolean; // default true, disables state verification when false - installationStore?: InstallationStore; // default MemoryInstallationStore - authVersion?: 'v1' | 'v2'; // default 'v2' - logger?: Logger; - logLevel?: LogLevel; - clientOptions?: Omit; - authorizationUrl?: string; -} - -export interface InstallURLOptions { - scopes: string | string[]; - teamId?: string; - redirectUri?: string; - userScopes?: string | string[]; // cannot be used with authVersion=v1 - metadata?: string; // Arbitrary data can be stored here, potentially to save app state or use for custom redirect -} - -export interface CallbackOptions { - // success is given control after handleCallback() has stored the - // installation. when provided, this function must complete the - // callbackRes. - success?: ( - installation: Installation | OrgInstallation, - options: InstallURLOptions, - callbackReq: IncomingMessage, - callbackRes: ServerResponse, - ) => void; - - // failure is given control when handleCallback() fails at any point. - // when provided, this function must complete the callbackRes. - // default: - // serve a generic "Error" web page (show detailed cause in development) - failure?: ( - error: CodedError, - options: InstallURLOptions, - callbackReq: IncomingMessage, - callbackRes: ServerResponse, - ) => void; -} - -export interface StateStore { - // Returned Promise resolves for a string which can be used as an - // OAuth state param. - // TODO: Revisit design. Does installOptions need to be encoded in state if metadata is static? - generateStateParam: (installOptions: InstallURLOptions, now: Date) => Promise; - - // Returned Promise resolves for InstallURLOptions that were stored in the state - // param. The Promise rejects with a CodedError when the state is invalid. - verifyStateParam: (now: Date, state: string) => Promise; -} - -// State object structure -interface StateObj { - now: Date; - installOptions: InstallURLOptions; -} - -export interface InstallationStore { - storeInstallation( - installation: Installation, - logger?: Logger): Promise; - fetchInstallation: - (query: InstallationQuery, logger?: Logger) => Promise>; - deleteInstallation?: - (query: InstallationQuery, logger?: Logger) => Promise; -} - -/** - * An individual installation of the Slack app. - * - * This interface creates a representation for installations that normalizes the responses from OAuth grant exchanges - * across auth versions (responses from the Web API methods `oauth.v2.access` and `oauth.access`). It describes some of - * these differences using the `AuthVersion` generic placeholder type. - * - * This interface also represents both installations which occur on individual Slack workspaces and on Slack enterprise - * organizations. The `IsEnterpriseInstall` generic placeholder type is used to describe some of those differences. - * - * This representation is designed to be used both when producing data that should be stored by an InstallationStore, - * and when consuming data that is fetched from an InstallationStore. Most often, InstallationStore implementations - * are a database. If you are going to implement an InstallationStore, it's advised that you **store as much of the - * data in these objects as possible so that you can return as much as possible inside `fetchInstallation()`**. - * - * A few properties are synthesized with a default value if they are not present when returned from - * `fetchInstallation()`. These properties are optional in the interface so that stored installations from previous - * versions of this library (from before those properties were introduced) continue to work without requiring a breaking - * change. However the synthesized default values are not always perfect and are based on some assumptions, so this is - * why it's recommended to store as much of that data as possible in any InstallationStore. - * - * Some of the properties (e.g. `team.name`) can change between when the installation occurred and when it is fetched - * from the InstallationStore. This can be seen as a reason not to store those properties. In most workspaces these - * properties rarely change, and for most Slack apps having a slightly out of date value has no impact. However if your - * app uses these values in a way where it must be up to date, it's recommended to implement a caching strategy in the - * InstallationStore to fetch the latest data from the Web API (using methods such as `auth.test`, `teams.info`, etc.) - * as often as it makes sense for your Slack app. - * - * TODO: IsEnterpriseInstall is always false when AuthVersion is v1 - */ -export interface Installation { - /** - * TODO: when performing a “single workspace” install with the admin scope on the enterprise, - * is the team property returned from oauth.access? - */ - team: IsEnterpriseInstall extends true ? undefined : { - id: string; - /** Left as undefined when not returned from fetch. */ - name?: string; - }; - - /** - * When the installation is an enterprise install or when the installation occurs on the org to acquire `admin` scope, - * the name and ID of the enterprise org. - */ - enterprise: IsEnterpriseInstall extends true ? EnterpriseInfo : (EnterpriseInfo | undefined); - - user: { - token: AuthVersion extends 'v1' ? string : (string | undefined); - refreshToken?: AuthVersion extends 'v1' ? never : (string | undefined); - expiresAt?: AuthVersion extends 'v1' ? never : (number | undefined); // utc, seconds - scopes: AuthVersion extends 'v1' ? string[] : (string[] | undefined); - id: string; - }; - - bot?: { - token: string; - refreshToken?: string; - expiresAt?: number; // utc, seconds - scopes: string[]; - id: string; // retrieved from auth.test - userId: string; - }; - incomingWebhook?: { - url: string; - /** Left as undefined when not returned from fetch. */ - channel?: string; - /** Left as undefined when not returned from fetch. */ - channelId?: string; - /** Left as undefined when not returned from fetch. */ - configurationUrl?: string; - }; - - /** The App ID, which does not vary per installation. Left as undefined when not returned from fetch. */ - appId?: AuthVersion extends 'v2' ? string : undefined; - - /** When the installation contains a bot user, the token type. Left as undefined when not returned from fetch. */ - tokenType?: 'bot'; - - /** - * When the installation is an enterprise org install, the URL of the landing page for all workspaces in the org. - * Left as undefined when not returned from fetch. - */ - enterpriseUrl?: AuthVersion extends 'v2' ? string : undefined; - - /** Whether the installation was performed on an enterprise org. Synthesized as `false` when not present. */ - isEnterpriseInstall?: IsEnterpriseInstall; - - /** The version of Slack's auth flow that produced this installation. Synthesized as `v2` when not present. */ - authVersion?: AuthVersion; - - /** A string value that can be held in the state parameter in the OAuth flow. */ - metadata?: string; -} - -/** - * A type to describe enterprise organization installations. - */ -export type OrgInstallation = Installation<'v2', true>; - -interface EnterpriseInfo { - id: string; - /* Not defined in v1 auth version. Left as undefined when not returned from fetch. */ - name?: string; -} - -// This is intentionally structurally identical to AuthorizeSourceData -// from App. It is redefined so that this class remains loosely coupled to -// the rest of Bolt. -export interface InstallationQuery { - teamId: isEnterpriseInstall extends false ? string : undefined; - enterpriseId: isEnterpriseInstall extends true ? string : (string | undefined); - userId?: string; - conversationId?: string; - isEnterpriseInstall: isEnterpriseInstall; -} - -export type OrgInstallationQuery = InstallationQuery; - -// This is intentionally structurally identical to AuthorizeResult from App -// It is redefined so that this class remains loosely coupled to the rest -// of Bolt. -export interface AuthorizeResult { - botToken?: string; - botRefreshToken?: string; - botTokenExpiresAt?: number; // utc, seconds - userToken?: string; - userRefreshToken?: string; - userTokenExpiresAt?: number; // utc, seconds - botId?: string; - botUserId?: string; - teamId?: string; - enterpriseId?: string; -} - -// Default function to call when OAuth flow is successful -function callbackSuccess( - installation: Installation, - _options: InstallURLOptions | undefined, - _req: IncomingMessage, - res: ServerResponse, -): void { - let redirectUrl: string; - - if (isNotOrgInstall(installation) && installation.appId !== undefined) { - // redirect back to Slack native app - // Changes to the workspace app was installed to, to the app home - redirectUrl = `slack://app?team=${installation.team.id}&id=${installation.appId}`; - } else if (isOrgInstall(installation)) { - // redirect to Slack app management dashboard - redirectUrl = `${installation.enterpriseUrl}manage/organization/apps/profile/${installation.appId}/workspaces/add`; - } else { - // redirect back to Slack native app - // does not change the workspace the slack client was last in - redirectUrl = 'slack://open'; - } - const htmlResponse = ` - - -

Success! Redirecting to the Slack App...

- - `; - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(htmlResponse); -} - -// Default function to call when OAuth flow is unsuccessful -function callbackFailure( - _error: CodedError, - _options: InstallURLOptions, - _req: IncomingMessage, - res: ServerResponse, -): void { - res.writeHead(500, { 'Content-Type': 'text/html' }); - res.end('

Oops, Something Went Wrong! Please Try Again or Contact the App Owner

'); -} - -// Gets the bot_id using the `auth.test` method. -async function runAuthTest(token: string, clientOptions: WebClientOptions): Promise { - const client = new WebClient(token, clientOptions); - const authResult = await client.auth.test(); - return authResult as any as AuthTestResult; -} - -// Type guard to narrow Installation type to OrgInstallation -function isOrgInstall(installation: Installation): installation is OrgInstallation { - return installation.isEnterpriseInstall || false; -} - -function isNotOrgInstall(installation: Installation): installation is Installation<'v1' | 'v2', false> { - return !(isOrgInstall(installation)); -} - -/** - * detectExpiredOrExpiringTokens determines access tokens' eligibility for refresh. - * - * The return value is an Array of expired or soon-to-expire access tokens. - */ -function detectExpiredOrExpiringTokens(authResult: AuthorizeResult, currentUTCSec: number): string[] { - const tokensToRefresh: string[] = []; - const EXPIRY_WINDOW: number = 7200; // 2 hours - - if (authResult.botRefreshToken && - (authResult.botTokenExpiresAt !== undefined && authResult.botTokenExpiresAt !== null)) { - const botTokenExpiresIn = authResult.botTokenExpiresAt - currentUTCSec; - if (botTokenExpiresIn <= EXPIRY_WINDOW) { - tokensToRefresh.push(authResult.botRefreshToken); - } - } - - if (authResult.userRefreshToken && - (authResult.userTokenExpiresAt !== undefined && authResult.userTokenExpiresAt !== null)) { - const userTokenExpiresIn = authResult.userTokenExpiresAt - currentUTCSec; - if (userTokenExpiresIn <= EXPIRY_WINDOW) { - tokensToRefresh.push(authResult.userRefreshToken); - } - } - - return tokensToRefresh; -} - -// Response shape from oauth.v2.access - https://api.slack.com/methods/oauth.v2.access#response -export interface OAuthV2Response extends WebAPICallResult { - app_id: string; - authed_user: { - id: string, - scope?: string, - access_token?: string, - token_type?: string, - refresh_token?: string, - expires_in?: number, - }; - scope?: string; - token_type?: 'bot'; - access_token?: string; - refresh_token?: string; - expires_in?: number; - bot_user_id?: string; - team: { id: string, name: string } | null; - enterprise: { name: string, id: string } | null; - is_enterprise_install: boolean; - incoming_webhook?: { - url: string, - channel: string, - channel_id: string, - configuration_url: string, - }; -} - -export interface OAuthV2TokenRefreshResponse extends WebAPICallResult { - app_id: string; - scope: string; - token_type: 'bot' | 'user'; - access_token: string; - refresh_token: string; - expires_in: number; - bot_user_id?: string; - team: { id: string, name: string }; - enterprise: { name: string, id: string } | null; - is_enterprise_install: boolean; -} - -// Response shape from oauth.access - https://api.slack.com/methods/oauth.access#response -interface OAuthV1Response extends WebAPICallResult { - access_token: string; - // scope parameter isn't returned in workspace apps - scope: string; - team_name: string; - team_id: string; - enterprise_id: string | null; - // if they request bot user token - bot?: { bot_user_id: string, bot_access_token: string }; - incoming_webhook?: { - url: string, - channel: string, - channel_id: string, - configuration_url: string, - }; - // app_id is currently undefined but leaving it in here incase the v1 method adds it - app_id: string | undefined; - // TODO: removed optional because logically there's no case where a user_id cannot be provided, but needs verification - user_id: string; // Not documented but showing up on responses -} - -interface AuthTestResult extends WebAPICallResult { - bot_id?: string; - url?: string; -} +// TODO: export CodedError and its implementations +// export * from './errors'; export { Logger, LogLevel } from './logger'; +export { AuthorizeResult } from './authorize-result'; +export { CallbackOptions } from './callback-options'; +export { + InstallProvider, + OAuthV2TokenRefreshResponse, + OAuthV2Response, +} from './install-provider'; +export { Installation, OrgInstallation } from './installation'; +export { InstallationQuery, OrgInstallationQuery } from './installation-query'; +export { InstallProviderOptions } from './install-provider-options'; +export { InstallURLOptions } from './install-url-options'; export * from './stores'; +export * from './state-stores'; diff --git a/packages/oauth/src/install-provider-options.spec.ts b/packages/oauth/src/install-provider-options.spec.ts new file mode 100644 index 000000000..e2245d5b5 --- /dev/null +++ b/packages/oauth/src/install-provider-options.spec.ts @@ -0,0 +1,14 @@ +import { assert } from 'chai'; +import { describe, it } from 'mocha'; +import { InstallProviderOptions } from './install-provider-options'; + +describe('InstallProviderOptions', async () => { + it('should be compatible with past versions in terms of required args', async () => { + const options: InstallProviderOptions = { + clientId: '111.222', + clientSecret: 'xxx', + }; + assert.equal(options.clientId, '111.222'); + assert.equal(options.clientSecret, 'xxx'); + }); +}); diff --git a/packages/oauth/src/install-provider-options.ts b/packages/oauth/src/install-provider-options.ts new file mode 100644 index 000000000..6805b88bb --- /dev/null +++ b/packages/oauth/src/install-provider-options.ts @@ -0,0 +1,18 @@ +import { Logger, LogLevel } from '@slack/logger'; +import { WebClientOptions } from '@slack/web-api'; +import { StateStore } from './state-stores'; +import { InstallationStore } from './stores'; + +export interface InstallProviderOptions { + clientId: string; + clientSecret: string; + stateStore?: StateStore; // default ClearStateStore + stateSecret?: string; // required with default ClearStateStore + stateVerification?: boolean; // default true, disables state verification when false + installationStore?: InstallationStore; // default MemoryInstallationStore + authVersion?: 'v1' | 'v2'; // default 'v2' + logger?: Logger; + logLevel?: LogLevel; + clientOptions?: Omit; + authorizationUrl?: string; +} diff --git a/packages/oauth/src/index.spec.js b/packages/oauth/src/install-provider.spec.js similarity index 70% rename from packages/oauth/src/index.spec.js rename to packages/oauth/src/install-provider.spec.js index aac7c7637..e3d050e07 100644 --- a/packages/oauth/src/index.spec.js +++ b/packages/oauth/src/install-provider.spec.js @@ -1,10 +1,9 @@ require('mocha'); -const { assert, expect } = require('chai'); +const { assert } = require('chai'); const url = require('url'); const rewiremock = require('rewiremock/node'); const sinon = require('sinon'); -const fs = require('fs'); const { ErrorCode } = require('./errors'); const { LogLevel } = require('./logger'); @@ -69,7 +68,6 @@ async function mockedV2AccessResp(options) { rewiremock.enable(); const { InstallProvider } = require('./index'); -const { FileInstallationStore, MemoryInstallationStore } = require('./stores'); rewiremock.disable(); const clientSecret = 'MY_SECRET'; @@ -134,6 +132,7 @@ const storedInstallation = { isEnterpriseInstall: false, } +// TODO: valid tests with org-wide installations const storedOrgInstallation = { team: null, enterprise: { @@ -165,7 +164,7 @@ const storedOrgInstallation = { devDB[storedInstallation.team.id] = storedInstallation; devDB[storedOrgInstallation.enterprise.id] = storedOrgInstallation; -describe('OAuth', async () => { +describe('InstallProvider', async () => { const noopLogger = { debug(..._msg) { /* noop */ }, info(..._msg) { /* noop */ }, @@ -631,232 +630,4 @@ describe('OAuth', async () => { assert.equal(fakeStateStore.verifyStateParam.callCount, 0); }); }); - - describe('MemoryInstallationStore', async () => { - it('should store and fetch an installation', async () => { - const installationStore = new MemoryInstallationStore(); - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const fakeTeamId = storedInstallation.team.id; - - assert.deepEqual({}, installer.installationStore.devDB); - - await installer.installationStore.storeInstallation(storedInstallation); - const fetchedResult = await installer.installationStore.fetchInstallation({ teamId: fakeTeamId }); - assert.deepEqual(fetchedResult, storedInstallation); - assert.deepEqual(storedInstallation, installer.installationStore.devDB[fakeTeamId]); - }); - - it('should delete a stored installation', async () => { - const installationStore = new MemoryInstallationStore(); - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const fakeTeamId = storedInstallation.team.id; - - await installer.installationStore.storeInstallation(storedInstallation); - assert.isNotEmpty(installer.installationStore.devDB); - - await installer.installationStore.deleteInstallation({ teamId: fakeTeamId }); - assert.isEmpty(installer.installationStore.devDB); - }); - }); - - describe('FileInstallationStore', async () => { - let fsMakeDir, fsWriteFile, fsReadFileSync, unlink; - - beforeEach(() => { - fsMakeDir = sinon.stub(fs, 'mkdir').returns({}); - fsWriteFile = sinon.stub(fs, 'writeFile').returns({}); - fsReadFileSync = sinon.stub(fs, 'readFileSync').returns(Buffer.from(JSON.stringify(storedInstallation))); - fsUnlink = sinon.stub(fs, 'unlink').returns({}); - fsReaddirSync = sinon.stub(fs, 'readdirSync').returns(['app-latest', 'user-userId-latest']); - }); - - afterEach(() => { - fsMakeDir.restore(); - fsWriteFile.restore(); - fsReadFileSync.restore(); - fsUnlink.restore(); - fsReaddirSync.restore(); - }); - - it('should store the latest installation', async () => { - const installationStore = new FileInstallationStore({ baseDir: '.' }); - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const { enterprise, team, user } = storedInstallation; - const fakeInstallDir = `./${enterprise.id}-${team.id}`; - const installationJSON = JSON.stringify(storedInstallation); - - installer.installationStore.storeInstallation(storedInstallation); - assert.equal(fsWriteFile.calledWith(`${fakeInstallDir}/app-latest`, installationJSON), true); - assert.equal(fsWriteFile.calledWith(sinon.match(`${fakeInstallDir}/user-${user.id}-latest`), installationJSON), true); - }); - - it('should store additional records for each installation with historicalDataEnabled', async () => { - const installationStore = new FileInstallationStore({ baseDir: '.', historicalDataEnabled: true }); - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const { enterprise, team, user } = storedInstallation; - const fakeInstallDir = `./${enterprise.id}-${team.id}`; - const installationJSON = JSON.stringify(storedInstallation); - - installer.installationStore.storeInstallation(storedInstallation); - - assert.equal(fsWriteFile.calledWith(sinon.match(`${fakeInstallDir}/app-`), installationJSON), true); - assert.equal(fsWriteFile.calledWith(sinon.match(`${fakeInstallDir}/user-${user.id}-`), installationJSON), true); - - // 1 store = 4 files = 2 latest + 2 timestamps - expect(fsWriteFile.callCount).equals(4); - }); - - it('should fetch a stored installation', async () => { - const installationStore = new FileInstallationStore({ baseDir: '.' }); - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const { enterprise, team, user } = storedInstallation; - const fakeInstallDir = `/${enterprise.id}-${team.id}`; - const query = { enterpriseId: enterprise.id, teamId: team.id }; - - installer.installationStore.storeInstallation(storedInstallation); - const installation = await installer.installationStore.fetchInstallation(query); - - assert.equal(fsReadFileSync.calledWith(sinon.match(`${fakeInstallDir}/app-latest`)), true); - assert.deepEqual(installation, storedInstallation); - }); - - it('should delete all records of installation if no userId is passed', async () => { - const installationStore = new FileInstallationStore({ baseDir: '.' }); - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const { enterprise, team } = storedInstallation; - const fakeInstallDir = `/${enterprise.id}-${team.id}`; - const query = { enterpriseId: enterprise.id, teamId: team.id }; - - await installer.installationStore.deleteInstallation(query); - - assert.equal(fsReaddirSync.calledWith(sinon.match(fakeInstallDir)), true); - assert.equal(fsUnlink.calledWith(sinon.match(`app-latest`)), true); - assert.equal(fsUnlink.calledWith(sinon.match(`user-userId-latest`)), true); - - // fsReaddirSync returns ['app-latest', 'user-userId-latest'] - expect(fsUnlink.callCount).equals(2); - }); - - it('should delete only user records of installation if userId is passed', async () => { - const installationStore = new FileInstallationStore({ baseDir: '.' }); - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const { enterprise, team, user } = storedInstallation; - const fakeInstallDir = `/${enterprise.id}-${team.id}`; - const query = { enterpriseId: enterprise.id, teamId: team.id, userId: user.id }; - - await installer.installationStore.deleteInstallation(query); - - assert.equal(fsReaddirSync.calledWith(sinon.match(fakeInstallDir)), true); - assert.equal(fsUnlink.calledWith(sinon.match(`user-${user.id}-latest`)), true); - - // fsReaddirSync returns ['app-latest', 'user-userId-latest'] - expect(fsUnlink.callCount).equals(1); - }); - - it('should run authorize with triage-bot\'s MongoDB data', async () => { - // Refer to https://github.com/slackapi/bolt-js/issues/1265 to learn the context - const storedInstallation = { - "_id": "6.....", - "id": "T....", - "__v": 0, - "appId": "A...", - "authVersion": "v2", - "bot": { - "scopes": [ - "channels:history", - "channels:join", - "channels:read", - "chat:write", - "commands", - "files:write" - ], - "token": "xoxb-...", - "userId": "U...", - "id": "B02SS7QU407" - }, - "db_record_created_at": "2022-01-08T02:24:40.470Z", - "db_record_updated_at": "2022-01-08T02:24:40.470Z", - "enterprise": null, - "isEnterpriseInstall": false, - "name": "My Team", - "tokenType": "bot", - "user": { - "scopes": null, - "id": "U..." - } - }; - const installationStore = { - fetchInstallation: (_) => { - return new Promise((resolve) => { - resolve(storedInstallation); - }); - }, - storeInstallation: () => {}, - deleteInstallation: (_) => {}, - } - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const authorizeResult = await installer.authorize({ teamId: 'T111' }); - assert.deepEqual(authorizeResult, { - "teamId": "T111", - "botId": "B02SS7QU407", - "botUserId": "U...", - "botToken": "xoxb-...", - "userToken": undefined, - }); - }); - it('should run authorize even if there are null objects in data', async () => { - const storedInstallation = { - // https://github.com/slackapi/template-triage-bot/blob/c1e54fb9d760b46cc8809c57e307061fdb3e0a91/app.js#L51-L55 - id: "T999", // template-triage-bot specific - name: 'My Team', // template-triage-bot specific - appId: 'A111', - tokenType: 'bot', - authVersion: 'v2', - bot: { - id: 'B111', - userId: 'U111', - scopes: [ - 'channels:history', - 'channels:join', - 'channels:read', - 'chat:write', - 'commands', - 'files:write', - ], - token: 'xoxb-____', - }, - enterprise: null, - team: null, // v2.3 does not work with this data due to "Error: Cannot read property 'id' of null" - isEnterpriseInstall: false, - user: null, - } - const installationStore = { - fetchInstallation: (_) => { - return new Promise((resolve) => { - resolve(storedInstallation); - }); - }, - storeInstallation: () => {}, - deleteInstallation: (_) => {}, - } - const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); - const authorizeResult = await installer.authorize({ teamId: 'T111' }); - assert.deepEqual(authorizeResult, { - "teamId": "T111", - "botId": "B111", - "botUserId": "U111", - "botToken": "xoxb-____", - }); - }); - }); - - describe('ClearStateStore', async () => { - it('should generate a state and return install options once verified', async () => { - const installer = new InstallProvider({ clientId, clientSecret, stateSecret }); - const installUrlOptions = { scopes: ['channels:read'] }; - const state = await installer.stateStore.generateStateParam(installUrlOptions, new Date()); - const returnedInstallUrlOptions = await installer.stateStore.verifyStateParam(new Date(), state); - assert.deepEqual(installUrlOptions, returnedInstallUrlOptions); - }); - }); }); diff --git a/packages/oauth/src/install-provider.ts b/packages/oauth/src/install-provider.ts new file mode 100644 index 000000000..af9359a0d --- /dev/null +++ b/packages/oauth/src/install-provider.ts @@ -0,0 +1,639 @@ +import { IncomingMessage, ServerResponse } from 'http'; +import { URLSearchParams, URL } from 'url'; + +import { WebAPICallResult, WebClient, WebClientOptions } from '@slack/web-api'; + +import { AuthorizeResult } from './authorize-result'; +import { CallbackOptions, defaultCallbackFailure, defaultCallbackSuccess } from './callback-options'; +import { + InstallerInitializationError, + UnknownError, + MissingStateError, + MissingCodeError, + GenerateInstallUrlError, + AuthorizationError, +} from './errors'; +import { Installation, OrgInstallation } from './installation'; +import { InstallationQuery } from './installation-query'; +import { InstallURLOptions } from './install-url-options'; +import { InstallProviderOptions } from './install-provider-options'; +import { Logger, LogLevel, getLogger } from './logger'; +import { ClearStateStore, StateStore } from './state-stores'; +import { InstallationStore, MemoryInstallationStore } from './stores'; + +/** + * InstallProvider Class. + * @param clientId - Your apps client ID + * @param clientSecret - Your apps client Secret + * @param stateSecret - Used to sign and verify the generated state when using the built-in `stateStore` + * @param stateStore - Replacement function for the built-in `stateStore` + * @param stateVerification - Pass in false to disable state parameter verification + * @param installationStore - Interface to store and retrieve installation data from the database + * @param authVersion - Can be either `v1` or `v2`. Determines which slack Oauth URL and method to use + * @param logger - Pass in your own Logger if you don't want to use the built-in one + * @param logLevel - Pass in the log level you want (ERROR, WARN, INFO, DEBUG). Default is INFO + */ +export class InstallProvider { + public stateStore?: StateStore; + + public installationStore: InstallationStore; + + private clientId: string; + + private clientSecret: string; + + private authVersion: string; + + private logger: Logger; + + private clientOptions: WebClientOptions; + + private authorizationUrl: string; + + private stateVerification: boolean; + + public constructor({ + clientId, + clientSecret, + stateSecret = undefined, + stateStore = undefined, + stateVerification = true, + installationStore = new MemoryInstallationStore(), + authVersion = 'v2', + logger = undefined, + logLevel = undefined, + clientOptions = {}, + authorizationUrl = 'https://slack.com/oauth/v2/authorize', + }: InstallProviderOptions) { + if (clientId === undefined || clientSecret === undefined) { + throw new InstallerInitializationError('You must provide a valid clientId and clientSecret'); + } + + // Setup the logger + if (typeof logger !== 'undefined') { + this.logger = logger; + if (typeof logLevel !== 'undefined') { + this.logger.debug('The logLevel given to OAuth was ignored as you also gave logger'); + } + } else { + this.logger = getLogger('OAuth:InstallProvider', logLevel ?? LogLevel.INFO, logger); + } + this.stateVerification = stateVerification; + if (!stateVerification) { + this.logger.warn("You've set InstallProvider#stateVerification to false. This flag is intended to enable org-wide app installations from admin pages. If this isn't your scenario, we recommend setting stateVerification to true and starting your OAuth flow from the provided `/slack/install` or your own starting endpoint."); + } + // Setup stateStore + if (stateStore !== undefined) { + this.stateStore = stateStore; + } else if (this.stateVerification) { + // if state verification is disabled, state store is not necessary + if (stateSecret !== undefined) { + this.stateStore = new ClearStateStore(stateSecret); + } else { + throw new InstallerInitializationError('To use the built-in state store you must provide a State Secret'); + } + } + + this.installationStore = installationStore; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.handleCallback = this.handleCallback.bind(this); + this.authorize = this.authorize.bind(this); + this.authVersion = authVersion; + + this.authorizationUrl = authorizationUrl; + if (authorizationUrl !== 'https://slack.com/oauth/v2/authorize' && authVersion === 'v1') { + this.logger.info('You provided both an authorizationUrl and an authVersion! The authVersion will be ignored in favor of the authorizationUrl.'); + } else if (authVersion === 'v1') { + this.authorizationUrl = 'https://slack.com/oauth/authorize'; + } + + this.clientOptions = { + logger, + logLevel: this.logger.getLevel(), + ...clientOptions, + }; + } + + /** + * Fetches data from the installationStore + */ + public async authorize(source: InstallationQuery): Promise { + try { + // Note that `queryResult` may unexpectedly include null values for some properties. + // For example, MongoDB can often save properties as null for some reasons. + // Inside this method, we should alwayss check if a value is either undefined or null. + let queryResult; + if (source.isEnterpriseInstall) { + queryResult = await this.installationStore.fetchInstallation(source as InstallationQuery, this.logger); + } else { + queryResult = await this.installationStore.fetchInstallation(source as InstallationQuery, this.logger); + } + + if (queryResult === undefined || queryResult === null) { + throw new Error('Failed fetching data from the Installation Store'); + } + + const authResult: AuthorizeResult = {}; + + if (queryResult.user) { + authResult.userToken = queryResult.user.token; + } + + if (queryResult.team?.id) { + authResult.teamId = queryResult.team.id; + } else if (source?.teamId) { + /** + * Since queryResult is a org installation, it won't have team.id. + * If one was passed in via source, we should add it to the authResult. + */ + authResult.teamId = source.teamId; + } + + if (queryResult?.enterprise?.id || source?.enterpriseId) { + authResult.enterpriseId = queryResult?.enterprise?.id || source?.enterpriseId; + } + + if (queryResult.bot) { + authResult.botToken = queryResult.bot.token; + authResult.botId = queryResult.bot.id; + authResult.botUserId = queryResult.bot.userId; + + // Token Rotation Enabled (Bot Token) + if (queryResult.bot.refreshToken) { + authResult.botRefreshToken = queryResult.bot.refreshToken; + authResult.botTokenExpiresAt = queryResult.bot.expiresAt; // utc, seconds + } + } + + // Token Rotation Enabled (User Token) + if (queryResult.user?.refreshToken) { + authResult.userRefreshToken = queryResult.user.refreshToken; + authResult.userTokenExpiresAt = queryResult.user.expiresAt; // utc, seconds + } + + /* + * Token Rotation (Expiry Check + Refresh) + * The presence of `(bot|user)TokenExpiresAt` indicates having opted into token rotation. + * If the token has expired, or will expire within 2 hours, the token is refreshed and + * the `authResult` and `Installation` are updated with the new values. + */ + if (authResult.botRefreshToken || authResult.userRefreshToken) { + const currentUTCSec = Math.floor(Date.now() / 1000); // seconds + const tokensToRefresh: string[] = detectExpiredOrExpiringTokens(authResult, currentUTCSec); + + if (tokensToRefresh.length > 0) { + const installationUpdates: any = { ...queryResult }; // TODO :: TS + const refreshResponses = await this.refreshExpiringTokens(tokensToRefresh); + + // TODO: perhaps this for..of loop could introduce an async delay due to await'ing once for each refreshResp? + // Could we rewrite to be more performant and not trigger the eslint warning? Perhaps a concurrent async + // map/reduce? But will the return value be the same? Does order of this array matter? + // eslint-disable-next-line no-restricted-syntax + for (const refreshResp of refreshResponses) { + const tokenType = refreshResp.token_type; + + // Update Authorization + if (tokenType === 'bot') { + authResult.botToken = refreshResp.access_token; + authResult.botRefreshToken = refreshResp.refresh_token; + authResult.botTokenExpiresAt = currentUTCSec + refreshResp.expires_in; + } + + if (tokenType === 'user') { + authResult.userToken = refreshResp.access_token; + authResult.userRefreshToken = refreshResp.refresh_token; + authResult.userTokenExpiresAt = currentUTCSec + refreshResp.expires_in; + } + + // Update Installation + installationUpdates[tokenType].token = refreshResp.access_token; + installationUpdates[tokenType].refreshToken = refreshResp.refresh_token; + installationUpdates[tokenType].expiresAt = currentUTCSec + refreshResp.expires_in; + + const updatedInstallation = { + ...installationUpdates, + [tokenType]: { ...queryResult[tokenType], ...installationUpdates[tokenType] }, + }; + + // TODO: related to the above TODO comment as well + // eslint-disable-next-line no-await-in-loop + await this.installationStore.storeInstallation(updatedInstallation); + } + } + } + + return authResult; + } catch (error: any) { + throw new AuthorizationError(error.message); + } + } + + /** + * refreshExpiringTokens refreshes expired access tokens using the `oauth.v2.access` endpoint. + * + * The return value is an Array of Promises made up of the resolution of each token refresh attempt. + */ + private async refreshExpiringTokens(tokensToRefresh: string[]): Promise { + const client = new WebClient(undefined, this.clientOptions); + + const refreshPromises = tokensToRefresh.map(async (refreshToken) => await client.oauth.v2.access({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }).catch((e) => e) as OAuthV2TokenRefreshResponse); + + return Promise.all(refreshPromises); + } + + /** + * Returns search params from a URL and ignores protocol / hostname as those + * aren't guaranteed to be accurate e.g. in x-forwarded- scenarios + */ + private static extractSearchParams(req: IncomingMessage): URLSearchParams { + const { searchParams } = new URL(req.url as string, `https://${req.headers.host}`); + return searchParams; + } + + /** + * Returns a URL that is suitable for including in an Add to Slack button + * Uses stateStore to generate a value for the state query param. + */ + public async generateInstallUrl(options: InstallURLOptions, stateVerification: boolean = true): Promise { + const slackURL = new URL(this.authorizationUrl); + + if (options.scopes === undefined || options.scopes === null) { + throw new GenerateInstallUrlError('You must provide a scope parameter when calling generateInstallUrl'); + } + + // scope + let scopes: string; + if (options.scopes instanceof Array) { + scopes = options.scopes.join(','); + } else { + scopes = options.scopes; + } + const params = new URLSearchParams(`scope=${scopes}`); + + // generate state + if (stateVerification && this.stateStore) { + const state = await this.stateStore.generateStateParam(options, new Date()); + params.append('state', state); + } + + // client id + params.append('client_id', this.clientId); + + // redirect uri + if (options.redirectUri !== undefined) { + params.append('redirect_uri', options.redirectUri); + } + + // team id + if (options.teamId !== undefined) { + params.append('team', options.teamId); + } + + // user scope, only available for OAuth v2 + if (options.userScopes !== undefined && this.authVersion === 'v2') { + let userScopes: string; + if (options.userScopes instanceof Array) { + userScopes = options.userScopes.join(','); + } else { + userScopes = options.userScopes; + } + params.append('user_scope', userScopes); + } + slackURL.search = params.toString(); + return slackURL.toString(); + } + + /** + * This method handles the incoming request to the callback URL. + * It can be used as a RequestListener in almost any HTTP server + * framework. + * + * Verifies the state using the stateStore, exchanges the grant in the + * query params for an access token, and stores token and associated data + * in the installationStore. + */ + public async handleCallback( + req: IncomingMessage, + res: ServerResponse, + options?: CallbackOptions, + installOptions?: InstallURLOptions, + ): Promise { + let code: string; + let flowError: string; + let state: string; + try { + if (req.url !== undefined) { + // Note: Protocol/ host of object are not necessarily accurate + // and shouldn't be relied on + // intended only for accessing searchParams only + const searchParams = InstallProvider.extractSearchParams(req); + flowError = searchParams.get('error') as string; + if (flowError === 'access_denied') { + throw new AuthorizationError('User cancelled the OAuth installation flow!'); + } + code = searchParams.get('code') as string; + state = searchParams.get('state') as string; + if (!code) { + throw new MissingCodeError('Redirect url is missing the required code query parameter'); + } + if (this.stateVerification && !state) { + throw new MissingStateError('Redirect url is missing the state query parameter. If this is intentional, see options for disabling default state verification.'); + } + } else { + throw new UnknownError('Something went wrong'); + } + // If state verification is enabled, attempt to verify, otherwise ignore + if (this.stateVerification && this.stateStore) { + // eslint-disable-next-line no-param-reassign + installOptions = await this.stateStore.verifyStateParam(new Date(), state); + } + if (!installOptions) { + const emptyInstallOptions: InstallURLOptions = { scopes: [] }; + // eslint-disable-next-line no-param-reassign + installOptions = emptyInstallOptions; + } + + const client = new WebClient(undefined, this.clientOptions); + + // Start: Build the installation object + let installation: Installation; + let resp: OAuthV1Response | OAuthV2Response; + + if (this.authVersion === 'v1') { + // convert response type from WebApiCallResult to OAuthResponse + const v1Resp = await client.oauth.access({ + code, + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: installOptions.redirectUri, + }) as OAuthV1Response; + + // resp obj for v1 - https://api.slack.com/methods/oauth.access#response + const v1Installation: Installation<'v1', false> = { + team: { id: v1Resp.team_id, name: v1Resp.team_name }, + enterprise: v1Resp.enterprise_id === null ? undefined : { id: v1Resp.enterprise_id }, + user: { + token: v1Resp.access_token, + scopes: v1Resp.scope.split(','), + id: v1Resp.user_id, + }, + + // synthesized properties: enterprise installation is unsupported in v1 auth + isEnterpriseInstall: false, + authVersion: 'v1', + }; + + // only can get botId if bot access token exists + // need to create a botUser + request bot scope to have this be part of resp + if (v1Resp.bot !== undefined) { + const authResult = await runAuthTest(v1Resp.bot.bot_access_token, this.clientOptions); + // We already tested that a bot user was in the response, so we know the following bot_id will be defined + const botId = authResult.bot_id as string; + + v1Installation.bot = { + id: botId, + scopes: ['bot'], + token: v1Resp.bot.bot_access_token, + userId: v1Resp.bot.bot_user_id, + }; + } + + resp = v1Resp; + installation = v1Installation; + } else { + // convert response type from WebApiCallResult to OAuthResponse + const v2Resp = await client.oauth.v2.access({ + code, + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: installOptions.redirectUri, + }) as OAuthV2Response; + + // resp obj for v2 - https://api.slack.com/methods/oauth.v2.access#response + const v2Installation: Installation<'v2', boolean> = { + team: v2Resp.team === null ? undefined : v2Resp.team, + enterprise: v2Resp.enterprise == null ? undefined : v2Resp.enterprise, + user: { + token: v2Resp.authed_user.access_token, + scopes: v2Resp.authed_user.scope?.split(','), + id: v2Resp.authed_user.id, + }, + tokenType: v2Resp.token_type, + isEnterpriseInstall: v2Resp.is_enterprise_install, + appId: v2Resp.app_id, + + // synthesized properties + authVersion: 'v2', + }; + + const currentUTC = Math.floor(Date.now() / 1000); // utc, seconds + + // Installation has Bot Token + if (v2Resp.access_token !== undefined && v2Resp.scope !== undefined && v2Resp.bot_user_id !== undefined) { + const authResult = await runAuthTest(v2Resp.access_token, this.clientOptions); + + v2Installation.bot = { + scopes: v2Resp.scope.split(','), + token: v2Resp.access_token, + userId: v2Resp.bot_user_id, + id: authResult.bot_id as string, + }; + + if (v2Resp.is_enterprise_install) { + v2Installation.enterpriseUrl = authResult.url; + } + + // Token Rotation is Enabled + if (v2Resp.refresh_token !== undefined && v2Resp.expires_in !== undefined) { + v2Installation.bot.refreshToken = v2Resp.refresh_token; + v2Installation.bot.expiresAt = currentUTC + v2Resp.expires_in; // utc, seconds + } + } + + // Installation has User Token + if (v2Resp.authed_user !== undefined && v2Resp.authed_user.access_token !== undefined) { + if (v2Resp.is_enterprise_install && v2Installation.enterpriseUrl === undefined) { + const authResult = await runAuthTest(v2Resp.authed_user.access_token, this.clientOptions); + v2Installation.enterpriseUrl = authResult.url; + } + + // Token Rotation is Enabled + if (v2Resp.authed_user.refresh_token !== undefined && v2Resp.authed_user.expires_in !== undefined) { + v2Installation.user.refreshToken = v2Resp.authed_user.refresh_token; + v2Installation.user.expiresAt = currentUTC + v2Resp.authed_user.expires_in; // utc, seconds + } + } + + resp = v2Resp; + installation = v2Installation; + } + + if (resp.incoming_webhook !== undefined) { + installation.incomingWebhook = { + url: resp.incoming_webhook.url, + channel: resp.incoming_webhook.channel, + channelId: resp.incoming_webhook.channel_id, + configurationUrl: resp.incoming_webhook.configuration_url, + }; + } + if (installOptions && installOptions.metadata !== undefined) { + // Pass the metadata in state parameter if exists. + // Developers can use the value for additional/custom data associated with the installation. + installation.metadata = installOptions.metadata; + } + // End: Build the installation object + + // Save installation object to installation store + if (installation.isEnterpriseInstall) { + await this.installationStore.storeInstallation(installation as OrgInstallation, this.logger); + } else { + await this.installationStore.storeInstallation(installation as Installation<'v1' | 'v2', false>, this.logger); + } + + // Call the success callback + if (options !== undefined && options.success !== undefined) { + this.logger.debug('calling passed in options.success'); + options.success(installation, installOptions, req, res); + } else { + this.logger.debug('run built-in success function'); + defaultCallbackSuccess(installation, installOptions, req, res); + } + } catch (error: any) { + this.logger.error(error); + + if (!installOptions) { + // To make the `installOptions` type compatible with `CallbackOptions#failure` signature + const emptyInstallOptions: InstallURLOptions = { scopes: [] }; + // eslint-disable-next-line no-param-reassign + installOptions = emptyInstallOptions; + } + + // Call the failure callback + if (options !== undefined && options.failure !== undefined) { + this.logger.debug('calling passed in options.failure'); + options.failure(error, installOptions, req, res); + } else { + this.logger.debug('run built-in failure function'); + defaultCallbackFailure(error, installOptions, req, res); + } + } + } +} + +// Response shape from oauth.v2.access - https://api.slack.com/methods/oauth.v2.access#response +export interface OAuthV2Response extends WebAPICallResult { + app_id: string; + authed_user: { + id: string, + scope?: string, + access_token?: string, + token_type?: string, + refresh_token?: string, + expires_in?: number, + }; + scope?: string; + token_type?: 'bot'; + access_token?: string; + refresh_token?: string; + expires_in?: number; + bot_user_id?: string; + team: { id: string, name: string } | null; + enterprise: { name: string, id: string } | null; + is_enterprise_install: boolean; + incoming_webhook?: { + url: string, + channel: string, + channel_id: string, + configuration_url: string, + }; +} + +export interface OAuthV2TokenRefreshResponse extends WebAPICallResult { + app_id: string; + scope: string; + token_type: 'bot' | 'user'; + access_token: string; + refresh_token: string; + expires_in: number; + bot_user_id?: string; + team: { id: string, name: string }; + enterprise: { name: string, id: string } | null; + is_enterprise_install: boolean; +} + +// ------------------------------------------ +// Internals +// ------------------------------------------ + +// Response shape from oauth.access - https://api.slack.com/methods/oauth.access#response +interface OAuthV1Response extends WebAPICallResult { + access_token: string; + // scope parameter isn't returned in workspace apps + scope: string; + team_name: string; + team_id: string; + enterprise_id: string | null; + // if they request bot user token + bot?: { bot_user_id: string, bot_access_token: string }; + incoming_webhook?: { + url: string, + channel: string, + channel_id: string, + configuration_url: string, + }; + // app_id is currently undefined but leaving it in here incase the v1 method adds it + app_id: string | undefined; + // TODO: removed optional because logically there's no case where a user_id cannot be provided, but needs verification + user_id: string; // Not documented but showing up on responses +} + +// --------------------- +// Gets the bot_id using the `auth.test` method. + +interface AuthTestResult extends WebAPICallResult { + bot_id?: string; + url?: string; +} + +async function runAuthTest(token: string, clientOptions: WebClientOptions): Promise { + const client = new WebClient(token, clientOptions); + const authResult = await client.auth.test(); + return authResult as any as AuthTestResult; +} + +// --------------------- +// token rotation + +/** + * detectExpiredOrExpiringTokens determines access tokens' eligibility for refresh. + * + * The return value is an Array of expired or soon-to-expire access tokens. + */ +function detectExpiredOrExpiringTokens(authResult: AuthorizeResult, currentUTCSec: number): string[] { + const tokensToRefresh: string[] = []; + const EXPIRY_WINDOW: number = 7200; // 2 hours + + if (authResult.botRefreshToken && + (authResult.botTokenExpiresAt !== undefined && authResult.botTokenExpiresAt !== null)) { + const botTokenExpiresIn = authResult.botTokenExpiresAt - currentUTCSec; + if (botTokenExpiresIn <= EXPIRY_WINDOW) { + tokensToRefresh.push(authResult.botRefreshToken); + } + } + + if (authResult.userRefreshToken && + (authResult.userTokenExpiresAt !== undefined && authResult.userTokenExpiresAt !== null)) { + const userTokenExpiresIn = authResult.userTokenExpiresAt - currentUTCSec; + if (userTokenExpiresIn <= EXPIRY_WINDOW) { + tokensToRefresh.push(authResult.userRefreshToken); + } + } + + return tokensToRefresh; +} diff --git a/packages/oauth/src/install-url-options.spec.ts b/packages/oauth/src/install-url-options.spec.ts new file mode 100644 index 000000000..eeb4d4e4c --- /dev/null +++ b/packages/oauth/src/install-url-options.spec.ts @@ -0,0 +1,17 @@ +import { assert } from 'chai'; +import { describe, it } from 'mocha'; +import { InstallURLOptions } from './install-url-options'; + +describe('InstallURLOptions', async () => { + // TODO: the use cases with only userScopes should be supported in TS + + it('should be compatible with past versions in terms of required args', async () => { + const options: InstallURLOptions = { + scopes: ['commands', 'chat:write'], + }; + const actual = typeof options.scopes === 'string' ? + options.scopes.split(',').sort() : + options.scopes.sort(); + assert.deepEqual(actual, ['chat:write', 'commands']); + }); +}); diff --git a/packages/oauth/src/install-url-options.ts b/packages/oauth/src/install-url-options.ts new file mode 100644 index 000000000..9fcde0601 --- /dev/null +++ b/packages/oauth/src/install-url-options.ts @@ -0,0 +1,7 @@ +export interface InstallURLOptions { + scopes: string | string[]; + teamId?: string; + redirectUri?: string; + userScopes?: string | string[]; // cannot be used with authVersion=v1 + metadata?: string; // Arbitrary data can be stored here, potentially to save app state or use for custom redirect +} diff --git a/packages/oauth/src/installation-query.spec.ts b/packages/oauth/src/installation-query.spec.ts new file mode 100644 index 000000000..371c1667c --- /dev/null +++ b/packages/oauth/src/installation-query.spec.ts @@ -0,0 +1,46 @@ +import { assert } from 'chai'; +import { describe, it } from 'mocha'; +import { InstallationQuery } from './installation-query'; + +describe('InstallationQuery', async () => { + it('should be a team-level installation query', async () => { + const teamQuery: InstallationQuery = { + enterpriseId: undefined, + teamId: 'T111', + isEnterpriseInstall: false, + }; + assert.isNotNull(teamQuery); + + const gridTeamQuery: InstallationQuery = { + enterpriseId: 'E111', + teamId: 'T111', + isEnterpriseInstall: false, + }; + assert.isNotNull(gridTeamQuery); + + const teamUserQuery: InstallationQuery = { + enterpriseId: undefined, + teamId: 'T111', + userId: 'W111', + isEnterpriseInstall: false, + }; + assert.isNotNull(teamUserQuery); + }); + + it('should be an org-wide installation query', async () => { + const orgWideQuery: InstallationQuery = { + enterpriseId: 'E111', + teamId: undefined, + isEnterpriseInstall: true, + }; + assert.isNotNull(orgWideQuery); + + const orgWideUserQuery: InstallationQuery = { + enterpriseId: 'E111', + teamId: undefined, + userId: 'W111', + isEnterpriseInstall: true, + }; + assert.isNotNull(orgWideUserQuery); + }); +}); diff --git a/packages/oauth/src/installation-query.ts b/packages/oauth/src/installation-query.ts new file mode 100644 index 000000000..496075e0d --- /dev/null +++ b/packages/oauth/src/installation-query.ts @@ -0,0 +1,12 @@ +// This is intentionally structurally identical to AuthorizeSourceData +// from App. It is redefined so that this class remains loosely coupled to +// the rest of Bolt. +export interface InstallationQuery { + teamId: isEnterpriseInstall extends false ? string : undefined; + enterpriseId: isEnterpriseInstall extends true ? string : (string | undefined); + userId?: string; + conversationId?: string; + isEnterpriseInstall: isEnterpriseInstall; +} + +export type OrgInstallationQuery = InstallationQuery; diff --git a/packages/oauth/src/installation.spec.ts b/packages/oauth/src/installation.spec.ts new file mode 100644 index 000000000..f785da49d --- /dev/null +++ b/packages/oauth/src/installation.spec.ts @@ -0,0 +1,75 @@ +import { assert } from 'chai'; +import { describe, it } from 'mocha'; +import { Installation } from './installation'; + +describe('Installation', async () => { + it('should be a team-level installation (bot)', async () => { + const installation: Installation<'v2', false> = { + enterprise: undefined, + team: { + id: 'T111', + }, + bot: { + id: 'B111', + userId: 'W111', + scopes: ['commands'], + token: 'xoxb-', + }, + user: { + id: 'W222', + scopes: undefined, + token: undefined, + }, + }; + assert.isNotNull(installation); + }); + it('should be a team-level installation (user)', async () => { + const installation: Installation<'v2', false> = { + enterprise: undefined, + team: { + id: 'T111', + }, + user: { + id: 'W222', + token: 'xoxp-', + scopes: ['chat:write'], + }, + }; + assert.isNotNull(installation); + }); + + it('should be an org-wide installation query (bot)', async () => { + const installation: Installation<'v2', true> = { + enterprise: { + id: 'E111', + }, + bot: { + id: 'B111', + userId: 'W111', + scopes: ['commands'], + token: 'xoxb-', + }, + team: undefined, + user: { + id: 'W222', + scopes: undefined, + token: undefined, + }, + }; + assert.isNotNull(installation); + }); + it('should be an org-wide installation query (user)', async () => { + const installation: Installation<'v2', true> = { + enterprise: { + id: 'E111', + }, + team: undefined, + user: { + id: 'W222', + token: 'xoxp-', + scopes: ['chat:write'], + }, + }; + assert.isNotNull(installation); + }); +}); diff --git a/packages/oauth/src/installation.ts b/packages/oauth/src/installation.ts new file mode 100644 index 000000000..3eb21f744 --- /dev/null +++ b/packages/oauth/src/installation.ts @@ -0,0 +1,106 @@ +/** + * An individual installation of the Slack app. + * + * This interface creates a representation for installations that normalizes the responses from OAuth grant exchanges + * across auth versions (responses from the Web API methods `oauth.v2.access` and `oauth.access`). It describes some of + * these differences using the `AuthVersion` generic placeholder type. + * + * This interface also represents both installations which occur on individual Slack workspaces and on Slack enterprise + * organizations. The `IsEnterpriseInstall` generic placeholder type is used to describe some of those differences. + * + * This representation is designed to be used both when producing data that should be stored by an InstallationStore, + * and when consuming data that is fetched from an InstallationStore. Most often, InstallationStore implementations + * are a database. If you are going to implement an InstallationStore, it's advised that you **store as much of the + * data in these objects as possible so that you can return as much as possible inside `fetchInstallation()`**. + * + * A few properties are synthesized with a default value if they are not present when returned from + * `fetchInstallation()`. These properties are optional in the interface so that stored installations from previous + * versions of this library (from before those properties were introduced) continue to work without requiring a breaking + * change. However the synthesized default values are not always perfect and are based on some assumptions, so this is + * why it's recommended to store as much of that data as possible in any InstallationStore. + * + * Some of the properties (e.g. `team.name`) can change between when the installation occurred and when it is fetched + * from the InstallationStore. This can be seen as a reason not to store those properties. In most workspaces these + * properties rarely change, and for most Slack apps having a slightly out of date value has no impact. However if your + * app uses these values in a way where it must be up to date, it's recommended to implement a caching strategy in the + * InstallationStore to fetch the latest data from the Web API (using methods such as `auth.test`, `teams.info`, etc.) + * as often as it makes sense for your Slack app. + * + * TODO: IsEnterpriseInstall is always false when AuthVersion is v1 + */ +export interface Installation { + /** + * TODO: when performing a “single workspace” install with the admin scope on the enterprise, + * is the team property returned from oauth.access? + */ + team: IsEnterpriseInstall extends true ? undefined : { + id: string; + /** Left as undefined when not returned from fetch. */ + name?: string; + }; + + /** + * When the installation is an enterprise install or when the installation occurs on the org to acquire `admin` scope, + * the name and ID of the enterprise org. + */ + enterprise: IsEnterpriseInstall extends true ? EnterpriseInfo : (EnterpriseInfo | undefined); + + user: { + token: AuthVersion extends 'v1' ? string : (string | undefined); + refreshToken?: AuthVersion extends 'v1' ? never : (string | undefined); + expiresAt?: AuthVersion extends 'v1' ? never : (number | undefined); // utc, seconds + scopes: AuthVersion extends 'v1' ? string[] : (string[] | undefined); + id: string; + }; + + bot?: { + token: string; + refreshToken?: string; + expiresAt?: number; // utc, seconds + scopes: string[]; + id: string; // retrieved from auth.test + userId: string; + }; + incomingWebhook?: { + url: string; + /** Left as undefined when not returned from fetch. */ + channel?: string; + /** Left as undefined when not returned from fetch. */ + channelId?: string; + /** Left as undefined when not returned from fetch. */ + configurationUrl?: string; + }; + + /** The App ID, which does not vary per installation. Left as undefined when not returned from fetch. */ + appId?: AuthVersion extends 'v2' ? string : undefined; + + /** When the installation contains a bot user, the token type. Left as undefined when not returned from fetch. */ + tokenType?: 'bot'; + + /** + * When the installation is an enterprise org install, the URL of the landing page for all workspaces in the org. + * Left as undefined when not returned from fetch. + */ + enterpriseUrl?: AuthVersion extends 'v2' ? string : undefined; + + /** Whether the installation was performed on an enterprise org. Synthesized as `false` when not present. */ + isEnterpriseInstall?: IsEnterpriseInstall; + + /** The version of Slack's auth flow that produced this installation. Synthesized as `v2` when not present. */ + authVersion?: AuthVersion; + + /** A string value that can be held in the state parameter in the OAuth flow. */ + metadata?: string; +} + +/** +* A type to describe enterprise organization installations. +*/ +export type OrgInstallation = Installation<'v2', true>; + +interface EnterpriseInfo { + id: string; + /* Not defined in v1 auth version. Left as undefined when not returned from fetch. */ + name?: string; +} diff --git a/packages/oauth/src/logger.spec.js b/packages/oauth/src/logger.spec.js new file mode 100644 index 000000000..46f24389b --- /dev/null +++ b/packages/oauth/src/logger.spec.js @@ -0,0 +1,21 @@ +require('mocha'); +const { assert } = require('chai'); +const { getLogger } = require('./logger'); +const { LogLevel } = require('@slack/logger'); + +describe('Logger', async () => { + it('should create a new logger instance', async () => { + const newLogger = getLogger('test-logger-name', LogLevel.DEBUG); + assert.match(newLogger.name, /test-logger-name:\d+/); + assert.equal(newLogger.getLevel(), LogLevel.DEBUG); + }); + it('should modify the passed logger', async () => { + const existingLogger = getLogger('test-logger-name', LogLevel.ERROR); + assert.match(existingLogger.name, /test-logger-name:\d+/); + const returnedLogger = getLogger('overriden-logger-name', LogLevel.DEBUG, existingLogger); + assert.match(returnedLogger.name, /overriden-logger-name:\d+/); + assert.equal(returnedLogger.getLevel(), LogLevel.DEBUG); + assert.match(existingLogger.name, /overriden-logger-name:\d+/); + assert.equal(existingLogger.getLevel(), LogLevel.DEBUG); + }); +}); diff --git a/packages/oauth/src/state-stores/clear-state-store.spec.js b/packages/oauth/src/state-stores/clear-state-store.spec.js new file mode 100644 index 000000000..1a4b05a60 --- /dev/null +++ b/packages/oauth/src/state-stores/clear-state-store.spec.js @@ -0,0 +1,14 @@ +require('mocha'); +const { assert } = require('chai'); + +const { default: ClearStateStore } = require('./clear-state-store'); + +describe('ClearStateStore', async () => { + it('should generate a state and return install options once verified', async () => { + const stateStore = new ClearStateStore('stateSecret'); + const installUrlOptions = { scopes: ['channels:read'] }; + const state = await stateStore.generateStateParam(installUrlOptions, new Date()); + const returnedInstallUrlOptions = await stateStore.verifyStateParam(new Date(), state); + assert.deepEqual(installUrlOptions, returnedInstallUrlOptions); + }); +}); diff --git a/packages/oauth/src/state-stores/clear-state-store.ts b/packages/oauth/src/state-stores/clear-state-store.ts new file mode 100644 index 000000000..5834a6eac --- /dev/null +++ b/packages/oauth/src/state-stores/clear-state-store.ts @@ -0,0 +1,24 @@ +import { sign, verify } from 'jsonwebtoken'; +import { InstallURLOptions } from '../install-url-options'; +import { StateStore, StateObj } from './interface'; + +// default implementation of StateStore +export default class ClearStateStore implements StateStore { + private stateSecret: string; + + public constructor(stateSecret: string) { + this.stateSecret = stateSecret; + } + + public async generateStateParam(installOptions: InstallURLOptions, now: Date): Promise { + return sign({ installOptions, now: now.toJSON() }, this.stateSecret); + } + + public async verifyStateParam(_now: Date, state: string): Promise { + // decode the state using the secret + const decoded: StateObj = verify(state, this.stateSecret) as StateObj; + + // return installOptions + return decoded.installOptions; + } +} diff --git a/packages/oauth/src/state-stores/index.ts b/packages/oauth/src/state-stores/index.ts new file mode 100644 index 000000000..bea43cd26 --- /dev/null +++ b/packages/oauth/src/state-stores/index.ts @@ -0,0 +1,2 @@ +export { StateStore, StateObj } from './interface'; +export { default as ClearStateStore } from './clear-state-store'; diff --git a/packages/oauth/src/state-stores/interface.ts b/packages/oauth/src/state-stores/interface.ts new file mode 100644 index 000000000..5776bf4fe --- /dev/null +++ b/packages/oauth/src/state-stores/interface.ts @@ -0,0 +1,18 @@ +import { InstallURLOptions } from '../install-url-options'; + +// State object structure +export interface StateObj { + now: Date; + installOptions: InstallURLOptions; +} + +export interface StateStore { + // Returned Promise resolves for a string which can be used as an + // OAuth state param. + // TODO: Revisit design. Does installOptions need to be encoded in state if metadata is static? + generateStateParam: (installOptions: InstallURLOptions, now: Date) => Promise; + + // Returned Promise resolves for InstallURLOptions that were stored in the state + // param. The Promise rejects with a CodedError when the state is invalid. + verifyStateParam: (now: Date, state: string) => Promise; +} diff --git a/packages/oauth/src/stores/file-store.spec.js b/packages/oauth/src/stores/file-store.spec.js new file mode 100644 index 000000000..20ed2a6b8 --- /dev/null +++ b/packages/oauth/src/stores/file-store.spec.js @@ -0,0 +1,263 @@ +require('mocha'); +const { assert, expect } = require('chai'); + +const sinon = require('sinon'); +const fs = require('fs'); +const os = require('os'); + +const { InstallProvider } = require('../index'); +const { FileInstallationStore } = require('./index'); + +const clientSecret = 'MY_SECRET'; +const clientId = 'MY_ID'; +const stateSecret = 'stateSecret'; + +const storedInstallation = { + team: { + id: 'test-team-id', + name: 'team-name', + }, + enterprise: { + id: 'test-enterprise-id', + name: 'ent-name', + }, + bot: { + token: 'botToken', + scopes: ['chat:write'], + id: 'botId', + userId: 'botUserId', + }, + user: { + token: 'userToken', + id: 'userId', + }, + incomingWebhook: { + url: 'example.com', + channel: 'someChannel', + channelId: 'someChannelID', + configurationUrl: 'someConfigURL', + }, + appId: 'fakeAppId', + tokenType: 'tokenType', + isEnterpriseInstall: false, +} + +// TODO: valid tests with org-wide installations +const storedOrgInstallation = { + team: null, + enterprise: { + id: 'test-enterprise-id', + name: 'ent-name', + }, + bot: { + token: 'botToken', + scopes: ['chat:write'], + id: 'botId', + userId: 'botUserId', + }, + user: { + token: 'userToken', + id: 'userId', + }, + incomingWebhook: { + url: 'example.com', + channel: 'someChannel', + channelId: 'someChannelID', + configurationUrl: 'someConfigURL', + }, + appId: undefined, + tokenType: 'tokenType', + isEnterpriseInstall: true, +} + +describe('FileInstallationStore', async () => { + + let fsMakeDir, fsWriteFile, fsReadFileSync; + + beforeEach(() => { + fsMakeDir = sinon.stub(fs, 'mkdir').returns({}); + fsWriteFile = sinon.stub(fs, 'writeFile').returns({}); + fsReadFileSync = sinon.stub(fs, 'readFileSync').returns(Buffer.from(JSON.stringify(storedInstallation))); + fsUnlink = sinon.stub(fs, 'unlink').returns({}); + fsReaddirSync = sinon.stub(fs, 'readdirSync').returns(['app-latest', 'user-userId-latest']); + }); + + afterEach(() => { + fsMakeDir.restore(); + fsWriteFile.restore(); + fsReadFileSync.restore(); + fsUnlink.restore(); + fsReaddirSync.restore(); + }); + + it('should store the latest installation', async () => { + const installationStore = new FileInstallationStore({ baseDir: os.tmpdir() }); + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const { enterprise, team, user } = storedInstallation; + const fakeInstallDir = `${os.tmpdir()}/${enterprise.id}-${team.id}`; + const installationJSON = JSON.stringify(storedInstallation); + + installer.installationStore.storeInstallation(storedInstallation); + assert.equal(fsWriteFile.calledWith(`${fakeInstallDir}/app-latest`, installationJSON), true); + assert.equal(fsWriteFile.calledWith(sinon.match(`${fakeInstallDir}/user-${user.id}-latest`), installationJSON), true); + }); + + it('should store additional records for each installation with historicalDataEnabled', async () => { + const installationStore = new FileInstallationStore({ baseDir: os.tmpdir(), historicalDataEnabled: true }); + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const { enterprise, team, user } = storedInstallation; + const fakeInstallDir = `${os.tmpdir()}/${enterprise.id}-${team.id}`; + const installationJSON = JSON.stringify(storedInstallation); + + installer.installationStore.storeInstallation(storedInstallation); + + assert.equal(fsWriteFile.calledWith(sinon.match(`${fakeInstallDir}/app-`), installationJSON), true); + assert.equal(fsWriteFile.calledWith(sinon.match(`${fakeInstallDir}/user-${user.id}-`), installationJSON), true); + + // 1 store = 4 files = 2 latest + 2 timestamps + expect(fsWriteFile.callCount).equals(4); + }); + + it('should fetch a stored installation', async () => { + const installationStore = new FileInstallationStore({ baseDir: os.tmpdir() }); + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const { enterprise, team } = storedInstallation; + const fakeInstallDir = `${os.tmpdir()}/${enterprise.id}-${team.id}`; + const query = { enterpriseId: enterprise.id, teamId: team.id }; + + installer.installationStore.storeInstallation(storedInstallation); + const installation = await installer.installationStore.fetchInstallation(query); + + assert.equal(fsReadFileSync.calledWith(sinon.match(`${fakeInstallDir}/app-latest`)), true); + assert.deepEqual(installation, storedInstallation); + }); + + it('should delete all records of installation if no userId is passed', async () => { + const installationStore = new FileInstallationStore({ baseDir: os.tmpdir() }); + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const { enterprise, team } = storedInstallation; + const fakeInstallDir = `${os.tmpdir()}/${enterprise.id}-${team.id}`; + const query = { enterpriseId: enterprise.id, teamId: team.id }; + + await installer.installationStore.deleteInstallation(query); + + assert.equal(fsReaddirSync.calledWith(sinon.match(fakeInstallDir)), true); + assert.equal(fsUnlink.calledWith(sinon.match(`app-latest`)), true); + assert.equal(fsUnlink.calledWith(sinon.match(`user-userId-latest`)), true); + + // fsReaddirSync returns ['app-latest', 'user-userId-latest'] + expect(fsUnlink.callCount).equals(2); + }); + + it('should delete only user records of installation if userId is passed', async () => { + const installationStore = new FileInstallationStore({ baseDir: os.tmpdir() }); + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const { enterprise, team, user } = storedInstallation; + const fakeInstallDir = `${os.tmpdir()}/${enterprise.id}-${team.id}`; + const query = { enterpriseId: enterprise.id, teamId: team.id, userId: user.id }; + + await installer.installationStore.deleteInstallation(query); + + assert.equal(fsReaddirSync.calledWith(sinon.match(fakeInstallDir)), true); + assert.equal(fsUnlink.calledWith(sinon.match(`user-${user.id}-latest`)), true); + + // fsReaddirSync returns ['app-latest', 'user-userId-latest'] + expect(fsUnlink.callCount).equals(1); + }); + + it('should run authorize with triage-bot\'s MongoDB data', async () => { + // Refer to https://github.com/slackapi/bolt-js/issues/1265 to learn the context + const storedInstallation = { + "_id": "6.....", + "id": "T....", + "__v": 0, + "appId": "A...", + "authVersion": "v2", + "bot": { + "scopes": [ + "channels:history", + "channels:join", + "channels:read", + "chat:write", + "commands", + "files:write" + ], + "token": "xoxb-...", + "userId": "U...", + "id": "B02SS7QU407" + }, + "db_record_created_at": "2022-01-08T02:24:40.470Z", + "db_record_updated_at": "2022-01-08T02:24:40.470Z", + "enterprise": null, + "isEnterpriseInstall": false, + "name": "My Team", + "tokenType": "bot", + "user": { + "scopes": null, + "id": "U..." + } + }; + const installationStore = { + fetchInstallation: (_) => { + return new Promise((resolve) => { + resolve(storedInstallation); + }); + }, + storeInstallation: () => {}, + deleteInstallation: (_) => {}, + } + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const authorizeResult = await installer.authorize({ teamId: 'T111' }); + assert.deepEqual(authorizeResult, { + "teamId": "T111", + "botId": "B02SS7QU407", + "botUserId": "U...", + "botToken": "xoxb-...", + "userToken": undefined, + }); + }); + it('should run authorize even if there are null objects in data', async () => { + const storedInstallation = { + // https://github.com/slackapi/template-triage-bot/blob/c1e54fb9d760b46cc8809c57e307061fdb3e0a91/app.js#L51-L55 + id: "T999", // template-triage-bot specific + name: 'My Team', // template-triage-bot specific + appId: 'A111', + tokenType: 'bot', + authVersion: 'v2', + bot: { + id: 'B111', + userId: 'U111', + scopes: [ + 'channels:history', + 'channels:join', + 'channels:read', + 'chat:write', + 'commands', + 'files:write', + ], + token: 'xoxb-____', + }, + enterprise: null, + team: null, // v2.3 does not work with this data due to "Error: Cannot read property 'id' of null" + isEnterpriseInstall: false, + user: null, + } + const installationStore = { + fetchInstallation: (_) => { + return new Promise((resolve) => { + resolve(storedInstallation); + }); + }, + storeInstallation: () => {}, + deleteInstallation: (_) => {}, + } + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const authorizeResult = await installer.authorize({ teamId: 'T111' }); + assert.deepEqual(authorizeResult, { + "teamId": "T111", + "botId": "B111", + "botUserId": "U111", + "botToken": "xoxb-____", + }); + }); +}); diff --git a/packages/oauth/src/stores/index.ts b/packages/oauth/src/stores/index.ts index 420c5b90d..7837d4513 100644 --- a/packages/oauth/src/stores/index.ts +++ b/packages/oauth/src/stores/index.ts @@ -1,2 +1,3 @@ +export { InstallationStore } from './interface'; export { default as MemoryInstallationStore } from './memory-store'; export { default as FileInstallationStore } from './file-store'; diff --git a/packages/oauth/src/stores/interface.ts b/packages/oauth/src/stores/interface.ts new file mode 100644 index 000000000..bb6260716 --- /dev/null +++ b/packages/oauth/src/stores/interface.ts @@ -0,0 +1,16 @@ +import { Logger } from '@slack/logger'; +import { Installation, InstallationQuery } from '..'; + +export interface InstallationStore { + + storeInstallation( + installation: Installation, + logger?: Logger): Promise; + + fetchInstallation: + (query: InstallationQuery, logger?: Logger) => Promise>; + + deleteInstallation?: + (query: InstallationQuery, logger?: Logger) => Promise; + +} diff --git a/packages/oauth/src/stores/memory-store.spec.js b/packages/oauth/src/stores/memory-store.spec.js new file mode 100644 index 000000000..e1352eabc --- /dev/null +++ b/packages/oauth/src/stores/memory-store.spec.js @@ -0,0 +1,94 @@ +require('mocha'); +const { assert } = require('chai'); + +const { InstallProvider } = require('../index'); +const { MemoryInstallationStore } = require('./index'); + +const clientSecret = 'MY_SECRET'; +const clientId = 'MY_ID'; +const stateSecret = 'stateSecret'; + +const storedInstallation = { + team: { + id: 'test-team-id', + name: 'team-name', + }, + enterprise: { + id: 'test-enterprise-id', + name: 'ent-name', + }, + bot: { + token: 'botToken', + scopes: ['chat:write'], + id: 'botId', + userId: 'botUserId', + }, + user: { + token: 'userToken', + id: 'userId', + }, + incomingWebhook: { + url: 'example.com', + channel: 'someChannel', + channelId: 'someChannelID', + configurationUrl: 'someConfigURL', + }, + appId: 'fakeAppId', + tokenType: 'tokenType', + isEnterpriseInstall: false, +} + +// TODO: valid tests with org-wide installations +const storedOrgInstallation = { + team: null, + enterprise: { + id: 'test-enterprise-id', + name: 'ent-name', + }, + bot: { + token: 'botToken', + scopes: ['chat:write'], + id: 'botId', + userId: 'botUserId', + }, + user: { + token: 'userToken', + id: 'userId', + }, + incomingWebhook: { + url: 'example.com', + channel: 'someChannel', + channelId: 'someChannelID', + configurationUrl: 'someConfigURL', + }, + appId: undefined, + tokenType: 'tokenType', + isEnterpriseInstall: true, +} + +describe('MemoryInstallationStore', async () => { + it('should store and fetch an installation', async () => { + const installationStore = new MemoryInstallationStore(); + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const fakeTeamId = storedInstallation.team.id; + + assert.deepEqual({}, installer.installationStore.devDB); + + await installer.installationStore.storeInstallation(storedInstallation); + const fetchedResult = await installer.installationStore.fetchInstallation({ teamId: fakeTeamId }); + assert.deepEqual(fetchedResult, storedInstallation); + assert.deepEqual(storedInstallation, installer.installationStore.devDB[fakeTeamId]); + }); + + it('should delete a stored installation', async () => { + const installationStore = new MemoryInstallationStore(); + const installer = new InstallProvider({ clientId, clientSecret, stateSecret, installationStore }); + const fakeTeamId = storedInstallation.team.id; + + await installer.installationStore.storeInstallation(storedInstallation); + assert.isNotEmpty(installer.installationStore.devDB); + + await installer.installationStore.deleteInstallation({ teamId: fakeTeamId }); + assert.isEmpty(installer.installationStore.devDB); + }); +}); diff --git a/packages/oauth/tsconfig.json b/packages/oauth/tsconfig.json index b64fbed6c..ae5123259 100644 --- a/packages/oauth/tsconfig.json +++ b/packages/oauth/tsconfig.json @@ -30,6 +30,7 @@ ], "exclude": [ "src/**/*.spec.js", + "src/**/*.spec.ts", "src/**/*.js" ], "jsdoc": {