Skip to content

Commit be80db1

Browse files
authored
feat(auth): introduce scoped based authorization
Signed-off-by: Arda Güçlü <[email protected]>
1 parent d4f3bd4 commit be80db1

File tree

5 files changed

+38
-44
lines changed

5 files changed

+38
-44
lines changed

pkg/http/authorization.go

Lines changed: 1 addition & 0 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,

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-mcp-server/cmd/root_test.go

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -255,28 +255,3 @@ func TestAuthorizationURL(t *testing.T) {
255255
}
256256
})
257257
}
258-
259-
func TestServerURL(t *testing.T) {
260-
t.Run("invalid server-url without protocol", func(t *testing.T) {
261-
ioStreams, _ := testStream()
262-
rootCmd := NewMCPServer(ioStreams)
263-
rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--server-url", "example.com:8080", "--authorization-url", "https://example.com/auth"})
264-
err := rootCmd.Execute()
265-
if err == nil {
266-
t.Fatal("Expected error for invalid server-url without protocol, got nil")
267-
}
268-
expected := "--server-url must be a valid URL"
269-
if !strings.Contains(err.Error(), expected) {
270-
t.Fatalf("Expected error to contain %s, got %s", expected, err.Error())
271-
}
272-
})
273-
t.Run("valid server-url with https", func(t *testing.T) {
274-
ioStreams, _ := testStream()
275-
rootCmd := NewMCPServer(ioStreams)
276-
rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--server-url", "https://example.com:8080", "--authorization-url", "https://example.com/auth"})
277-
err := rootCmd.Execute()
278-
if err != nil {
279-
t.Fatalf("Expected no error for valid https server-url, got %s", err.Error())
280-
}
281-
})
282-
}

pkg/mcp/mcp.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
"github.com/containers/kubernetes-mcp-server/pkg/version"
2020
)
2121

22+
const TokenScopesContextKey = "TokenScopesContextKey"
23+
2224
type Configuration struct {
2325
Profile Profile
2426
ListOutput output.Output
@@ -45,20 +47,29 @@ func (c *Configuration) isToolApplicable(tool server.ServerTool) bool {
4547
type Server struct {
4648
configuration *Configuration
4749
server *server.MCPServer
50+
enabledTools []string
4851
k *internalk8s.Manager
4952
}
5053

5154
func NewServer(configuration Configuration) (*Server, error) {
55+
var serverOptions []server.ServerOption
56+
serverOptions = append(serverOptions,
57+
server.WithResourceCapabilities(true, true),
58+
server.WithPromptCapabilities(true),
59+
server.WithToolCapabilities(true),
60+
server.WithLogging(),
61+
server.WithToolHandlerMiddleware(toolCallLoggingMiddleware),
62+
)
63+
if configuration.StaticConfig.RequireOAuth {
64+
serverOptions = append(serverOptions, server.WithToolHandlerMiddleware(toolScopedAuthorizationMiddleware))
65+
}
66+
5267
s := &Server{
5368
configuration: &configuration,
5469
server: server.NewMCPServer(
5570
version.BinaryName,
5671
version.Version,
57-
server.WithResourceCapabilities(true, true),
58-
server.WithPromptCapabilities(true),
59-
server.WithToolCapabilities(true),
60-
server.WithLogging(),
61-
server.WithToolHandlerMiddleware(toolCallLoggingMiddleware),
72+
serverOptions...,
6273
),
6374
}
6475
if err := s.reloadKubernetesClient(); err != nil {
@@ -81,6 +92,7 @@ func (s *Server) reloadKubernetesClient() error {
8192
continue
8293
}
8394
applicableTools = append(applicableTools, tool)
95+
s.enabledTools = append(s.enabledTools, tool.Tool.Name)
8496
}
8597
s.server.SetTools(applicableTools...)
8698
return nil
@@ -125,6 +137,10 @@ func (s *Server) GetKubernetesAPIServerHost() string {
125137
return s.k.GetAPIServerHost()
126138
}
127139

140+
func (s *Server) GetEnabledTools() []string {
141+
return s.enabledTools
142+
}
143+
128144
func (s *Server) Close() {
129145
if s.k != nil {
130146
s.k.Close()
@@ -181,3 +197,16 @@ func toolCallLoggingMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFu
181197
return next(ctx, ctr)
182198
}
183199
}
200+
201+
func toolScopedAuthorizationMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFunc {
202+
return func(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
203+
scopes, ok := ctx.Value(TokenScopesContextKey).([]string)
204+
if !ok {
205+
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
206+
}
207+
if !slices.Contains(scopes, "mcp:"+ctr.Params.Name) && !slices.Contains(scopes, ctr.Params.Name) {
208+
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
209+
}
210+
return next(ctx, ctr)
211+
}
212+
}

0 commit comments

Comments
 (0)