From e01684ace72c0a0ff5f1771056d82c4b82030c6e Mon Sep 17 00:00:00 2001 From: mdnanocom Date: Mon, 18 May 2026 10:42:20 +0200 Subject: [PATCH] fix(gchat): accept `endpointUrl` as a direct-webhook JWT audience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Google Chat app is configured with **HTTP endpoint URL** as its authentication audience (the recommended option for HTTP-hosted apps not behind Cloud Run IAM), Google issues OIDC tokens whose `aud` is the endpoint URL rather than the GCP project number. The adapter previously only verified against `googleChatProjectNumber`, so URL-audience tokens always 401'd. Verify the bearer token against `googleChatProjectNumber` and/or an explicitly-configured `endpointUrl`, accepting either when both are set. The constructor's fail-closed check now also accepts an explicit `endpointUrl` as a valid direct-webhook verifier. Auto-detected endpoint URLs (populated from the request URL inside `handleWebhook`) are intentionally NOT promoted to verifier status — that would let a caller hitting the bot at any URL bypass verification. Tests cover the URL-audience-only path, the both-audiences path, and the auto-detected-URL-must-not-bypass-verification regression. Co-authored-by: Cursor --- .changeset/gchat-endpoint-url-audience.md | 24 ++++ .../content/adapters/official/google-chat.mdx | 28 ++++- packages/adapter-gchat/src/index.test.ts | 112 +++++++++++++++--- packages/adapter-gchat/src/index.ts | 60 +++++----- packages/adapter-gchat/src/types.ts | 21 +++- 5 files changed, 192 insertions(+), 53 deletions(-) create mode 100644 .changeset/gchat-endpoint-url-audience.md diff --git a/.changeset/gchat-endpoint-url-audience.md b/.changeset/gchat-endpoint-url-audience.md new file mode 100644 index 00000000..d0dd8a3d --- /dev/null +++ b/.changeset/gchat-endpoint-url-audience.md @@ -0,0 +1,24 @@ +--- +"@chat-adapter/gchat": patch +--- + +fix(gchat): accept `endpointUrl` as a direct-webhook JWT audience + +When a Google Chat app's connection setting **Authentication audience** is set +to **HTTP endpoint URL** — Google's recommended option for HTTP-hosted apps +not behind Cloud Run IAM, and the only mode available for Workspace Add-on +Chat apps — incoming JWTs have `aud` equal to the endpoint URL rather than +the GCP project number. Previously the adapter only verified against +`googleChatProjectNumber`, so URL-audience tokens always failed with 401 +Unauthorized. The adapter now verifies the bearer token against +`googleChatProjectNumber` and/or `endpointUrl`, accepting either when both +are set, and the constructor's fail-closed check accepts `endpointUrl` as a +valid direct-webhook verifier. + +**Behavior change**: `endpointUrl` is no longer inferred from the first +incoming request's URL — it must be explicitly configured. Inferring it from +the request URL coupled deployment URL to the spoofable Host header and made +audience verification depend on whichever URL hit the bot first. Apps that +post cards with buttons must now set `endpointUrl` to route button clicks +correctly; the JSDoc and connection-settings docs already labelled it as +required for HTTP-endpoint apps. diff --git a/apps/docs/content/adapters/official/google-chat.mdx b/apps/docs/content/adapters/official/google-chat.mdx index 6886472a..2397e861 100644 --- a/apps/docs/content/adapters/official/google-chat.mdx +++ b/apps/docs/content/adapters/official/google-chat.mdx @@ -111,7 +111,12 @@ bot.onNewMention(async (thread, message) => { googleChatProjectNumber: { type: "string", description: - "GCP project number for direct webhook JWT verification.", + "GCP project number for direct webhook JWT verification. Use when the Chat app's authentication audience is set to 'Project number'.", + }, + endpointUrl: { + type: "string", + description: + "Public URL of the webhook endpoint. Required for routing button click actions to your app, and used as an accepted JWT audience for direct webhooks when the Chat app's authentication audience is set to 'HTTP endpoint URL'.", }, impersonateUser: { type: "string", @@ -125,7 +130,7 @@ bot.onNewMention(async (thread, message) => { }} /> -One of `googleChatProjectNumber`, `pubsubAudience`, or `disableSignatureVerification: true` is required — the constructor throws otherwise. Configure the verifier(s) for each transport you actually receive. +One of `googleChatProjectNumber`, `endpointUrl`, `pubsubAudience`, or `disableSignatureVerification: true` is required — the constructor throws otherwise. Configure the verifier(s) for each transport you actually receive. ## Authentication @@ -196,10 +201,25 @@ Required for Workspace Events subscriptions and initiating DMs. The two transports share one HTTP endpoint, so each verifier only covers its own request shape: -- **Direct webhooks** — Google Chat sends a signed JWT whose `aud` claim is your GCP project number. Configure with `googleChatProjectNumber`. +- **Direct webhooks** — Google Chat sends a signed JWT in the `Authorization: Bearer …` header. The expected `aud` claim depends on how the Chat app is configured (see [Verify requests from Google Chat](https://developers.google.com/workspace/chat/verify-requests-from-chat)). - **Pub/Sub push** — Cloud Pub/Sub sends a signed OIDC JWT whose audience is whatever you configured on the push subscription. Configure with `pubsubAudience`. -If you only configure `googleChatProjectNumber`, incoming Pub/Sub-shaped requests are rejected with HTTP 401 — and vice versa. Configure both if you receive both. +If you only configure a direct-webhook verifier, incoming Pub/Sub-shaped requests are rejected with HTTP 401 — and vice versa. Configure both transports if you receive both. + +#### Which direct-webhook option do I need? + +| Your Chat app | JWT `aud` | JWT `email` | Set | +| -------------------------------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Standalone Chat app, **Authentication audience: Project number** (in Chat API config) | project number | `chat@system.gserviceaccount.com` | `googleChatProjectNumber` | +| Standalone Chat app, **Authentication audience: HTTP endpoint URL** | endpoint URL | `chat@system.gserviceaccount.com` | `endpointUrl` | +| **Workspace Add-on Chat app** (built via Google Workspace Marketplace SDK; the audience is hardcoded) | endpoint URL | `service-{projectNumber}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com` | `endpointUrl` | +| Mixed across envs / not sure | varies | varies | both `googleChatProjectNumber` and `endpointUrl` | + +When both `googleChatProjectNumber` and `endpointUrl` are set, either audience is accepted. If you don't know which mode your app uses, look at the JWT `email` claim of an incoming request — `gcp-sa-gsuiteaddons` means it's a Workspace Add-on (URL audience). + + +Workspace Add-on Chat apps don't expose an "Authentication audience" radio; their token `aud` is always the endpoint URL. Set `endpointUrl` for these. + ### Limitations diff --git a/packages/adapter-gchat/src/index.test.ts b/packages/adapter-gchat/src/index.test.ts index d01bc759..00bd7de3 100644 --- a/packages/adapter-gchat/src/index.test.ts +++ b/packages/adapter-gchat/src/index.test.ts @@ -881,7 +881,11 @@ describe("GoogleChatAdapter", () => { expect(response.status).toBe(200); }); - it("should auto-detect endpoint URL from request", async () => { + it("should not infer endpointUrl from incoming request URL", async () => { + // endpointUrl must be explicitly configured. Inferring it from the + // first incoming request couples deployment URL to spoofable host + // headers and conflates a routing concern with a security one + // (audience verification). const { adapter } = await createInitializedAdapter(); const event: GoogleChatEvent = { chat: {} }; const request = new Request( @@ -894,24 +898,7 @@ describe("GoogleChatAdapter", () => { await adapter.handleWebhook(request); - expect((adapter as any).endpointUrl).toBe( - "https://my-app.vercel.app/api/webhooks/gchat" - ); - }); - - it("should not overwrite existing endpointUrl", async () => { - const { adapter } = await createInitializedAdapter({ - endpointUrl: "https://original.com/webhook", - }); - const event: GoogleChatEvent = { chat: {} }; - const request = new Request("https://other.com/webhook", { - method: "POST", - body: JSON.stringify(event), - }); - - await adapter.handleWebhook(request); - - expect((adapter as any).endpointUrl).toBe("https://original.com/webhook"); + expect((adapter as any).endpointUrl).toBeUndefined(); }); it("should route Pub/Sub push messages", async () => { @@ -2931,6 +2918,93 @@ describe("GoogleChatAdapter", () => { }); }); + it("should allow direct webhook with valid Bearer token when only endpointUrl is configured (URL audience)", async () => { + // Chat apps configured with "HTTP endpoint URL" as the authentication + // audience issue tokens whose `aud` is the endpoint URL rather than + // the project number. `endpointUrl` should satisfy direct-webhook + // verification on its own. + verifyIdTokenSpy.mockResolvedValue({ + getPayload: () => ({ + iss: "https://accounts.google.com", + aud: "https://example.com/webhook", + email: "chat@system.gserviceaccount.com", + }), + }); + + const { adapter } = await createInitializedAdapter({ + endpointUrl: "https://example.com/webhook", + }); + + const event = makeMessageEvent({ messageText: "Hello" }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer valid-google-jwt", + }, + body: JSON.stringify(event), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(verifyIdTokenSpy).toHaveBeenCalledWith({ + idToken: "valid-google-jwt", + audience: "https://example.com/webhook", + }); + }); + + it("should accept either audience when both googleChatProjectNumber and endpointUrl are configured", async () => { + verifyIdTokenSpy.mockResolvedValue({ + getPayload: () => ({ + iss: "https://accounts.google.com", + aud: "https://example.com/webhook", + email: "chat@system.gserviceaccount.com", + }), + }); + + const { adapter } = await createInitializedAdapter({ + googleChatProjectNumber: "123456789", + endpointUrl: "https://example.com/webhook", + }); + + const event = makeMessageEvent({ messageText: "Hello" }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer valid-google-jwt", + }, + body: JSON.stringify(event), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(verifyIdTokenSpy).toHaveBeenCalledWith({ + idToken: "valid-google-jwt", + audience: ["123456789", "https://example.com/webhook"], + }); + }); + + it("should not throw in constructor when only endpointUrl is configured", () => { + // endpointUrl alone is enough for direct-webhook verification when + // the Chat app uses "HTTP endpoint URL" authentication audience. + const previous = process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION; + process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION = "false"; + try { + expect(() => + createGoogleChatAdapter({ + credentials: TEST_CREDENTIALS, + logger: mockLogger, + endpointUrl: "https://example.com/webhook", + }) + ).not.toThrow(); + } finally { + if (previous !== undefined) { + process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION = previous; + } + } + }); + it("should fail-closed in constructor when no JWT verification config is provided", () => { const previous = process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION; // Any value other than "true" disables the opt-out and should make the diff --git a/packages/adapter-gchat/src/index.ts b/packages/adapter-gchat/src/index.ts index dff1664a..f3ca2f77 100644 --- a/packages/adapter-gchat/src/index.ts +++ b/packages/adapter-gchat/src/index.ts @@ -218,8 +218,11 @@ export class GoogleChatAdapter implements Adapter { private readonly pendingSubscriptions = new Map>(); /** Chat API client with impersonation for user-context operations (DMs, etc.) */ protected readonly impersonatedChatApi?: chat_v1.Chat; - /** HTTP endpoint URL for button click actions */ - protected endpointUrl?: string; + /** + * HTTP endpoint URL. Used for button-click action routing on cards, and as + * an accepted JWT audience for direct-webhook verification. + */ + protected readonly endpointUrl?: string; /** Google Cloud project number for verifying direct webhook JWTs */ protected readonly googleChatProjectNumber?: string; /** Expected audience for Pub/Sub push message JWT verification */ @@ -258,16 +261,22 @@ export class GoogleChatAdapter implements Adapter { // the operator has not explicitly opted into the unsafe path. Previously // the adapter accepted any webhook in this state, allowing forged // payloads to impersonate users / trigger handlers. + // + // `endpointUrl` counts as a direct-webhook verifier because Chat apps + // configured with "HTTP endpoint URL" as the authentication audience + // (Google's recommended setting for non-IAM-hosted apps) issue tokens + // whose `aud` is the endpoint URL rather than the project number. if ( !( this.googleChatProjectNumber || + this.endpointUrl || this.pubsubAudience || this.disableSignatureVerification ) ) { throw new ValidationError( "gchat", - "Webhook signature verification is required. Set googleChatProjectNumber (or GOOGLE_CHAT_PROJECT_NUMBER) for direct webhooks and/or pubsubAudience (or GOOGLE_CHAT_PUBSUB_AUDIENCE) for Pub/Sub. To accept unverified webhooks (NOT recommended in production), set disableSignatureVerification: true." + "Webhook signature verification is required. Set googleChatProjectNumber (or GOOGLE_CHAT_PROJECT_NUMBER) and/or endpointUrl for direct webhooks and/or pubsubAudience (or GOOGLE_CHAT_PUBSUB_AUDIENCE) for Pub/Sub. To accept unverified webhooks (NOT recommended in production), set disableSignatureVerification: true." ); } const apiRootUrl = config.apiUrl ?? process.env.GOOGLE_CHAT_API_URL; @@ -623,7 +632,7 @@ export class GoogleChatAdapter implements Adapter { */ protected async verifyBearerToken( request: Request, - expectedAudience: string + expectedAudience: string | string[] ): Promise { const authHeader = request.headers.get("authorization"); if (!authHeader?.startsWith("Bearer ")) { @@ -677,21 +686,6 @@ export class GoogleChatAdapter implements Adapter { request: Request, options?: WebhookOptions ): Promise { - // Auto-detect endpoint URL from incoming request for button click routing - // This allows HTTP endpoint apps to work without manual endpointUrl configuration - if (!this.endpointUrl) { - try { - const url = new URL(request.url); - // Preserve the full URL including query strings - this.endpointUrl = url.toString(); - this.logger.debug("Auto-detected endpoint URL", { - endpointUrl: this.endpointUrl, - }); - } catch { - // URL parsing failed, endpointUrl will remain undefined - } - } - const body = await request.text(); this.logger.debug("GChat webhook raw body", { body }); @@ -738,14 +732,26 @@ export class GoogleChatAdapter implements Adapter { return this.handlePubSubMessage(maybePubSub, options); } - // Verify direct Google Chat webhook JWT if project number is configured. + // Verify direct Google Chat webhook JWT if a verifier is configured. // Same reasoning as the Pub/Sub branch: each shape requires its own // verifier (or explicit opt-out) to prevent cross-transport bypass. - if (this.googleChatProjectNumber) { - const valid = await this.verifyBearerToken( - request, - this.googleChatProjectNumber - ); + // + // The expected `aud` depends on the Chat app's "Authentication audience" + // setting: it's the project number when set to "Project number", and + // the endpoint URL when set to "HTTP endpoint URL". We accept either + // when both are configured so a single deployment can support both + // modes (e.g. across envs) without code changes. + const directAudiences = [ + this.googleChatProjectNumber, + this.endpointUrl, + ].filter((a): a is string => Boolean(a)); + if (directAudiences.length > 0) { + // Pass a string when only one verifier is configured to keep the + // common single-audience case identical to prior behavior; pass an + // array only when both are configured (verifyIdToken accepts either). + const audience = + directAudiences.length === 1 ? directAudiences[0] : directAudiences; + const valid = await this.verifyBearerToken(request, audience); if (!valid) { return new Response("Unauthorized", { status: 401 }); } @@ -753,12 +759,12 @@ export class GoogleChatAdapter implements Adapter { if (!this.warnedNoWebhookVerification) { this.warnedNoWebhookVerification = true; this.logger.warn( - "Google Chat webhook verification is disabled. Set GOOGLE_CHAT_PROJECT_NUMBER or googleChatProjectNumber to verify incoming requests." + "Google Chat webhook verification is disabled. Set GOOGLE_CHAT_PROJECT_NUMBER, googleChatProjectNumber, or endpointUrl to verify incoming requests." ); } } else { this.logger.warn( - "Rejected direct Google Chat webhook: googleChatProjectNumber is not configured. Set GOOGLE_CHAT_PROJECT_NUMBER, or set disableSignatureVerification to accept unverified payloads." + "Rejected direct Google Chat webhook: neither googleChatProjectNumber nor endpointUrl is configured. Set one of them, or set disableSignatureVerification to accept unverified payloads." ); return new Response("Unauthorized", { status: 401 }); } diff --git a/packages/adapter-gchat/src/types.ts b/packages/adapter-gchat/src/types.ts index d5c6802e..29779979 100644 --- a/packages/adapter-gchat/src/types.ts +++ b/packages/adapter-gchat/src/types.ts @@ -26,9 +26,24 @@ export interface GoogleChatAdapterBaseConfig { */ disableSignatureVerification?: boolean; /** - * HTTP endpoint URL for button click actions. - * Required for HTTP endpoint apps - button clicks will be routed to this URL. - * Should be the full URL of your webhook endpoint (e.g., "https://your-app.vercel.app/api/webhooks/gchat") + * HTTP endpoint URL for button click action routing, and an accepted JWT + * audience for direct-webhook verification. + * + * - **Button click routing**: required for HTTP-endpoint Chat apps — + * button clicks are dispatched back to this URL. + * - **Webhook verification**: when set, this value is accepted as a valid + * `aud` claim for incoming direct webhooks. Configure this when the Chat + * app's authentication audience is "HTTP endpoint URL" (always the case + * for Workspace Add-on Chat apps, where Google issues OIDC tokens whose + * `aud` is the endpoint URL and whose `email` is + * `service-{projectNumber}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`). + * + * One of `endpointUrl`, `googleChatProjectNumber`, `pubsubAudience`, or + * `disableSignatureVerification: true` is required. May be combined with + * `googleChatProjectNumber` — when both are set, either audience verifies. + * + * Should be the full URL of your webhook endpoint, e.g. + * `https://your-app.vercel.app/api/webhooks/gchat`. */ endpointUrl?: string; /**