9
9
"io"
10
10
"net/http"
11
11
"net/url"
12
+ "strconv"
12
13
"strings"
13
14
"sync"
14
15
"time"
@@ -22,6 +23,12 @@ import (
22
23
"github.com/stacklok/toolhive/pkg/versions"
23
24
)
24
25
26
+ // Google OAuth endpoints
27
+ const (
28
+ //nolint:gosec // This is a public API endpoint URL, not credentials
29
+ googleTokeninfoURL = "https://oauth2.googleapis.com/tokeninfo"
30
+ )
31
+
25
32
// Common errors
26
33
var (
27
34
ErrNoToken = errors .New ("no token provided" )
@@ -372,10 +379,124 @@ func parseIntrospectionClaims(r io.Reader) (jwt.MapClaims, error) {
372
379
return claims , nil
373
380
}
374
381
382
+ func parseGoogleTokeninfoClaims (r io.Reader ) (jwt.MapClaims , error ) {
383
+ var googleResp struct {
384
+ AZP string `json:"azp,omitempty"`
385
+ AUD string `json:"aud,omitempty"`
386
+ Sub string `json:"sub,omitempty"`
387
+ Scope string `json:"scope,omitempty"`
388
+ Exp string `json:"exp,omitempty"` // Google returns as string
389
+ ExpiresIn string `json:"expires_in,omitempty"` // Google returns as string
390
+ Email string `json:"email,omitempty"`
391
+ EmailVerified string `json:"email_verified,omitempty"` // Google returns as string ("true"/"false")
392
+ }
393
+
394
+ if err := json .NewDecoder (r ).Decode (& googleResp ); err != nil {
395
+ return nil , fmt .Errorf ("failed to decode Google tokeninfo JSON: %w" , err )
396
+ }
397
+
398
+ // Parse and validate expiration first
399
+ expInt , err := strconv .ParseInt (googleResp .Exp , 10 , 64 )
400
+ if err != nil || googleResp .Exp == "" {
401
+ return nil , ErrInvalidToken
402
+ }
403
+
404
+ // Check if token is active (not expired)
405
+ currentTime := time .Now ().Unix ()
406
+ isActive := expInt > currentTime
407
+ if ! isActive {
408
+ return nil , ErrTokenExpired
409
+ }
410
+
411
+ claims := jwt.MapClaims {
412
+ "active" : true ,
413
+ "exp" : float64 (expInt ), // JWT expects float64
414
+ "iss" : "https://accounts.google.com" , // Default issuer
415
+ }
416
+
417
+ // Map standard fields that ToolHive uses
418
+ if googleResp .Sub != "" {
419
+ claims ["sub" ] = strings .TrimSpace (googleResp .Sub )
420
+ }
421
+ if googleResp .AUD != "" {
422
+ claims ["aud" ] = googleResp .AUD
423
+ }
424
+ if googleResp .Scope != "" {
425
+ claims ["scope" ] = strings .TrimSpace (googleResp .Scope )
426
+ }
427
+
428
+ // Preserve Google-specific fields
429
+ if googleResp .Email != "" {
430
+ claims ["email" ] = googleResp .Email
431
+ }
432
+ if googleResp .EmailVerified != "" {
433
+ claims ["email_verified" ] = googleResp .EmailVerified
434
+ }
435
+ if googleResp .AZP != "" {
436
+ claims ["azp" ] = googleResp .AZP
437
+ }
438
+ if googleResp .ExpiresIn != "" {
439
+ claims ["expires_in" ] = googleResp .ExpiresIn
440
+ }
441
+
442
+ return claims , nil
443
+ }
444
+
445
+ func (v * TokenValidator ) introspectGoogleToken (ctx context.Context , tokenStr string ,
446
+ introspectionURL string ) (jwt.MapClaims , error ) {
447
+ // Parse the introspection URL and add query parameter safely
448
+ parsedURL , err := url .Parse (introspectionURL )
449
+ if err != nil {
450
+ return nil , fmt .Errorf ("failed to parse introspection URL: %w" , err )
451
+ }
452
+
453
+ // Add access_token query parameter
454
+ query := parsedURL .Query ()
455
+ query .Set ("access_token" , tokenStr )
456
+ parsedURL .RawQuery = query .Encode ()
457
+
458
+ tokeninfoURL := parsedURL .String ()
459
+
460
+ req , err := http .NewRequestWithContext (ctx , "GET" , tokeninfoURL , nil )
461
+ if err != nil {
462
+ return nil , fmt .Errorf ("failed to create Google tokeninfo request: %w" , err )
463
+ }
464
+
465
+ req .Header .Set ("Accept" , "application/json" )
466
+ req .Header .Set ("User-Agent" , fmt .Sprintf ("ToolHive/%s" , versions .Version ))
467
+
468
+ resp , err := v .client .Do (req )
469
+ if err != nil {
470
+ return nil , fmt .Errorf ("google tokeninfo request failed: %w" , err )
471
+ }
472
+ defer resp .Body .Close ()
473
+
474
+ if resp .StatusCode != http .StatusOK {
475
+ return nil , fmt .Errorf ("google tokeninfo failed, status %d" , resp .StatusCode )
476
+ }
477
+
478
+ claims , err := parseGoogleTokeninfoClaims (resp .Body )
479
+ if err != nil {
480
+ return nil , err
481
+ }
482
+
483
+ // Validate required claims (exp, iss, aud if configured)
484
+ if err := v .validateClaims (claims ); err != nil {
485
+ return nil , err
486
+ }
487
+
488
+ return claims , nil
489
+ }
490
+
375
491
func (v * TokenValidator ) introspectOpaqueToken (ctx context.Context , tokenStr string ) (jwt.MapClaims , error ) {
376
492
if v .introspectURL == "" {
377
493
return nil , fmt .Errorf ("no introspection endpoint available" )
378
494
}
495
+
496
+ // Special case for Google tokeninfo endpoint
497
+ if v .introspectURL == googleTokeninfoURL {
498
+ return v .introspectGoogleToken (ctx , tokenStr , v .introspectURL )
499
+ }
379
500
form := url.Values {"token" : {tokenStr }}
380
501
form .Set ("token_type_hint" , "access_token" )
381
502
0 commit comments