Skip to content

Commit 62ad0bb

Browse files
fix(gchat): accept endpointUrl as a direct-webhook JWT audience
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 <cursoragent@cursor.com>
1 parent 9f7bbc8 commit 62ad0bb

5 files changed

Lines changed: 218 additions & 17 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@chat-adapter/gchat": patch
3+
---
4+
5+
fix(gchat): accept `endpointUrl` as a direct-webhook JWT audience
6+
7+
When a Google Chat app's connection setting **Authentication audience** is set
8+
to **HTTP endpoint URL** (Google's recommended option for HTTP-hosted apps that
9+
aren't behind Cloud Run IAM), incoming JWTs have `aud` equal to the endpoint
10+
URL rather than the GCP project number. Previously the adapter only verified
11+
against `googleChatProjectNumber`, so URL-audience tokens always failed with
12+
401 Unauthorized. The adapter now verifies the bearer token against
13+
`googleChatProjectNumber` and/or `endpointUrl`, accepting either when both are
14+
set, and the constructor's fail-closed check accepts `endpointUrl` as a valid
15+
direct-webhook verifier.

apps/docs/content/adapters/official/google-chat.mdx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,12 @@ bot.onNewMention(async (thread, message) => {
111111
googleChatProjectNumber: {
112112
type: "string",
113113
description:
114-
"GCP project number for direct webhook JWT verification.",
114+
"GCP project number for direct webhook JWT verification. Use when the Chat app's authentication audience is set to 'Project number'.",
115+
},
116+
endpointUrl: {
117+
type: "string",
118+
description:
119+
"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'.",
115120
},
116121
impersonateUser: {
117122
type: "string",
@@ -125,7 +130,7 @@ bot.onNewMention(async (thread, message) => {
125130
}}
126131
/>
127132

128-
One of `googleChatProjectNumber`, `pubsubAudience`, or `disableSignatureVerification: true` is required — the constructor throws otherwise. Configure the verifier(s) for each transport you actually receive.
133+
One of `googleChatProjectNumber`, `endpointUrl`, `pubsubAudience`, or `disableSignatureVerification: true` is required — the constructor throws otherwise. Configure the verifier(s) for each transport you actually receive.
129134

130135
## Authentication
131136

@@ -196,10 +201,25 @@ Required for Workspace Events subscriptions and initiating DMs.
196201

197202
The two transports share one HTTP endpoint, so each verifier only covers its own request shape:
198203

199-
- **Direct webhooks** — Google Chat sends a signed JWT whose `aud` claim is your GCP project number. Configure with `googleChatProjectNumber`.
204+
- **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)).
200205
- **Pub/Sub push** — Cloud Pub/Sub sends a signed OIDC JWT whose audience is whatever you configured on the push subscription. Configure with `pubsubAudience`.
201206

202-
If you only configure `googleChatProjectNumber`, incoming Pub/Sub-shaped requests are rejected with HTTP 401 — and vice versa. Configure both if you receive both.
207+
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.
208+
209+
#### Which direct-webhook option do I need?
210+
211+
| Your Chat app | JWT `aud` | JWT `email` | Set |
212+
| -------------------------------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------ |
213+
| Standalone Chat app, **Authentication audience: Project number** (in Chat API config) | project number | `chat@system.gserviceaccount.com` | `googleChatProjectNumber` |
214+
| Standalone Chat app, **Authentication audience: HTTP endpoint URL** | endpoint URL | `chat@system.gserviceaccount.com` | `endpointUrl` |
215+
| **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` |
216+
| Mixed across envs / not sure | varies | varies | both `googleChatProjectNumber` and `endpointUrl` |
217+
218+
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).
219+
220+
<Callout type="info">
221+
Workspace Add-on Chat apps don't expose an "Authentication audience" radio; their token `aud` is always the endpoint URL. Set `endpointUrl` for these.
222+
</Callout>
203223

204224
### Limitations
205225

packages/adapter-gchat/src/index.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2931,6 +2931,126 @@ describe("GoogleChatAdapter", () => {
29312931
});
29322932
});
29332933

2934+
it("should allow direct webhook with valid Bearer token when only endpointUrl is configured (URL audience)", async () => {
2935+
// Chat apps configured with "HTTP endpoint URL" as the authentication
2936+
// audience issue tokens whose `aud` is the endpoint URL rather than
2937+
// the project number. `endpointUrl` should satisfy direct-webhook
2938+
// verification on its own.
2939+
verifyIdTokenSpy.mockResolvedValue({
2940+
getPayload: () => ({
2941+
iss: "https://accounts.google.com",
2942+
aud: "https://example.com/webhook",
2943+
email: "chat@system.gserviceaccount.com",
2944+
}),
2945+
});
2946+
2947+
const { adapter } = await createInitializedAdapter({
2948+
endpointUrl: "https://example.com/webhook",
2949+
});
2950+
2951+
const event = makeMessageEvent({ messageText: "Hello" });
2952+
const request = new Request("https://example.com/webhook", {
2953+
method: "POST",
2954+
headers: {
2955+
"content-type": "application/json",
2956+
authorization: "Bearer valid-google-jwt",
2957+
},
2958+
body: JSON.stringify(event),
2959+
});
2960+
2961+
const response = await adapter.handleWebhook(request);
2962+
expect(response.status).toBe(200);
2963+
expect(verifyIdTokenSpy).toHaveBeenCalledWith({
2964+
idToken: "valid-google-jwt",
2965+
audience: "https://example.com/webhook",
2966+
});
2967+
});
2968+
2969+
it("should accept either audience when both googleChatProjectNumber and endpointUrl are configured", async () => {
2970+
verifyIdTokenSpy.mockResolvedValue({
2971+
getPayload: () => ({
2972+
iss: "https://accounts.google.com",
2973+
aud: "https://example.com/webhook",
2974+
email: "chat@system.gserviceaccount.com",
2975+
}),
2976+
});
2977+
2978+
const { adapter } = await createInitializedAdapter({
2979+
googleChatProjectNumber: "123456789",
2980+
endpointUrl: "https://example.com/webhook",
2981+
});
2982+
2983+
const event = makeMessageEvent({ messageText: "Hello" });
2984+
const request = new Request("https://example.com/webhook", {
2985+
method: "POST",
2986+
headers: {
2987+
"content-type": "application/json",
2988+
authorization: "Bearer valid-google-jwt",
2989+
},
2990+
body: JSON.stringify(event),
2991+
});
2992+
2993+
const response = await adapter.handleWebhook(request);
2994+
expect(response.status).toBe(200);
2995+
expect(verifyIdTokenSpy).toHaveBeenCalledWith({
2996+
idToken: "valid-google-jwt",
2997+
audience: ["123456789", "https://example.com/webhook"],
2998+
});
2999+
});
3000+
3001+
it("should not promote auto-detected endpoint URL to a verifier when not explicitly configured", async () => {
3002+
// Auto-detected endpointUrl from the request URL must not be treated
3003+
// as an accepted JWT audience: doing so would let any caller bypass
3004+
// verification by simply hitting the bot at a chosen URL.
3005+
const previous = process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION;
3006+
process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION = "false";
3007+
try {
3008+
const { adapter } = await createInitializedAdapter({
3009+
googleChatProjectNumber: "123456789",
3010+
});
3011+
3012+
const event = makeMessageEvent({ messageText: "Hello" });
3013+
const request = new Request("https://attacker.example.com/webhook", {
3014+
method: "POST",
3015+
headers: {
3016+
"content-type": "application/json",
3017+
authorization: "Bearer some-token",
3018+
},
3019+
body: JSON.stringify(event),
3020+
});
3021+
3022+
await adapter.handleWebhook(request);
3023+
expect(verifyIdTokenSpy).toHaveBeenCalledWith({
3024+
idToken: "some-token",
3025+
audience: "123456789",
3026+
});
3027+
} finally {
3028+
if (previous !== undefined) {
3029+
process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION = previous;
3030+
}
3031+
}
3032+
});
3033+
3034+
it("should not throw in constructor when only endpointUrl is configured", () => {
3035+
// endpointUrl alone is enough for direct-webhook verification when
3036+
// the Chat app uses "HTTP endpoint URL" authentication audience.
3037+
const previous = process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION;
3038+
process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION = "false";
3039+
try {
3040+
expect(() =>
3041+
createGoogleChatAdapter({
3042+
credentials: TEST_CREDENTIALS,
3043+
logger: mockLogger,
3044+
endpointUrl: "https://example.com/webhook",
3045+
})
3046+
).not.toThrow();
3047+
} finally {
3048+
if (previous !== undefined) {
3049+
process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION = previous;
3050+
}
3051+
}
3052+
});
3053+
29343054
it("should fail-closed in constructor when no JWT verification config is provided", () => {
29353055
const previous = process.env.GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION;
29363056
// Any value other than "true" disables the opt-out and should make the

packages/adapter-gchat/src/index.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,16 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
220220
protected readonly impersonatedChatApi?: chat_v1.Chat;
221221
/** HTTP endpoint URL for button click actions */
222222
protected endpointUrl?: string;
223+
/**
224+
* Whether `endpointUrl` was explicitly configured by the caller and may
225+
* therefore be used as an accepted JWT audience for direct webhooks. We
226+
* intentionally don't promote auto-detected endpoint URLs (populated from
227+
* the incoming request URL inside `handleWebhook`) to verifier status:
228+
* doing so would let an attacker hitting the bot at any URL bypass
229+
* verification, since the auto-detected URL would always equal the token's
230+
* `aud` after a fresh request.
231+
*/
232+
protected readonly endpointUrlIsAudience: boolean;
223233
/** Google Cloud project number for verifying direct webhook JWTs */
224234
protected readonly googleChatProjectNumber?: string;
225235
/** Expected audience for Pub/Sub push message JWT verification */
@@ -246,6 +256,7 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
246256
this.impersonateUser =
247257
config.impersonateUser ?? process.env.GOOGLE_CHAT_IMPERSONATE_USER;
248258
this.endpointUrl = config.endpointUrl;
259+
this.endpointUrlIsAudience = Boolean(config.endpointUrl);
249260
this.googleChatProjectNumber =
250261
config.googleChatProjectNumber ?? process.env.GOOGLE_CHAT_PROJECT_NUMBER;
251262
this.pubsubAudience =
@@ -258,16 +269,22 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
258269
// the operator has not explicitly opted into the unsafe path. Previously
259270
// the adapter accepted any webhook in this state, allowing forged
260271
// payloads to impersonate users / trigger handlers.
272+
//
273+
// `endpointUrl` counts as a direct-webhook verifier because Chat apps
274+
// configured with "HTTP endpoint URL" as the authentication audience
275+
// (Google's recommended setting for non-IAM-hosted apps) issue tokens
276+
// whose `aud` is the endpoint URL rather than the project number.
261277
if (
262278
!(
263279
this.googleChatProjectNumber ||
280+
this.endpointUrlIsAudience ||
264281
this.pubsubAudience ||
265282
this.disableSignatureVerification
266283
)
267284
) {
268285
throw new ValidationError(
269286
"gchat",
270-
"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."
287+
"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."
271288
);
272289
}
273290
const apiRootUrl = config.apiUrl ?? process.env.GOOGLE_CHAT_API_URL;
@@ -623,7 +640,7 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
623640
*/
624641
protected async verifyBearerToken(
625642
request: Request,
626-
expectedAudience: string
643+
expectedAudience: string | string[]
627644
): Promise<boolean> {
628645
const authHeader = request.headers.get("authorization");
629646
if (!authHeader?.startsWith("Bearer ")) {
@@ -738,27 +755,39 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
738755
return this.handlePubSubMessage(maybePubSub, options);
739756
}
740757

741-
// Verify direct Google Chat webhook JWT if project number is configured.
758+
// Verify direct Google Chat webhook JWT if a verifier is configured.
742759
// Same reasoning as the Pub/Sub branch: each shape requires its own
743760
// verifier (or explicit opt-out) to prevent cross-transport bypass.
744-
if (this.googleChatProjectNumber) {
745-
const valid = await this.verifyBearerToken(
746-
request,
747-
this.googleChatProjectNumber
748-
);
761+
//
762+
// The expected `aud` depends on the Chat app's "Authentication audience"
763+
// setting: it's the project number when set to "Project number", and
764+
// the endpoint URL when set to "HTTP endpoint URL". We accept either
765+
// when both are configured so a single deployment can support both
766+
// modes (e.g. across envs) without code changes.
767+
const directAudiences = [
768+
this.googleChatProjectNumber,
769+
this.endpointUrlIsAudience ? this.endpointUrl : undefined,
770+
].filter((a): a is string => Boolean(a));
771+
if (directAudiences.length > 0) {
772+
// Pass a string when only one verifier is configured to keep the
773+
// common single-audience case identical to prior behavior; pass an
774+
// array only when both are configured (verifyIdToken accepts either).
775+
const audience =
776+
directAudiences.length === 1 ? directAudiences[0] : directAudiences;
777+
const valid = await this.verifyBearerToken(request, audience);
749778
if (!valid) {
750779
return new Response("Unauthorized", { status: 401 });
751780
}
752781
} else if (this.disableSignatureVerification) {
753782
if (!this.warnedNoWebhookVerification) {
754783
this.warnedNoWebhookVerification = true;
755784
this.logger.warn(
756-
"Google Chat webhook verification is disabled. Set GOOGLE_CHAT_PROJECT_NUMBER or googleChatProjectNumber to verify incoming requests."
785+
"Google Chat webhook verification is disabled. Set GOOGLE_CHAT_PROJECT_NUMBER, googleChatProjectNumber, or endpointUrl to verify incoming requests."
757786
);
758787
}
759788
} else {
760789
this.logger.warn(
761-
"Rejected direct Google Chat webhook: googleChatProjectNumber is not configured. Set GOOGLE_CHAT_PROJECT_NUMBER, or set disableSignatureVerification to accept unverified payloads."
790+
"Rejected direct Google Chat webhook: neither googleChatProjectNumber nor endpointUrl is configured. Set one of them, or set disableSignatureVerification to accept unverified payloads."
762791
);
763792
return new Response("Unauthorized", { status: 401 });
764793
}

packages/adapter-gchat/src/types.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,26 @@ export interface GoogleChatAdapterBaseConfig {
2626
*/
2727
disableSignatureVerification?: boolean;
2828
/**
29-
* HTTP endpoint URL for button click actions.
30-
* Required for HTTP endpoint apps - button clicks will be routed to this URL.
31-
* Should be the full URL of your webhook endpoint (e.g., "https://your-app.vercel.app/api/webhooks/gchat")
29+
* HTTP endpoint URL for button click actions, and an accepted JWT
30+
* audience for direct-webhook verification.
31+
*
32+
* - **Button click routing**: required for HTTP-endpoint Chat apps —
33+
* button clicks are dispatched back to this URL.
34+
* - **Webhook verification**: when set, this value is accepted as a valid
35+
* `aud` claim for incoming direct webhooks. Configure this when the Chat
36+
* app's authentication audience is "HTTP endpoint URL" (always the case
37+
* for Workspace Add-on Chat apps, where Google issues OIDC tokens whose
38+
* `aud` is the endpoint URL and whose `email` is
39+
* `service-{projectNumber}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`).
40+
* Auto-detected URLs (populated from the incoming request URL when not
41+
* explicitly configured) are intentionally NOT promoted to verifier
42+
* status.
43+
*
44+
* May be combined with `googleChatProjectNumber` — when both are set,
45+
* either audience verifies.
46+
*
47+
* Should be the full URL of your webhook endpoint, e.g.
48+
* `https://your-app.vercel.app/api/webhooks/gchat`.
3249
*/
3350
endpointUrl?: string;
3451
/**

0 commit comments

Comments
 (0)