From 533eeaa5c1d866bfaa1fd7dea8a70b8cc286e400 Mon Sep 17 00:00:00 2001 From: duwenxin Date: Fri, 3 Apr 2026 15:05:38 -0400 Subject: [PATCH 1/7] add helper to validate opaquer token --- internal/auth/generic/generic.go | 91 ++++++++++++++- internal/auth/generic/generic_test.go | 155 ++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) diff --git a/internal/auth/generic/generic.go b/internal/auth/generic/generic.go index c2bd75d7dee9..336e87bd850d 100644 --- a/internal/auth/generic/generic.go +++ b/internal/auth/generic/generic.go @@ -230,7 +230,20 @@ func (a AuthService) ValidateMCPAuth(ctx context.Context, h http.Header) error { return &MCPAuthError{Code: http.StatusUnauthorized, Message: "authorization header must be in the format 'Bearer '", ScopesRequired: a.ScopesRequired} } - token, err := jwt.Parse(headerParts[1], a.kf.Keyfunc) + tokenStr := headerParts[1] + + if isJWTFormat(tokenStr) { + return a.validateJwtToken(ctx, tokenStr) + } + return a.validateOpaqueToken(ctx, tokenStr) +} + +func isJWTFormat(token string) bool { + return strings.Count(token, ".") == 2 +} + +func (a AuthService) validateJwtToken(ctx context.Context, tokenStr string) error { + token, err := jwt.Parse(tokenStr, a.kf.Keyfunc) if err != nil || !token.Valid { return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid or expired token", ScopesRequired: a.ScopesRequired} } @@ -280,3 +293,79 @@ func (a AuthService) ValidateMCPAuth(ctx context.Context, h http.Header) error { return nil } + +func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) error { + introspectionURL := a.AuthorizationServer + "/introspect" + + data := url.Values{} + data.Set("token", 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("Accept", "application/json") + + // Use a client similar to the one in discoverJWKSURL + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return &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 { + return &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 fmt.Errorf("failed to read introspection response: %w", err) + } + + var introspectResp struct { + Active bool `json:"active"` + Scope string `json:"scope"` + ClientId string `json:"client_id"` + 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 { + return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired} + } + + // Verify audience (client_id) + if a.Audience != "" && introspectResp.ClientId != a.Audience { + return &MCPAuthError{Code: http.StatusUnauthorized, Message: "audience validation failed", ScopesRequired: a.ScopesRequired} + } + + // Verify expiration + if introspectResp.Exp > 0 && time.Now().Unix() > introspectResp.Exp { + return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token has expired", ScopesRequired: a.ScopesRequired} + } + + // Verify scopes + if len(a.ScopesRequired) > 0 { + tokenScopes := strings.Split(introspectResp.Scope, " ") + scopeMap := make(map[string]bool) + for _, s := range tokenScopes { + scopeMap[s] = true + } + + for _, requiredScope := range a.ScopesRequired { + if !scopeMap[requiredScope] { + return &MCPAuthError{Code: http.StatusForbidden, Message: "insufficient scopes", ScopesRequired: a.ScopesRequired} + } + } + } + + return nil +} diff --git a/internal/auth/generic/generic_test.go b/internal/auth/generic/generic_test.go index 0238eb1b6c6e..04a67981a288 100644 --- a/internal/auth/generic/generic_test.go +++ b/internal/auth/generic/generic_test.go @@ -206,3 +206,158 @@ func TestGetClaimsFromHeader(t *testing.T) { }) } } + +func TestValidateMCPAuth_Opaque(t *testing.T) { + tests := []struct { + name string + token string + scopesRequired []string + audience string + mockResponse map[string]any + mockStatus int + wantError bool + errContains string + }{ + { + name: "valid opaque token", + token: "opaque-valid", + scopesRequired: []string{"read:files"}, + audience: "my-audience", + mockResponse: map[string]any{ + "active": true, + "scope": "read:files write:files", + "client_id": "my-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: false, + }, + { + name: "inactive opaque token", + token: "opaque-inactive", + scopesRequired: []string{"read:files"}, + mockResponse: map[string]any{ + "active": false, + }, + mockStatus: http.StatusOK, + wantError: true, + errContains: "token is not active", + }, + { + name: "insufficient scopes", + token: "opaque-bad-scope", + scopesRequired: []string{"read:files", "write:files"}, + mockResponse: map[string]any{ + "active": true, + "scope": "read:files", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: true, + errContains: "insufficient scopes", + }, + { + name: "audience mismatch", + token: "opaque-bad-aud", + audience: "my-audience", + mockResponse: map[string]any{ + "active": true, + "client_id": "wrong-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: true, + errContains: "audience validation failed", + }, + { + name: "expired token", + token: "opaque-expired", + mockResponse: map[string]any{ + "active": true, + "exp": time.Now().Add(-1 * time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: true, + errContains: "token has expired", + }, + { + name: "introspection error status", + token: "opaque-error", + mockResponse: map[string]any{ + "error": "server_error", + }, + mockStatus: http.StatusInternalServerError, + wantError: true, + errContains: "introspection failed with status: 500", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + handler := 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" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "keys": []any{}, + }) + return + } + if r.URL.Path == "/introspect" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.mockStatus) + _ = json.NewEncoder(w).Encode(tc.mockResponse) + return + } + http.NotFound(w, r) + }) + server := httptest.NewServer(handler) + defer server.Close() + + cfg := Config{ + Name: "test-generic-auth", + Type: "generic", + Audience: tc.audience, + AuthorizationServer: server.URL, + ScopesRequired: tc.scopesRequired, + } + + authService, err := cfg.Initialize() + if err != nil { + t.Fatalf("failed to initialize auth service: %v", err) + } + + genericAuth, ok := authService.(*AuthService) + if !ok { + t.Fatalf("expected *AuthService, got %T", authService) + } + + ctx := context.Background() + header := http.Header{} + header.Set("Authorization", "Bearer "+tc.token) + + err = genericAuth.ValidateMCPAuth(ctx, header) + + if tc.wantError { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) { + t.Errorf("expected error containing %q, got: %v", tc.errContains, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} + From 897d5e91aee5a9aa9e32bd267e1df0925b34fc0e Mon Sep 17 00:00:00 2001 From: duwenxin Date: Fri, 3 Apr 2026 17:12:46 -0400 Subject: [PATCH 2/7] add tests --- internal/auth/generic/generic.go | 2 +- internal/auth/generic/generic_test.go | 279 ++++++++++++++++++++++++-- tests/auth/auth_integration_test.go | 152 ++++++++------ 3 files changed, 354 insertions(+), 79 deletions(-) diff --git a/internal/auth/generic/generic.go b/internal/auth/generic/generic.go index 336e87bd850d..949027205656 100644 --- a/internal/auth/generic/generic.go +++ b/internal/auth/generic/generic.go @@ -307,7 +307,7 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - // Use a client similar to the one in discoverJWKSURL + // Send request to auth server's introspection endpoint client := &http.Client{ Timeout: 10 * time.Second, } diff --git a/internal/auth/generic/generic_test.go b/internal/auth/generic/generic_test.go index 04a67981a288..83b9d60e75ba 100644 --- a/internal/auth/generic/generic_test.go +++ b/internal/auth/generic/generic_test.go @@ -239,8 +239,8 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { mockResponse: map[string]any{ "active": false, }, - mockStatus: http.StatusOK, - wantError: true, + mockStatus: http.StatusOK, + wantError: true, errContains: "token is not active", }, { @@ -252,21 +252,21 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { "scope": "read:files", "exp": time.Now().Add(time.Hour).Unix(), }, - mockStatus: http.StatusOK, - wantError: true, + mockStatus: http.StatusOK, + wantError: true, errContains: "insufficient scopes", }, { - name: "audience mismatch", - token: "opaque-bad-aud", - audience: "my-audience", + name: "audience mismatch", + token: "opaque-bad-aud", + audience: "my-audience", mockResponse: map[string]any{ "active": true, "client_id": "wrong-audience", "exp": time.Now().Add(time.Hour).Unix(), }, - mockStatus: http.StatusOK, - wantError: true, + mockStatus: http.StatusOK, + wantError: true, errContains: "audience validation failed", }, { @@ -276,8 +276,8 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { "active": true, "exp": time.Now().Add(-1 * time.Hour).Unix(), }, - mockStatus: http.StatusOK, - wantError: true, + mockStatus: http.StatusOK, + wantError: true, errContains: "token has expired", }, { @@ -286,8 +286,8 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { mockResponse: map[string]any{ "error": "server_error", }, - mockStatus: http.StatusInternalServerError, - wantError: true, + mockStatus: http.StatusInternalServerError, + wantError: true, errContains: "introspection failed with status: 500", }, } @@ -361,3 +361,256 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { } } +func TestValidateJwtToken(t *testing.T) { + privateKey := generateRSAPrivateKey(t) + keyID := "test-key-id" + server := setupJWKSMockServer(t, privateKey, keyID) + defer server.Close() + + cfg := Config{ + Name: "test-generic-auth", + Type: "generic", + Audience: "my-audience", + AuthorizationServer: server.URL, + ScopesRequired: []string{"read:files"}, + } + + authService, err := cfg.Initialize() + if err != nil { + t.Fatalf("failed to initialize auth service: %v", err) + } + + genericAuth, ok := authService.(*AuthService) + if !ok { + t.Fatalf("expected *AuthService, got %T", authService) + } + + tests := []struct { + name string + token string + wantError bool + errContains string + }{ + { + name: "valid jwt", + token: generateValidToken(t, privateKey, keyID, jwt.MapClaims{ + "aud": "my-audience", + "scope": "read:files", + "exp": time.Now().Add(time.Hour).Unix(), + }), + wantError: false, + }, + { + name: "invalid token (wrong signature)", + token: "header.payload.signature", + wantError: true, + errContains: "invalid or expired token", + }, + { + name: "audience mismatch", + token: generateValidToken(t, privateKey, keyID, jwt.MapClaims{ + "aud": "wrong-audience", + "scope": "read:files", + "exp": time.Now().Add(time.Hour).Unix(), + }), + wantError: true, + errContains: "audience validation failed", + }, + { + name: "insufficient scopes", + token: generateValidToken(t, privateKey, keyID, jwt.MapClaims{ + "aud": "my-audience", + "scope": "wrong:scope", + "exp": time.Now().Add(time.Hour).Unix(), + }), + wantError: true, + errContains: "insufficient scopes", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := genericAuth.validateJwtToken(context.Background(), tc.token) + if tc.wantError { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) { + t.Errorf("expected error containing %q, got: %v", tc.errContains, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateOpaqueToken(t *testing.T) { + tests := []struct { + name string + token string + scopesRequired []string + audience string + mockResponse map[string]any + mockStatus int + wantError bool + errContains string + }{ + { + name: "valid opaque token", + token: "opaque-valid", + scopesRequired: []string{"read:files"}, + audience: "my-audience", + mockResponse: map[string]any{ + "active": true, + "scope": "read:files write:files", + "client_id": "my-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: false, + }, + { + name: "inactive opaque token", + token: "opaque-inactive", + scopesRequired: []string{"read:files"}, + mockResponse: map[string]any{ + "active": false, + }, + mockStatus: http.StatusOK, + wantError: true, + errContains: "token is not active", + }, + { + name: "insufficient scopes", + token: "opaque-bad-scope", + scopesRequired: []string{"read:files", "write:files"}, + mockResponse: map[string]any{ + "active": true, + "scope": "read:files", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: true, + errContains: "insufficient scopes", + }, + { + name: "audience mismatch", + token: "opaque-bad-aud", + audience: "my-audience", + mockResponse: map[string]any{ + "active": true, + "client_id": "wrong-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: true, + errContains: "audience validation failed", + }, + { + name: "expired token", + token: "opaque-expired", + mockResponse: map[string]any{ + "active": true, + "exp": time.Now().Add(-1 * time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: true, + errContains: "token has expired", + }, + { + name: "introspection error status", + token: "opaque-error", + mockResponse: map[string]any{ + "error": "server_error", + }, + mockStatus: http.StatusInternalServerError, + wantError: true, + errContains: "introspection failed with status: 500", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/introspect" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.mockStatus) + _ = json.NewEncoder(w).Encode(tc.mockResponse) + return + } + http.NotFound(w, r) + }) + server := httptest.NewServer(handler) + defer server.Close() + + genericAuth := &AuthService{ + Config: Config{ + Audience: tc.audience, + AuthorizationServer: server.URL, + ScopesRequired: tc.scopesRequired, + }, + } + + err := genericAuth.validateOpaqueToken(context.Background(), tc.token) + + if tc.wantError { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) { + t.Errorf("expected error containing %q, got: %v", tc.errContains, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} + +func TestIsJWTFormat(t *testing.T) { + tests := []struct { + name string + token string + want bool + }{ + { + name: "valid JWT format", + token: "header.payload.signature", + want: true, + }, + { + name: "opaque token", + token: "opaque-token", + want: false, + }, + { + name: "too many dots", + token: "a.b.c.d", + want: false, + }, + { + name: "too few dots", + token: "a.b", + want: false, + }, + { + name: "empty string", + token: "", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := isJWTFormat(tc.token) + if got != tc.want { + t.Errorf("isJWTFormat(%q) = %v; want %v", tc.token, got, tc.want) + } + }) + } +} diff --git a/tests/auth/auth_integration_test.go b/tests/auth/auth_integration_test.go index e434ac8c2d38..32ba88414d6b 100644 --- a/tests/auth/auth_integration_test.go +++ b/tests/auth/auth_integration_test.go @@ -65,6 +65,16 @@ func TestMcpAuth(t *testing.T) { }) 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", + "client_id": "test-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }) + return + } http.NotFound(w, r) })) defer jwksServer.Close() @@ -82,7 +92,7 @@ func TestMcpAuth(t *testing.T) { }, "tools": map[string]any{}, } - args := []string{"--enable-api"} + args := []string{"--enable-api", "--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) @@ -99,73 +109,85 @@ func TestMcpAuth(t *testing.T) { api := "http://127.0.0.1:5000/mcp/sse" - t.Run("401 Unauthorized without token", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodGet, api, nil) - 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.StatusUnauthorized { - t.Fatalf("expected 401, got %d", resp.StatusCode) - } - authHeader := resp.Header.Get("WWW-Authenticate") - if !strings.Contains(authHeader, `resource_metadata="/.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) - } + // Generate invalid token (wrong scopes) + invalidToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "aud": "test-audience", + "scope": "wrong:scope", + "sub": "test-user", + "exp": time.Now().Add(time.Hour).Unix(), }) - - t.Run("403 Forbidden with insufficient scopes", func(t *testing.T) { - // Generate valid token but wrong scopes - token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ - "aud": "test-audience", - "scope": "wrong:scope", - "sub": "test-user", - "exp": time.Now().Add(time.Hour).Unix(), - }) - token.Header["kid"] = "test-key-id" - signedString, _ := token.SignedString(privateKey) - - req, _ := http.NewRequest(http.MethodGet, api, nil) - req.Header.Add("Authorization", "Bearer "+signedString) - 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.StatusForbidden { - t.Fatalf("expected 403, got %d", resp.StatusCode) - } - authHeader := resp.Header.Get("WWW-Authenticate") - if !strings.Contains(authHeader, `resource_metadata="/.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) - } + invalidToken.Header["kid"] = "test-key-id" + invalidSignedString, _ := invalidToken.SignedString(privateKey) + + // Generate valid token (correct scopes) + validToken := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "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) + + tests := []struct { + name string + token string + 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, + }, + } - t.Run("200 OK with valid token", func(t *testing.T) { - // Generate valid token with correct scopes - token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ - "aud": "test-audience", - "scope": "read:files", - "sub": "test-user", - "exp": time.Now().Add(time.Hour).Unix(), - }) - token.Header["kid"] = "test-key-id" - signedString, _ := token.SignedString(privateKey) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, api, nil) + if tc.token != "" { + req.Header.Add("Authorization", "Bearer "+tc.token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("unable to send request: %s", err) + } + defer resp.Body.Close() - req, _ := http.NewRequest(http.MethodGet, api, nil) - req.Header.Add("Authorization", "Bearer "+signedString) - 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 resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) - } - }) + if tc.checkWWWAuth != nil { + authHeader := resp.Header.Get("WWW-Authenticate") + tc.checkWWWAuth(t, authHeader) + } + }) + } } From 5b4ae2674bfb8c7792290a368503b0e6536af04a Mon Sep 17 00:00:00 2001 From: duwenxin Date: Fri, 3 Apr 2026 17:37:56 -0400 Subject: [PATCH 3/7] update docs --- .../configuration/authentication/generic.md | 108 +++++++++++++++--- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/docs/en/documentation/configuration/authentication/generic.md b/docs/en/documentation/configuration/authentication/generic.md index b5fa8d0c5a11..9098dcb4e5cf 100644 --- a/docs/en/documentation/configuration/authentication/generic.md +++ b/docs/en/documentation/configuration/authentication/generic.md @@ -19,26 +19,33 @@ your client ID or the intended audience for the token), the `authorizationServer` of your identity provider, and optionally a list of `scopesRequired` that must be present in the token's claims. -## Behavior +## Usage Modes -### Token Validation +The Generic Auth Service supports two distinct modes of operation: -When a request is received, the service will: +### 1. Toolbox Auth -1. Extract the token from the `_token` header (e.g., - `my-generic-auth_token`). -2. Fetch the JWKS from the configured `authorizationServer` (caching it in the - background) to verify the token's signature. -3. Validate that the token is not expired and its signature is valid. -4. Verify that the `aud` (audience) claim matches the configured `audience`. - claim contains all required scopes. -5. Return the validated claims to be used for [Authenticated - Parameters][auth-params] or [Authorized Invocations][auth-invoke]. +This mode is used for Toolbox's native authentication/authorization features. It +is active when you reference the auth service in a tool's configuration and +`mcpEnabled` is set to false. -[auth-invoke]: ../tools/_index.md#authorized-invocations -[auth-params]: ../tools/_index.md#authenticated-parameters +- **Header**: Expects the token in a custom header matching `_token` + (e.g., `my-generic-auth_token`). +- **Token Type**: Only supports **JWT** (OIDC) tokens. +- **Usage**: Used for [Authenticated Parameters][auth-params] and [Authorized + Invocations][auth-invoke]. + +#### Token Validation + +When a request is received in this mode, the service will: -## Example +1. Extract the token from the `_token` header. +2. Treat it as a JWT (opaque tokens are not supported in this mode). +3. Validates signature using JWKS fetched from `authorizationServer`. +4. Verifies expiration (`exp`) and audience (`aud`). +5. Verifies required scopes in `scope` claim. + +#### Example ```yaml kind: authServices @@ -46,7 +53,71 @@ name: my-generic-auth type: generic audience: ${YOUR_OIDC_AUDIENCE} authorizationServer: https://your-idp.example.com -mcpEnabled: false +# mcpEnabled: false +scopesRequired: + - read + - write +``` + +#### Tool Usage Example + +To use this auth service for **Authenticated Parameters** or **Authorized +Invocations**, reference it in your tool configuration: + +```yaml +kind: tool +name: secure_query +type: postgres-sql +source: my-pg-instance +statement: | + SELECT * FROM data WHERE user_id = $1 +parameters: + - name: user_id + type: strings + description: Auto-populated from token + authServices: + - name: my-generic-auth + field: sub # Extract 'sub' claim from JWT +authRequired: + - my-generic-auth # Require valid token for invocation +``` + +### 2. MCP Authorization + +This mode enforces global authentication for all MCP endpoints. It is active +when `mcpEnabled` is set to `true` in the auth service configuration. + +- **Header**: Expects the token in the standard `Authorization: Bearer ` + header. +- **Token Type**: Supports both **JWT** and **Opaque** tokens. +- **Usage**: Used to secure the entire MCP server. + +#### Token Validation + +When a request is received in this mode, the service will: + +1. Extract the token from the `Authorization` header after `Bearer ` prefix. +2. Determine if the token is a JWT or an opaque token based on format (JWTs + contain exactly two dots). +3. For **JWTs**: + - Validates signature using JWKS fetched from `authorizationServer`. + - Verifies expiration (`exp`) and audience (`aud`). + - Verifies required scopes in `scope` claim. +4. For **Opaque Tokens**: + - Calls the introspection endpoint (`/introspect`). + - Verifies that the token is `active`. + - Verifies expiration (`exp`) and audience (`client_id`). + - Verifies required scopes in `scope` field. + +#### Example + +```yaml +kind: authServices +name: my-generic-auth +type: generic +audience: ${YOUR_TOKEN_AUDIENCE} +authorizationServer: https://your-idp.example.com +mcpEnabled: true scopesRequired: - read - write @@ -56,6 +127,11 @@ scopesRequired: ${ENV_NAME} instead of hardcoding your secrets into the configuration file. {{< /notice >}} +[auth-invoke]: ../tools/_index.md#authorized-invocations +[auth-params]: ../tools/_index.md#authenticated-parameters +[mcp-auth]: + https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + ## Reference | **field** | **type** | **required** | **description** | From 7ccf83eaada6f357e7786e767fb184b170f4c648 Mon Sep 17 00:00:00 2001 From: duwenxin Date: Fri, 3 Apr 2026 17:48:59 -0400 Subject: [PATCH 4/7] add logger and leeway --- internal/auth/generic/generic.go | 56 ++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/internal/auth/generic/generic.go b/internal/auth/generic/generic.go index 949027205656..70e03fb2a3b6 100644 --- a/internal/auth/generic/generic.go +++ b/internal/auth/generic/generic.go @@ -28,6 +28,7 @@ import ( "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" @@ -71,6 +72,22 @@ func (cfg Config) Initialize() (auth.AuthService, error) { 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 discoverJWKSURL(AuthorizationServer string) (string, error) { u, err := url.Parse(AuthorizationServer) if err != nil { @@ -86,20 +103,7 @@ func discoverJWKSURL(AuthorizationServer string) (string, error) { } // HTTP Client - client := &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, - }, - // Prevent redirect loops or redirects to internal sites - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } + client := newSecureHTTPClient() resp, err := client.Get(oidcConfigURL) if err != nil { @@ -295,7 +299,15 @@ func (a AuthService) validateJwtToken(ctx context.Context, tokenStr string) erro } func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) error { - introspectionURL := a.AuthorizationServer + "/introspect" + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return fmt.Errorf("failed to get logger from context: %w", err) + } + + introspectionURL, err := url.JoinPath(a.AuthorizationServer, "introspect") + if err != nil { + return fmt.Errorf("failed to construct introspection URL: %w", err) + } data := url.Values{} data.Set("token", tokenStr) @@ -308,17 +320,17 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e req.Header.Set("Accept", "application/json") // Send request to auth server's introspection endpoint - client := &http.Client{ - Timeout: 10 * time.Second, - } + client := newSecureHTTPClient() resp, err := client.Do(req) if err != nil { + logger.ErrorContext(ctx, "failed to call introspection endpoint: %v", err) return &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 &MCPAuthError{Code: http.StatusUnauthorized, Message: fmt.Sprintf("introspection failed with status: %d", resp.StatusCode), ScopesRequired: a.ScopesRequired} } @@ -339,16 +351,20 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e } if !introspectResp.Active { + logger.InfoContext(ctx, "token is not active") return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired} } // Verify audience (client_id) if a.Audience != "" && introspectResp.ClientId != a.Audience { + logger.WarnContext(ctx, "audience validation failed: expected %s, got %s", a.Audience, introspectResp.ClientId) return &MCPAuthError{Code: http.StatusUnauthorized, Message: "audience validation failed", ScopesRequired: a.ScopesRequired} } - // Verify expiration - if introspectResp.Exp > 0 && time.Now().Unix() > introspectResp.Exp { + // Verify expiration (with 1 minute leeway) to account for potential time difference between Toolbox and the auth server + const leeway = 60 + if introspectResp.Exp > 0 && time.Now().Unix() > (introspectResp.Exp+leeway) { + logger.WarnContext(ctx, "token has expired: exp=%d, now=%d", introspectResp.Exp, time.Now().Unix()) return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token has expired", ScopesRequired: a.ScopesRequired} } From 0cf096da59a92ba83cf41728a9597dfa6df7e1a1 Mon Sep 17 00:00:00 2001 From: duwenxin Date: Thu, 9 Apr 2026 11:28:57 -0400 Subject: [PATCH 5/7] resolve comments --- internal/auth/generic/generic.go | 162 ++++++++++++++------------ internal/auth/generic/generic_test.go | 137 ++++++++++++++++++---- 2 files changed, 204 insertions(+), 95 deletions(-) diff --git a/internal/auth/generic/generic.go b/internal/auth/generic/generic.go index 70e03fb2a3b6..7385a6176493 100644 --- a/internal/auth/generic/generic.go +++ b/internal/auth/generic/generic.go @@ -53,10 +53,12 @@ func (cfg Config) AuthServiceConfigType() string { // Initialize a generic auth service func (cfg Config) Initialize() (auth.AuthService, error) { - // Discover the JWKS URL from the OIDC configuration endpoint - jwksURL, err := discoverJWKSURL(cfg.AuthorizationServer) + httpClient := newSecureHTTPClient() + + // Discover OIDC endpoints + jwksURL, introspectionURL, err := discoverOIDCConfig(httpClient, cfg.AuthorizationServer) if err != nil { - return nil, fmt.Errorf("failed to discover JWKS URL: %w", err) + return nil, fmt.Errorf("failed to discover OIDC config: %w", err) } // Create the keyfunc to fetch and cache the JWKS in the background @@ -66,8 +68,10 @@ func (cfg Config) Initialize() (auth.AuthService, error) { } a := &AuthService{ - Config: cfg, - kf: kf, + Config: cfg, + kf: kf, + client: httpClient, + introspectionURL: introspectionURL, } return a, nil } @@ -88,10 +92,10 @@ func newSecureHTTPClient() *http.Client { } } -func discoverJWKSURL(AuthorizationServer string) (string, error) { +func discoverOIDCConfig(client *http.Client, AuthorizationServer string) (jwksURI string, introspectionEndpoint string, err error) { u, err := url.Parse(AuthorizationServer) if err != nil { - return "", fmt.Errorf("invalid auth URL") + return "", "", fmt.Errorf("invalid auth URL") } if u.Scheme != "https" { log.Printf("WARNING: HTTP instead of HTTPS is being used for AuthorizationServer: %s", AuthorizationServer) @@ -99,49 +103,47 @@ func discoverJWKSURL(AuthorizationServer string) (string, error) { oidcConfigURL, err := url.JoinPath(AuthorizationServer, ".well-known/openid-configuration") if err != nil { - return "", err + return "", "", err } - // HTTP Client - client := newSecureHTTPClient() - resp, err := client.Get(oidcConfigURL) if err != nil { - return "", fmt.Errorf("failed to fetch OIDC config: %w", err) + 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) + 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 + return "", "", err } var config struct { - JWKSURI string `json:"jwks_uri"` + JwksUri string `json:"jwks_uri"` + IntrospectionEndpoint string `json:"introspection_endpoint"` } if err := json.Unmarshal(body, &config); err != nil { - return "", err + return "", "", err } - if config.JWKSURI == "" { - return "", fmt.Errorf("jwks_uri 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) + parsedJWKS, err := url.Parse(config.JwksUri) if err != nil { - return "", fmt.Errorf("invalid jwks_uri detected") + 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) + log.Printf("WARNING: HTTP instead of HTTPS is being used for JWKS URI: %s", config.JwksUri) } - return config.JWKSURI, nil + return config.JwksUri, config.IntrospectionEndpoint, nil } var _ auth.AuthService = AuthService{} @@ -149,7 +151,9 @@ var _ auth.AuthService = AuthService{} // struct used to store auth service info type AuthService struct { Config - kf keyfunc.Keyfunc + kf keyfunc.Keyfunc + client *http.Client + introspectionURL string } // Returns the auth service type @@ -246,6 +250,7 @@ func isJWTFormat(token string) bool { return strings.Count(token, ".") == 2 } +// validateJwtToken validates a JWT token locally func (a AuthService) validateJwtToken(ctx context.Context, tokenStr string) error { token, err := jwt.Parse(tokenStr, a.kf.Keyfunc) if err != nil || !token.Valid { @@ -263,50 +268,24 @@ func (a AuthService) validateJwtToken(ctx context.Context, tokenStr string) erro return &MCPAuthError{Code: http.StatusUnauthorized, Message: "could not parse audience from token", ScopesRequired: a.ScopesRequired} } - isAudValid := false - for _, audItem := range aud { - if audItem == a.Audience { - isAudValid = true - break - } - } - - if !isAudValid { - return &MCPAuthError{Code: http.StatusUnauthorized, Message: "audience validation failed", ScopesRequired: a.ScopesRequired} - } - - // Check scopes - if len(a.ScopesRequired) > 0 { - scopeClaim, ok := claims["scope"].(string) - if !ok { - return &MCPAuthError{Code: http.StatusForbidden, Message: "insufficient scopes", ScopesRequired: a.ScopesRequired} - } - - tokenScopes := strings.Split(scopeClaim, " ") - scopeMap := make(map[string]bool) - for _, s := range tokenScopes { - scopeMap[s] = true - } - - for _, requiredScope := range a.ScopesRequired { - if !scopeMap[requiredScope] { - return &MCPAuthError{Code: http.StatusForbidden, Message: "insufficient scopes", ScopesRequired: a.ScopesRequired} - } - } - } + scopeClaim, _ := claims["scope"].(string) - return nil + return a.validateClaims(ctx, aud, scopeClaim) } +// validateOpaqueToken validates an opaque token by calling the introspection endpoint func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) error { logger, err := util.LoggerFromContext(ctx) if err != nil { return fmt.Errorf("failed to get logger from context: %w", err) } - introspectionURL, err := url.JoinPath(a.AuthorizationServer, "introspect") - if err != nil { - return fmt.Errorf("failed to construct introspection URL: %w", err) + introspectionURL := a.introspectionURL + if introspectionURL == "" { + introspectionURL, err = url.JoinPath(a.AuthorizationServer, "introspect") + if err != nil { + return fmt.Errorf("failed to construct introspection URL: %w", err) + } } data := url.Values{} @@ -320,9 +299,7 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e req.Header.Set("Accept", "application/json") // Send request to auth server's introspection endpoint - client := newSecureHTTPClient() - - resp, err := client.Do(req) + resp, err := a.client.Do(req) if err != nil { logger.ErrorContext(ctx, "failed to call introspection endpoint: %v", err) return &MCPAuthError{Code: http.StatusInternalServerError, Message: fmt.Sprintf("failed to call introspection endpoint: %v", err), ScopesRequired: a.ScopesRequired} @@ -340,10 +317,10 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e } var introspectResp struct { - Active bool `json:"active"` - Scope string `json:"scope"` - ClientId string `json:"client_id"` - Exp int64 `json:"exp"` + Active bool `json:"active"` + Scope string `json:"scope"` + Aud json.RawMessage `json:"aud"` + Exp int64 `json:"exp"` } if err := json.Unmarshal(body, &introspectResp); err != nil { @@ -355,22 +332,58 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token is not active", ScopesRequired: a.ScopesRequired} } - // Verify audience (client_id) - if a.Audience != "" && introspectResp.ClientId != a.Audience { - logger.WarnContext(ctx, "audience validation failed: expected %s, got %s", a.Audience, introspectResp.ClientId) - return &MCPAuthError{Code: http.StatusUnauthorized, Message: "audience validation failed", ScopesRequired: a.ScopesRequired} - } - - // Verify expiration (with 1 minute leeway) to account for potential time difference between Toolbox and the auth server + // Verify expiration (with 1 minute leeway) const leeway = 60 if introspectResp.Exp > 0 && time.Now().Unix() > (introspectResp.Exp+leeway) { logger.WarnContext(ctx, "token has expired: exp=%d, now=%d", introspectResp.Exp, time.Now().Unix()) return &MCPAuthError{Code: http.StatusUnauthorized, Message: "token has expired", ScopesRequired: a.ScopesRequired} } - // Verify scopes + // Extract audience + // According to RFC 7662, the aud claim can be a string or an array of strings + var aud []string + if len(introspectResp.Aud) > 0 { + var audStr string + var audArr []string + if err := json.Unmarshal(introspectResp.Aud, &audStr); err == nil { + aud = []string{audStr} + } else if err := json.Unmarshal(introspectResp.Aud, &audArr); err == nil { + aud = audArr + } else { + logger.WarnContext(ctx, "failed to parse aud claim in introspection response") + return &MCPAuthError{Code: http.StatusUnauthorized, Message: "invalid aud claim", ScopesRequired: a.ScopesRequired} + } + } + + return a.validateClaims(ctx, aud, introspectResp.Scope) +} + +// validateClaims validates the audience and scopes of a token +func (a AuthService) validateClaims(ctx context.Context, 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 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.Split(introspectResp.Scope, " ") + tokenScopes := strings.Split(scopeStr, " ") scopeMap := make(map[string]bool) for _, s := range tokenScopes { scopeMap[s] = true @@ -378,6 +391,7 @@ func (a AuthService) validateOpaqueToken(ctx context.Context, tokenStr string) e 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} } } diff --git a/internal/auth/generic/generic_test.go b/internal/auth/generic/generic_test.go index 83b9d60e75ba..90a126ea1c59 100644 --- a/internal/auth/generic/generic_test.go +++ b/internal/auth/generic/generic_test.go @@ -15,6 +15,7 @@ package generic import ( + "bytes" "context" "crypto/rand" "crypto/rsa" @@ -27,6 +28,8 @@ import ( "github.com/MicahParks/jwkset" "github.com/golang-jwt/jwt/v5" + "github.com/googleapis/genai-toolbox/internal/log" + "github.com/googleapis/genai-toolbox/internal/util" ) func generateRSAPrivateKey(t *testing.T) *rsa.PrivateKey { @@ -213,6 +216,7 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { token string scopesRequired []string audience string + mockOidcConfig map[string]any mockResponse map[string]any mockStatus int wantError bool @@ -224,10 +228,41 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { scopesRequired: []string{"read:files"}, audience: "my-audience", mockResponse: map[string]any{ - "active": true, - "scope": "read:files write:files", - "client_id": "my-audience", - "exp": time.Now().Add(time.Hour).Unix(), + "active": true, + "scope": "read:files write:files", + "aud": "my-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: false, + }, + { + name: "valid opaque token with custom introspection endpoint", + token: "opaque-valid-custom", + scopesRequired: []string{"read:files"}, + audience: "my-audience", + mockOidcConfig: map[string]any{ + "introspection_endpoint": "http://SERVER_HOST/custom-introspect", + }, + mockResponse: map[string]any{ + "active": true, + "scope": "read:files", + "aud": "my-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: false, + }, + { + name: "valid opaque token with array aud", + token: "opaque-valid-array-aud", + scopesRequired: []string{"read:files"}, + audience: "my-audience", + mockResponse: map[string]any{ + "active": true, + "scope": "read:files", + "aud": []string{"other-audience", "my-audience"}, + "exp": time.Now().Add(time.Hour).Unix(), }, mockStatus: http.StatusOK, wantError: false, @@ -261,9 +296,9 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { token: "opaque-bad-aud", audience: "my-audience", mockResponse: map[string]any{ - "active": true, - "client_id": "wrong-audience", - "exp": time.Now().Add(time.Hour).Unix(), + "active": true, + "aud": "wrong-audience", + "exp": time.Now().Add(time.Hour).Unix(), }, mockStatus: http.StatusOK, wantError: true, @@ -297,10 +332,21 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { handler := 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{}{ + config := map[string]interface{}{ "issuer": "https://example.com", "jwks_uri": "http://" + r.Host + "/jwks", - }) + } + if tc.mockOidcConfig != nil { + for k, v := range tc.mockOidcConfig { + valStr, ok := v.(string) + if ok && strings.Contains(valStr, "SERVER_HOST") { + config[k] = strings.Replace(valStr, "SERVER_HOST", r.Host, 1) + } else { + config[k] = v + } + } + } + _ = json.NewEncoder(w).Encode(config) return } if r.URL.Path == "/jwks" { @@ -310,7 +356,7 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { }) return } - if r.URL.Path == "/introspect" { + if r.URL.Path == "/introspect" || r.URL.Path == "/custom-introspect" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(tc.mockStatus) _ = json.NewEncoder(w).Encode(tc.mockResponse) @@ -339,7 +385,12 @@ func TestValidateMCPAuth_Opaque(t *testing.T) { t.Fatalf("expected *AuthService, got %T", authService) } - ctx := context.Background() + logger, err := log.NewLogger("standard", log.Debug, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + ctx := util.WithLogger(context.Background(), logger) + header := http.Header{} header.Set("Authorization", "Bearer "+tc.token) @@ -430,7 +481,12 @@ func TestValidateJwtToken(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - err := genericAuth.validateJwtToken(context.Background(), tc.token) + logger, err := log.NewLogger("standard", log.Debug, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + ctx := util.WithLogger(context.Background(), logger) + err = genericAuth.validateJwtToken(ctx, tc.token) if tc.wantError { if err == nil { t.Fatalf("expected error, got nil") @@ -453,6 +509,7 @@ func TestValidateOpaqueToken(t *testing.T) { token string scopesRequired []string audience string + mockOidcConfig map[string]any mockResponse map[string]any mockStatus int wantError bool @@ -464,10 +521,41 @@ func TestValidateOpaqueToken(t *testing.T) { scopesRequired: []string{"read:files"}, audience: "my-audience", mockResponse: map[string]any{ - "active": true, - "scope": "read:files write:files", - "client_id": "my-audience", - "exp": time.Now().Add(time.Hour).Unix(), + "active": true, + "scope": "read:files write:files", + "aud": "my-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: false, + }, + { + name: "valid opaque token with custom introspection endpoint", + token: "opaque-valid-custom", + scopesRequired: []string{"read:files"}, + audience: "my-audience", + mockOidcConfig: map[string]any{ + "introspection_endpoint": "http://SERVER_HOST/custom-introspect", + }, + mockResponse: map[string]any{ + "active": true, + "scope": "read:files", + "aud": "my-audience", + "exp": time.Now().Add(time.Hour).Unix(), + }, + mockStatus: http.StatusOK, + wantError: false, + }, + { + name: "valid opaque token with array aud", + token: "opaque-valid-array-aud", + scopesRequired: []string{"read:files"}, + audience: "my-audience", + mockResponse: map[string]any{ + "active": true, + "scope": "read:files", + "aud": []string{"other-audience", "my-audience"}, + "exp": time.Now().Add(time.Hour).Unix(), }, mockStatus: http.StatusOK, wantError: false, @@ -501,9 +589,9 @@ func TestValidateOpaqueToken(t *testing.T) { token: "opaque-bad-aud", audience: "my-audience", mockResponse: map[string]any{ - "active": true, - "client_id": "wrong-audience", - "exp": time.Now().Add(time.Hour).Unix(), + "active": true, + "aud": "wrong-audience", + "exp": time.Now().Add(time.Hour).Unix(), }, mockStatus: http.StatusOK, wantError: true, @@ -535,7 +623,7 @@ func TestValidateOpaqueToken(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/introspect" { + if r.URL.Path == "/introspect" || r.URL.Path == "/custom-introspect" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(tc.mockStatus) _ = json.NewEncoder(w).Encode(tc.mockResponse) @@ -552,9 +640,16 @@ func TestValidateOpaqueToken(t *testing.T) { AuthorizationServer: server.URL, ScopesRequired: tc.scopesRequired, }, + client: newSecureHTTPClient(), + } + + logger, err := log.NewLogger("standard", log.Debug, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) } + ctx := util.WithLogger(context.Background(), logger) - err := genericAuth.validateOpaqueToken(context.Background(), tc.token) + err = genericAuth.validateOpaqueToken(ctx, tc.token) if tc.wantError { if err == nil { From c28078f9c0212c72bfd121b0d431dfc906447016 Mon Sep 17 00:00:00 2001 From: duwenxin Date: Thu, 9 Apr 2026 11:37:46 -0400 Subject: [PATCH 6/7] update test --- internal/auth/generic/generic_test.go | 4 ++-- tests/auth/auth_integration_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/auth/generic/generic_test.go b/internal/auth/generic/generic_test.go index 90a126ea1c59..d68b383409d5 100644 --- a/internal/auth/generic/generic_test.go +++ b/internal/auth/generic/generic_test.go @@ -28,8 +28,8 @@ import ( "github.com/MicahParks/jwkset" "github.com/golang-jwt/jwt/v5" - "github.com/googleapis/genai-toolbox/internal/log" - "github.com/googleapis/genai-toolbox/internal/util" + "github.com/googleapis/mcp-toolbox/internal/log" + "github.com/googleapis/mcp-toolbox/internal/util" ) func generateRSAPrivateKey(t *testing.T) *rsa.PrivateKey { diff --git a/tests/auth/auth_integration_test.go b/tests/auth/auth_integration_test.go index 32ba88414d6b..990c769b3c93 100644 --- a/tests/auth/auth_integration_test.go +++ b/tests/auth/auth_integration_test.go @@ -68,10 +68,10 @@ func TestMcpAuth(t *testing.T) { if r.URL.Path == "/introspect" { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "active": true, - "scope": "read:files", - "client_id": "test-audience", - "exp": time.Now().Add(time.Hour).Unix(), + "active": true, + "scope": "read:files", + "aud": "test-audience", + "exp": time.Now().Add(time.Hour).Unix(), }) return } From 1cf66f53ac787abd4844253703b0ff36d23f29dc Mon Sep 17 00:00:00 2001 From: duwenxin Date: Thu, 9 Apr 2026 14:41:47 -0400 Subject: [PATCH 7/7] update doc --- .../en/documentation/configuration/authentication/generic.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/en/documentation/configuration/authentication/generic.md b/docs/en/documentation/configuration/authentication/generic.md index 9098dcb4e5cf..dd1b3268d300 100644 --- a/docs/en/documentation/configuration/authentication/generic.md +++ b/docs/en/documentation/configuration/authentication/generic.md @@ -104,9 +104,10 @@ 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 (`/introspect`). + - Calls the introspection endpoint (as listed in the `authorizationServer`'s + OIDC configuration). - Verifies that the token is `active`. - - Verifies expiration (`exp`) and audience (`client_id`). + - Verifies expiration (`exp`) and audience (`aud`). - Verifies required scopes in `scope` field. #### Example