diff --git a/src/WebClient.spec.js b/src/WebClient.spec.js index 65e7a9580..7f9eaf5ba 100644 --- a/src/WebClient.spec.js +++ b/src/WebClient.spec.js @@ -1,6 +1,7 @@ require('mocha'); const fs = require('fs'); const path = require('path'); +const { parse: qsParse } = require('querystring'); const { Agent } = require('https'); const { Readable } = require('stream'); const { assert } = require('chai'); @@ -16,6 +17,9 @@ const Busboy = require('busboy'); const sinon = require('sinon'); const token = 'xoxa-faketoken'; +const refreshToken = 'xoxr-refreshtoken'; +const clientId = 'CLIENTID'; +const clientSecret = 'CLIENTSECRET'; describe('WebClient', function () { @@ -902,6 +906,162 @@ describe('WebClient', function () { }); }); + describe('has support for token refresh', function () { + it('should accept client credentials and refresh token on initialization', function () { + const client = new WebClient(token, { + refreshToken, + clientId, + clientSecret, + }); + assert.equal(client.token, token); + }); + + describe('when the access token is expired', function () { + beforeEach(function () { + this.expiredToken = 'xoxa-expired-access-token'; + this.client = new WebClient(this.expiredToken, { refreshToken, clientId, clientSecret }); + + // NOTE: this is bad because it depends on internal implementation details. in the future we should allow the + // client to perform a refresh and actually send back a response from `oauth.access` with a very short (or + // possibly negative) expires_in value. + this.client.accessTokenExpiresAt = Date.now() - 100; + }); + + it('should refresh the token before making the API call', function () { + const scope = nock('https://slack.com') + .post(/api\/oauth\.access/, function (body) { + // verify that the body contains the required arguments for token refresh + return (body.client_id === clientId && body.client_secret === clientSecret && + body.grant_type === 'refresh_token' && body.refresh_token === refreshToken); + }) + .reply(200, { ok: true, access_token: token, expires_in: 5, team_id: 'TEAMID', enterprise_id: 'ORGID' }) + .post(/api/, function (body) { + // verify the body contains the unexpired token + return body.token === token; + }) + .reply(200, { ok: true }); + return this.client.apiCall('method') + .then((result) => { + assert.isTrue(result.ok); + scope.done(); + }); + }); + + it('should emit the token_refreshed event after a successful token refresh', function () { + const spy = sinon.spy(); + const scope = nock('https://slack.com') + .post(/api\/oauth\.access/, function (body) { + // verify that the body contains the required arguments for token refresh + return (body.client_id === clientId && body.client_secret === clientSecret && + body.grant_type === 'refresh_token' && body.refresh_token === refreshToken); + }) + .reply(200, { ok: true, access_token: token, expires_in: 5, team_id: 'TEAMID', enterprise_id: 'ORGID' }) + .post(/api/) + .reply(200, { ok: true }); + this.client.on('token_refreshed', spy); + return this.client.apiCall('method') + .then((result) => { + assert(spy.calledOnce); + assert.isTrue(result.ok); + scope.done(); + }); + + }); + + it('should retry an API call that fails during a token refresh', function (done) { + // this value is assigned just to force the client to forget its known expiration time for the token + const anotherExpiredToken = 'xoxa-another-expired-token'; + this.client.token = anotherExpiredToken; + + const scope = nock('https://slack.com') + .persist() + .post(/api\/first/) + .reply(200, function (uri, requestBody) { + if (qsParse(requestBody).token === token) { + return { ok: true }; + } else { + return { ok: false, error: 'invalid_auth' }; + } + }) + .post(/api\/second/) + .reply(200, function (uri, requestBody) { + if (qsParse(requestBody).token === token) { + return { ok: true }; + } else { + return { ok: false, error: 'invalid_auth' }; + } + }) + .post(/api\/oauth\.access/, function (body) { + // verify that the body contains the required arguments for token refresh + return (body.client_id === clientId && body.client_secret === clientSecret && + body.grant_type === 'refresh_token' && body.refresh_token === refreshToken); + }) + .reply(200, { ok: true, access_token: token, expires_in: 5, team_id: 'TEAMID', enterprise_id: 'ORGID' }) + + + // the first API call will fail because of an `invalid_auth`, which should trigger a token refresh. + this.client.apiCall('first').catch(done); + + // while that token refresh is in progress, we'll make another API call, which will still be using + // anotherExpiredToken (since the refresh hasn't completed), it would fail, and then we need to verify it will + // retry with a valid token. + this.client.apiCall('second') + .then((result) => { + scope.done(); + assert.isTrue(result.ok); + done(); + }) + .catch(done); + }); + it('should retry an API call that fails and began before the last token refresh'); + + it('should fail with a RefreshFailedError when the refresh token is not valid', function () { + const scope = nock('https://slack.com') + .post(/api\/oauth\.access/, function (body) { + // verify that the body contains the required arguments for token refresh + return (body.client_id === clientId && body.client_secret === clientSecret && + body.grant_type === 'refresh_token' && body.refresh_token === refreshToken); + }) + .reply(200, { ok: false, error: 'invalid_auth' }) + return this.client.apiCall('method') + .then((result) => { + assert(false); + }) + .catch((error) => { + assert.instanceOf(error, Error); + assert.equal(error.code, ErrorCode.RefreshFailedError); + scope.done(); + }); + }); + }); + + describe('manually setting the access token', function () { + beforeEach(function () { + this.expiredToken = 'xoxa-expired-access-token'; + this.client = new WebClient(this.expiredToken, { refreshToken, clientId, clientSecret }); + + // NOTE: this is bad because it depends on internal implementation details. in the future we should allow the + // client to perform a refresh and actually send back a response from `oauth.access` with a very short (or + // possibly negative) expires_in value. + this.client.accessTokenExpiresAt = Date.now() - 100; + this.newToken = 'xoxa-new-token'; + this.client.token = this.newToken; + }); + + it('should not refresh the token before making the API call', function () { + const scope = nock('https://slack.com') + .post(/api\/method/) + .reply(200, { ok: true }); + return this.client.apiCall('method') + .then((result) => { + assert.isTrue(result.ok); + scope.done(); + }) + }); + }); + + }); + describe('warnings', function () { it('should warn when calling a deprecated method', function () { const capture = new CaptureConsole(); diff --git a/src/WebClient.ts b/src/WebClient.ts index 38e9f8ce6..8b0383725 100644 --- a/src/WebClient.ts +++ b/src/WebClient.ts @@ -26,16 +26,61 @@ const pkg = require('../package.json'); // tslint:disable-line:no-require-import * a convenience wrapper for calling the {@link WebClient#apiCall} method using the method name as the first parameter. */ export class WebClient extends EventEmitter { + + /** + * Authentication and authorization token for accessing Slack Web API (usually begins with `xoxa`, xoxp`, or `xoxb`) + */ + public get token(): string | undefined { + return this._accessToken; + } + public set token(newToken: string | undefined) { + this.accessTokenExpiresAt = undefined; + this.isTokenRefreshing = false; + this._accessToken = newToken; + } + + /** + * OAuth 2.0 refresh token used to automatically create new access tokens (`token`) when the current is expired. + */ + public readonly refreshToken?: string; + + /** + * OAuth 2.0 client identifier + */ + public readonly clientId?: string; + /** - * Authentication and authorization token for accessing Slack Web API (usually begins with `xoxp`, `xoxb`, or `xoxa`) + * OAuth 2.0 client secret */ - public readonly token?: string; + public readonly clientSecret?: string; /** * The base URL for reaching Slack's Web API. Consider changing this value for testing purposes. */ public readonly slackApiUrl: string; + /** + * The backing store for the current access token. + */ + private _accessToken?: string; + + /** + * The time (in milliseconds) when the current access token will expire + */ + private accessTokenExpiresAt?: number; + + /** + * Whether or not a token refresh is currently in progress + * TODO: maybe this should be a Promise so that other API calls can await this and we don't fill the queue with + * calls that are destined to fail. + */ + private isTokenRefreshing: boolean = false; + + /** + * The time (in milliseconds) when the last token refresh completed + */ + private accessTokenLastRefreshedAt?: number; + /** * Configuration for retry operations. See {@link https://github.com/tim-kos/node-retry|node-retry} for more details. */ @@ -90,9 +135,15 @@ export class WebClient extends EventEmitter { tls = undefined, pageSize = 200, rejectRateLimitedCalls = false, + clientId = undefined, + clientSecret = undefined, + refreshToken = undefined, }: WebClientOptions = {}) { super(); - this.token = token; + this._accessToken = token; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.refreshToken = refreshToken; this.slackApiUrl = slackApiUrl; this.retryConfig = retryConfig; @@ -158,6 +209,12 @@ export class WebClient extends EventEmitter { ); } + // optimistically check for an expired access token, and refresh it if possible + if ((options === undefined || !('token' in options)) && this.shouldAutomaticallyRefreshToken && + this.accessTokenExpiresAt !== undefined && this.accessTokenExpiresAt < Date.now()) { + await this.performTokenRefresh(); + } + // build headers const headers = {}; if (options !== undefined && optionsAreUserPerspectiveEnabled(options)) { @@ -183,7 +240,7 @@ export class WebClient extends EventEmitter { this.logger.warn('Options include traditional paging while the method cannot support that technique'); } else if (methodSupportsCursorPagination && optionsPaginationType !== PaginationType.Cursor && optionsPaginationType !== PaginationType.None) { - this.logger.warn('Method supports cursor-based pagination and a different tecnique is used in options. ' + + this.logger.warn('Method supports cursor-based pagination and a different technique is used in options. ' + 'Always prefer cursor-based pagination when available'); } @@ -210,78 +267,54 @@ export class WebClient extends EventEmitter { (objectEntries(paginationOptions = paginationOptionsForNextPage(result, this.pageSize)).length > 0) ) ) { - const task = () => this.requestQueue.add( - async () => { - this.logger.debug('will perform http request'); - try { - const response = await this.axios.post(method, Object.assign( - { token: this.token }, - paginationOptions, - options, - ), Object.assign({ - headers, - }, this.tlsConfig)); - this.logger.debug('http response received'); - - if (response.status === 429) { - const retrySec = parseRetryHeaders(response); - if (retrySec !== undefined) { - this.emit('rate_limited', retrySec); - if (this.rejectRateLimitedCalls) { - throw new pRetry.AbortError(rateLimitedErrorWithDelay(retrySec)); - } - this.logger.info(`API Call failed due to rate limiting. Will retry in ${retrySec} seconds.`); - // pause the request queue and then delay the rejection by the amount of time in the retry header - this.requestQueue.pause(); - // NOTE: if there was a way to introspect the current RetryOperation and know what the next timeout - // would be, then we could subtract that time from the following delay, knowing that it the next - // attempt still wouldn't occur until after the rate-limit header has specified. an even better - // solution would be to subtract the time from only the timeout of this next attempt of the - // RetryOperation. this would result in the staying paused for the entire duration specified in the - // header, yet this operation not having to pay the timeout cost in addition to that. - await delay(retrySec * 1000); - // resume the request queue and throw a non-abort error to signal a retry - this.requestQueue.start(); - throw Error('A rate limit was exceeded.'); - } else { - // TODO: turn this into some CodedError - throw new pRetry.AbortError(new Error('Retry header did not contain a valid timeout.')); - } - } - - // Slack's Web API doesn't use meaningful status codes besides 429 and 200 - if (response.status !== 200) { - throw httpErrorFromResponse(response); - } + // NOTE: this is a really inelegant way of capturing the request time + let requestTime: number | undefined; + + result = await (this.makeRequest(method, Object.assign( + { token: this._accessToken }, + paginationOptions, + options, + ), headers) + .then((response) => { + requestTime = response.request[requestTimePropName]; + const result = this.buildResult(response); + + // log warnings in response metadata + if (result.response_metadata !== undefined && result.response_metadata.warnings !== undefined) { + result.response_metadata.warnings.forEach(this.logger.warn); + } - result = this.buildResult(response); + if (!result.ok) { + throw platformErrorFromResult(result as (WebAPICallResult & { error: string; })); + } - // log warnings in response metadata - if (result.response_metadata !== undefined && result.response_metadata.warnings !== undefined) { - result.response_metadata.warnings.forEach(this.logger.warn); + return result; + }) + // Automatic token refresh concerns + .catch(async (error) => { + if (this.shouldAutomaticallyRefreshToken && + error.code === ErrorCode.PlatformError && error.data.error === 'invalid_auth') { + if (requestTime === undefined) { + // TODO: create an inconsistent state error + throw new Error('A logical error with tracking the request time occurred.'); } - if (!result.ok) { - const error = errorWithCode( - new Error(`An API error occurred: ${result.error}`), - ErrorCode.PlatformError, - ); - error.data = result; - throw new pRetry.AbortError(error); + if (this.accessTokenLastRefreshedAt === undefined) { + if (!this.isTokenRefreshing) { + await this.performTokenRefresh(); + return implementation(); + } + return implementation(); } - - return result; - } catch (error) { - this.logger.debug('http request failed'); - if (error.request) { - throw requestErrorWithOriginal(error); + if (!this.isTokenRefreshing && requestTime > this.accessTokenLastRefreshedAt) { + await this.performTokenRefresh(); + return implementation(); } - throw error; + return implementation(); } - }, - ); + throw error; + })); - result = await pRetry(task, this.retryConfig); yield result; } } @@ -605,6 +638,62 @@ export class WebClient extends EventEmitter { }, }; + // TODO: better input types - remove any + private async makeRequest(url: string, body: any, headers: any = {}): Promise { + const task = () => this.requestQueue.add(async () => { + this.logger.debug('will perform http request'); + try { + const requestTime = Date.now(); + const response = await this.axios.post(url, body, Object.assign({ + headers, + }, this.tlsConfig)); + response.request[requestTimePropName] = requestTime; + this.logger.debug('http response received'); + + if (response.status === 429) { + const retrySec = parseRetryHeaders(response); + if (retrySec !== undefined) { + this.emit('rate_limited', retrySec); + if (this.rejectRateLimitedCalls) { + throw new pRetry.AbortError(rateLimitedErrorWithDelay(retrySec)); + } + this.logger.info(`API Call failed due to rate limiting. Will retry in ${retrySec} seconds.`); + // pause the request queue and then delay the rejection by the amount of time in the retry header + this.requestQueue.pause(); + // NOTE: if there was a way to introspect the current RetryOperation and know what the next timeout + // would be, then we could subtract that time from the following delay, knowing that it the next + // attempt still wouldn't occur until after the rate-limit header has specified. an even better + // solution would be to subtract the time from only the timeout of this next attempt of the + // RetryOperation. this would result in the staying paused for the entire duration specified in the + // header, yet this operation not having to pay the timeout cost in addition to that. + await delay(retrySec * 1000); + // resume the request queue and throw a non-abort error to signal a retry + this.requestQueue.start(); + throw Error('A rate limit was exceeded.'); + } else { + // TODO: turn this into some CodedError + throw new pRetry.AbortError(new Error('Retry header did not contain a valid timeout.')); + } + } + + // Slack's Web API doesn't use meaningful status codes besides 429 and 200 + if (response.status !== 200) { + throw httpErrorFromResponse(response); + } + + return response; + } catch (error) { + this.logger.debug('http request failed'); + if (error.request) { + throw requestErrorWithOriginal(error); + } + throw error; + } + }); + + return pRetry(task, this.retryConfig); + } + /** * Transforms options (a simple key-value object) into an acceptable value for a body. This can be either * a string, used when posting with a content-type of url-encoded. Or, it can be a readable stream, used @@ -706,6 +795,57 @@ export class WebClient extends EventEmitter { return data; } + + /** + * Determine if this client is in automatic token-refreshing mode + */ + private get shouldAutomaticallyRefreshToken(): boolean { + return (this.clientId !== undefined && this.clientSecret !== undefined && this.refreshToken !== undefined); + } + + /** + * Perform a token refresh. Before calling this method, this.shouldAutomaticallyRefreshToken should be checked. + * + * This method avoids using `apiCall()` because that could infinitely recurse when that method determines that the + * access token is already expired. + */ + private async performTokenRefresh(): Promise { + let refreshResponse: AxiosResponse | undefined; + + try { + // TODO: if we change isTokenRefreshing to a promise, we could await it here. + this.isTokenRefreshing = true; + + refreshResponse = await this.makeRequest('oauth.access', { + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'refresh_token', + refresh_token: this.refreshToken, + }); + + if (!refreshResponse.data.ok) { + throw platformErrorFromResponse(refreshResponse); + } + + } catch (error) { + this.isTokenRefreshing = false; + throw refreshFailedErrorWithOriginal(error); + } + + this.isTokenRefreshing = false; + this.accessTokenLastRefreshedAt = Date.now(); + this._accessToken = refreshResponse.data.access_token; + this.accessTokenExpiresAt = Date.now() + (refreshResponse.data.expires_in * 1000); + + const tokenRefreshedEvent: TokenRefreshedEvent = { + access_token: refreshResponse.data.access_token, + expires_in: refreshResponse.data.expires_in, + team_id: refreshResponse.data.team_id, + enterprise_id: refreshResponse.data.enterprise_id, + }; + + this.emit('token_refreshed', tokenRefreshedEvent); + } } export default WebClient; @@ -724,6 +864,9 @@ export interface WebClientOptions { tls?: TLSOptions; pageSize?: number; rejectRateLimitedCalls?: boolean; + clientId?: string; + clientSecret?: string; + refreshToken?: string; } export interface WebAPICallOptions { @@ -745,8 +888,8 @@ export interface WebAPIResultCallback { (error: WebAPICallError, result: WebAPICallResult): void; } -export type WebAPICallError = - WebAPIPlatformError | WebAPIRequestError | WebAPIReadError | WebAPIHTTPError | WebAPIRateLimitedError; +export type WebAPICallError = WebAPIPlatformError | WebAPIRequestError | WebAPIReadError | WebAPIHTTPError | + WebAPIRateLimitedError | WebAPIRefreshFailedError; export interface WebAPIPlatformError extends CodedError { code: ErrorCode.PlatformError; @@ -779,11 +922,24 @@ export interface WebAPIRateLimitedError extends CodedError { retryAfter: number; } +export interface WebAPIRefreshFailedError extends CodedError { + code: ErrorCode.RefreshFailedError; + original: Error; +} + +export interface TokenRefreshedEvent { + access_token: string; + expires_in: number; + team_id: string; + enterprise_id?: string; +} + /* * Helpers */ const defaultFilename = 'Untitled'; +const requestTimePropName = 'slack_webclient_request_time'; /** * Detects whether an object is an http.Agent @@ -831,7 +987,7 @@ function requestErrorWithOriginal(original: Error): WebAPIRequestError { /** * A factory to create WebAPIHTTPError objects - * @param original - original error + * @param response - original error */ function httpErrorFromResponse(response: AxiosResponse): WebAPIHTTPError { const error = errorWithCode( @@ -846,6 +1002,32 @@ function httpErrorFromResponse(response: AxiosResponse): WebAPIHTTPError { return (error as WebAPIHTTPError); } +/** + * A factory to create WebAPIPlatformError objects + * @param result - Web API call result + */ +function platformErrorFromResult(result: WebAPICallResult & { error: string; }): WebAPIPlatformError { + const error = errorWithCode( + new Error(`An API error occurred: ${result.error}`), + ErrorCode.PlatformError, + ) as Partial; + error.data = result; + return (error as WebAPIPlatformError); +} + +/** + * A factory to create WebAPIPlatformError objects + * @param response - Axios response + */ +function platformErrorFromResponse(response: AxiosResponse & { data: { error: string; };}): WebAPIPlatformError { + const error = errorWithCode( + new Error(`An API error occurred: ${response.data.error}`), + ErrorCode.PlatformError, + ) as Partial; + error.data = response.data; + return (error as WebAPIPlatformError); +} + /** * A factory to create WebAPIRateLimitedError objects * @param retrySec - Number of seconds that the request can be retried in @@ -859,6 +1041,19 @@ function rateLimitedErrorWithDelay(retrySec: number): WebAPIRateLimitedError { return (error as WebAPIRateLimitedError); } +/** + * A factory to create WebAPIRefreshFailedError objects + * @param original - Original error + */ +function refreshFailedErrorWithOriginal(original: WebAPICallError): WebAPIRefreshFailedError { + const error = errorWithCode( + new Error(`A token refresh error occurred: ${original.message}`), + ErrorCode.RefreshFailedError, + ) as Partial; + error.original = original; + return (error as WebAPIRefreshFailedError); +} + enum PaginationType { Cursor = 'Cursor', Timeline = 'Timeline', diff --git a/src/errors.ts b/src/errors.ts index d8970ef61..b0efffc6a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,6 +20,7 @@ export enum ErrorCode { HTTPError = 'slackclient_http_error', // Corresponds to WebAPIHTTPError PlatformError = 'slackclient_platform_error', // Corresponds to WebAPIPlatformError RateLimitedError = 'slackclient_rate_limited_error', // Corresponds to WebAPIRateLimitedError + RefreshFailedError = 'slackclient_refresh_failed_error', // Corresponds to WebAPIRefreshFailedError // RTMClient RTMSendWhileDisconnectedError = 'slackclient_rtmclient_send_while_disconnected_error', diff --git a/src/index.ts b/src/index.ts index b48328f59..79b701b98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,8 +29,11 @@ export { WebAPIReadError, // NOTE: this is no longer used, but might once again be used if a more specific means to detect it // becomes evident WebAPIHTTPError, + WebAPIRateLimitedError, + WebAPIRefreshFailedError, WebAPICallError, WebAPIResultCallback, + TokenRefreshedEvent, } from './WebClient'; export * from './methods'; diff --git a/src/methods.ts b/src/methods.ts index 5493c3576..bcbc5ccfc 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -612,8 +612,10 @@ export type MPIMRepliesArguments = TokenOverridable & { export type OAuthAccessArguments = { client_id: string; client_secret: string; - code: string; redirect_uri?: string; + grant_type?: 'authorization_code' | 'refresh_token'; + code?: string; // only when grant_type = 'authorization_code' + refresh_token?: string; // only when grant_type = 'refresh_token' }; export type OAuthTokenArguments = { client_id: string;