Skip to content

Commit c5f4bea

Browse files
committed
Add scope based authorization for resource_* tools
Signed-off-by: Arda Güçlü <[email protected]>
1 parent 49dcff3 commit c5f4bea

File tree

5 files changed

+42
-44
lines changed

5 files changed

+42
-44
lines changed

pkg/http/authorization.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *
8888
// Scopes are likely to be used for authorization.
8989
scopes := claims.GetScopes()
9090
klog.V(2).Infof("JWT token validated - Scopes: %v", scopes)
91+
r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes))
9192

9293
// Now, there are a couple of options:
9394
// 1. If there is no authorization url configured for this MCP Server,
@@ -101,7 +102,7 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *
101102
// 2. b. If this is not the only token in the headers, the token in here is used
102103
// only for authentication and authorization. Therefore, we need to send TokenReview request
103104
// with the other token in the headers (TODO: still need to validate aud and exp of this token separately).
104-
_, _, err = mcpServer.VerifyTokenAPIServer(r.Context(), token, audience)
105+
/*_, _, err = mcpServer.VerifyTokenAPIServer(r.Context(), token, audience)
105106
if err != nil {
106107
klog.V(1).Infof("Authentication failed - API Server token validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
107108
@@ -112,7 +113,7 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *
112113
}
113114
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
114115
return
115-
}
116+
}*/
116117

117118
next.ServeHTTP(w, r)
118119
})

pkg/http/http.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import (
44
"context"
55
"encoding/json"
66
"errors"
7-
"github.com/coreos/go-oidc/v3/oidc"
87
"net/http"
98
"os"
109
"os/signal"
1110
"syscall"
1211
"time"
1312

13+
"github.com/coreos/go-oidc/v3/oidc"
14+
1415
"k8s.io/klog/v2"
1516

1617
"github.com/containers/kubernetes-mcp-server/pkg/config"
@@ -61,7 +62,7 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat
6162
response := map[string]interface{}{
6263
"authorization_servers": authServers,
6364
"authorization_server": authServers[0],
64-
"scopes_supported": []string{},
65+
"scopes_supported": mcpServer.GetEnabledTools(),
6566
"bearer_methods_supported": []string{"header"},
6667
}
6768

pkg/kubernetes-mcp-server/cmd/root.go

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -227,18 +227,6 @@ func (m *MCPServerOptions) Validate() error {
227227
klog.Warningf("authorization-url is using http://, this is not recommended production use")
228228
}
229229
}
230-
if m.StaticConfig.ServerURL != "" {
231-
u, err := url.Parse(m.StaticConfig.ServerURL)
232-
if err != nil {
233-
return err
234-
}
235-
if u.Scheme != "https" && u.Scheme != "http" {
236-
return fmt.Errorf("--server-url must be a valid URL")
237-
}
238-
if u.Scheme == "http" {
239-
klog.Warningf("server-url is using http://, this is not recommended production use")
240-
}
241-
}
242230
if m.StaticConfig.JwksURL != "" {
243231
u, err := url.Parse(m.StaticConfig.JwksURL)
244232
if err != nil {

pkg/kubernetes/kubernetes.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package kubernetes
22

33
import (
44
"context"
5-
"errors"
65
"strings"
76

87
"k8s.io/apimachinery/pkg/runtime"
@@ -146,9 +145,6 @@ func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) {
146145
func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
147146
authorization, ok := ctx.Value(OAuthAuthorizationHeader).(string)
148147
if !ok || !strings.HasPrefix(authorization, "Bearer ") {
149-
if m.staticConfig.RequireOAuth {
150-
return nil, errors.New("oauth token required")
151-
}
152148
return &Kubernetes{manager: m}, nil
153149
}
154150
klog.V(5).Infof("%s header found (Bearer), using provided bearer token", OAuthAuthorizationHeader)
@@ -172,10 +168,6 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
172168
}
173169
clientCmdApiConfig, err := m.clientCmdConfig.RawConfig()
174170
if err != nil {
175-
if m.staticConfig.RequireOAuth {
176-
klog.Errorf("failed to get kubeconfig: %v", err)
177-
return nil, errors.New("failed to get kubeconfig")
178-
}
179171
return &Kubernetes{manager: m}, nil
180172
}
181173
clientCmdApiConfig.AuthInfos = make(map[string]*clientcmdapi.AuthInfo)
@@ -186,10 +178,6 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
186178
}}
187179
derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig)
188180
if err != nil {
189-
if m.staticConfig.RequireOAuth {
190-
klog.Errorf("failed to get kubeconfig: %v", err)
191-
return nil, errors.New("failed to get kubeconfig")
192-
}
193181
return &Kubernetes{manager: m}, nil
194182
}
195183
derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient())
@@ -199,10 +187,6 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
199187
)
200188
derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg)
201189
if err != nil {
202-
if m.staticConfig.RequireOAuth {
203-
klog.Errorf("failed to initialize dynamic client: %v", err)
204-
return nil, errors.New("failed to initialize dynamic client")
205-
}
206190
return &Kubernetes{manager: m}, nil
207191
}
208192
return derived, nil

