feat: add JWT verification middleware for Google Chat webhook

Verifies Bearer tokens from Google Chat using google-auth-library.
Checks issuer (chat@system.gserviceaccount.com) and audience
(CHAT_PROJECT_NUMBER). Verification is skipped when project number
is not configured, allowing local testing without tokens.
This commit is contained in:
Adam Weidman
2026-02-12 10:56:42 -05:00
parent b85a3bafe5
commit 9d12980baa
4 changed files with 113 additions and 23 deletions
+25 -1
View File
@@ -2255,6 +2255,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2435,6 +2436,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2468,6 +2470,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2836,6 +2839,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2869,6 +2873,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -2921,6 +2926,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4136,6 +4142,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4430,6 +4437,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5422,6 +5430,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8431,6 +8440,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8971,6 +8981,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -10584,6 +10595,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14368,6 +14380,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14378,6 +14391,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16614,6 +16628,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16837,7 +16852,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16845,6 +16861,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -17017,6 +17034,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17224,6 +17242,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17337,6 +17356,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17349,6 +17369,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18053,6 +18074,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18075,6 +18097,7 @@
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"google-auth-library": "^9.11.0",
"tar": "^7.5.2",
"uuid": "^13.0.0",
"winston": "^3.17.0"
@@ -18351,6 +18374,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+1
View File
@@ -29,6 +29,7 @@
"@google-cloud/storage": "^7.16.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"google-auth-library": "^9.11.0",
"fs-extra": "^11.3.0",
"tar": "^7.5.2",
"uuid": "^13.0.0",
+86 -22
View File
@@ -7,46 +7,110 @@
/**
* Express routes for the Google Chat bridge webhook.
* Adds a POST /chat/webhook endpoint to the existing Express app.
* Includes JWT verification for Google Chat requests when configured.
*/
import type { Router, Request, Response } from 'express';
import type { Router, Request, Response, NextFunction } from 'express';
import { Router as createRouter } from 'express';
import { OAuth2Client } from 'google-auth-library';
import type { ChatEvent, ChatBridgeConfig } from './types.js';
import { ChatBridgeHandler } from './handler.js';
import { logger } from '../utils/logger.js';
const CHAT_ISSUER = 'chat@system.gserviceaccount.com';
/**
* Creates middleware that verifies Google Chat JWT tokens.
* When projectNumber is set, requests must include a valid Bearer token
* signed by Google Chat with the correct audience.
* When not set, verification is skipped (for local testing).
*/
function createAuthMiddleware(
projectNumber: string | undefined,
): (req: Request, res: Response, next: NextFunction) => void {
if (!projectNumber) {
logger.warn(
'[ChatBridge] CHAT_PROJECT_NUMBER not set — JWT verification disabled. ' +
'Set it in production to verify requests come from Google Chat.',
);
return (_req: Request, _res: Response, next: NextFunction) => {
next();
};
}
const authClient = new OAuth2Client();
return (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn('[ChatBridge] Missing or invalid Authorization header');
res.status(401).json({ error: 'Unauthorized: missing Bearer token' });
return;
}
const token = authHeader.substring(7);
authClient
.verifyIdToken({
idToken: token,
audience: projectNumber,
})
.then((ticket) => {
const payload = ticket.getPayload();
if (payload?.iss !== CHAT_ISSUER) {
logger.warn(
`[ChatBridge] Invalid token issuer: ${payload?.iss ?? 'unknown'}`,
);
res.status(403).json({ error: 'Forbidden: invalid token issuer' });
return;
}
next();
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error';
logger.warn(`[ChatBridge] Token verification failed: ${msg}`);
res.status(401).json({ error: 'Unauthorized: invalid token' });
});
};
}
/**
* Creates Express routes for the Google Chat bridge.
*/
export function createChatBridgeRoutes(config: ChatBridgeConfig): Router {
const router = createRouter();
const handler = new ChatBridgeHandler(config);
const authMiddleware = createAuthMiddleware(config.projectNumber);
// Google Chat sends webhook events as POST requests
router.post('/chat/webhook', async (req: Request, res: Response) => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const event = req.body as ChatEvent;
router.post(
'/chat/webhook',
authMiddleware,
async (req: Request, res: Response) => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const event = req.body as ChatEvent;
if (!event || !event.type) {
res.status(400).json({ error: 'Invalid event: missing type field' });
return;
if (!event || !event.type) {
res.status(400).json({ error: 'Invalid event: missing type field' });
return;
}
logger.info(`[ChatBridge] Webhook received: type=${event.type}`);
const response = await handler.handleEvent(event);
res.json(response);
} catch (error) {
const errorMsg =
error instanceof Error ? error.message : 'Unknown error';
logger.error(`[ChatBridge] Webhook error: ${errorMsg}`, error);
res.status(500).json({
text: `Internal error: ${errorMsg}`,
});
}
},
);
logger.info(`[ChatBridge] Webhook received: type=${event.type}`);
const response = await handler.handleEvent(event);
res.json(response);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[ChatBridge] Webhook error: ${errorMsg}`, error);
res.status(500).json({
text: `Internal error: ${errorMsg}`,
});
}
});
// Health check endpoint for the chat bridge
// Health check endpoint for the chat bridge (no auth required)
router.get('/chat/health', (_req: Request, res: Response) => {
res.json({
status: 'ok',
+1
View File
@@ -314,6 +314,7 @@ export async function createApp() {
if (chatBridgeUrl) {
const chatRoutes = createChatBridgeRoutes({
a2aServerUrl: chatBridgeUrl,
projectNumber: process.env['CHAT_PROJECT_NUMBER'],
debug: process.env['CHAT_BRIDGE_DEBUG'] === 'true',
});
expressApp.use(chatRoutes);