Skip to content

Add incoming webhook custom starter demo - #2

Open
sqrrrl wants to merge 2 commits into
googleworkspace:mainfrom
sqrrrl:main
Open

sqrrrl wants to merge 2 commits into
googleworkspace:mainfrom
sqrrrl:main

Conversation

@sqrrrl

@sqrrrl sqrrrl commented Oct 2, 2026

Copy link
Copy Markdown
Member

No description provided.


### Prerequisites

- [Node.js](https://nodejs.org/) (v20 or higher recommended)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

src/db/index.ts imports { DatabaseSync } from 'node:sqlite', which was introduced in Node.js v22.5.0 and does not exist in Node 20 (running on Node 20 fails immediately with ERR_UNKNOWN_BUILTIN_MODULE: node:sqlite).

Please update this prerequisite to Node.js v22.5.0 or higher (and consider adding "engines": { "node": ">=22.5.0" } to package.json).

Also, package.json runs node --env-file .env and .gitignore has !.env.example, but there is no .env.example in the repo and README.md doesn't document the required environment variables (GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_ADDON_SERVICE_ACCOUNT_EMAIL, GOOGLE_ADDON_CLIENT_ID, COOKIE_SECRET, PUBLIC_BASE_URL). Could we add a .env.example file and an environment setup section here?

Comment on lines +9 to +10
"start": "node --env-file .env dist/server.js",
"dev": "tsx watch --env-file .env src/server.ts",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Node's --env-file .env flag exits with a fatal error (ENOENT: .env not found) if .env does not exist yet. Please include a .env.example template in studio/incoming-webhook-starter/ (or use --env-file-if-exists=.env in Node 22.9+) and regenerate package-lock.json (package-lock.json currently still lists better-sqlite3 and @types/better-sqlite3 in the root package entry even though they were removed from package.json).

Comment on lines +134 to +140
// Fallback to sub claim on caller token
if (!systemPayload.sub) {
throw new StudioAuthError('Missing sub claim in token payload.');
}

return systemPayload.sub;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Security / Auth bug: In production, systemPayload is the verified Bearer token from the Google Add-on Service Account (GOOGLE_ADDON_SERVICE_ACCOUNT_EMAIL), so systemPayload.sub is the service account's subject ID, not the end user's ID.

If authorizationEventObject.userIdToken is missing in production, falling back to systemPayload.sub causes all requests without userIdToken to share the service account's sub as their userId. In production (isProd), we should require userIdFromAddon and throw StudioAuthError if it is missing (only falling back to systemPayload.sub in local/test environments).

Comment on lines +159 to +177
/**
* Backward-compatible helper for verifying user token explicitly.
*/
export async function verifyAddonUserIdToken(
event: RootEventObject
): Promise<string> {
const rawToken =
event.authorizationEventObject?.userIdToken ||
event.authorizationEventObject?.systemIdToken;

if (!rawToken) {
throw new StudioAuthError('Missing user ID token in authorizationEventObject.');
}

if (customTokenVerifier) {
const verified = await customTokenVerifier(rawToken);
return verified.userId;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two issues in verifyAddonUserIdToken:

  1. When userIdToken is absent, line 167 falls back to systemIdToken and verifies it against expectedAudience = process.env.GOOGLE_ADDON_CLIENT_ID (line 178). In production, systemIdToken has the HTTP endpoint URL as its aud claim (not GOOGLE_ADDON_CLIENT_ID), so that fallback will fail audience verification.
  2. /studio/on-manage (src/studio/routes.ts:326-327) calls both verifySystemIdToken(req, event) (which already calls authenticateStudioRequest) and verifyAddonUserIdToken(event). Having /studio/on-manage call const userId = await authenticateStudioRequest(req, event) directly (like the other 4 routes) avoids verifying the tokens twice and eliminates the need for verifySystemIdToken / verifyAddonUserIdToken and the as TokenPayload cast on line 156.

Comment on lines +213 to +214
// eslint-disable-next-line @typescript-eslint/no-explicit-any
code_challenge_method: 'S256' as any,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

google-auth-library exports the CodeChallengeMethod enum, so you can import CodeChallengeMethod from 'google-auth-library' and pass code_challenge_method: CodeChallengeMethod.S256 directly without as any or the eslint-disable comment.

Comment on lines +190 to +197
modifyOperations.push({
replaceSection: buildEndpointSection({
baseUrl,
instanceId,
requireApiKey,
}),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug on partial card update: When a workflow is already active, /studio/on-config (line 127) passes triggerId: activeSubscription?.triggerId so the card displays https://.../webhook/{triggerId}.

However, both /studio/on-toggle-api-key (here) and /studio/on-add-secret (lines 244–248) call buildEndpointSection without triggerId. If a user opens an active workflow and clicks "Add Secret" or toggles the switch, replaceSection overwrites the active /webhook/{triggerId} URL with /webhook/{instanceId}!

Please look up activeSubscription = getActiveSubscriptionByInstanceId(instanceId, userId) in both handlers and pass triggerId: activeSubscription?.triggerId to buildEndpointSection.

Comment on lines +355 to +357
const notifyUri =
triggerCreation.notifyUri ??
`https://workspacestudio.googleapis.com/v1/triggers/${triggerId}:fire`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Defense-in-depth (SSRF / token exfiltration): notifyUri is persisted from the request payload and later called in POST /webhook/:identifier with Authorization: Bearer ${accessToken} (containing the user's workspace.studio.trigger token). Please validate that notifyUri is a valid https:// URL with hostname workspacestudio.googleapis.com (or ending in .googleapis.com) before storing or calling it.

Comment on lines +133 to +139
let matched = false;
for (const sec of storedSecrets) {
if (constantTimeCompare(providedHash, sec.secretHash)) {
matched = true;
break;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: You can simplify this loop using Array.prototype.some():

const matched = storedSecrets.some((sec) =>
  constantTimeCompare(providedHash, sec.secretHash)
);

Comment on lines +207 to +214
let upstreamJson: unknown = null;
if (upstreamText) {
try {
upstreamJson = JSON.parse(upstreamText);
} catch {
// Leave as string if not JSON
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The catch block on line 212 says // Leave as string if not JSON, but leaves upstreamJson as null, causing line 246 (studioResponse: upstreamJson ?? {}) to silently drop non-JSON upstream response bodies on 200 OK. Assign upstreamJson = upstreamText; in the catch block so non-JSON text is preserved.

Comment on lines +305 to +309
await request(app)
.post('/webhook/trig-abc-123')
.set('X-Webhook-Secret', 'whsec_testsecret999')
.send('{"event":"third"}');
expect(refreshCallCount).toBe(1); // Still 1!

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two test hygiene items:

  1. Here on lines 305–309, the third POST /webhook/trig-abc-123 call does not assert the HTTP response status (expect(res.status).toBe(200)). Because refreshCallCount is already 1 before line 305 executes, expect(refreshCallCount).toBe(1) passes vacuously even if the third request fails with a 4xx/5xx error.
  2. In beforeEach (lines 16–31), reset setTokenExchangerForTesting, setTokenRefresherForTesting, and setStudioFetchForTesting so test doubles mutated inside individual tests cannot leak across test cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants