Skip to content

Commit 6c3cec0

Browse files
committed
fix(googlechat): implement custom OIDC verification with Google Chat JWK endpoint
1 parent 1270a98 commit 6c3cec0

4 files changed

Lines changed: 258 additions & 29 deletions

File tree

go.mod

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,10 @@ require (
2626
go.opentelemetry.io/otel/trace v1.40.0
2727
golang.org/x/oauth2 v0.35.0
2828
golang.org/x/time v0.14.0
29-
google.golang.org/api v0.269.0
3029
tailscale.com v1.94.2
3130
)
3231

3332
require (
34-
cloud.google.com/go/auth v0.18.2 // indirect
35-
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
3633
cloud.google.com/go/compute/metadata v0.9.0 // indirect
3734
filippo.io/edwards25519 v1.1.0 // indirect
3835
github.com/akutz/memconn v0.1.0 // indirect
@@ -68,17 +65,13 @@ require (
6865
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
6966
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
7067
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
71-
github.com/felixge/httpsnoop v1.0.4 // indirect
7268
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
7369
github.com/gaissmai/bart v0.18.0 // indirect
7470
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
7571
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
7672
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
7773
github.com/google/btree v1.1.3 // indirect
7874
github.com/google/go-cmp v0.7.0 // indirect
79-
github.com/google/s2a-go v0.1.9 // indirect
80-
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
81-
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
8275
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
8376
github.com/huin/goupnp v1.3.0 // indirect
8477
github.com/invopop/jsonschema v0.13.0 // indirect
@@ -107,7 +100,6 @@ require (
107100
github.com/x448/float16 v0.8.4 // indirect
108101
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
109102
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
110-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
111103
go.uber.org/atomic v1.11.0 // indirect
112104
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
113105
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect

go.sum

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
22
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
3-
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
4-
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
5-
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
6-
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
73
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
84
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
95
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
@@ -208,14 +204,8 @@ github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
208204
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
209205
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
210206
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
211-
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
212-
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
213207
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
214208
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
215-
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
216-
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
217-
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
218-
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
219209
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
220210
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
221211
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
@@ -429,8 +419,6 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
429419
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
430420
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
431421
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
432-
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
433-
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
434422
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
435423
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
436424
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
@@ -508,8 +496,6 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
508496
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
509497
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
510498
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
511-
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
512-
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
513499
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
514500
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
515501
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package googlechat
2+
3+
import (
4+
"context"
5+
"crypto"
6+
"crypto/rsa"
7+
"encoding/base64"
8+
"encoding/json"
9+
"fmt"
10+
"math/big"
11+
"net/http"
12+
"strings"
13+
"sync"
14+
"time"
15+
)
16+
17+
const (
18+
// chatJWKURL is the JWK endpoint for Google Chat's signing keys.
19+
// Standard idtoken.Validate() uses googleapis.com/oauth2/v3/certs which does NOT
20+
// include Chat's signing keys, causing "could not find matching cert keyId" errors.
21+
chatJWKURL = "https://www.googleapis.com/service_accounts/v1/jwk/[email protected]"
22+
23+
// chatIssuer is the expected issuer claim in Google Chat JWT tokens.
24+
chatIssuer = "[email protected]"
25+
26+
// jwkCacheTTL is how long to cache fetched JWKs before refreshing.
27+
jwkCacheTTL = 1 * time.Hour
28+
29+
// clockSkewLeeway allows for minor clock differences between servers.
30+
clockSkewLeeway = 5 * time.Minute
31+
)
32+
33+
// chatCertCache stores fetched Google Chat JWKs with a TTL.
34+
var chatCertCache = &jwkCache{keys: make(map[string]*rsa.PublicKey)}
35+
36+
type jwkCache struct {
37+
mu sync.RWMutex
38+
keys map[string]*rsa.PublicKey // kid → public key
39+
fetched time.Time
40+
}
41+
42+
// jwkSet is the JSON structure from Google's JWK endpoint.
43+
type jwkSet struct {
44+
Keys []jwkKey `json:"keys"`
45+
}
46+
47+
type jwkKey struct {
48+
Kid string `json:"kid"`
49+
Kty string `json:"kty"`
50+
N string `json:"n"`
51+
E string `json:"e"`
52+
}
53+
54+
// verifyChatToken verifies a Google Chat JWT token against the Chat-specific JWK endpoint.
55+
// audiences is a list of acceptable audience values (webhook URL, project number, etc.).
56+
func verifyChatToken(ctx context.Context, token string, audiences []string) error {
57+
parts := strings.Split(token, ".")
58+
if len(parts) != 3 {
59+
return fmt.Errorf("invalid JWT format")
60+
}
61+
62+
// 1. Parse header to get kid
63+
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
64+
if err != nil {
65+
return fmt.Errorf("decode header: %w", err)
66+
}
67+
var header struct {
68+
Kid string `json:"kid"`
69+
Alg string `json:"alg"`
70+
}
71+
if err := json.Unmarshal(headerJSON, &header); err != nil {
72+
return fmt.Errorf("parse header: %w", err)
73+
}
74+
if header.Alg != "RS256" {
75+
return fmt.Errorf("unsupported algorithm: %s", header.Alg)
76+
}
77+
78+
// 2. Get signing key (fetch + cache, force-refresh on miss)
79+
pubKey, err := getChatSigningKey(ctx, header.Kid)
80+
if err != nil {
81+
return err
82+
}
83+
84+
// 3. Verify RS256 signature
85+
signed := []byte(parts[0] + "." + parts[1])
86+
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
87+
if err != nil {
88+
return fmt.Errorf("decode signature: %w", err)
89+
}
90+
h := crypto.SHA256.New()
91+
h.Write(signed)
92+
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, h.Sum(nil), sig); err != nil {
93+
return fmt.Errorf("invalid signature: %w", err)
94+
}
95+
96+
// 4. Validate claims
97+
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
98+
if err != nil {
99+
return fmt.Errorf("decode claims: %w", err)
100+
}
101+
var claims struct {
102+
Iss string `json:"iss"`
103+
Aud string `json:"aud"`
104+
Exp int64 `json:"exp"`
105+
}
106+
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
107+
return fmt.Errorf("parse claims: %w", err)
108+
}
109+
110+
if claims.Iss != chatIssuer {
111+
return fmt.Errorf("invalid issuer: %s", claims.Iss)
112+
}
113+
114+
if time.Now().Unix() > claims.Exp+int64(clockSkewLeeway.Seconds()) {
115+
return fmt.Errorf("token expired")
116+
}
117+
118+
for _, aud := range audiences {
119+
if claims.Aud == aud {
120+
return nil
121+
}
122+
}
123+
return fmt.Errorf("audience mismatch: got %s", claims.Aud)
124+
}
125+
126+
// getChatSigningKey returns the RSA public key for the given kid.
127+
// Fetches from cache first; on miss, force-refreshes the cache and retries.
128+
func getChatSigningKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
129+
keys, err := fetchChatJWKs(ctx, false)
130+
if err != nil {
131+
return nil, fmt.Errorf("fetch certs: %w", err)
132+
}
133+
if key, ok := keys[kid]; ok {
134+
return key, nil
135+
}
136+
137+
// Key not found — force refresh (Google may have rotated keys)
138+
keys, err = fetchChatJWKs(ctx, true)
139+
if err != nil {
140+
return nil, fmt.Errorf("refresh certs: %w", err)
141+
}
142+
if key, ok := keys[kid]; ok {
143+
return key, nil
144+
}
145+
return nil, fmt.Errorf("unknown signing key: %s", kid)
146+
}
147+
148+
// fetchChatJWKs fetches Google Chat JWKs with caching.
149+
// forceRefresh bypasses the cache TTL.
150+
func fetchChatJWKs(ctx context.Context, forceRefresh bool) (map[string]*rsa.PublicKey, error) {
151+
chatCertCache.mu.RLock()
152+
if !forceRefresh && time.Since(chatCertCache.fetched) < jwkCacheTTL && len(chatCertCache.keys) > 0 {
153+
keys := chatCertCache.keys
154+
chatCertCache.mu.RUnlock()
155+
return keys, nil
156+
}
157+
chatCertCache.mu.RUnlock()
158+
159+
chatCertCache.mu.Lock()
160+
defer chatCertCache.mu.Unlock()
161+
162+
// Double-check after acquiring write lock
163+
if !forceRefresh && time.Since(chatCertCache.fetched) < jwkCacheTTL && len(chatCertCache.keys) > 0 {
164+
return chatCertCache.keys, nil
165+
}
166+
167+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, chatJWKURL, nil)
168+
if err != nil {
169+
return nil, err
170+
}
171+
resp, err := http.DefaultClient.Do(req)
172+
if err != nil {
173+
return nil, fmt.Errorf("fetch JWKs: %w", err)
174+
}
175+
defer resp.Body.Close()
176+
177+
if resp.StatusCode != http.StatusOK {
178+
return nil, fmt.Errorf("JWK endpoint returned %d", resp.StatusCode)
179+
}
180+
181+
var jwks jwkSet
182+
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
183+
return nil, fmt.Errorf("decode JWKs: %w", err)
184+
}
185+
186+
keys := make(map[string]*rsa.PublicKey, len(jwks.Keys))
187+
for _, k := range jwks.Keys {
188+
if k.Kty != "RSA" {
189+
continue
190+
}
191+
pub, err := rsaPubFromJWK(k.N, k.E)
192+
if err != nil {
193+
continue
194+
}
195+
keys[k.Kid] = pub
196+
}
197+
198+
chatCertCache.keys = keys
199+
chatCertCache.fetched = time.Now()
200+
return keys, nil
201+
}
202+
203+
// rsaPubFromJWK converts base64url-encoded RSA modulus and exponent to an rsa.PublicKey.
204+
func rsaPubFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) {
205+
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
206+
if err != nil {
207+
return nil, err
208+
}
209+
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
210+
if err != nil {
211+
return nil, err
212+
}
213+
214+
e := 0
215+
for _, b := range eBytes {
216+
e = e*256 + int(b)
217+
}
218+
219+
return &rsa.PublicKey{
220+
N: new(big.Int).SetBytes(nBytes),
221+
E: e,
222+
}, nil
223+
}