pkg/mcp/mcp.go

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import (
44
"bytes"
55
"context"
66
"fmt"
7-
"k8s.io/klog/v2"
87
"net/http"
98
"slices"
109

10+
"k8s.io/klog/v2"
11+
1112
"github.com/mark3labs/mcp-go/mcp"
1213
"github.com/mark3labs/mcp-go/server"
1314
authenticationapiv1 "k8s.io/api/authentication/v1"
@@ -19,6 +20,8 @@ import (
1920
"github.com/containers/kubernetes-mcp-server/pkg/version"
2021
)
2122

23+
const TokenScopesContextKey = "TokenScopesContextKey"
24+
2225
type Configuration struct {
2326
Profile Profile
2427
ListOutput output.Output
@@ -45,20 +48,29 @@ func (c *Configuration) isToolApplicable(tool server.ServerTool) bool {
4548
type Server struct {
4649
configuration *Configuration
4750
server *server.MCPServer
51+
enabledTools []string
4852
k *internalk8s.Manager
4953
}
5054

5155
func NewServer(configuration Configuration) (*Server, error) {
56+
var serverOptions []server.ServerOption
57+
serverOptions = append(serverOptions,
58+
server.WithResourceCapabilities(true, true),
59+
server.WithPromptCapabilities(true),
60+
server.WithToolCapabilities(true),
61+
server.WithLogging(),
62+
server.WithToolHandlerMiddleware(toolCallLoggingMiddleware),
63+
)
64+
if configuration.StaticConfig.RequireOAuth {
65+
serverOptions = append(serverOptions, server.WithToolHandlerMiddleware(toolScopedAuthorizationMiddleware))
66+
}
67+
5268
s := &Server{
5369
configuration: &configuration,
5470
server: server.NewMCPServer(
5571
version.BinaryName,
5672
version.Version,
57-
server.WithResourceCapabilities(true, true),
58-
server.WithPromptCapabilities(true),
59-
server.WithToolCapabilities(true),
60-
server.WithLogging(),
61-
server.WithToolHandlerMiddleware(toolCallLoggingMiddleware),
73+
serverOptions...,
6274
),
6375
}
6476
if err := s.reloadKubernetesClient(); err != nil {
@@ -81,6 +93,7 @@ func (s *Server) reloadKubernetesClient() error {
8193
continue
8294
}
8395
applicableTools = append(applicableTools, tool)
96+
s.enabledTools = append(s.enabledTools, tool.Tool.Name)
8497
}
8598
s.server.SetTools(applicableTools...)
8699
return nil
@@ -125,6 +138,10 @@ func (s *Server) GetKubernetesAPIServerHost() string {
125138
return s.k.GetAPIServerHost()
126139
}
127140

141+
func (s *Server) GetEnabledTools() []string {
142+
return s.enabledTools
143+
}
144+
128145
func (s *Server) Close() {
129146
if s.k != nil {
130147
s.k.Close()
@@ -154,12 +171,6 @@ func NewTextResult(content string, err error) *mcp.CallToolResult {
154171
}
155172

156173
func contextFunc(ctx context.Context, r *http.Request) context.Context {
157-
// Get the standard Authorization header (OAuth compliant)
158-
authHeader := r.Header.Get(string(internalk8s.OAuthAuthorizationHeader))
159-
if authHeader != "" {
160-
return context.WithValue(ctx, internalk8s.OAuthAuthorizationHeader, authHeader)
161-
}
162-
163174
// Fallback to custom header for backward compatibility
164175
customAuthHeader := r.Header.Get(string(internalk8s.CustomAuthorizationHeader))
165176
if customAuthHeader != "" {
@@ -181,3 +192,16 @@ func toolCallLoggingMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFu
181192
return next(ctx, ctr)
182193
}
183194
}
195+
196+
func toolScopedAuthorizationMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFunc {
197+
return func(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
198+
scopes, ok := ctx.Value(TokenScopesContextKey).([]string)
199+
if !ok {
200+
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
201+
}
202+
if !slices.Contains(scopes, "mcp:"+ctr.Params.Name) && !slices.Contains(scopes, ctr.Params.Name) {
203+
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
204+
}
205+
return next(ctx, ctr)
206+
}
207+
}

0 commit comments

Comments
 (0)