Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions lib/actions/slack/slack.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hub.ActionResponse>;
/**
* 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<Hub.ActionForm>;
loginForm(request: Hub.ActionRequest, form?: Hub.ActionForm): Promise<Hub.ActionForm>;
/**
* Checks if the OAuth connection is valid.
* Opportunistically decrypts the state and returns whether the connection holds.
*/
oauthCheck(request: Hub.ActionRequest): Promise<Hub.ActionForm>;
authTest(clients: WebClient[]): Promise<any[]>;
/**
* 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;
}
83 changes: 79 additions & 4 deletions lib/actions/slack/slack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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`);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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()) {
Expand All @@ -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 });
Expand All @@ -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) {
Expand All @@ -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());
11 changes: 10 additions & 1 deletion lib/actions/slack/slack_client_manager.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 11 additions & 2 deletions lib/actions/slack/slack_client_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
}
Expand Down
92 changes: 87 additions & 5 deletions src/actions/slack/slack.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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})
Expand All @@ -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
}

Expand All @@ -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") {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we decrypted the state then checked against request.params.state_json isn't that going to be encrypted?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we check if it matches the original (unencrypted case) then we know to migrate them to use encryption.

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<string | undefined> {
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<string> {
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())
Loading
Loading