From 9d12980baaeb7da2e0ecf0cd4c7f2949f4851477 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 12 Feb 2026 10:56:42 -0500 Subject: [PATCH] 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. --- package-lock.json | 26 ++++- packages/a2a-server/package.json | 1 + packages/a2a-server/src/chat-bridge/routes.ts | 108 ++++++++++++++---- packages/a2a-server/src/http/app.ts | 1 + 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 682dbf2777..9092231c7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 7544b68ce7..fc6fef0f08 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -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", diff --git a/packages/a2a-server/src/chat-bridge/routes.ts b/packages/a2a-server/src/chat-bridge/routes.ts index 444c2bc484..ea71f62a19 100644 --- a/packages/a2a-server/src/chat-bridge/routes.ts +++ b/packages/a2a-server/src/chat-bridge/routes.ts @@ -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', diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 328e91c10e..f2fb6be8c2 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -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);