feat(auth): introduce scoped based authorization

Signed-off-by: Arda Güçlü <aguclu@redhat.com>
This commit is contained in:
Arda Güçlü
2025-07-31 12:01:26 +03:00
committed by GitHub
parent d4f3bd4a99
commit be80db1a01
5 changed files with 38 additions and 44 deletions

View File

@@ -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,

View File

@@ -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"},
}

View File

@@ -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 {

View File

@@ -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())
}
})
}

View File

@@ -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)
}
}