From 11d1aeecf47cec4c253b5973dd98ee97daf9f290 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Wed, 19 May 2021 18:34:09 -0700 Subject: [PATCH 1/3] Add support for token rotation --- packages/oauth/src/index.ts | 135 +++++++++++++++++++++++++++++--- packages/web-api/src/methods.ts | 5 +- 2 files changed, 127 insertions(+), 13 deletions(-) diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 6fc6fe3df..ecccbad34 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -91,7 +91,7 @@ export class InstallProvider { } /** - * Fetches data from the installationStore for non Org Installations. + * Fetches data from the installationStore */ public async authorize(source: InstallationQuery): Promise { try { @@ -129,6 +129,87 @@ export class InstallProvider { authResult.botToken = queryResult.bot.token; authResult.botId = queryResult.bot.id; authResult.botUserId = queryResult.bot.userId; + + // Token Rotation Enabled (Bot Token) + if (queryResult.bot.refreshToken !== undefined) { + authResult.botRefreshToken = queryResult.bot.refreshToken; + authResult.botTokenExpiresAt = queryResult.bot.expiresAt; // utc, seconds + } + } + + // Token Rotation Enabled (User Token) + if (queryResult.user.refreshToken !== undefined) { + 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 !== undefined || authResult.userRefreshToken !== undefined) { + const currentUTCSec = Math.floor(Date.now() / 1000); // seconds + const tokensToRefresh: string[] = []; + let client: WebClient; + + if (authResult.botRefreshToken !== undefined && authResult.botTokenExpiresAt !== undefined) { + const botTokenExpiresIn = authResult.botTokenExpiresAt - currentUTCSec; + if (botTokenExpiresIn <= 7200) { // 2 hours + tokensToRefresh.push(authResult.botRefreshToken); + } + } + + if (authResult.userRefreshToken !== undefined && authResult.userTokenExpiresAt !== undefined) { + const userTokenExpiresIn = authResult.userTokenExpiresAt - currentUTCSec; + if (userTokenExpiresIn <= 7200) { // 2 hours + tokensToRefresh.push(authResult.userRefreshToken); + } + } + + if (tokensToRefresh.length > 0) { + const installationUpdates: any = { ...queryResult }; // TODO :: TS + client = new WebClient(undefined, this.clientOptions); + + // TODO :: Concurrent Calls? + // TODO :: Retry Strategy + for (const refreshToken of tokensToRefresh) { + + const refreshResp = await client.oauth.v2.access({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }) as OAuthV2Response; + + // TODO :: Sort out TS issues + const tokenType: 'bot' | 'user' = refreshResp.token_type!; // TODO :: TS + + if (tokenType === 'bot') { + authResult.botToken = refreshResp.access_token; + authResult.botRefreshToken = refreshResp.refresh_token; + authResult.botTokenExpiresAt = currentUTCSec + refreshResp.expires_in!; // TODO :: TS + } + + if (tokenType === 'user') { + authResult.userToken = refreshResp.access_token; + authResult.userRefreshToken = refreshResp.refresh_token; + authResult.userTokenExpiresAt = currentUTCSec + refreshResp.expires_in!; // TODO :: TS + } + + installationUpdates[tokenType].token = refreshResp.access_token; + installationUpdates[tokenType].refreshToken = refreshResp.refresh_token; + installationUpdates[tokenType].expiresAt = currentUTCSec + refreshResp.expires_in!; // TODO :: TS + + const updatedInstallation = { + ...installationUpdates, + [tokenType]: { ...queryResult[tokenType], ...installationUpdates[tokenType] }, // bot | user + }; + + await this.installationStore.storeInstallation(updatedInstallation); + } + } } return authResult; @@ -227,6 +308,7 @@ export class InstallProvider { // 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({ @@ -269,6 +351,7 @@ export class InstallProvider { resp = v1Resp; installation = v1Installation; } else { + // convert response type from WebApiCallResult to OAuthResponse const v2Resp = await client.oauth.v2.access({ code, @@ -294,9 +377,13 @@ export class InstallProvider { 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) { - // A bot user/scope was requested + const authResult = await runAuthTest(v2Resp.access_token, this.clientOptions); + v2Installation.bot = { scopes: v2Resp.scope.split(','), token: v2Resp.access_token, @@ -305,20 +392,31 @@ export class InstallProvider { }; if (v2Resp.is_enterprise_install) { - // if it is an org enterprise install, add the enterprise url v2Installation.enterpriseUrl = authResult.url; } - } else if (v2Resp.authed_user.access_token !== undefined) { - // Only user scopes were requested + // 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) { + // TODO: confirm if it is possible to do an org enterprise install without a bot user const authResult = await runAuthTest(v2Resp.authed_user.access_token, this.clientOptions); - if (v2Resp.is_enterprise_install) { + + if (v2Resp.is_enterprise_install && v2Installation.enterpriseUrl === undefined) { v2Installation.enterpriseUrl = authResult.url; } - } else { - // TODO: make this a coded error - throw new Error('The response from the authorization URL contained inconsistent information. Please file a bug.'); + + // 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; @@ -333,6 +431,7 @@ export class InstallProvider { configurationUrl: resp.incoming_webhook.configuration_url, }; } + if (installOptions !== undefined && installOptions.metadata !== undefined) { // Pass the metadata in state parameter if exists. // Developers can use the value for additional/custom data associated with the installation. @@ -455,7 +554,7 @@ export interface InstallationStore { installation: Installation, logger?: Logger): Promise; fetchInstallation: - (query: InstallationQuery, logger?: Logger) => Promise>; + (query: InstallationQuery, logger?: Logger) => Promise>; } // using a javascript object as a makeshift database for development @@ -565,12 +664,16 @@ export interface Installation; // of Bolt. export interface AuthorizeResult { botToken?: string; + botRefreshToken?: string; + botTokenExpiresAt?: number; userToken?: string; + userRefreshToken?: string; + userTokenExpiresAt?: number; botId?: string; botUserId?: string; teamId?: string; @@ -702,17 +809,21 @@ function isNotOrgInstall(installation: Installation): installation is Installati } // Response shape from oauth.v2.access - https://api.slack.com/methods/oauth.v2.access#response -interface OAuthV2Response extends WebAPICallResult { +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'; + token_type?: 'bot' | 'user'; 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; diff --git a/packages/web-api/src/methods.ts b/packages/web-api/src/methods.ts index b80107252..cdd5571e6 100644 --- a/packages/web-api/src/methods.ts +++ b/packages/web-api/src/methods.ts @@ -506,6 +506,7 @@ export abstract class Methods extends EventEmitter { access: bindApiCall(this, 'oauth.access'), v2: { access: bindApiCall(this, 'oauth.v2.access'), + exchange: bindApiCall(this, 'oauth.v2.exchange'), }, }; @@ -1596,8 +1597,10 @@ export interface OAuthAccessArguments extends WebAPICallOptions { export interface OAuthV2AccessArguments extends WebAPICallOptions { client_id: string; client_secret: string; - code: string; + code?: string; // code is not required for token rotation redirect_uri?: string; + grant_type?: string; + refresh_token?: string; } /* * `pins.*` From 4c55fbfe5262a2a8995e8b91b344ecd5dbfc30f9 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Wed, 2 Jun 2021 14:47:50 -0700 Subject: [PATCH 2/3] Incorporate PR suggestions --- packages/oauth/src/index.ts | 113 +++++++++++++++++++++----------- packages/web-api/src/methods.ts | 11 +++- 2 files changed, 84 insertions(+), 40 deletions(-) diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index ecccbad34..a9e337efe 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -151,60 +151,37 @@ export class InstallProvider { */ if (authResult.botRefreshToken !== undefined || authResult.userRefreshToken !== undefined) { const currentUTCSec = Math.floor(Date.now() / 1000); // seconds - const tokensToRefresh: string[] = []; - let client: WebClient; - - if (authResult.botRefreshToken !== undefined && authResult.botTokenExpiresAt !== undefined) { - const botTokenExpiresIn = authResult.botTokenExpiresAt - currentUTCSec; - if (botTokenExpiresIn <= 7200) { // 2 hours - tokensToRefresh.push(authResult.botRefreshToken); - } - } - - if (authResult.userRefreshToken !== undefined && authResult.userTokenExpiresAt !== undefined) { - const userTokenExpiresIn = authResult.userTokenExpiresAt - currentUTCSec; - if (userTokenExpiresIn <= 7200) { // 2 hours - tokensToRefresh.push(authResult.userRefreshToken); - } - } + const tokensToRefresh: string[] = detectExpiredOrExpiringTokens(authResult, currentUTCSec); if (tokensToRefresh.length > 0) { const installationUpdates: any = { ...queryResult }; // TODO :: TS - client = new WebClient(undefined, this.clientOptions); + const refreshResponses = await this.refreshExpiringTokens(tokensToRefresh); - // TODO :: Concurrent Calls? - // TODO :: Retry Strategy - for (const refreshToken of tokensToRefresh) { + for (const refreshResp of refreshResponses) { - const refreshResp = await client.oauth.v2.access({ - client_id: this.clientId, - client_secret: this.clientSecret, - grant_type: 'refresh_token', - refresh_token: refreshToken, - }) as OAuthV2Response; - - // TODO :: Sort out TS issues - const tokenType: 'bot' | 'user' = refreshResp.token_type!; // TODO :: TS + 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!; // TODO :: TS + 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!; // TODO :: TS + 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!; // TODO :: TS + installationUpdates[tokenType].expiresAt = currentUTCSec + refreshResp.expires_in; const updatedInstallation = { ...installationUpdates, - [tokenType]: { ...queryResult[tokenType], ...installationUpdates[tokenType] }, // bot | user + [tokenType]: { ...queryResult[tokenType], ...installationUpdates[tokenType] }, }; await this.installationStore.storeInstallation(updatedInstallation); @@ -218,6 +195,26 @@ export class InstallProvider { } } + /** + * 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) => { + return await client.oauth.v2.access({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }).catch(e => e) as OAuthV2ExchangeResponse; + }); + + return Promise.all(refreshPromises); + } + /** * 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. @@ -665,7 +662,7 @@ export interface Installation; export interface AuthorizeResult { botToken?: string; botRefreshToken?: string; - botTokenExpiresAt?: number; + botTokenExpiresAt?: number; // utc, seconds userToken?: string; userRefreshToken?: string; - userTokenExpiresAt?: number; + userTokenExpiresAt?: number; // utc, seconds botId?: string; botUserId?: string; teamId?: string; @@ -808,6 +805,32 @@ function isNotOrgInstall(installation: Installation): installation is Installati 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 !== undefined && authResult.botTokenExpiresAt !== undefined) { + const botTokenExpiresIn = authResult.botTokenExpiresAt - currentUTCSec; + if (botTokenExpiresIn <= EXPIRY_WINDOW) { + tokensToRefresh.push(authResult.botRefreshToken); + } + } + + if (authResult.userRefreshToken !== undefined && authResult.userTokenExpiresAt !== undefined) { + 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; @@ -820,7 +843,7 @@ export interface OAuthV2Response extends WebAPICallResult { expires_in?: number, }; scope?: string; - token_type?: 'bot' | 'user'; + token_type?: 'bot'; access_token?: string; refresh_token?: string; expires_in?: number; @@ -836,6 +859,20 @@ export interface OAuthV2Response extends WebAPICallResult { }; } +export interface OAuthV2ExchangeResponse 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_metadata: {}; // TODO +} + // Response shape from oauth.access - https://api.slack.com/methods/oauth.access#response interface OAuthV1Response extends WebAPICallResult { access_token: string; diff --git a/packages/web-api/src/methods.ts b/packages/web-api/src/methods.ts index cdd5571e6..b89ba9d2d 100644 --- a/packages/web-api/src/methods.ts +++ b/packages/web-api/src/methods.ts @@ -506,7 +506,7 @@ export abstract class Methods extends EventEmitter { access: bindApiCall(this, 'oauth.access'), v2: { access: bindApiCall(this, 'oauth.v2.access'), - exchange: bindApiCall(this, 'oauth.v2.exchange'), + exchange: bindApiCall(this, 'oauth.v2.exchange'), }, }; @@ -1597,11 +1597,18 @@ export interface OAuthAccessArguments extends WebAPICallOptions { export interface OAuthV2AccessArguments extends WebAPICallOptions { client_id: string; client_secret: string; - code?: string; // code is not required for token rotation + code?: string; // not required for token rotation redirect_uri?: string; grant_type?: string; refresh_token?: string; } + +export interface OAuthV2ExchangeArguments extends WebAPICallOptions { + client_id: string; + client_secret: string; + grant_type: string; + refresh_token: string; +} /* * `pins.*` */ From 3ff9daf951d26428bfdc637690fa25e13eb6fbb2 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Fri, 4 Jun 2021 10:50:37 -0700 Subject: [PATCH 3/3] update interface name to better suit purpose --- packages/oauth/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index a9e337efe..a5446099e 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -200,7 +200,7 @@ export class InstallProvider { * * The return value is an Array of Promises made up of the resolution of each token refresh attempt. */ - private async refreshExpiringTokens(tokensToRefresh: string[]): Promise { + private async refreshExpiringTokens(tokensToRefresh: string[]): Promise { const client = new WebClient(undefined, this.clientOptions); const refreshPromises = tokensToRefresh.map(async (refreshToken) => { @@ -209,7 +209,7 @@ export class InstallProvider { client_secret: this.clientSecret, grant_type: 'refresh_token', refresh_token: refreshToken, - }).catch(e => e) as OAuthV2ExchangeResponse; + }).catch(e => e) as OAuthV2TokenRefreshResponse; }); return Promise.all(refreshPromises); @@ -859,7 +859,7 @@ export interface OAuthV2Response extends WebAPICallResult { }; } -export interface OAuthV2ExchangeResponse extends WebAPICallResult { +export interface OAuthV2TokenRefreshResponse extends WebAPICallResult { app_id: string; scope: string; token_type: 'bot' | 'user';