Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/gchat-endpoint-url-audience.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 24 additions & 4 deletions apps/docs/content/adapters/official/google-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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

Expand Down Expand Up @@ -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).

<Callout type="info">
Workspace Add-on Chat apps don't expose an "Authentication audience" radio; their token `aud` is always the endpoint URL. Set `endpointUrl` for these.
</Callout>

### Limitations

Expand Down
112 changes: 93 additions & 19 deletions packages/adapter-gchat/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down
60 changes: 33 additions & 27 deletions packages/adapter-gchat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,11 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
private readonly pendingSubscriptions = new Map<string, Promise<void>>();
/** 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 */
Expand Down Expand Up @@ -258,16 +261,22 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
// 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;
Expand Down Expand Up @@ -623,7 +632,7 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
*/
protected async verifyBearerToken(
request: Request,
expectedAudience: string
expectedAudience: string | string[]
): Promise<boolean> {
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
Expand Down Expand Up @@ -677,21 +686,6 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
request: Request,
options?: WebhookOptions
): Promise<Response> {
// 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 });

Expand Down Expand Up @@ -738,27 +732,39 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
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 });
}
} else if (this.disableSignatureVerification) {
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 });
}
Expand Down
21 changes: 18 additions & 3 deletions packages/adapter-gchat/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down