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
525 lines
16 KiB
Go
525 lines
16 KiB
Go
// Copyright 2026 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 generic
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MicahParks/keyfunc/v3"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/googleapis/mcp-toolbox/internal/auth"
|
|
"github.com/googleapis/mcp-toolbox/internal/util"
|
|
)
|
|
|
|
const AuthServiceType string = "generic"
|
|
|
|
// validate interface
|
|
var _ auth.AuthServiceConfig = Config{}
|
|
|
|
// Auth service configuration
|
|
type Config struct {
|
|
Name string `yaml:"name" validate:"required"`
|
|
Type string `yaml:"type" validate:"required"`
|
|
Audience string `yaml:"audience" validate:"required"`
|
|
McpEnabled bool `yaml:"mcpEnabled"`
|
|
AuthorizationServer string `yaml:"authorizationServer" validate:"required"`
|
|
ScopesRequired []string `yaml:"scopesRequired"`
|
|
IntrospectionEndpoint string `yaml:"introspectionEndpoint"`
|
|
IntrospectionMethod string `yaml:"introspectionMethod"`
|
|
IntrospectionParamName string `yaml:"introspectionParamName"`
|
|
}
|
|
|
|
// Returns the auth service type
|
|
func (cfg Config) AuthServiceConfigType() string {
|
|
return AuthServiceType
|
|
}
|
|
|
|
func (cfg Config) IsMCPEnabled() bool {
|
|
return cfg.McpEnabled
|
|
}
|
|
|
|
// Initialize a generic auth service
|
|
func (cfg Config) Initialize() (auth.AuthService, error) {
|
|
if !cfg.McpEnabled {
|
|
if cfg.IntrospectionEndpoint != "" {
|
|
return nil, fmt.Errorf("`introspectionEndpoint` is not allowed when `mcpEnabled` is false")
|
|
}
|
|
if cfg.IntrospectionMethod != "" {
|
|
return nil, fmt.Errorf("`introspectionMethod` is not allowed when `mcpEnabled` is false")
|
|
}
|
|
if cfg.IntrospectionParamName != "" {
|
|
return nil, fmt.Errorf("`introspectionParamName` 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 := newSecureHTTPClient()
|
|
|
|
// Discover OIDC endpoints
|
|
jwksURL, introspectionURL, issuer, err := discoverOIDCConfig(httpClient, cfg.AuthorizationServer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to discover OIDC config: %w", err)
|
|
}
|
|
|
|
// Override introspection URL if configured
|
|
if cfg.IntrospectionEndpoint != "" {
|
|
introspectionURL = cfg.IntrospectionEndpoint
|
|
}
|
|
|
|
// Create the keyfunc to fetch and cache the JWKS in the background
|
|
kf, err := keyfunc.NewDefault([]string{jwksURL})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create keyfunc from JWKS URL %s: %w", jwksURL, err)
|
|
}
|
|
|
|
a := &AuthService{
|
|
Config: cfg,
|
|
kf: kf,
|
|
client: httpClient,
|
|
introspectionURL: introspectionURL,
|
|
issuer: issuer,
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func newSecureHTTPClient() *http.Client {
|
|
return &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
|
|
},
|
|
}
|
|
}
|
|
|
|
func discoverOIDCConfig(client *http.Client, AuthorizationServer string) (jwksURI string, introspectionEndpoint string, issuer string, err error) {
|
|
u, err := url.Parse(AuthorizationServer)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("invalid auth URL")
|
|
}
|
|
if u.Scheme != "https" {
|
|
log.Printf("WARNING: HTTP instead of HTTPS is being used for AuthorizationServer: %s", AuthorizationServer)
|
|
}
|
|
|
|
oidcConfigURL, err := url.JoinPath(AuthorizationServer, ".well-known/openid-configuration")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
resp, err := client.Get(oidcConfigURL)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("failed to fetch OIDC config: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
|
|
// Limit read size to 1MB to prevent memory exhaustion
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
var config struct {
|
|
Issuer string `json:"issuer"`
|
|
JwksUri string `json:"jwks_uri"`
|
|
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
|
}
|
|
if err := json.Unmarshal(body, &config); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
if config.Issuer == "" {
|
|
return "", "", "", fmt.Errorf("issuer not found in config")
|
|
}
|
|
|
|
if config.JwksUri == "" {
|
|
return "", "", "", fmt.Errorf("jwks_uri not found in config")
|
|
}
|
|
|
|
// Sanitize the resulting JWKS URI before returning it
|
|
parsedJWKS, err := url.Parse(config.JwksUri)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("invalid jwks_uri detected")
|
|
}
|
|
if parsedJWKS.Scheme != "https" {
|
|
log.Printf("WARNING: HTTP instead of HTTPS is being used for JWKS URI: %s", config.JwksUri)
|
|
}
|
|
|
|
return config.JwksUri, config.IntrospectionEndpoint, config.Issuer, nil
|
|
}
|
|
|
|
var _ auth.MCPAuthService = AuthService{}
|
|
|
|
// struct used to store auth service info
|
|
type AuthService struct {
|
|
Config
|
|
kf keyfunc.Keyfunc
|
|
client *http.Client
|
|
introspectionURL string
|
|
issuer string
|
|
}
|
|
|
|
// 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 a.AuthorizationServer
|
|
}
|
|
|
|
// Verifies generic JWT access token inside the Authorization header
|
|
func (a AuthService) GetClaimsFromHeader(ctx context.Context, h http.Header) (map[string]any, error) {
|
|
if a.McpEnabled {
|
|
return nil, nil
|
|
}
|
|
|
|
tokenString := h.Get(a.Name + "_token")
|
|
if tokenString == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
// Parse and verify the token signature
|
|
token, err := jwt.Parse(tokenString, a.kf.Keyfunc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse and verify JWT token: %w", err)
|
|
}
|
|
|
|
if !token.Valid {
|
|
return nil, fmt.Errorf("invalid JWT token")
|
|
}
|
|
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid JWT claims format")
|
|
}
|
|
|
|
// Validate 'aud' (audience) claim
|
|
aud, err := claims.GetAudience()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not parse audience from token: %w", err)
|
|
}
|
|
|
|
isAudValid := false
|
|
for _, audItem := range aud {
|
|
if audItem == a.Audience {
|
|
isAudValid = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !isAudValid {
|
|
return nil, fmt.Errorf("audience validation failed: expected %s, got %v", a.Audience, aud)
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// MCPAuthError represents an error during MCP authentication validation.
|
|
type MCPAuthError = auth.MCPAuthError
|
|
|
|
// ValidateMCPAuth handles MCP auth token validation
|
|
func (a AuthService) ValidateMCPAuth(ctx context.Context, h http.Header) (map[string]any, error) {
|
|
tokenString := h.Get("Authorization")
|
|
if tokenString == "" {
|
|
return nil, &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, &MCPAuthError{Code: http.StatusUnauthorized, Message: "authorization header must be in the format 'Bearer <token>'", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
tokenStr := headerParts[1]
|
|
|
|
if isJWTFormat(tokenStr) {
|
|
return a.validateJwtToken(ctx, tokenStr)
|
|
}
|
|
return a.validateOpaqueToken(ctx, tokenStr)
|
|
}
|
|
|
|
func isJWTFormat(token string) bool {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return false
|
|
}
|
|
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var header map[string]any
|
|
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
|
return false
|
|
}
|
|
_, hasAlg := header["alg"]
|
|
return hasAlg
|
|
}
|
|
|
|
// validateJwtToken validates a JWT token locally
|
|
func (a AuthService) validateJwtToken(ctx context.Context, tokenStr string) (map[string]any, error) {
|
|
token, err := jwt.Parse(tokenStr, a.kf.Keyfunc)
|
|
if err != nil || !token.Valid {
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid or expired token", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid JWT claims format", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
// Validate issuer
|
|
iss, err := claims.GetIssuer()
|
|
if err != nil {
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "could not parse issuer from token", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
if iss == "" {
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "missing issuer claim in token", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
// Validate audience
|
|
aud, err := claims.GetAudience()
|
|
if err != nil {
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "could not parse audience from token", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
scopeClaim, _ := claims["scope"].(string)
|
|
|
|
err = a.validateClaims(ctx, iss, aud, scopeClaim)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// validateOpaqueToken validates an opaque token by calling the introspection endpoint
|
|
func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) (map[string]any, error) {
|
|
logger, err := util.LoggerFromContext(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get logger from context: %w", err)
|
|
}
|
|
|
|
introspectionURL := a.introspectionURL
|
|
if introspectionURL == "" {
|
|
introspectionURL, err = url.JoinPath(a.AuthorizationServer, "introspect")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to construct introspection URL: %w", err)
|
|
}
|
|
}
|
|
|
|
paramName := a.IntrospectionParamName
|
|
if paramName == "" {
|
|
paramName = "token"
|
|
}
|
|
|
|
var req *http.Request
|
|
if a.IntrospectionMethod == "GET" {
|
|
u, err := url.Parse(introspectionURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse introspection URL: %w", err)
|
|
}
|
|
q := u.Query()
|
|
q.Set(paramName, tokenStr)
|
|
u.RawQuery = q.Encode()
|
|
req, err = http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create introspection request: %w", err)
|
|
}
|
|
} else {
|
|
data := url.Values{}
|
|
data.Set(paramName, tokenStr)
|
|
req, err = http.NewRequestWithContext(ctx, "POST", introspectionURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create introspection request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
// Send request to auth server's introspection endpoint
|
|
resp, err := a.client.Do(req)
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "failed to call introspection endpoint: %v", err)
|
|
return nil, &MCPAuthError{Code: http.StatusInternalServerError, Message: fmt.Sprintf("failed to call introspection endpoint: %v", err), ScopesRequired: a.ScopesRequired}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
logger.WarnContext(ctx, "introspection failed with status: %d", resp.StatusCode)
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: fmt.Sprintf("introspection 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 introspection response: %w", err)
|
|
}
|
|
|
|
var introspectResp struct {
|
|
Active *bool `json:"active"`
|
|
Scope string `json:"scope"`
|
|
Aud json.RawMessage `json:"aud"`
|
|
Audience json.RawMessage `json:"audience"`
|
|
Exp json.Number `json:"exp"`
|
|
Iss string `json:"iss"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &introspectResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse introspection response: %w", err)
|
|
}
|
|
|
|
if introspectResp.Active == nil || !*introspectResp.Active {
|
|
logger.InfoContext(ctx, "token is not active")
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
var expVal int64
|
|
if introspectResp.Exp != "" {
|
|
expVal, err = introspectResp.Exp.Int64()
|
|
if err != nil {
|
|
logger.WarnContext(ctx, "failed to parse exp claim in introspection response: %v", err)
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid exp claim", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
}
|
|
|
|
// Verify expiration (with 1 minute leeway)
|
|
const leeway = 60
|
|
if expVal > 0 && time.Now().Unix() > (expVal+leeway) {
|
|
logger.WarnContext(ctx, "token has expired: exp=%d, now=%d", expVal, time.Now().Unix())
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "token has expired", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
// Extract audience
|
|
// According to RFC 7662, the aud claim can be a string or an array of strings
|
|
// Fallback to "audience" for Google tokeninfo
|
|
audData := introspectResp.Aud
|
|
if len(audData) == 0 {
|
|
audData = introspectResp.Audience
|
|
}
|
|
|
|
var aud []string
|
|
if len(audData) > 0 {
|
|
var audStr string
|
|
var audArr []string
|
|
if err := json.Unmarshal(audData, &audStr); err == nil {
|
|
aud = []string{audStr}
|
|
} else if err := json.Unmarshal(audData, &audArr); err == nil {
|
|
aud = audArr
|
|
} else {
|
|
logger.WarnContext(ctx, "failed to parse aud or audience claim in introspection response")
|
|
return nil, &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid aud claim", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
}
|
|
|
|
err = a.validateClaims(ctx, introspectResp.Iss, aud, introspectResp.Scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
claims := map[string]any{
|
|
"active": introspectResp.Active,
|
|
"scope": introspectResp.Scope,
|
|
"aud": aud,
|
|
"exp": expVal,
|
|
"iss": introspectResp.Iss,
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// validateClaims validates the audience and scopes of a token
|
|
func (a AuthService) validateClaims(ctx context.Context, iss string, aud []string, scopeStr string) error {
|
|
logger, err := util.LoggerFromContext(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get logger from context: %w", err)
|
|
}
|
|
|
|
// Validate issuer
|
|
if iss == "" {
|
|
logger.WarnContext(ctx, "issuer validation failed: missing issuer in token")
|
|
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "missing issuer in token validation", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
if iss != a.issuer {
|
|
logger.WarnContext(ctx, "issuer validation failed: expected %s, got %s", a.issuer, iss)
|
|
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "issuer validation failed", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
|
|
// Validate audience
|
|
if a.Audience != "" {
|
|
isAudValid := false
|
|
for _, audItem := range aud {
|
|
if audItem == a.Audience {
|
|
isAudValid = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !isAudValid {
|
|
logger.WarnContext(ctx, "audience validation failed: expected %s", a.Audience)
|
|
return &MCPAuthError{Code: http.StatusUnauthorized, Message: "audience validation failed", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
}
|
|
|
|
// Check scopes
|
|
if len(a.ScopesRequired) > 0 {
|
|
tokenScopes := strings.Fields(scopeStr)
|
|
scopeMap := make(map[string]bool)
|
|
for _, s := range tokenScopes {
|
|
scopeMap[s] = true
|
|
}
|
|
|
|
for _, requiredScope := range a.ScopesRequired {
|
|
if !scopeMap[requiredScope] {
|
|
logger.WarnContext(ctx, "insufficient scopes: missing %s", requiredScope)
|
|
return &MCPAuthError{Code: http.StatusForbidden, Message: "insufficient scopes", ScopesRequired: a.ScopesRequired}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|