Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:32:45 +08:00

434 lines
13 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 auth
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"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"
)
// TestMcpAuth test for MCP Authorization
func TestMcpAuth(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// Set up generic auth mock server
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to create RSA private key: %v", err)
}
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": "https://example.com",
"jwks_uri": "http://" + r.Host + "/jwks",
})
return
}
if r.URL.Path == "/jwks" {
options := jwkset.JWKOptions{
Metadata: jwkset.JWKMetadataOptions{
KID: "test-key-id",
},
}
jwk, _ := jwkset.NewJWKFromKey(privateKey.Public(), options)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"keys": []jwkset.JWKMarshal{jwk.Marshal()},
})
return
}
if r.URL.Path == "/introspect" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"active": true,
"scope": "read:files execute:sql",
"aud": "test-audience",
"exp": time.Now().Add(time.Hour).Unix(),
"iss": "https://example.com",
})
return
}
http.NotFound(w, r)
}))
defer jwksServer.Close()
toolsFile := map[string]any{
"sources": map[string]any{
"my-sqlite": map[string]any{
"type": "sqlite",
"database": ":memory:",
},
},
"authServices": map[string]any{
"my-generic-auth": map[string]any{
"type": "generic",
"audience": "test-audience",
"authorizationServer": jwksServer.URL,
"scopesRequired": []string{"read:files"},
"mcpEnabled": true,
},
},
"tools": map[string]any{
"my-tool": map[string]any{
"type": "sqlite-execute-sql",
"source": "my-sqlite",
"description": "Execute SQL on SQLite",
"scopesRequired": []string{"execute:sql"},
"authRequired": []string{"my-generic-auth"},
},
},
}
args := []string{"--toolbox-url=http://127.0.0.1:5000"}
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)
}
apiSSE := "http://127.0.0.1:5000/mcp/sse"
apiMCP := "http://127.0.0.1:5000/mcp"
// Generate invalid token (wrong scopes)
invalidToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": "https://example.com",
"aud": "test-audience",
"scope": "wrong:scope",
"sub": "test-user",
"exp": time.Now().Add(time.Hour).Unix(),
})
invalidToken.Header["kid"] = "test-key-id"
invalidSignedString, _ := invalidToken.SignedString(privateKey)
// Generate valid token
validToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": "https://example.com",
"aud": "test-audience",
"scope": "read:files",
"sub": "test-user",
"exp": time.Now().Add(time.Hour).Unix(),
})
validToken.Header["kid"] = "test-key-id"
validSignedString, _ := validToken.SignedString(privateKey)
// Generate token with only read:files scope
tokenOnlyRead := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": "https://example.com",
"aud": "test-audience",
"scope": "read:files",
"sub": "test-user",
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenOnlyRead.Header["kid"] = "test-key-id"
tokenOnlyReadStr, _ := tokenOnlyRead.SignedString(privateKey)
// Generate token with BOTH scopes
tokenBoth := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": "https://example.com",
"aud": "test-audience",
"scope": "read:files execute:sql",
"sub": "test-user",
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenBoth.Header["kid"] = "test-key-id"
tokenBothStr, _ := tokenBoth.SignedString(privateKey)
tests := []struct {
name string
token string
method string
url string
body []byte
wantStatusCode int
checkWWWAuth func(t *testing.T, authHeader string)
}{
{
name: "401 Unauthorized without token",
token: "",
wantStatusCode: http.StatusUnauthorized,
checkWWWAuth: func(t *testing.T, authHeader string) {
if !strings.Contains(authHeader, `resource_metadata="http://127.0.0.1:5000/.well-known/oauth-protected-resource"`) || !strings.Contains(authHeader, `scope="read:files"`) {
t.Fatalf("expected WWW-Authenticate header to contain resource_metadata and scope, got: %s", authHeader)
}
},
},
{
name: "403 Forbidden with insufficient scopes",
token: invalidSignedString,
wantStatusCode: http.StatusForbidden,
checkWWWAuth: func(t *testing.T, authHeader string) {
if !strings.Contains(authHeader, `resource_metadata="http://127.0.0.1:5000/.well-known/oauth-protected-resource"`) || !strings.Contains(authHeader, `scope="read:files"`) || !strings.Contains(authHeader, `error="insufficient_scope"`) {
t.Fatalf("expected WWW-Authenticate header to contain error, scope, and resource_metadata, got: %s", authHeader)
}
},
},
{
name: "200 OK with valid token",
token: validSignedString,
wantStatusCode: http.StatusOK,
},
{
name: "200 OK with valid opaque token",
token: "this-is-an-opaque-token",
wantStatusCode: http.StatusOK,
},
{
name: "200 OK with valid opaque token containing two dots",
token: "this.is.opaque",
wantStatusCode: http.StatusOK,
},
{
name: "403 Forbidden with insufficient tool scopes",
token: tokenOnlyReadStr,
method: http.MethodPost,
url: apiMCP,
body: func() []byte {
b, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": map[string]any{
"name": "my-tool",
"arguments": map[string]any{
"sql": "SELECT 1;",
},
},
})
return b
}(),
wantStatusCode: http.StatusForbidden,
checkWWWAuth: func(t *testing.T, authHeader string) {
if !strings.Contains(authHeader, `error="insufficient_scope"`) || !strings.Contains(authHeader, `scope="execute:sql"`) {
t.Fatalf("expected WWW-Authenticate header to contain error and tool scope, got: %s", authHeader)
}
},
},
{
name: "200 OK with sufficient tool scopes",
token: tokenBothStr,
method: http.MethodPost,
url: apiMCP,
body: func() []byte {
b, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": map[string]any{
"name": "my-tool",
"arguments": map[string]any{
"sql": "SELECT 1;",
},
},
})
return b
}(),
wantStatusCode: http.StatusOK,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
method := tc.method
if method == "" {
method = http.MethodGet
}
url := tc.url
if url == "" {
url = apiSSE
}
runTest := func(t *testing.T, protocolVersion string) {
var body io.Reader
if tc.body != nil {
body = bytes.NewBuffer(tc.body)
}
req, _ := http.NewRequest(method, url, body)
if tc.token != "" {
req.Header.Add("Authorization", "Bearer "+tc.token)
}
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
if protocolVersion != "" {
req.Header.Set("MCP-Protocol-Version", protocolVersion)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.wantStatusCode {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected %d, got %d: %s", tc.wantStatusCode, resp.StatusCode, string(bodyBytes))
}
if tc.checkWWWAuth != nil {
authHeader := resp.Header.Get("WWW-Authenticate")
tc.checkWWWAuth(t, authHeader)
}
}
if method == http.MethodPost {
versions := []string{"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"}
for _, v := range versions {
t.Run("version_"+v, func(t *testing.T) {
runTest(t, v)
})
}
} else {
runTest(t, "")
}
})
}
}
// TestGoogleOAuthValidation tests validation of Google access token using type: google
func TestGoogleOAuthValidation(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": "google",
"audience": aud,
"mcpEnabled": true,
},
},
"tools": map[string]any{},
}
args := []string{"--toolbox-url=http://127.0.0.1:5006", "--port=5006"}
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:5006/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))
}
}
// TestGoogleOAuthValidationNoClientIDOrAudienceFails tests that initialization fails when neither clientId nor audience is configured with mcpEnabled: true.
func TestGoogleOAuthValidationNoClientIDOrAudienceFails(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
toolsFile := map[string]any{
"sources": map[string]any{},
"authServices": map[string]any{
"google-auth": map[string]any{
"type": "google",
"mcpEnabled": true,
},
},
"tools": map[string]any{},
}
args := []string{"--toolbox-url=http://127.0.0.1:5007", "--port=5007"}
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()
expectedErrPattern := regexp.MustCompile(`audience.*or.*clientId.*is required when.*mcpEnabled.*is true`)
out, err := testutils.WaitForString(waitCtx, expectedErrPattern, cmd.Out)
if err != nil {
t.Logf("toolbox command logs: \n%s", out)
t.Fatalf("expected toolbox to fail with validation error, but it did not: %s", err)
}
}