From be80db1a0129960e70b6bd296527ef4920c6b6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arda=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 31 Jul 2025 12:01:26 +0300 Subject: [PATCH] feat(auth): introduce scoped based authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Arda Güçlü --- pkg/http/authorization.go | 1 + pkg/http/http.go | 5 +-- pkg/kubernetes-mcp-server/cmd/root.go | 12 ------- pkg/kubernetes-mcp-server/cmd/root_test.go | 25 -------------- pkg/mcp/mcp.go | 39 +++++++++++++++++++--- 5 files changed, 38 insertions(+), 44 deletions(-) diff --git a/pkg/http/authorization.go b/pkg/http/authorization.go index b604416..2b3152b 100644 --- a/pkg/http/authorization.go +++ b/pkg/http/authorization.go @@ -88,6 +88,7 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider * // Scopes are likely to be used for authorization. scopes := claims.GetScopes() klog.V(2).Infof("JWT token validated - Scopes: %v", scopes) + r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes)) // Now, there are a couple of options: // 1. If there is no authorization url configured for this MCP Server, diff --git a/pkg/http/http.go b/pkg/http/http.go index cb22e90..602a400 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -4,13 +4,14 @@ import ( "context" "encoding/json" "errors" - "github.com/coreos/go-oidc/v3/oidc" "net/http" "os" "os/signal" "syscall" "time" + "github.com/coreos/go-oidc/v3/oidc" + "k8s.io/klog/v2" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -61,7 +62,7 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat response := map[string]interface{}{ "authorization_servers": authServers, "authorization_server": authServers[0], - "scopes_supported": []string{}, + "scopes_supported": mcpServer.GetEnabledTools(), "bearer_methods_supported": []string{"header"}, } diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 92557a4..a96ba76 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -227,18 +227,6 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } - if m.StaticConfig.ServerURL != "" { - u, err := url.Parse(m.StaticConfig.ServerURL) - if err != nil { - return err - } - if u.Scheme != "https" && u.Scheme != "http" { - return fmt.Errorf("--server-url must be a valid URL") - } - if u.Scheme == "http" { - klog.Warningf("server-url is using http://, this is not recommended production use") - } - } if m.StaticConfig.JwksURL != "" { u, err := url.Parse(m.StaticConfig.JwksURL) if err != nil { diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index f6b7ef2..31d79ac 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -255,28 +255,3 @@ func TestAuthorizationURL(t *testing.T) { } }) } - -func TestServerURL(t *testing.T) { - t.Run("invalid server-url without protocol", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--server-url", "example.com:8080", "--authorization-url", "https://example.com/auth"}) - err := rootCmd.Execute() - if err == nil { - t.Fatal("Expected error for invalid server-url without protocol, got nil") - } - expected := "--server-url must be a valid URL" - if !strings.Contains(err.Error(), expected) { - t.Fatalf("Expected error to contain %s, got %s", expected, err.Error()) - } - }) - t.Run("valid server-url with https", func(t *testing.T) { - ioStreams, _ := testStream() - rootCmd := NewMCPServer(ioStreams) - rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--server-url", "https://example.com:8080", "--authorization-url", "https://example.com/auth"}) - err := rootCmd.Execute() - if err != nil { - t.Fatalf("Expected no error for valid https server-url, got %s", err.Error()) - } - }) -} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index fb2ceff..5a4f1d5 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -19,6 +19,8 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/version" ) +const TokenScopesContextKey = "TokenScopesContextKey" + type Configuration struct { Profile Profile ListOutput output.Output @@ -45,20 +47,29 @@ func (c *Configuration) isToolApplicable(tool server.ServerTool) bool { type Server struct { configuration *Configuration server *server.MCPServer + enabledTools []string k *internalk8s.Manager } func NewServer(configuration Configuration) (*Server, error) { + var serverOptions []server.ServerOption + serverOptions = append(serverOptions, + server.WithResourceCapabilities(true, true), + server.WithPromptCapabilities(true), + server.WithToolCapabilities(true), + server.WithLogging(), + server.WithToolHandlerMiddleware(toolCallLoggingMiddleware), + ) + if configuration.StaticConfig.RequireOAuth { + serverOptions = append(serverOptions, server.WithToolHandlerMiddleware(toolScopedAuthorizationMiddleware)) + } + s := &Server{ configuration: &configuration, server: server.NewMCPServer( version.BinaryName, version.Version, - server.WithResourceCapabilities(true, true), - server.WithPromptCapabilities(true), - server.WithToolCapabilities(true), - server.WithLogging(), - server.WithToolHandlerMiddleware(toolCallLoggingMiddleware), + serverOptions..., ), } if err := s.reloadKubernetesClient(); err != nil { @@ -81,6 +92,7 @@ func (s *Server) reloadKubernetesClient() error { continue } applicableTools = append(applicableTools, tool) + s.enabledTools = append(s.enabledTools, tool.Tool.Name) } s.server.SetTools(applicableTools...) return nil @@ -125,6 +137,10 @@ func (s *Server) GetKubernetesAPIServerHost() string { return s.k.GetAPIServerHost() } +func (s *Server) GetEnabledTools() []string { + return s.enabledTools +} + func (s *Server) Close() { if s.k != nil { s.k.Close() @@ -181,3 +197,16 @@ func toolCallLoggingMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFu return next(ctx, ctr) } } + +func toolScopedAuthorizationMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFunc { + return func(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { + scopes, ok := ctx.Value(TokenScopesContextKey).([]string) + if !ok { + return NewTextResult("", fmt.Errorf("Authorization failed: Access denied: Tool '%s' requires scope 'mcp:%s' but no scope is available", ctr.Params.Name, ctr.Params.Name)), nil + } + if !slices.Contains(scopes, "mcp:"+ctr.Params.Name) && !slices.Contains(scopes, ctr.Params.Name) { + return NewTextResult("", fmt.Errorf("Authorization failed: Access denied: Tool '%s' requires scope 'mcp:%s' but only scopes %s are available", ctr.Params.Name, ctr.Params.Name, scopes)), nil + } + return next(ctx, ctr) + } +}