e04ed9c211
CF: Deploy Dev Docs / deploy (push) Has been cancelled
Sync Labels / build (push) Has been cancelled
tests / unit tests (macos-latest) (push) Has been cancelled
tests / unit tests (windows-latest) (push) Has been cancelled
tests / unit tests (ubuntu-latest) (push) Has been cancelled
259 lines
7.4 KiB
Go
259 lines
7.4 KiB
Go
// Copyright 2024 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package google
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/googleapis/mcp-toolbox/internal/auth"
|
|
"google.golang.org/api/idtoken"
|
|
)
|
|
|
|
const AuthServiceType string = "google"
|
|
|
|
// validate interface
|
|
var _ auth.AuthServiceConfig = Config{}
|
|
|
|
// Auth service configuration
|
|
type Config struct {
|
|
Name string `yaml:"name" validate:"required"`
|
|
Type string `yaml:"type" validate:"required"`
|
|
ClientID string `yaml:"clientId"`
|
|
Audience string `yaml:"audience"`
|
|
McpEnabled bool `yaml:"mcpEnabled"`
|
|
ScopesRequired []string `yaml:"scopesRequired"`
|
|
}
|
|
|
|
// Returns the auth service type
|
|
func (cfg Config) AuthServiceConfigType() string {
|
|
return AuthServiceType
|
|
}
|
|
|
|
func (cfg Config) IsMCPEnabled() bool {
|
|
return cfg.McpEnabled
|
|
}
|
|
|
|
// Initialize a Google auth service
|
|
func (cfg Config) Initialize() (auth.AuthService, error) {
|
|
if cfg.McpEnabled {
|
|
if cfg.Audience == "" && cfg.ClientID == "" {
|
|
return nil, fmt.Errorf("`audience` or `clientId` is required when `mcpEnabled` is true")
|
|
}
|
|
} else {
|
|
if cfg.Audience != "" {
|
|
return nil, fmt.Errorf("`audience` is not allowed when `mcpEnabled` is false")
|
|
}
|
|
if len(cfg.ScopesRequired) > 0 {
|
|
return nil, fmt.Errorf("`scopesRequired` is not allowed when `mcpEnabled` is false")
|
|
}
|
|
}
|
|
httpClient := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
a := &AuthService{
|
|
Config: cfg,
|
|
client: httpClient,
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
var _ auth.MCPAuthService = AuthService{}
|
|
|
|
// struct used to store auth service info
|
|
type AuthService struct {
|
|
Config
|
|
client *http.Client
|
|
}
|
|
|
|
// Returns the auth service type
|
|
func (a AuthService) AuthServiceType() string {
|
|
return AuthServiceType
|
|
}
|
|
|
|
func (a AuthService) ToConfig() auth.AuthServiceConfig {
|
|
return a.Config
|
|
}
|
|
|
|
// Returns the name of the auth service
|
|
func (a AuthService) GetName() string {
|
|
return a.Name
|
|
}
|
|
|
|
func (a AuthService) IsMCPEnabled() bool {
|
|
return a.McpEnabled
|
|
}
|
|
|
|
func (a AuthService) GetScopesRequired() []string {
|
|
return a.ScopesRequired
|
|
}
|
|
|
|
func (a AuthService) GetAuthorizationServer() string {
|
|
return "https://accounts.google.com"
|
|
}
|
|
|
|
// Verifies Google ID token and return claims
|
|
func (a AuthService) GetClaimsFromHeader(ctx context.Context, h http.Header) (map[string]any, error) {
|
|
if token := h.Get(a.Name + "_token"); token != "" {
|
|
payload, err := idtoken.Validate(ctx, token, a.ClientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("google ID token verification failure: %w", err)
|
|
}
|
|
return payload.Claims, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// ValidateMCPAuth handles MCP auth token validation for Google
|
|
func (a AuthService) ValidateMCPAuth(ctx context.Context, h http.Header) (map[string]any, error) {
|
|
tokenString := h.Get("Authorization")
|
|
if tokenString == "" {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusUnauthorized, Message: "missing access token", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
headerParts := strings.Split(tokenString, " ")
|
|
if len(headerParts) != 2 || strings.ToLower(headerParts[0]) != "bearer" {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusUnauthorized, Message: "authorization header must be in the format 'Bearer <token>'", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
tokenStr := headerParts[1]
|
|
|
|
if isJWTFormat(tokenStr) {
|
|
aud := a.Audience
|
|
if aud == "" {
|
|
aud = a.ClientID
|
|
}
|
|
if aud == "" {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusUnauthorized, Message: "audience or client ID is required for ID token validation", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
payload, err := idtoken.Validate(ctx, tokenStr, aud)
|
|
if err != nil {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusUnauthorized, Message: fmt.Sprintf("Google ID token verification failure: %v", err), ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
scopeClaim, _ := payload.Claims["scope"].(string)
|
|
if len(a.ScopesRequired) > 0 {
|
|
tokenScopes := strings.Fields(scopeClaim)
|
|
scopeMap := make(map[string]bool)
|
|
for _, s := range tokenScopes {
|
|
scopeMap[s] = true
|
|
}
|
|
|
|
for _, requiredScope := range a.ScopesRequired {
|
|
if !scopeMap[requiredScope] {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusForbidden, Message: "insufficient scopes", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
}
|
|
}
|
|
return payload.Claims, nil
|
|
}
|
|
|
|
// Validate opaque Google access token via tokeninfo
|
|
data := url.Values{}
|
|
data.Set("access_token", tokenStr)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://oauth2.googleapis.com/tokeninfo", strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Google tokeninfo request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
client := a.client
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusInternalServerError, Message: fmt.Sprintf("failed to call Google tokeninfo: %v", err), ScopesRequired: a.ScopesRequired}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusUnauthorized, Message: fmt.Sprintf("Google token validation failed with status: %d", resp.StatusCode), ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read Google tokeninfo response: %w", err)
|
|
}
|
|
|
|
var tokenInfo struct {
|
|
Aud string `json:"aud"`
|
|
Azp string `json:"azp"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
if err := json.Unmarshal(body, &tokenInfo); err != nil {
|
|
return nil, fmt.Errorf("failed to decode Google tokeninfo response: %w", err)
|
|
}
|
|
|
|
aud := tokenInfo.Aud
|
|
if aud == "" {
|
|
aud = tokenInfo.Azp
|
|
}
|
|
|
|
audLimit := a.Audience
|
|
if audLimit == "" {
|
|
audLimit = a.ClientID
|
|
}
|
|
|
|
if audLimit != "" && aud != audLimit {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusUnauthorized, Message: "audience validation failed", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
if len(a.ScopesRequired) > 0 {
|
|
tokenScopes := strings.Fields(tokenInfo.Scope)
|
|
scopeMap := make(map[string]bool)
|
|
for _, s := range tokenScopes {
|
|
scopeMap[s] = true
|
|
}
|
|
|
|
for _, requiredScope := range a.ScopesRequired {
|
|
if !scopeMap[requiredScope] {
|
|
return nil, &auth.MCPAuthError{Code: http.StatusForbidden, Message: "insufficient scopes", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
}
|
|
}
|
|
|
|
claims := map[string]any{
|
|
"aud": aud,
|
|
"scope": tokenInfo.Scope,
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
func isJWTFormat(token string) bool {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(parts[0], "eyJ")
|
|
}
|