internal/channels/googlechat/handler.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import (
77
"log/slog"
88
"net/http"
99
"strings"
10-
11-
"google.golang.org/api/idtoken"
1210
)
1311

1412
// NewWebhookHandler creates an http.HandlerFunc for Google Chat webhook events.
@@ -25,11 +23,13 @@ func NewWebhookHandler(projectNumber string, onMessage func(event *SpaceEvent))
2523
return
2624
}
2725

28-
// OIDC verification
26+
// OIDC verification using Google Chat's specific JWK endpoint
2927
if projectNumber != "" {
3028
if err := verifyOIDC(r, projectNumber); err != nil {
3129
slog.Warn("googlechat: OIDC verification failed", "error", err)
32-
http.Error(w, "unauthorized", http.StatusUnauthorized)
30+
w.Header().Set("Content-Type", "application/json")
31+
w.WriteHeader(http.StatusUnauthorized)
32+
w.Write([]byte(`{}`))
3333
return
3434
}
3535
}
@@ -70,6 +70,8 @@ func NewWebhookHandler(projectNumber string, onMessage func(event *SpaceEvent))
7070
}
7171

7272
// verifyOIDC validates the Google OIDC token from the Authorization header.
73+
// Uses Google Chat's specific JWK endpoint ([email protected])
74+
// instead of the generic OAuth2 certs endpoint, which doesn't include Chat signing keys.
7375
func verifyOIDC(r *http.Request, projectNumber string) error {
7476
auth := r.Header.Get("Authorization")
7577
if auth == "" {
@@ -80,9 +82,35 @@ func verifyOIDC(r *http.Request, projectNumber string) error {
8082
return fmt.Errorf("invalid Authorization format")
8183
}
8284

83-
// idtoken.Validate caches keys internally
84-
_, err := idtoken.Validate(r.Context(), token, projectNumber)
85-
return err
85+
// Build list of acceptable audiences: webhook URL + project number
86+
var audiences []string
87+
if url := buildRequestURL(r); url != "" {
88+
audiences = append(audiences, url)
89+
}
90+
audiences = append(audiences, projectNumber)
91+
92+
return verifyChatToken(r.Context(), token, audiences)
93+
}
94+
95+
// buildRequestURL reconstructs the original full URL from request headers.
96+
// Returns empty string if scheme/host cannot be determined.
97+
func buildRequestURL(r *http.Request) string {
98+
scheme := r.Header.Get("X-Forwarded-Proto")
99+
if scheme == "" {
100+
if r.TLS != nil {
101+
scheme = "https"
102+
} else {
103+
scheme = "http"
104+
}
105+
}
106+
host := r.Header.Get("X-Forwarded-Host")
107+
if host == "" {
108+
host = r.Host
109+
}
110+
if host == "" {
111+
return ""
112+
}
113+
return scheme + "://" + host + r.URL.Path
86114
}
87115

88116
// isBotMentioned checks if the bot was @mentioned in the message annotations.

0 commit comments

Comments
 (0)