diff --git a/docs/en/documentation/configuration/authentication/generic.md b/docs/en/documentation/configuration/authentication/generic.md index dd1b3268d300..134b39c310d8 100644 --- a/docs/en/documentation/configuration/authentication/generic.md +++ b/docs/en/documentation/configuration/authentication/generic.md @@ -104,10 +104,9 @@ When a request is received in this mode, the service will: - Verifies expiration (`exp`) and audience (`aud`). - Verifies required scopes in `scope` claim. 4. For **Opaque Tokens**: - - Calls the introspection endpoint (as listed in the `authorizationServer`'s - OIDC configuration). - - Verifies that the token is `active`. - - Verifies expiration (`exp`) and audience (`aud`). + - Calls the introspection endpoint (either configured via `introspectionEndpoint` + or discovered from the `authorizationServer`'s OIDC configuration). + - Verifies expiration (`exp`) and audience (`aud` or `"audience"` fallback). - Verifies required scopes in `scope` field. #### Example @@ -124,6 +123,22 @@ scopesRequired: - write ``` +#### Google Opaque Access Token Validation Example + +To use Google's `tokeninfo` endpoint for validating opaque access tokens, configure the service to use the `GET` method and `access_token` parameter name: + +```yaml +kind: authServices +name: google-auth +type: generic +audience: "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com" +authorizationServer: https://accounts.google.com +introspectionEndpoint: https://www.googleapis.com/oauth2/v1/tokeninfo +introspectionMethod: GET +introspectionParamName: access_token +mcpEnabled: true +``` + {{< notice tip >}} Use environment variable replacement with the format ${ENV_NAME} instead of hardcoding your secrets into the configuration file. {{< /notice >}} @@ -142,3 +157,6 @@ ${ENV_NAME} instead of hardcoding your secrets into the configuration file. | authorizationServer | string | true | The base URL of your OIDC provider. The service will append `/.well-known/openid-configuration` to discover the JWKS URI. HTTP is allowed but logs a warning. | | mcpEnabled | bool | false | Indicates if MCP endpoint authentication should be applied. Defaults to false. | | scopesRequired | []string | false | A list of required scopes that must be present in the token's `scope` claim to be considered valid. | +| introspectionEndpoint| string | false | Optional override for the token introspection URL. Useful if the provider does not list it in OIDC discovery (e.g., Google). | +| introspectionMethod | string | false | HTTP method to use for introspection. Defaults to "POST". Set to "GET" for providers like Google. | +| introspectionParamName|string | false | Parameter name for the token in the introspection request. Defaults to "token". Set to "access_token" for Google. | diff --git a/internal/auth/generic/generic.go b/internal/auth/generic/generic.go index 7385a6176493..d7e4674c6a9b 100644 --- a/internal/auth/generic/generic.go +++ b/internal/auth/generic/generic.go @@ -38,12 +38,15 @@ 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"` + 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 @@ -61,6 +64,11 @@ func (cfg Config) Initialize() (auth.AuthService, error) { 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 { @@ -288,14 +296,33 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e } } - data := url.Values{} - data.Set("token", tokenStr) + paramName := a.IntrospectionParamName + if paramName == "" { + paramName = "token" + } - req, err := http.NewRequestWithContext(ctx, "POST", introspectionURL, strings.NewReader(data.Encode())) - if err != nil { - return fmt.Errorf("failed to create introspection request: %w", err) + var req *http.Request + if a.IntrospectionMethod == "GET" { + u, err := url.Parse(introspectionURL) + if err != nil { + return 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 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 fmt.Errorf("failed to create introspection request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") // Send request to auth server's introspection endpoint @@ -317,17 +344,18 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e } var introspectResp struct { - Active bool `json:"active"` - Scope string `json:"scope"` - Aud json.RawMessage `json:"aud"` - Exp int64 `json:"exp"` + Active *bool `json:"active"` + Scope string `json:"scope"` + Aud json.RawMessage `json:"aud"` + Audience json.RawMessage `json:"audience"` + Exp int64 `json:"exp"` } if err := json.Unmarshal(body, &introspectResp); err != nil { return fmt.Errorf("failed to parse introspection response: %w", err) } - if !introspectResp.Active { + if introspectResp.Active != nil && !*introspectResp.Active { logger.InfoContext(ctx, "token is not active") return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired} } @@ -341,16 +369,22 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e // 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(introspectResp.Aud) > 0 { + if len(audData) > 0 { var audStr string var audArr []string - if err := json.Unmarshal(introspectResp.Aud, &audStr); err == nil { + if err := json.Unmarshal(audData, &audStr); err == nil { aud = []string{audStr} - } else if err := json.Unmarshal(introspectResp.Aud, &audArr); err == nil { + } else if err := json.Unmarshal(audData, &audArr); err == nil { aud = audArr } else { - logger.WarnContext(ctx, "failed to parse aud claim in introspection response") + logger.WarnContext(ctx, "failed to parse aud or audience claim in introspection response") return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid aud claim", ScopesRequired: a.ScopesRequired} } } diff --git a/tests/auth/auth_integration_test.go b/tests/auth/auth_integration_test.go index 990c769b3c93..e485a4932029 100644 --- a/tests/auth/auth_integration_test.go +++ b/tests/auth/auth_integration_test.go @@ -29,6 +29,7 @@ import ( "github.com/MicahParks/jwkset" "github.com/golang-jwt/jwt/v5" + "github.com/googleapis/mcp-toolbox/internal/sources" "github.com/googleapis/mcp-toolbox/internal/testutils" "github.com/googleapis/mcp-toolbox/tests" ) @@ -191,3 +192,86 @@ func TestMcpAuth(t *testing.T) { }) } } + +// TestGoogleTokenValidation tests validation of Google access token +func TestGoogleTokenValidation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + // Get access token + accessToken, err := sources.GetIAMAccessToken(ctx) + if err != nil { + t.Errorf("error getting access token from ADC: %s", err) + } + + // Call tokeninfo to get audience + resp, err := http.Get("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=" + accessToken) + if err != nil { + t.Fatalf("failed to call tokeninfo: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("tokeninfo returned non-200 status %d: %s", resp.StatusCode, string(body)) + } + + var tokenInfo struct { + Audience string `json:"audience"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil { + t.Fatalf("failed to decode tokeninfo response: %v", err) + } + + aud := tokenInfo.Audience + if aud == "" { + t.Fatalf("audience is empty in tokeninfo response") + } + + toolsFile := map[string]any{ + "sources": map[string]any{}, + "authServices": map[string]any{ + "google-auth": map[string]any{ + "type": "generic", + "audience": aud, + "authorizationServer": "https://accounts.google.com", + "introspectionEndpoint": "https://www.googleapis.com/oauth2/v1/tokeninfo", + "introspectionMethod": "GET", + "introspectionParamName": "access_token", + "mcpEnabled": true, + }, + }, + "tools": map[string]any{}, + } + + args := []string{"--enable-api", "--toolbox-url=http://127.0.0.1:5005", "--port=5005"} + cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...) + if err != nil { + t.Fatalf("command initialization returned an error: %s", err) + } + defer cleanup() + + waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out) + if err != nil { + t.Logf("toolbox command logs: \n%s", out) + t.Fatalf("toolbox didn't start successfully: %s", err) + } + + api := "http://127.0.0.1:5005/mcp/sse" + + req, _ := http.NewRequest(http.MethodGet, api, nil) + req.Header.Add("Authorization", "Bearer "+accessToken) + + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("unable to send request: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) + } +}