Skip to content

Commit 923260c

Browse files
committed
fix(gchat): verify project-number tokens against Chat issuer certs; infer endpoint URL only after verification
Two fixes to the direct-webhook verification introduced in this PR: 1. Project-number-audience tokens are self-signed JWTs from chat@system.gserviceaccount.com, not OIDC ID tokens. verifyIdToken can never validate them: it checks Google's OIDC federated certs and only accepts accounts.google.com issuers, so the previous iss claim check was unreachable and real project-number tokens always failed. Verify them per Google's reference implementation instead: verifySignedJwtWithCertsAsync against the Chat service account's X.509 certs (cached 1h) with issuer chat@system.gserviceaccount.com. Endpoint-URL tokens keep the OIDC verifyIdToken path plus email/email_verified claim checks. When both verifiers are configured, either token type is accepted. 2. Move button-click endpoint URL inference after successful verification. Previously an unauthenticated request (401) could still poison inferredEndpointUrl via a spoofed Host header, first-write-wins, and the poisoned URL flowed into every outbound card's action routing. Adds regression tests for both; updates docs, README, and the changeset.
1 parent d84deb4 commit 923260c

5 files changed

Lines changed: 269 additions & 97 deletions

File tree

.changeset/gchat-endpoint-url-audience.md

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,34 @@
22
"@chat-adapter/gchat": patch
33
---
44

5-
fix(gchat): accept `endpointUrl` as a direct-webhook JWT audience
5+
fix(gchat): accept `endpointUrl` as a direct-webhook verifier and verify each token type correctly
66

