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
+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);