diff --git a/lib/actions/slack/slack.d.ts b/lib/actions/slack/slack.d.ts index 3aec530e..903b84e3 100644 --- a/lib/actions/slack/slack.d.ts +++ b/lib/actions/slack/slack.d.ts @@ -18,9 +18,38 @@ export declare class SlackAction extends Hub.DelegateOAuthAction { minimumSupportedLookerVersion: string; usesStreaming: boolean; executeInOwnProcess: boolean; + private readonly crypto; + /** + * Executes the Slack action. + * Decrypts state_json if it was previously encrypted and passes it to the client manager. + * If execution succeeds and the feature flag is on, it encrypts the state before returning. + */ execute(request: Hub.ActionRequest): Promise; + /** + * Retrieves the form fields for the action. + * Decrypts state_json before creating clients to fetch available workspaces. + * If the response state needs to be maintained, it encrypts it before sending it back to Looker. + */ form(request: Hub.ActionRequest): Promise; loginForm(request: Hub.ActionRequest, form?: Hub.ActionForm): Promise; + /** + * Checks if the OAuth connection is valid. + * Opportunistically decrypts the state and returns whether the connection holds. + */ oauthCheck(request: Hub.ActionRequest): Promise; authTest(clients: WebClient[]): Promise; + /** + * Re-encrypts the state_json if it was decrypted successfully and the feature flag is on. + */ + private updateStateIfNeeded; + /** + * Decrypts the state_json parsing it as plain text if decryption fails. + * This ensures backward compatibility with older, unencrypted states. + */ + private decryptStateIfNeeded; + /** + * Encrypts the state_json string if the feature flag is enabled. + * Returns the encrypted string or the original state if encryption fails or is disabled. + */ + private encryptStateJson; } diff --git a/lib/actions/slack/slack.js b/lib/actions/slack/slack.js index 3bbc4b90..49acba7d 100644 --- a/lib/actions/slack/slack.js +++ b/lib/actions/slack/slack.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.SlackAction = void 0; const winston = require("winston"); +const aes_transit_crypto_1 = require("../../crypto/aes_transit_crypto"); const http_errors_1 = require("../../error_types/http_errors"); const Hub = require("../../hub"); const action_response_1 = require("../../hub/action_response"); @@ -30,10 +31,17 @@ class SlackAction extends Hub.DelegateOAuthAction { this.minimumSupportedLookerVersion = "6.23.0"; this.usesStreaming = true; this.executeInOwnProcess = true; + this.crypto = new aes_transit_crypto_1.AESTransitCrypto(); } + /** + * Executes the Slack action. + * Decrypts state_json if it was previously encrypted and passes it to the client manager. + * If execution succeeds and the feature flag is on, it encrypts the state before returning. + */ async execute(request) { const resp = new Hub.ActionResponse(); - const clientManager = new slack_client_manager_1.SlackClientManager(request, true); + const decryptedState = await this.decryptStateIfNeeded(request); + const clientManager = new slack_client_manager_1.SlackClientManager(request, true, decryptedState); const selectedClient = clientManager.getSelectedClient(); if (!selectedClient) { const error = (0, action_response_1.errorWith)(http_errors_1.HTTP_ERROR.bad_request, `${LOG_PREFIX} Missing client`); @@ -45,11 +53,19 @@ class SlackAction extends Hub.DelegateOAuthAction { return resp; } else { - return await (0, utils_1.handleExecute)(request, selectedClient); + const executedResponse = await (0, utils_1.handleExecute)(request, selectedClient); + await this.updateStateIfNeeded(executedResponse, decryptedState, request.params.state_json, request.webhookId); + return executedResponse; } } + /** + * Retrieves the form fields for the action. + * Decrypts state_json before creating clients to fetch available workspaces. + * If the response state needs to be maintained, it encrypts it before sending it back to Looker. + */ async form(request) { - const clientManager = new slack_client_manager_1.SlackClientManager(request, false); + const decryptedState = await this.decryptStateIfNeeded(request); + const clientManager = new slack_client_manager_1.SlackClientManager(request, false, decryptedState); if (!clientManager.hasAnyClients()) { return this.loginForm(request); } @@ -89,6 +105,7 @@ class SlackAction extends Hub.DelegateOAuthAction { winston.error(`${LOG_PREFIX} Displaying Form Fields: ${e.message}`, { webhookId: request.webhookId }); return this.loginForm(request, form); } + await this.updateStateIfNeeded(form, decryptedState, request.params.state_json, request.webhookId); return form; } async loginForm(request, form = new Hub.ActionForm()) { @@ -109,9 +126,14 @@ class SlackAction extends Hub.DelegateOAuthAction { } return form; } + /** + * Checks if the OAuth connection is valid. + * Opportunistically decrypts the state and returns whether the connection holds. + */ async oauthCheck(request) { const form = new Hub.ActionForm(); - const clientManager = new slack_client_manager_1.SlackClientManager(request); + const decryptedState = await this.decryptStateIfNeeded(request); + const clientManager = new slack_client_manager_1.SlackClientManager(request, false, decryptedState); if (!clientManager.hasAnyClients()) { form.error = AUTH_MESSAGE; winston.error(`${LOG_PREFIX} ${AUTH_MESSAGE}`, { webhookId: request.webhookId }); @@ -134,6 +156,7 @@ class SlackAction extends Hub.DelegateOAuthAction { form.error = utils_1.displayError[e.message] || e; winston.error(`${LOG_PREFIX} ${form.error}`, { webhookId: request.webhookId }); } + await this.updateStateIfNeeded(form, decryptedState, request.params.state_json, request.webhookId); return form; } async authTest(clients) { @@ -147,6 +170,58 @@ class SlackAction extends Hub.DelegateOAuthAction { } return result; } + /** + * Re-encrypts the state_json if it was decrypted successfully and the feature flag is on. + */ + async updateStateIfNeeded(response, decryptedState, originalState, webhookId = undefined) { + if (decryptedState && decryptedState === originalState && process.env.ENCRYPT_PAYLOAD_SLACK_APP === "true") { + response.state = new Hub.ActionState(); + response.state.data = await this.encryptStateJson(decryptedState, webhookId); + } + } + /** + * Decrypts the state_json parsing it as plain text if decryption fails. + * This ensures backward compatibility with older, unencrypted states. + */ + async decryptStateIfNeeded(request) { + if (!request.params.state_json) { + return undefined; + } + try { + const parsed = JSON.parse(request.params.state_json); + if (parsed.cid && parsed.payload) { + winston.info("Extracting encrypted state_json", { webhookId: request.webhookId, action: this.name }); + return await this.crypto.decrypt(parsed.payload); + } + } + catch (e) { + winston.info("Extracting unencrypted state_json", { webhookId: request.webhookId, action: this.name }); + return request.params.state_json; + } + winston.info("Extracting unencrypted state_json", { webhookId: request.webhookId, action: this.name }); + return request.params.state_json; + } + /** + * Encrypts the state_json string if the feature flag is enabled. + * Returns the encrypted string or the original state if encryption fails or is disabled. + */ + async encryptStateJson(stateJson, webhookId) { + if (process.env.ENCRYPT_PAYLOAD_SLACK_APP === "true") { + try { + const encrypted = await this.crypto.encrypt(stateJson); + const payload = { + cid: "1", + payload: encrypted, + }; + return JSON.stringify(payload); + } + catch (e) { + winston.error(`${LOG_PREFIX} Encryption failed: ${e.message}`, { webhookId, action: this.name }); + return stateJson; + } + } + return stateJson; + } } exports.SlackAction = SlackAction; Hub.addAction(new SlackAction()); diff --git a/lib/actions/slack/slack_client_manager.d.ts b/lib/actions/slack/slack_client_manager.d.ts index c9f64864..e11dcd88 100644 --- a/lib/actions/slack/slack_client_manager.d.ts +++ b/lib/actions/slack/slack_client_manager.d.ts @@ -7,10 +7,19 @@ export declare const makeSlackClient: (token: string, disableRetries?: boolean) export declare class SlackClientManager { private selectedInstallId; private clients; - constructor(request: Hub.ActionRequest, disableRetries?: boolean); + /** + * Initializes the Slack clients by parsing the stateJson payload. + * Overrides with decryptedStateJson if provided (opportunistic decryption). + */ + constructor(request: Hub.ActionRequest, disableRetries?: boolean, decryptedStateJson?: string); + /** Checks if there are any initialized Slack clients. */ hasAnyClients: () => boolean; + /** Gets all initialized Slack clients as an array. */ getClients: () => WebClient[]; + /** Checks if a specific client is selected. */ hasSelectedClient: () => boolean; + /** Gets the currently selected Slack client or defaults to the first available connection. */ getSelectedClient: () => WebClient | undefined; + /** Gets a specific client by its install ID. */ getClient: (installId: string) => WebClient | undefined; } diff --git a/lib/actions/slack/slack_client_manager.js b/lib/actions/slack/slack_client_manager.js index 63b8273f..6baf2d5b 100644 --- a/lib/actions/slack/slack_client_manager.js +++ b/lib/actions/slack/slack_client_manager.js @@ -19,14 +19,23 @@ const makeSlackClient = (token, disableRetries = false) => { }; exports.makeSlackClient = makeSlackClient; class SlackClientManager { - constructor(request, disableRetries = false) { + /** + * Initializes the Slack clients by parsing the stateJson payload. + * Overrides with decryptedStateJson if provided (opportunistic decryption). + */ + constructor(request, disableRetries = false, decryptedStateJson) { + /** Checks if there are any initialized Slack clients. */ this.hasAnyClients = () => Object.entries(this.clients).length > 0; + /** Gets all initialized Slack clients as an array. */ this.getClients = () => Object.values(this.clients); + /** Checks if a specific client is selected. */ this.hasSelectedClient = () => !!this.selectedInstallId; + /** Gets the currently selected Slack client or defaults to the first available connection. */ this.getSelectedClient = () => this.selectedInstallId ? this.clients[this.selectedInstallId] : undefined; + /** Gets a specific client by its install ID. */ this.getClient = (installId) => this.clients[installId]; - const stateJson = request.params.state_json; + const stateJson = decryptedStateJson || request.params.state_json; if (!stateJson) { this.clients = {}; } diff --git a/src/actions/slack/slack.ts b/src/actions/slack/slack.ts index de28fa80..a3962aeb 100644 --- a/src/actions/slack/slack.ts +++ b/src/actions/slack/slack.ts @@ -1,6 +1,7 @@ import {WebClient} from "@slack/web-api" import {WebAPICallResult} from "@slack/web-api/dist/WebClient" import * as winston from "winston" +import { AESTransitCrypto } from "../../crypto/aes_transit_crypto" import {HTTP_ERROR} from "../../error_types/http_errors" import * as Hub from "../../hub" import {Error, errorWith} from "../../hub/action_response" @@ -37,9 +38,17 @@ export class SlackAction extends Hub.DelegateOAuthAction { usesStreaming = true executeInOwnProcess = true + private readonly crypto = new AESTransitCrypto() + + /** + * Executes the Slack action. + * Decrypts state_json if it was previously encrypted and passes it to the client manager. + * If execution succeeds and the feature flag is on, it encrypts the state before returning. + */ async execute(request: Hub.ActionRequest) { const resp = new Hub.ActionResponse() - const clientManager = new SlackClientManager(request, true) + const decryptedState = await this.decryptStateIfNeeded(request) + const clientManager = new SlackClientManager(request, true, decryptedState) const selectedClient = clientManager.getSelectedClient() if (!selectedClient) { const error: Error = errorWith( @@ -55,12 +64,20 @@ export class SlackAction extends Hub.DelegateOAuthAction { winston.error(`${error.message}`, {error, webhookId: request.webhookId}) return resp } else { - return await handleExecute(request, selectedClient) + const executedResponse = await handleExecute(request, selectedClient) + await this.updateStateIfNeeded(executedResponse, decryptedState, request.params.state_json, request.webhookId) + return executedResponse } } + /** + * Retrieves the form fields for the action. + * Decrypts state_json before creating clients to fetch available workspaces. + * If the response state needs to be maintained, it encrypts it before sending it back to Looker. + */ async form(request: Hub.ActionRequest) { - const clientManager = new SlackClientManager(request, false) + const decryptedState = await this.decryptStateIfNeeded(request) + const clientManager = new SlackClientManager(request, false, decryptedState) if (!clientManager.hasAnyClients()) { return this.loginForm(request) } @@ -107,6 +124,7 @@ export class SlackAction extends Hub.DelegateOAuthAction { return this.loginForm(request, form) } + await this.updateStateIfNeeded(form, decryptedState, request.params.state_json, request.webhookId) return form } @@ -128,10 +146,14 @@ export class SlackAction extends Hub.DelegateOAuthAction { return form } + /** + * Checks if the OAuth connection is valid. + * Opportunistically decrypts the state and returns whether the connection holds. + */ async oauthCheck(request: Hub.ActionRequest) { const form = new Hub.ActionForm() - - const clientManager = new SlackClientManager(request) + const decryptedState = await this.decryptStateIfNeeded(request) + const clientManager = new SlackClientManager(request, false, decryptedState) if (!clientManager.hasAnyClients()) { form.error = AUTH_MESSAGE winston.error(`${LOG_PREFIX} ${AUTH_MESSAGE}`, {webhookId: request.webhookId}) @@ -155,6 +177,7 @@ export class SlackAction extends Hub.DelegateOAuthAction { form.error = displayError[e.message] || e winston.error(`${LOG_PREFIX} ${form.error}`, {webhookId: request.webhookId}) } + await this.updateStateIfNeeded(form, decryptedState, request.params.state_json, request.webhookId) return form } @@ -172,6 +195,65 @@ export class SlackAction extends Hub.DelegateOAuthAction { } return result } + /** + * Re-encrypts the state_json if it was decrypted successfully and the feature flag is on. + */ + private async updateStateIfNeeded( + response: Hub.ActionResponse | Hub.ActionForm, + decryptedState: string | undefined, + originalState: string | undefined, + webhookId: string | undefined = undefined, + ) { + if (decryptedState && decryptedState === originalState && process.env.ENCRYPT_PAYLOAD_SLACK_APP === "true") { + response.state = new Hub.ActionState() + response.state.data = await this.encryptStateJson(decryptedState, webhookId) + } + } + + /** + * Decrypts the state_json parsing it as plain text if decryption fails. + * This ensures backward compatibility with older, unencrypted states. + */ + private async decryptStateIfNeeded(request: Hub.ActionRequest): Promise { + if (!request.params.state_json) { + return undefined + } + + try { + const parsed = JSON.parse(request.params.state_json) + if (parsed.cid && parsed.payload) { + winston.info("Extracting encrypted state_json", { webhookId: request.webhookId, action: this.name }) + return await this.crypto.decrypt(parsed.payload) + } + } catch (e: any) { + winston.info("Extracting unencrypted state_json", { webhookId: request.webhookId, action: this.name }) + return request.params.state_json + } + + winston.info("Extracting unencrypted state_json", { webhookId: request.webhookId, action: this.name }) + return request.params.state_json + } + + /** + * Encrypts the state_json string if the feature flag is enabled. + * Returns the encrypted string or the original state if encryption fails or is disabled. + */ + private async encryptStateJson(stateJson: string, webhookId: string | undefined): Promise { + if (process.env.ENCRYPT_PAYLOAD_SLACK_APP === "true") { + try { + const encrypted = await this.crypto.encrypt(stateJson) + const payload = { + cid: "1", + payload: encrypted, + } + return JSON.stringify(payload) + } catch (e: any) { + winston.error(`${LOG_PREFIX} Encryption failed: ${e.message}`, { webhookId, action: this.name }) + return stateJson + } + } + return stateJson + } } Hub.addAction(new SlackAction()) diff --git a/src/actions/slack/slack_client_manager.ts b/src/actions/slack/slack_client_manager.ts index acf4fc3c..4e451582 100644 --- a/src/actions/slack/slack_client_manager.ts +++ b/src/actions/slack/slack_client_manager.ts @@ -29,8 +29,12 @@ export class SlackClientManager { private selectedInstallId: string | undefined private clients: { [key: string]: WebClient } - constructor(request: Hub.ActionRequest, disableRetries = false) { - const stateJson = request.params.state_json + /** + * Initializes the Slack clients by parsing the stateJson payload. + * Overrides with decryptedStateJson if provided (opportunistic decryption). + */ + constructor(request: Hub.ActionRequest, disableRetries = false, decryptedStateJson?: string) { + const stateJson = decryptedStateJson || request.params.state_json if (!stateJson) { this.clients = {} @@ -72,14 +76,19 @@ export class SlackClientManager { } } + /** Checks if there are any initialized Slack clients. */ hasAnyClients = (): boolean => Object.entries(this.clients).length > 0 + /** Gets all initialized Slack clients as an array. */ getClients = (): WebClient[] => Object.values(this.clients) + /** Checks if a specific client is selected. */ hasSelectedClient = (): boolean => !!this.selectedInstallId + /** Gets the currently selected Slack client or defaults to the first available connection. */ getSelectedClient = (): WebClient | undefined => this.selectedInstallId ? this.clients[this.selectedInstallId] : undefined + /** Gets a specific client by its install ID. */ getClient = (installId: string): WebClient | undefined => this.clients[installId] } diff --git a/src/actions/slack/test_slack.ts b/src/actions/slack/test_slack.ts index d989bb04..6c48be96 100644 --- a/src/actions/slack/test_slack.ts +++ b/src/actions/slack/test_slack.ts @@ -1,5 +1,6 @@ import * as chai from "chai" import * as sinon from "sinon" +import {AESTransitCrypto} from "../../crypto/aes_transit_crypto" import * as Hub from "../../hub" import {SlackAction} from "./slack" @@ -449,4 +450,70 @@ describe(`${action.constructor.name} tests`, () => { }) }) }) + describe("Token Encryption", () => { + let originalCipherMaster: string | undefined + let originalEncryptPayload: string | undefined + + beforeEach(() => { + originalCipherMaster = process.env.CIPHER_MASTER + originalEncryptPayload = process.env.ENCRYPT_PAYLOAD_SLACK_APP + process.env.CIPHER_MASTER = "0000000000000000000000000000000000000000000000000000000000000000" + process.env.ENCRYPT_PAYLOAD_SLACK_APP = "true" + }) + + afterEach(() => { + process.env.CIPHER_MASTER = originalCipherMaster + process.env.ENCRYPT_PAYLOAD_SLACK_APP = originalEncryptPayload + }) + + /** + * Tests that when plain text state is received and encryption is enabled, + * the action returns an ActionState with the encrypted payload. + */ + it("should read unencrypted state and return encrypted state", async () => { + const mockClient = { auth: { test: sinon.stub().resolves({ ok: true, team: "test", team_id: "T123" }) } } + const stubClient = sinon.stub(SlackClientManager, "makeSlackClient").returns(mockClient as any) + + const request = new Hub.ActionRequest() + request.params = { + state_json: "plain-token", + } + + const response = await action.oauthCheck(request) + chai.expect(response.fields.some((f: any) => f.name === "Connected")).to.be.true + chai.expect(response.state).to.be.an.instanceOf(Hub.ActionState) + chai.expect(response.state!.data).to.not.equal(request.params.state_json) + const parsedData = JSON.parse(response.state!.data!) + chai.expect(parsedData.cid).to.equal("1") + chai.expect(parsedData.payload).to.be.a("string") + + stubClient.restore() + }) + + /** + * Tests that when encrypted state is received, the action correctly + * decrypts it before passing it to the Slack client manager. + */ + it("should decrypt encrypted state", async () => { + const crypto = new AESTransitCrypto() + const plainState = "secret-token" + const encState = await crypto.encrypt(plainState) + const payload = JSON.stringify({ cid: "1", payload: encState }) + + const request = new Hub.ActionRequest() + request.params = { + state_json: payload, + } + + const mockClient = { auth: { test: sinon.stub().resolves({ ok: true, team: "test", team_id: "T123" }) } } + const stubClient = sinon.stub(SlackClientManager, "makeSlackClient").callsFake((p) => { + chai.expect(p).to.equal(plainState) + return mockClient as any + }) + + await action.oauthCheck(request) + + stubClient.restore() + }) + }) })