diff --git a/.changeset/gchat-endpoint-url-audience.md b/.changeset/gchat-endpoint-url-audience.md
new file mode 100644
index 00000000..6f289839
--- /dev/null
+++ b/.changeset/gchat-endpoint-url-audience.md
@@ -0,0 +1,27 @@
+---
+"@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.
+
+Direct-webhook tokens are now also matched against the expected Google Chat
+issuer/email claims (`chat@system.gserviceaccount.com`, or the
+`service-{projectNumber}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`
+service identity for Workspace Add-on Chat apps) with `email_verified: true`,
+so a public endpoint URL audience alone is not sufficient to forge a request.
+
+The adapter still infers an endpoint URL from incoming requests for
+button-click action routing only — that inferred value is never used as a
+JWT verification audience, because `request.url` derives from the
+attacker-controllable `Host` header in serverless runtimes.
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/README.md b/packages/adapter-gchat/README.md
index 0aec8c96..3533f647 100644
--- a/packages/adapter-gchat/README.md
+++ b/packages/adapter-gchat/README.md
@@ -154,7 +154,8 @@ Set `GOOGLE_CHAT_IMPERSONATE_USER` to an admin user email in your domain (e.g.,
## Configuration
-All options are auto-detected from environment variables when not provided.
+Most options are auto-detected from environment variables when not provided.
+`endpointUrl` must be passed in config.
| Option | Required | Description |
|--------|----------|-------------|
@@ -162,7 +163,8 @@ All options are auto-detected from environment variables when not provided.
| `useApplicationDefaultCredentials` | No | Use Application Default Credentials. Auto-detected from `GOOGLE_CHAT_USE_ADC` |
| `pubsubTopic` | No | Pub/Sub topic for Workspace Events. Auto-detected from `GOOGLE_CHAT_PUBSUB_TOPIC` |
| `pubsubAudience` | No† | Expected JWT audience for Pub/Sub webhook verification. Auto-detected from `GOOGLE_CHAT_PUBSUB_AUDIENCE` |
-| `googleChatProjectNumber` | No† | GCP project number for direct webhook JWT verification. Auto-detected from `GOOGLE_CHAT_PROJECT_NUMBER` |
+| `googleChatProjectNumber` | No† | GCP project number for direct webhook JWT verification when the Chat app's authentication audience is "Project number". Auto-detected from `GOOGLE_CHAT_PROJECT_NUMBER` |
+| `endpointUrl` | No† | Public webhook URL for button click routing and direct webhook JWT verification when the Chat app's authentication audience is "HTTP endpoint URL". Must be passed in config |
| `disableSignatureVerification` | No† | Disable JWT verification entirely (development only). Auto-detected from `GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION=true` |
| `impersonateUser` | No | User email for domain-wide delegation. Auto-detected from `GOOGLE_CHAT_IMPERSONATE_USER` |
| `auth` | No | Custom auth object (advanced) |
@@ -171,7 +173,7 @@ All options are auto-detected from environment variables when not provided.
*Either `credentials`, `GOOGLE_CHAT_CREDENTIALS` env var, `useApplicationDefaultCredentials`, or `GOOGLE_CHAT_USE_ADC=true` is required.
-†One of `googleChatProjectNumber`, `pubsubAudience`, or `disableSignatureVerification: true` is required — the constructor throws otherwise. Configure the verifier(s) for each transport you actually receive; requests of a shape whose verifier is unconfigured are rejected with HTTP 401.
+†One of `googleChatProjectNumber`, `endpointUrl`, `pubsubAudience`, or `disableSignatureVerification: true` is required — the constructor throws otherwise. Configure the verifier(s) for each transport you actually receive; requests of a shape whose verifier is unconfigured are rejected with HTTP 401.
## Environment variables
@@ -182,8 +184,8 @@ GOOGLE_CHAT_CREDENTIALS={"type":"service_account",...}
GOOGLE_CHAT_PUBSUB_TOPIC=projects/your-project/topics/chat-events
GOOGLE_CHAT_IMPERSONATE_USER=admin@yourdomain.com
-# Webhook verification — at least one of the three is required
-GOOGLE_CHAT_PROJECT_NUMBER=123456789 # For direct webhook JWT verification
+# Webhook verification — at least one verifier or the explicit opt-out is required
+GOOGLE_CHAT_PROJECT_NUMBER=123456789 # Direct webhooks with project-number audience
GOOGLE_CHAT_PUBSUB_AUDIENCE=https://your-domain.com/api/webhooks/gchat # For Pub/Sub JWT verification
# GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION=true # Escape hatch for local dev only
@@ -197,15 +199,16 @@ The adapter supports JWT verification for both webhook types. When configured, t
Verification is required. The constructor throws `ValidationError` unless one of the following is set:
-- `googleChatProjectNumber` (or `GOOGLE_CHAT_PROJECT_NUMBER`) — direct webhooks
+- `googleChatProjectNumber` (or `GOOGLE_CHAT_PROJECT_NUMBER`) — direct webhooks when the Chat app's authentication audience is "Project number"
+- `endpointUrl` — direct webhooks when the Chat app's authentication audience is "HTTP endpoint URL"; also required for routing card button clicks in HTTP endpoint apps
- `pubsubAudience` (or `GOOGLE_CHAT_PUBSUB_AUDIENCE`) — Pub/Sub push deliveries
- `disableSignatureVerification: true` (or `GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION=true`) — explicit opt-out, intended for local development only
-The two transports share one HTTP endpoint, so each verifier only covers its own request shape. If you only configure `googleChatProjectNumber`, incoming Pub/Sub-shaped requests are rejected with HTTP 401, and vice versa — configure both if you receive both.
+The two transports share one HTTP endpoint, so each verifier only covers its own request shape. If you only configure a direct-webhook verifier, incoming Pub/Sub-shaped requests are rejected with HTTP 401, and vice versa — configure both if you receive both.
### Direct webhooks (Google Chat API)
-Google Chat sends a signed JWT with every webhook request. The JWT audience (`aud` claim) is your GCP project number.
+Google Chat sends a signed token with every webhook request. The expected JWT audience (`aud` claim) depends on the Chat app's **Authentication audience** setting:
```typescript
createGoogleChatAdapter({
@@ -213,7 +216,15 @@ createGoogleChatAdapter({
});
```
-Find your project number in the [GCP Console dashboard](https://console.cloud.google.com/home/dashboard) (it's different from the project ID).
+Use `googleChatProjectNumber` when the setting is **Project number**. Find your project number in the [GCP Console dashboard](https://console.cloud.google.com/home/dashboard) (it's different from the project ID).
+
+```typescript
+createGoogleChatAdapter({
+ endpointUrl: "https://your-domain.com/api/webhooks/gchat",
+});
+```
+
+Use `endpointUrl` when the setting is **HTTP endpoint URL**. Workspace Add-on Chat apps always use URL audience tokens. When both `googleChatProjectNumber` and `endpointUrl` are set, either audience is accepted.
### Pub/Sub push messages
diff --git a/packages/adapter-gchat/src/index.test.ts b/packages/adapter-gchat/src/index.test.ts
index d01bc759..68ffb8e2 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 infer button-click endpoint URL from request but never expose it as a verification audience", async () => {
+ // The inferred URL is used only for routing button clicks back to the
+ // app. It is intentionally NOT used as a JWT audience, because
+ // `request.url` derives from the Host header in serverless runtimes
+ // and is attacker-controllable.
const { adapter } = await createInitializedAdapter();
const event: GoogleChatEvent = { chat: {} };
const request = new Request(
@@ -894,24 +898,30 @@ describe("GoogleChatAdapter", () => {
await adapter.handleWebhook(request);
- expect((adapter as any).endpointUrl).toBe(
+ // Explicit config field stays unset.
+ expect((adapter as any).endpointUrl).toBeUndefined();
+ // Routing-only inferred field is populated.
+ expect((adapter as any).inferredEndpointUrl).toBe(
"https://my-app.vercel.app/api/webhooks/gchat"
);
});
- it("should not overwrite existing endpointUrl", async () => {
+ it("should not overwrite explicitly-configured endpointUrl with a request URL", async () => {
const { adapter } = await createInitializedAdapter({
- endpointUrl: "https://original.com/webhook",
+ endpointUrl: "https://original.example.com/webhook",
});
const event: GoogleChatEvent = { chat: {} };
- const request = new Request("https://other.com/webhook", {
+ const request = new Request("https://other.example.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).toBe(
+ "https://original.example.com/webhook"
+ );
+ expect((adapter as any).inferredEndpointUrl).toBeUndefined();
});
it("should route Pub/Sub push messages", async () => {
@@ -2931,6 +2941,210 @@ 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",
+ email_verified: true,
+ }),
+ });
+
+ 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 allow direct webhook with Workspace Add-on service account email when endpointUrl is configured", async () => {
+ verifyIdTokenSpy.mockResolvedValue({
+ getPayload: () => ({
+ iss: "https://accounts.google.com",
+ aud: "https://example.com/webhook",
+ email:
+ "service-123456789@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
+ email_verified: true,
+ }),
+ });
+
+ 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);
+ });
+
+ it("should reject endpointUrl direct webhook when token email is not Google Chat", async () => {
+ verifyIdTokenSpy.mockResolvedValue({
+ getPayload: () => ({
+ iss: "https://accounts.google.com",
+ aud: "https://example.com/webhook",
+ email: "attacker@example.com",
+ email_verified: true,
+ }),
+ });
+
+ 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 attacker-google-id-token",
+ },
+ body: JSON.stringify(event),
+ });
+
+ const response = await adapter.handleWebhook(request);
+ expect(response.status).toBe(401);
+ });
+
+ it("should reject endpointUrl direct webhook when token email is not verified", async () => {
+ verifyIdTokenSpy.mockResolvedValue({
+ getPayload: () => ({
+ iss: "https://accounts.google.com",
+ aud: "https://example.com/webhook",
+ email: "chat@system.gserviceaccount.com",
+ email_verified: false,
+ }),
+ });
+
+ 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 unverified-email-token",
+ },
+ body: JSON.stringify(event),
+ });
+
+ const response = await adapter.handleWebhook(request);
+ expect(response.status).toBe(401);
+ });
+
+ it("should not use a request-inferred endpoint URL as a verification audience", async () => {
+ // Defense in depth: even if a malicious caller poisons the inferred URL
+ // by sending the first request with a spoofed Host, that value must not
+ // be accepted as a JWT audience.
+ const { adapter } = await createInitializedAdapter({
+ googleChatProjectNumber: "123456789",
+ });
+ // Simulate a prior request that populated `inferredEndpointUrl`.
+ (adapter as any).inferredEndpointUrl = "https://attacker.example/webhook";
+
+ const event = makeMessageEvent({ messageText: "Hello" });
+ const request = new Request("https://attacker.example/webhook", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: "Bearer attacker-token",
+ },
+ body: JSON.stringify(event),
+ });
+
+ await adapter.handleWebhook(request);
+
+ // verifyIdToken must be called with only the explicit project number,
+ // never the inferred URL.
+ expect(verifyIdTokenSpy).toHaveBeenCalledWith({
+ idToken: "attacker-token",
+ audience: "123456789",
+ });
+ });
+
+ 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",
+ email_verified: true,
+ }),
+ });
+
+ 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..307317eb 100644
--- a/packages/adapter-gchat/src/index.ts
+++ b/packages/adapter-gchat/src/index.ts
@@ -76,6 +76,9 @@ const SUBSCRIPTION_REFRESH_BUFFER_MS = 60 * 60 * 1000;
const SUBSCRIPTION_CACHE_TTL_MS = 25 * 60 * 60 * 1000;
/** Key prefix for space subscription cache */
const SPACE_SUB_KEY_PREFIX = "gchat:space-sub:";
+const GOOGLE_CHAT_SERVICE_ACCOUNT_EMAIL = "chat@system.gserviceaccount.com";
+const GOOGLE_WORKSPACE_ADD_ON_SERVICE_ACCOUNT_PATTERN =
+ /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$/;
const REACTION_MESSAGE_NAME_PATTERN = /(spaces\/[^/]+\/messages\/[^/]+)/;
// Re-export GoogleChatThreadId from thread-utils
@@ -218,8 +221,19 @@ 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;
+ /**
+ * Endpoint URL inferred from the first incoming request's `request.url`.
+ * Used **only** for button-click routing when `endpointUrl` is not
+ * explicitly configured — never used as a JWT verification audience,
+ * because `request.url` derives from the attacker-controllable Host
+ * header in serverless environments.
+ */
+ private inferredEndpointUrl?: 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 +272,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 +643,13 @@ export class GoogleChatAdapter implements Adapter {
*/
protected async verifyBearerToken(
request: Request,
- expectedAudience: string
+ expectedAudience: string | string[],
+ validatePayload?: (payload: {
+ aud?: string | string[];
+ email?: string;
+ email_verified?: boolean;
+ iss?: string;
+ }) => boolean
): Promise {
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
@@ -647,6 +673,14 @@ export class GoogleChatAdapter implements Adapter {
aud: payload.aud,
email: payload.email,
});
+ if (validatePayload && !validatePayload(payload)) {
+ this.logger.warn("JWT payload failed claim validation", {
+ iss: payload.iss,
+ aud: payload.aud,
+ email: payload.email,
+ });
+ return false;
+ }
return true;
} catch (error) {
this.logger.warn("JWT verification failed", { error });
@@ -654,6 +688,49 @@ export class GoogleChatAdapter implements Adapter {
}
}
+ private validateDirectWebhookTokenPayload(payload: {
+ aud?: string | string[];
+ email?: string;
+ email_verified?: boolean;
+ iss?: string;
+ }): boolean {
+ const audiences = Array.isArray(payload.aud)
+ ? payload.aud
+ : [payload.aud].filter((aud): aud is string => Boolean(aud));
+
+ if (
+ this.endpointUrl &&
+ audiences.includes(this.endpointUrl) &&
+ payload.email_verified === true &&
+ (payload.email === GOOGLE_CHAT_SERVICE_ACCOUNT_EMAIL ||
+ GOOGLE_WORKSPACE_ADD_ON_SERVICE_ACCOUNT_PATTERN.test(
+ payload.email ?? ""
+ ))
+ ) {
+ return true;
+ }
+
+ if (
+ this.googleChatProjectNumber &&
+ audiences.includes(this.googleChatProjectNumber) &&
+ payload.iss === GOOGLE_CHAT_SERVICE_ACCOUNT_EMAIL
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * URL used for routing button-click actions on cards. Prefers explicit
+ * config; falls back to a value inferred from incoming requests so simple
+ * deployments work without manual configuration. Never used as a JWT
+ * audience — see `inferredEndpointUrl`.
+ */
+ private getButtonClickEndpointUrl(): string | undefined {
+ return this.endpointUrl ?? this.inferredEndpointUrl;
+ }
+
async getUser(userId: string): Promise {
try {
const cached = await this.userInfoCache.get(userId);
@@ -677,21 +754,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 });
@@ -702,6 +764,22 @@ export class GoogleChatAdapter implements Adapter {
return new Response("Invalid JSON", { status: 400 });
}
+ // Infer an endpoint URL for button-click routing if one isn't explicitly
+ // configured. This is intentionally separate from the verification audience
+ // (which only ever uses `this.endpointUrl`), because `request.url` derives
+ // from the Host header in serverless runtimes and is therefore
+ // attacker-controllable.
+ if (!(this.endpointUrl || this.inferredEndpointUrl)) {
+ try {
+ this.inferredEndpointUrl = new URL(request.url).toString();
+ this.logger.debug("Inferred button-click endpoint URL from request", {
+ inferredEndpointUrl: this.inferredEndpointUrl,
+ });
+ } catch {
+ // request.url not a valid URL — leave inference unset
+ }
+ }
+
// Check if this is a Pub/Sub push message (from Workspace Events subscription)
const maybePubSub = parsed as PubSubPushMessage;
if (maybePubSub.message?.data && maybePubSub.subscription) {
@@ -738,13 +816,27 @@ 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, (payload) =>
+ this.validateDirectWebhookTokenPayload(payload)
);
if (!valid) {
return new Response("Unauthorized", { status: 401 });
@@ -753,12 +845,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 });
}
@@ -1299,7 +1391,7 @@ export class GoogleChatAdapter implements Adapter {
const cardId = `card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const googleCard = cardToGoogleCard(card, {
cardId,
- endpointUrl: this.endpointUrl,
+ endpointUrl: this.getButtonClickEndpointUrl(),
});
this.logger.debug("GChat API: spaces.messages.create (card)", {
@@ -1391,7 +1483,7 @@ export class GoogleChatAdapter implements Adapter {
const cardId = `card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const googleCard = cardToGoogleCard(card, {
cardId,
- endpointUrl: this.endpointUrl,
+ endpointUrl: this.getButtonClickEndpointUrl(),
});
requestBody.cardsV2 = [googleCard];
@@ -1562,7 +1654,7 @@ export class GoogleChatAdapter implements Adapter {
const cardId = `card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const googleCard = cardToGoogleCard(card, {
cardId,
- endpointUrl: this.endpointUrl,
+ endpointUrl: this.getButtonClickEndpointUrl(),
});
this.logger.debug("GChat API: spaces.messages.update (card)", {
@@ -2466,7 +2558,7 @@ export class GoogleChatAdapter implements Adapter {
const cardId = `card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const googleCard = cardToGoogleCard(card, {
cardId,
- endpointUrl: this.endpointUrl,
+ endpointUrl: this.getButtonClickEndpointUrl(),
});
this.logger.debug("GChat API: spaces.messages.create (channel, card)", {
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;
/**