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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,9 @@ typings/
.DS_Store

# ActionHub specifics
status.json
status.json

# Internal Tooling
.agent/
.gemini/
tasks/
78 changes: 63 additions & 15 deletions lib/actions/airtable/airtable.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,25 @@ class AirtableAction extends Hub.OAuthAction {
let accessToken;
let tokens;
if (request.params.state_json) {
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
accessToken = tokens.access_token;
const encrypted = await this.oauthMaybeEncryptTokens(new airtable_tokens_1.AirtableTokens(tokens.refresh_token, accessToken, tokens.redirectUri), request.webhookId);
state.data = typeof encrypted === "string" ? encrypted : JSON.stringify(encrypted);
const parsedState = JSON.parse(request.params.state_json);
if (parsedState.cid && parsedState.payload) {
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
accessToken = tokens.access_token;
const encrypted = await this.oauthMaybeEncryptTokens(new airtable_tokens_1.AirtableTokens(tokens.refresh_token, accessToken, tokens.redirectUri), request.webhookId);
state.data = typeof encrypted === "string" ? encrypted : JSON.stringify(encrypted);
Comment on lines +57 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The call to AirtableTokens.fromJson(stateJson) is unsafe because oauthExtractTokensFromStateJson returns null if decryption fails or the JSON is malformed. This will cause a crash when attempting to access properties on null. You should ensure stateJson is truthy before proceeding.

Suggested change
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
accessToken = tokens.access_token;
const encrypted = await this.oauthMaybeEncryptTokens(new airtable_tokens_1.AirtableTokens(tokens.refresh_token, accessToken, tokens.redirectUri), request.webhookId);
state.data = typeof encrypted === "string" ? encrypted : JSON.stringify(encrypted);
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
if (stateJson) {
tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
accessToken = tokens.access_token;
const encrypted = await this.oauthMaybeEncryptTokens(new airtable_tokens_1.AirtableTokens(tokens.refresh_token, accessToken, tokens.redirectUri), request.webhookId);
state.data = typeof encrypted === "string" ? encrypted : JSON.stringify(encrypted);
}

}
else {
// Keeping the literal old code to ensure no regressions for unencrypted payloads
tokens = airtable_tokens_1.AirtableTokens.fromJson(parsedState);
accessToken = tokens.access_token;
state.data = JSON.stringify({
tokens: {
refresh_token: tokens.refresh_token,
access_token: accessToken,
},
});
}
}
try {
if (!accessToken) {
Expand Down Expand Up @@ -104,14 +118,30 @@ class AirtableAction extends Hub.OAuthAction {
});
}
async form(request) {
// The form function handles the Airtable configuration form.
// It attempts to list the user's bases using the existing tokens in request.params.state_json.
// If listing succeeds, we return the base/table fields directly.
// if listing fails (e.g., token expired), we try to auto-refresh the token.
// If no tokens exist or refresh fails, it catches the error (in the outer catch block)
// and generates an OAuth link with a PKCE code_verifier to allow the user to re-authenticate.
const form = new Hub.ActionForm();
try {
let accessToken;
let tokens;
let isEncrypted = false;
if (request.params.state_json) {
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
accessToken = tokens.access_token;
const parsedState = JSON.parse(request.params.state_json);
if (parsedState.cid && parsedState.payload) {
isEncrypted = true;
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
accessToken = tokens.access_token;
Comment on lines +136 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Similar to the issue in the execute method, AirtableTokens.fromJson(stateJson) will throw an error if stateJson is null. A check for a truthy stateJson is required here as well.

Suggested change
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
accessToken = tokens.access_token;
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
if (stateJson) {
tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
accessToken = tokens.access_token;
}

}
else {
// Keeping the literal old code to ensure no regressions for unencrypted payloads
tokens = airtable_tokens_1.AirtableTokens.fromJson(parsedState);
accessToken = tokens.access_token;
}
}
try {
if (!accessToken) {
Expand All @@ -120,12 +150,16 @@ class AirtableAction extends Hub.OAuthAction {
await this.checkBaseList(accessToken);
if (form.state === undefined) {
form.state = new hub_1.ActionState();
if (tokens) {
if (isEncrypted && tokens) {
const encrypted = await this.oauthMaybeEncryptTokens(tokens, request.webhookId);
const encryptedStr = typeof encrypted === "string" ? encrypted : JSON.stringify(encrypted);
request.params.state_json = encryptedStr;
form.state.data = encryptedStr;
}
else {
// Keeping the literal old code to ensure no regressions for unencrypted payloads
form.state.data = request.params.state_json;
}
}
}
catch (_a) {
Expand Down Expand Up @@ -160,7 +194,8 @@ class AirtableAction extends Hub.OAuthAction {
}];
}
catch (e) {
// prevents others from impersonating you
// If no valid tokens exist (or refresh fail), we generate an OAuth link fallback.
// We create a code_verifier (PKCE) and encrypt it in the state payload to secure the exchange.
const codeVerifier = crypto.randomBytes(96).toString("base64url"); // 128 characters
const actionCrypto = new Hub.ActionCrypto();
const jsonString = JSON.stringify({ stateurl: request.params.state_url, verifier: codeVerifier });
Expand All @@ -177,16 +212,26 @@ class AirtableAction extends Hub.OAuthAction {
}
return form;
}
// oauthCheck determines if the user is authenticated by verifying that request.params.state_json
// contains valid (encrypted or unencrypted) token state.
async oauthCheck(request) {
if (request.params.state_json) {
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
if (stateJson) {
return true;
const parsedState = JSON.parse(request.params.state_json);
if (parsedState.cid && parsedState.payload) {
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
return !!stateJson;
}
else {
// Keeping the literal old code to ensure no regressions for unencrypted payloads
const tokens = airtable_tokens_1.AirtableTokens.fromJson(parsedState);
return !!tokens.access_token;
}
Comment on lines +219 to 228

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The JSON.parse call on request.params.state_json is unsafe as it is not wrapped in a try...catch block, which could lead to unhandled exceptions if the input is malformed. Furthermore, the logic can be significantly simplified by relying on oauthExtractTokensFromStateJson, which already handles both encrypted and unencrypted states safely and returns the parsed object.

Suggested change
const parsedState = JSON.parse(request.params.state_json);
if (parsedState.cid && parsedState.payload) {
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
return !!stateJson;
}
else {
// Keeping the literal old code to ensure no regressions for unencrypted payloads
const tokens = airtable_tokens_1.AirtableTokens.fromJson(parsedState);
return !!tokens.access_token;
}
const stateJson = await this.oauthExtractTokensFromStateJson(request.params.state_json, request.webhookId);
if (stateJson) {
const tokens = airtable_tokens_1.AirtableTokens.fromJson(stateJson);
return !!tokens.access_token;
}

}
return false;
}
async oauthFetchInfo(urlParams, redirectUri) {
// oauthFetchInfo exchanges the authorization code for access and refresh tokens from Airtable.
// We decrypt the state to retrieve the stateurl and code_verifier.
const actionCrypto = new Hub.ActionCrypto();
const plaintext = await actionCrypto.decrypt(urlParams.state).catch((err) => {
winston.error("Encryption not correctly configured" + err);
Expand Down Expand Up @@ -214,12 +259,14 @@ class AirtableAction extends Hub.OAuthAction {
// Pass back context to Looker
if (response.status === 200) {
const data = response.data;
// Successful token exchange. Return tokens to Looker via the stateurl callback.
const tokenPayload = new airtable_tokens_1.AirtableTokens(data.refresh_token, data.access_token, redirectUri);
const encrypted = await this.oauthMaybeEncryptTokens(tokenPayload, undefined);
// In this case we expect this function to return an encrypted token because AirTable is "enabled" for encryption.
const payloadWithEncryptedToken = await this.oauthMaybeEncryptTokens(tokenPayload, undefined);
await gaxios.request({
url: payload.stateurl,
method: "POST",
body: encrypted,
data: payloadWithEncryptedToken,
}).catch((_err) => { winston.error(_err.toString()); });
}
else {
Expand All @@ -228,6 +275,7 @@ class AirtableAction extends Hub.OAuthAction {
}
}
async oauthUrl(redirectUri, encryptedState) {
// oauthUrl constructs the authorization URL to redirect the user to Airtable's auth page.
const clientId = process.env.AIRTABLE_CLIENT_ID ? process.env.AIRTABLE_CLIENT_ID : "must exist";
const actionCrypto = new Hub.ActionCrypto();
const plaintext = await actionCrypto.decrypt(encryptedState).catch((err) => {
Expand Down
7 changes: 7 additions & 0 deletions lib/actions/airtable/airtable_tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.AirtableTokens = void 0;
const hub_1 = require("../../hub");
// AirtableTokens holds the access and refresh tokens for Airtable OAuth.
// It extends TokenPayload to integrate with the Action Hub's OAuth framework.
class AirtableTokens extends hub_1.TokenPayload {
// fromJson parses token payloads from two possible JSON shapes:
// 1. A nested shape (`{ tokens: { access_token, refresh_token }, redirect }`) used by newer ActionHub serialization.
// 2. A legacy flat shape (`{ access_token, refresh_token, redirectUri }`) used by older states.
static fromJson(json) {
if (json.tokens) {
return new AirtableTokens(json.tokens.refresh_token, json.tokens.access_token, json.redirect);
Expand All @@ -15,6 +20,8 @@ class AirtableTokens extends hub_1.TokenPayload {
this.access_token = accessToken;
this.redirectUri = redirectUri;
}
// asJson serializes the tokens into a standard JSON shape that ActionHub expects.
// It matches the shape used by newer ActionHub serialization for encrypted payloads.
asJson() {
return {
tokens: {
Expand Down
5 changes: 5 additions & 0 deletions lib/actions/dropbox/dropbox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export declare class DropboxAction extends Hub.OAuthAction {
}, redirectUri: string): Promise<void>;
oauthCheck(request: Hub.ActionRequest): Promise<boolean>;
dropboxFilename(request: Hub.ActionRequest): string | undefined;
/**
* Exchanges the authorization code for an access token with Dropbox.
* Parameters are sent in the request body as application/x-www-form-urlencoded
* to comply with RFC 6749 and avoid leaking secrets in URL logs (b/426567813).
*/
protected getAccessTokenFromCode(stateJson: any): Promise<any>;
protected dropboxClientFromRequest(request: Hub.ActionRequest, token: string): Promise<Dropbox>;
}
26 changes: 21 additions & 5 deletions lib/actions/dropbox/dropbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.DropboxAction = void 0;
const dropbox_1 = require("dropbox");
const gaxios = require("gaxios");
const querystring = require("querystring");
const https = require("request-promise-native");
const url_1 = require("url");
Expand Down Expand Up @@ -173,6 +174,7 @@ class DropboxAction extends Hub.OAuthAction {
await https.post({
url: payload.stateurl,
body: encrypted,
json: true,
}).catch((_err) => { winston.error(_err.toString()); });
}
// oauthCheck verifies if the Action Hub has a valid state for rendering or running.
Expand All @@ -196,23 +198,37 @@ class DropboxAction extends Hub.OAuthAction {
return request.formParams.filename;
}
}
/**
* Exchanges the authorization code for an access token with Dropbox.
* Parameters are sent in the request body as application/x-www-form-urlencoded
* to comply with RFC 6749 and avoid leaking secrets in URL logs (b/426567813).
*/
async getAccessTokenFromCode(stateJson) {
const url = new url_1.URL("https://api.dropboxapi.com/oauth2/token");
const url = "https://api.dropboxapi.com/oauth2/token";
if (stateJson.code && stateJson.redirect) {
url.search = querystring.stringify({
const data = {
grant_type: "authorization_code",
code: stateJson.code,
client_id: process.env.DROPBOX_ACTION_APP_KEY,
client_secret: process.env.DROPBOX_ACTION_APP_SECRET,
redirect_uri: stateJson.redirect,
};
const response = await gaxios.request({
method: "POST",
url,
data: querystring.stringify(data),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}).catch((err) => {
winston.error(`OAuth token exchange failed: ${err.message}`);
throw err;
});
return response.data.access_token;
}
else {
throw "state_json does not contain correct members";
}
const response = await https.post(url.toString(), { json: true })
.catch((_err) => { winston.error("Error requesting access_token"); });
return response.access_token;
}
// dropboxClientFromRequest initializes a Dropbox client instance.
// If token is defined, it uses it directly. Otherwise, it attempts to parse state_json to find an access_token.
Expand Down
12 changes: 12 additions & 0 deletions lib/hub/action_request.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ class ActionRequest {
if (json.data) {
request.params = json.data;
}
if (json.state_url) {
if (request.params === undefined) {
request.params = {};
}
request.params.state_url = json.state_url;
}
if (json.state_redir_url) {
if (request.params === undefined) {
request.params = {};
}
request.params.state_redir_url = json.state_redir_url;
}
if (json.form_params) {
request.formParams = json.form_params;
}
Expand Down
13 changes: 12 additions & 1 deletion lib/hub/oauth_action.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,19 @@ export declare abstract class OAuthAction extends Action {
[key: string]: string;
}, redirectUri: string): Promise<void>;
asJson(router: RouteBuilder, request: ActionRequest): any;
/**
* Extracts token state from a JSON string.
* - If parsing fails, returns null.
* - If encryption markers are present, attempts to decrypt.
* - Otherwise, returns the unencrypted JSON object.
*/
oauthExtractTokensFromStateJson(stateJson: string, requestWebhookId: string | undefined): Promise<any>;
oauthMaybeEncryptTokens(tokenPayload: any, requestWebhookId: string | undefined): Promise<EncryptedPayload | string>;
/**
* Conditionally encrypts token payloads based on the per-action environment config.
* - If `ENCRYPT_PAYLOAD_<ACTION_NAME>` is "true", returns an EncryptedPayload.
* - Otherwise, returns the raw JSON object so standard libraries like gaxios can set the correct Content-Type header.
*/
oauthMaybeEncryptTokens(tokenPayload: any, requestWebhookId: string | undefined): Promise<EncryptedPayload | any>;
oauthDecryptTokens(encryptedPayload: EncryptedPayload, requestWebhookId: string | undefined): Promise<Record<string, any>>;
}
export declare function isOauthAction(action: Action): boolean;
57 changes: 29 additions & 28 deletions lib/hub/oauth_action.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ class OAuthAction extends action_1.Action {
json.uses_oauth = true;
return json;
}
/**
* Extracts token state from a JSON string.
* - If parsing fails, returns null.
* - If encryption markers are present, attempts to decrypt.
* - Otherwise, returns the unencrypted JSON object.
*/
async oauthExtractTokensFromStateJson(stateJson, requestWebhookId) {
let state;
try {
Expand All @@ -21,44 +27,39 @@ class OAuthAction extends action_1.Action {
winston.error(`Failed to parse state_json`, { webhookId: requestWebhookId, action: this.name });
return null;
}
if (state.cid && state.payload) {
winston.info("Extracting encrypted state_json", { webhookId: requestWebhookId, action: this.name });
const encryptedPayload = new encrypted_payload_1.EncryptedPayload(state.cid, state.payload);
try {
const tokenPayload = await this.oauthDecryptTokens(encryptedPayload, requestWebhookId);
return tokenPayload;
}
catch (e) {
winston.error(`Failed to decrypt or parse encrypted payload: ${e.message}`, { webhookId: requestWebhookId, action: this.name });
return null;
}
}
else {
if (!state.cid || !state.payload) {
winston.info("Extracting unencrypted state_json", { webhookId: requestWebhookId, action: this.name });
return state;
}
winston.info("Extracting encrypted state_json", { webhookId: requestWebhookId, action: this.name });
const encryptedPayload = new encrypted_payload_1.EncryptedPayload(state.cid, state.payload);
try {
return await this.oauthDecryptTokens(encryptedPayload, requestWebhookId);
}
catch (e) {
winston.error(`Failed to decrypt or parse encrypted payload: ${e.message}`, { webhookId: requestWebhookId, action: this.name });
return null;
}
}
/**
* Conditionally encrypts token payloads based on the per-action environment config.
* - If `ENCRYPT_PAYLOAD_<ACTION_NAME>` is "true", returns an EncryptedPayload.
* - Otherwise, returns the raw JSON object so standard libraries like gaxios can set the correct Content-Type header.
*/
async oauthMaybeEncryptTokens(tokenPayload, requestWebhookId) {
// Generate the per-action environment variable name
// e.g. "salesforce_campaigns" -> "ENCRYPT_PAYLOAD_SALESFORCE_CAMPAIGNS"
const envVarName = `ENCRYPT_PAYLOAD_${this.name.toUpperCase()}`;
const perActionEncryptionValue = process.env[envVarName];
// Check per-action variable. Default to false if not set.
// We explicitly do NOT fallback to ENCRYPT_PAYLOAD as that is reserved for Google Drive.
const shouldEncrypt = perActionEncryptionValue === "true";
if (shouldEncrypt) {
const encrypted = await OAuthAction.actionCrypto.encrypt(JSON.stringify(tokenPayload)).catch((err) => {
winston.error("Encryption not correctly configured", { webhookId: requestWebhookId, action: this.name });
throw err;
});
const payload = new encrypted_payload_1.EncryptedPayload(encrypted_payload_1.EncryptedPayload.currentCipherId, encrypted);
return payload;
}
else {
return JSON.stringify(tokenPayload);
if (perActionEncryptionValue !== "true") {
return tokenPayload;
}
const encrypted = await OAuthAction.actionCrypto.encrypt(JSON.stringify(tokenPayload)).catch((err) => {
winston.error("Encryption not correctly configured", { webhookId: requestWebhookId, action: this.name });
throw err;
});
return new encrypted_payload_1.EncryptedPayload(encrypted_payload_1.EncryptedPayload.currentCipherId, encrypted);
}
async oauthDecryptTokens(encryptedPayload, requestWebhookId) {
// This method decrypts the payload and validates the JSON shape.
const jsonPayload = await OAuthAction.actionCrypto.decrypt(encryptedPayload.payload).catch((err) => {
winston.error("Failed to decrypt state_json", { webhookId: requestWebhookId, action: this.name });
throw err;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "looker-action-hub",
"version": "1.5.31",
"version": "1.5.33",
"description": "",
"main": "lib/index",
"scripts": {
Expand Down
Loading
Loading