77
When a Google Chat app's connection setting **Authentication audience** is set
88
to **HTTP endpoint URL** — Google's recommended option for HTTP-hosted apps
99
not behind Cloud Run IAM, and the only mode available for Workspace Add-on
10-
Chat apps — incoming JWTs have `aud` equal to the endpoint URL rather than
11-
the GCP project number. Previously the adapter only verified against
12-
`googleChatProjectNumber`, so URL-audience tokens always failed with 401
13-
Unauthorized. The adapter now verifies the bearer token against
14-
`googleChatProjectNumber` and/or `endpointUrl`, accepting either when both
15-
are set, and the constructor's fail-closed check accepts `endpointUrl` as a
16-
valid direct-webhook verifier.
10+
Chat apps — incoming tokens are Google OIDC ID tokens whose `aud` is the
11+
endpoint URL rather than the GCP project number. Previously the adapter only
12+
verified against `googleChatProjectNumber`, so URL-audience tokens always
13+
failed with 401 Unauthorized. The adapter now accepts `endpointUrl` as a
14+
direct-webhook verifier (including in the constructor's fail-closed check),
15+
validating the OIDC token's audience plus the Google Chat issuer email claims
16+
(`chat@system.gserviceaccount.com`, or the
17+
`service-{projectNumber}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com` service
18+
identity for Workspace Add-on Chat apps) with `email_verified: true` — a
19+
public endpoint URL audience alone is not sufficient to forge a request.
1720

18-
Direct-webhook tokens are now also matched against the expected Google Chat
19-
issuer/email claims (`chat@system.gserviceaccount.com`, or the
20-
`service-{projectNumber}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`
21-
service identity for Workspace Add-on Chat apps) with `email_verified: true`,
22-
so a public endpoint URL audience alone is not sufficient to forge a request.
21+
Project-number-audience tokens are now verified per Google's reference
22+
implementation: they are JWTs self-signed by
23+
`chat@system.gserviceaccount.com`, so the adapter checks them against that
24+
service account's X.509 certificates with issuer
25+
`chat@system.gserviceaccount.com` (previously it used `verifyIdToken`, which
26+
only accepts Google OIDC issuers and certs and therefore rejected every real
27+
project-number token). When both verifiers are configured, either token type
28+
is accepted.
2329

2430
The adapter still infers an endpoint URL from incoming requests for
2531
button-click action routing only — that inferred value is never used as a
26-
JWT verification audience, because `request.url` derives from the
27-
attacker-controllable `Host` header in serverless runtimes.
32+
JWT verification audience, and inference now only happens after a request
33+
has passed verification (or verification was explicitly disabled), because
34+
`request.url` derives from the attacker-controllable `Host` header in
35+
serverless runtimes.

apps/docs/content/adapters/official/gchat.mdx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,14 @@ If you only configure a direct-webhook verifier, incoming Pub/Sub-shaped request
208208

209209
#### Which direct-webhook option do I need?
210210

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).
211+
| Your Chat app | Token type & `aud` | Signed by | Set |
212+
| -------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------ |
213+
| Standalone Chat app, **Authentication audience: Project number** (in Chat API config) | self-signed JWT, `aud` = project number | `chat@system.gserviceaccount.com` (its own X.509 certs) | `googleChatProjectNumber` |
214+
| Standalone Chat app, **Authentication audience: HTTP endpoint URL** | Google OIDC ID token, `aud` = endpoint URL | Google (`email`: `chat@system.gserviceaccount.com`) | `endpointUrl` |
215+
| **Workspace Add-on Chat app** (built via Google Workspace Marketplace SDK; the audience is hardcoded) | Google OIDC ID token, `aud` = endpoint URL | Google (`email`: `service-{projectNumber}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`) | `endpointUrl` |
216+
| Mixed across envs / not sure | varies | varies | both `googleChatProjectNumber` and `endpointUrl` |
217+
218+
The two token types are verified differently, matching [Google's reference implementation](https://developers.google.com/workspace/chat/verify-requests-from-chat): endpoint-URL tokens are standard OIDC ID tokens checked against Google's public certs plus the Chat service-account `email` claim; project-number tokens are self-signed by `chat@system.gserviceaccount.com` and checked against that service account's own X.509 certificates. When both `googleChatProjectNumber` and `endpointUrl` are set, either token type is accepted. If you don't know which mode your app uses, look at an incoming token: an `email` claim containing `gcp-sa-gsuiteaddons` means it's a Workspace Add-on (URL audience).
219219

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

packages/adapter-gchat/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,9 @@ createGoogleChatAdapter({
240240
});
241241
```
242242

243-
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.
243+
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 token type is accepted.
244+
245+
The two modes carry different token types, and the adapter verifies each per [Google's reference implementation](https://developers.google.com/workspace/chat/verify-requests-from-chat): endpoint-URL tokens are standard Google OIDC ID tokens (checked against Google's public certs, plus the Chat service-account `email` claim with `email_verified`), while project-number tokens are JWTs self-signed by `chat@system.gserviceaccount.com` (checked against that service account's X.509 certificates with issuer `chat@system.gserviceaccount.com`). The configured `endpointUrl` must exactly match the URL registered in the Chat API console — a trailing-slash or scheme mismatch fails verification.
244246

245247
### Pub/Sub push messages
246248

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

Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2788,15 +2788,28 @@ describe("GoogleChatAdapter", () => {
27882788

27892789
describe("webhook verification", () => {
27902790
let verifyIdTokenSpy: ReturnType<typeof vi.spyOn>;
2791+
let verifySignedJwtSpy: ReturnType<typeof vi.spyOn>;
2792+
let fetchSpy: ReturnType<typeof vi.spyOn>;
27912793

27922794
beforeEach(() => {
27932795
verifyIdTokenSpy = vi
27942796
.spyOn(auth.OAuth2.prototype, "verifyIdToken")
27952797
.mockRejectedValue(new Error("Invalid token"));
2798+
// Project-number tokens are self-signed by chat@system and verified
2799+
// against its X.509 certs, not via verifyIdToken.
2800+
verifySignedJwtSpy = vi
2801+
.spyOn(auth.OAuth2.prototype, "verifySignedJwtWithCertsAsync")
2802+
.mockRejectedValue(new Error("Invalid token"));
2803+
// Stub the Chat issuer cert fetch — never hit the network in unit tests.
2804+
fetchSpy = vi
2805+
.spyOn(globalThis, "fetch")
2806+
.mockResolvedValue(Response.json({ "test-kid": "test-cert" }));
27962807
});
27972808

27982809
afterEach(() => {
27992810
verifyIdTokenSpy.mockRestore();
2811+
verifySignedJwtSpy.mockRestore();
2812+
fetchSpy.mockRestore();
28002813
});
28012814

28022815
it("should reject direct webhook without Authorization header when project number is configured", async () => {
@@ -2813,8 +2826,9 @@ describe("GoogleChatAdapter", () => {
28132826

28142827
const response = await adapter.handleWebhook(request);
28152828
expect(response.status).toBe(401);
2816-
// Should not even call verifyIdToken — no Bearer token present
2829+
// Should not even attempt verification — no Bearer token present
28172830
expect(verifyIdTokenSpy).not.toHaveBeenCalled();
2831+
expect(verifySignedJwtSpy).not.toHaveBeenCalled();
28182832
});
28192833

28202834
it("should reject direct webhook with invalid Bearer token when project number is configured", async () => {
@@ -2834,18 +2848,48 @@ describe("GoogleChatAdapter", () => {
28342848

28352849
const response = await adapter.handleWebhook(request);
28362850
expect(response.status).toBe(401);
2837-
expect(verifyIdTokenSpy).toHaveBeenCalledWith({
2838-
idToken: "invalid-token",
2839-
audience: "123456789",
2851+
// Project-number tokens are verified against the Chat service
2852+
// account's certs with issuer chat@system.gserviceaccount.com.
2853+
expect(verifySignedJwtSpy).toHaveBeenCalledWith(
2854+
"invalid-token",
2855+
{ "test-kid": "test-cert" },
2856+
"123456789",
2857+
["chat@system.gserviceaccount.com"]
2858+
);
2859+
});
2860+
2861+
it("should not infer an endpoint URL from a request that fails verification", async () => {
2862+
// An unauthenticated caller must not be able to poison the
2863+
// button-click routing URL: inference only happens after the request
2864+
// has been verified (or verification explicitly disabled).
2865+
const { adapter } = await createInitializedAdapter({
2866+
googleChatProjectNumber: "123456789",
2867+
});
2868+
2869+
const event = makeMessageEvent({ messageText: "Hello" });
2870+
const request = new Request("https://attacker.example/webhook", {
2871+
method: "POST",
2872+
headers: { "content-type": "application/json" },
2873+
body: JSON.stringify(event),
28402874
});
2875+
2876+
const response = await adapter.handleWebhook(request);
2877+
expect(response.status).toBe(401);
2878+
expect(
2879+
(adapter as unknown as { inferredEndpointUrl?: string })
2880+
.inferredEndpointUrl
2881+
).toBeUndefined();
28412882
});
28422883

28432884
it("should allow direct webhook with valid Bearer token when project number is configured", async () => {
2844-
verifyIdTokenSpy.mockResolvedValue({
2885+
// Real project-number tokens are self-signed JWTs from
2886+
// chat@system.gserviceaccount.com — verifiable only against that
2887+
// service account's X.509 certs, never via verifyIdToken (whose
2888+
// default issuer allowlist is accounts.google.com).
2889+
verifySignedJwtSpy.mockResolvedValue({
28452890
getPayload: () => ({
28462891
iss: "chat@system.gserviceaccount.com",
28472892
aud: "123456789",
2848-
email: "chat@system.gserviceaccount.com",
28492893
}),
28502894
});
28512895

@@ -2865,10 +2909,13 @@ describe("GoogleChatAdapter", () => {
28652909

28662910
const response = await adapter.handleWebhook(request);
28672911
expect(response.status).toBe(200);
2868-
expect(verifyIdTokenSpy).toHaveBeenCalledWith({
2869-
idToken: "valid-google-jwt",
2870-
audience: "123456789",
2871-
});
2912+
expect(verifySignedJwtSpy).toHaveBeenCalledWith(
2913+
"valid-google-jwt",
2914+
{ "test-kid": "test-cert" },
2915+
"123456789",
2916+
["chat@system.gserviceaccount.com"]
2917+
);
2918+
expect(verifyIdTokenSpy).not.toHaveBeenCalled();
28722919
});
28732920

28742921
it("should allow direct webhook with valid Bearer token when only endpointUrl is configured (URL audience)", async () => {
@@ -3014,15 +3061,18 @@ describe("GoogleChatAdapter", () => {
30143061

30153062
await adapter.handleWebhook(request);
30163063

3017-
// verifyIdToken must be called with only the explicit project number,
3018-
// never the inferred URL.
3019-
expect(verifyIdTokenSpy).toHaveBeenCalledWith({
3020-
idToken: "attacker-token",
3021-
audience: "123456789",
3022-
});
3064+
// Only the project-number verifier runs; the inferred URL must never
3065+
// be used as an OIDC audience.
3066+
expect(verifyIdTokenSpy).not.toHaveBeenCalled();
3067+
expect(verifySignedJwtSpy).toHaveBeenCalledWith(
3068+
"attacker-token",
3069+
{ "test-kid": "test-cert" },
3070+
"123456789",
3071+
["chat@system.gserviceaccount.com"]
3072+
);
30233073
});
30243074

3025-
it("should accept either audience when both googleChatProjectNumber and endpointUrl are configured", async () => {
3075+
it("should accept an endpoint-URL token when both googleChatProjectNumber and endpointUrl are configured", async () => {
30263076
verifyIdTokenSpy.mockResolvedValue({
30273077
getPayload: () => ({
30283078
iss: "https://accounts.google.com",
@@ -3051,8 +3101,45 @@ describe("GoogleChatAdapter", () => {
30513101
expect(response.status).toBe(200);
30523102
expect(verifyIdTokenSpy).toHaveBeenCalledWith({
30533103
idToken: "valid-google-jwt",
3054-
audience: ["123456789", "https://example.com/webhook"],
3104+
audience: "https://example.com/webhook",
3105+
});
3106+
// The project-number verifier is not needed when the OIDC path passes.
3107+
expect(verifySignedJwtSpy).not.toHaveBeenCalled();
3108+
});
3109+
3110+
it("should fall back to the project-number verifier when both are configured and the OIDC check fails", async () => {
3111+
// verifyIdTokenSpy keeps its default rejection (not an OIDC token);
3112+
// the self-signed project-number verification succeeds.
3113+
verifySignedJwtSpy.mockResolvedValue({
3114+
getPayload: () => ({
3115+
iss: "chat@system.gserviceaccount.com",
3116+
aud: "123456789",
3117+
}),
3118+
});
3119+
3120+
const { adapter } = await createInitializedAdapter({
3121+
googleChatProjectNumber: "123456789",
3122+
endpointUrl: "https://example.com/webhook",
30553123
});
3124+
3125+
const event = makeMessageEvent({ messageText: "Hello" });
3126+
const request = new Request("https://example.com/webhook", {
3127+
method: "POST",
3128+
headers: {
3129+
"content-type": "application/json",
3130+
authorization: "Bearer self-signed-chat-jwt",
3131+
},
3132+
body: JSON.stringify(event),
3133+
});
3134+
3135+
const response = await adapter.handleWebhook(request);
3136+
expect(response.status).toBe(200);
3137+
expect(verifySignedJwtSpy).toHaveBeenCalledWith(
3138+
"self-signed-chat-jwt",
3139+
{ "test-kid": "test-cert" },
3140+
"123456789",
3141+
["chat@system.gserviceaccount.com"]
3142+
);
30563143
});
30573144

30583145
it("should not throw in constructor when only endpointUrl is configured", () => {

0 commit comments

Comments
 (0)