Skip to content

Commit 2585714

Browse files
committed
Support Google introspection endpoint
Google doesn't follow the usual RFC 7662 format but instead uses their own. This PR adds support for parsgin their format if using a well-known Google endpoint. To test: - Go to Google OAuth 2.0 Playground - Select the scopes you want (e.g. `https://www.googleapis.com/auth/userinfo.email`) - Click "Authorize APIs" and complete the OAuth flow You'll get an access token that you can copy and use in the inspector. Fixes: #1411
1 parent 2539324 commit 2585714

File tree

2 files changed

+476
-3
lines changed

2 files changed

+476
-3
lines changed

pkg/auth/token.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"net/http"
1111
"net/url"
12+
"strconv"
1213
"strings"
1314
"sync"
1415
"time"
@@ -22,6 +23,12 @@ import (
2223
"github.com/stacklok/toolhive/pkg/versions"
2324
)
2425

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+
2532
// Common errors
2633
var (
2734
ErrNoToken = errors.New("no token provided")
@@ -372,10 +379,124 @@ func parseIntrospectionClaims(r io.Reader) (jwt.MapClaims, error) {
372379
return claims, nil
373380
}
374381

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+
375491
func (v *TokenValidator) introspectOpaqueToken(ctx context.Context, tokenStr string) (jwt.MapClaims, error) {
376492
if v.introspectURL == "" {
377493
return nil, fmt.Errorf("no introspection endpoint available")
378494
}
495+
496+
// Special case for Google tokeninfo endpoint
497+
if v.introspectURL == googleTokeninfoURL {
498+
return v.introspectGoogleToken(ctx, tokenStr, v.introspectURL)
499+
}
379500
form := url.Values{"token": {tokenStr}}
380501
form.Set("token_type_hint", "access_token")
381502

0 commit comments

Comments
 (0)