Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ For comprehensive TOML configuration documentation, including:
- Denied resources for restricting access to sensitive resource types
- Server instructions for MCP Tool Search
- [Custom MCP prompts](docs/prompts.md)
- [OAuth/OIDC authentication](docs/KEYCLOAK_OIDC_SETUP.md) for HTTP mode
- OAuth/OIDC authentication for HTTP mode ([Keycloak](docs/KEYCLOAK_OIDC_SETUP.md), [Microsoft Entra ID](docs/ENTRA_ID_SETUP.md))

See the **[Configuration Reference](docs/configuration.md)**.

Expand Down
503 changes: 503 additions & 0 deletions docs/ENTRA_ID_SETUP.md

Large diffs are not rendered by default.

23 changes: 21 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,10 +468,15 @@ Configure OAuth/OIDC authentication for HTTP mode deployments.
| `sts_client_secret` | string | `""` | OAuth client secret for backend token exchange. |
| `sts_audience` | string | `""` | Audience for STS token exchange. |
| `sts_scopes` | string[] | `[]` | Scopes for STS token exchange. |
| `token_exchange_strategy` | string | `""` | Token exchange strategy: `rfc8693`, `keycloak-v1`, or `entra-obo`. |
| `sts_auth_style` | string | `"params"` | How client credentials are sent: `params` (body), `header` (Basic Auth), or `assertion` (JWT). |
| `sts_client_cert_file` | string | `""` | Path to client certificate PEM file (for `assertion` auth style). |
| `sts_client_key_file` | string | `""` | Path to client private key PEM file (for `assertion` auth style). |
| `cluster_auth_mode` | string | `""` | Cluster auth mode: `passthrough` (use OAuth token) or `kubeconfig` (use kubeconfig credentials). |
| `certificate_authority` | string | `""` | Path to CA certificate for validating authorization server connections. |
| `server_url` | string | `""` | Public URL of the MCP server (used for OAuth metadata). |

**Example:**
**Example (with client secret):**
```toml
require_oauth = true
authorization_url = "https://keycloak.example.com/realms/mcp"
Expand All @@ -483,7 +488,21 @@ sts_client_secret = "your-client-secret"
sts_audience = "kubernetes-api"
```

For a complete OIDC setup guide, see [KEYCLOAK_OIDC_SETUP.md](KEYCLOAK_OIDC_SETUP.md).
**Example (with certificate-based auth for Entra ID):**
```toml
require_oauth = true
authorization_url = "https://login.microsoftonline.com/<TENANT_ID>/v2.0"
oauth_audience = "<CLIENT_ID>"

token_exchange_strategy = "entra-obo"
sts_client_id = "<CLIENT_ID>"
sts_auth_style = "assertion"
sts_client_cert_file = "/path/to/client.crt"
sts_client_key_file = "/path/to/client.key"
sts_scopes = ["api://<DOWNSTREAM_API>/.default"]
```

For a complete OIDC setup guide, see [KEYCLOAK_OIDC_SETUP.md](KEYCLOAK_OIDC_SETUP.md) or [ENTRA_ID_SETUP.md](ENTRA_ID_SETUP.md).

### Telemetry

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/google/gnostic-models v0.7.1
github.com/google/jsonschema-go v0.4.2
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2
Expand Down Expand Up @@ -85,7 +86,6 @@ require (
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/gosuri/uitable v0.0.4 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
Expand Down
24 changes: 24 additions & 0 deletions pkg/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,30 @@ const (
ClusterProviderKcp = "kcp"
)

// ClusterAuthMode constants define how the MCP server authenticates to the cluster.
const (
// ClusterAuthPassthrough passes the OAuth token to the cluster.
// If token exchange is configured (token_exchange_strategy or sts_audience),
// the token is exchanged first before being passed through.
ClusterAuthPassthrough = "passthrough"

// ClusterAuthKubeconfig uses kubeconfig credentials (e.g., ServiceAccount token).
// Use when cluster auth is separate from MCP client auth.
ClusterAuthKubeconfig = "kubeconfig"
)

type AuthProvider interface {
// IsRequireOAuth indicates whether OAuth authentication is required.
IsRequireOAuth() bool
}

// ClusterAuthProvider provides configuration for how the MCP server authenticates to clusters.
type ClusterAuthProvider interface {
// GetClusterAuthMode returns the cluster authentication mode.
// Returns empty string for auto-detection based on other config.
GetClusterAuthMode() string
}

type ClusterProvider interface {
// GetClusterProviderStrategy returns the cluster provider strategy (if configured).
GetClusterProviderStrategy() string
Expand Down Expand Up @@ -51,6 +70,10 @@ type StsConfigProvider interface {
GetStsClientSecret() string
GetStsAudience() string
GetStsScopes() []string
GetStsStrategy() string
GetStsAuthStyle() string
GetStsClientCertFile() string
GetStsClientKeyFile() string
}

// ValidationEnabledProvider provides access to validation enabled setting.
Expand All @@ -65,6 +88,7 @@ type RequireTLSProvider interface {

type BaseConfig interface {
AuthProvider
ClusterAuthProvider
ClusterProvider
ConfirmationRulesProvider
DeniedResourcesProvider
Expand Down
50 changes: 47 additions & 3 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,25 @@ type StaticConfig struct {
// StsAudience is the audience for the STS token exchange.
StsAudience string `toml:"sts_audience,omitempty"`
// StsScopes is the scopes for the STS token exchange.
StsScopes []string `toml:"sts_scopes,omitempty"`
CertificateAuthority string `toml:"certificate_authority,omitempty"`
ServerURL string `toml:"server_url,omitempty"`
StsScopes []string `toml:"sts_scopes,omitempty"`
// TokenExchangeStrategy is the token exchange strategy to use (rfc8693, keycloak-v1, entra-obo).
// When set with passthrough mode, the token is exchanged before being passed to the cluster.
TokenExchangeStrategy string `toml:"token_exchange_strategy,omitempty"`
// StsAuthStyle specifies how client credentials are sent during token exchange.
// "params" (default): client_id/secret in request body
// "header": HTTP Basic Authentication header
// "assertion": JWT client assertion (RFC 7523, for Entra ID certificate auth)
StsAuthStyle string `toml:"sts_auth_style,omitempty"`
// StsClientCertFile is the path to the client certificate PEM file for JWT assertion auth
StsClientCertFile string `toml:"sts_client_cert_file,omitempty"`
// StsClientKeyFile is the path to the client private key PEM file for JWT assertion auth
StsClientKeyFile string `toml:"sts_client_key_file,omitempty"`
// ClusterAuthMode determines how the MCP server authenticates to the cluster.
// Valid values: "passthrough" (use OAuth token, with optional exchange), "kubeconfig" (use kubeconfig credentials).
// If empty, auto-detects: passthrough when require_oauth=true, otherwise kubeconfig.
ClusterAuthMode string `toml:"cluster_auth_mode,omitempty"`
CertificateAuthority string `toml:"certificate_authority,omitempty"`
ServerURL string `toml:"server_url,omitempty"`

// TLS configuration for the HTTP server
// TLSCert is the path to the TLS certificate file for HTTPS
Expand Down Expand Up @@ -389,6 +405,22 @@ func (c *StaticConfig) GetStsScopes() []string {
return c.StsScopes
}

func (c *StaticConfig) GetStsStrategy() string {
return c.TokenExchangeStrategy
}

func (c *StaticConfig) GetStsAuthStyle() string {
return c.StsAuthStyle
}

func (c *StaticConfig) GetStsClientCertFile() string {
return c.StsClientCertFile
}

func (c *StaticConfig) GetStsClientKeyFile() string {
return c.StsClientKeyFile
}

func (c *StaticConfig) IsValidationEnabled() bool {
return c.ValidationEnabled
}
Expand Down Expand Up @@ -417,3 +449,15 @@ func (c *StaticConfig) ValidateRequireTLS() error {
"sse_base_url": c.SSEBaseURL,
})
}

func (c *StaticConfig) GetClusterAuthMode() string {
return c.ClusterAuthMode
}

// ValidateClusterAuthMode validates that cluster_auth_mode is a known value.
func (c *StaticConfig) ValidateClusterAuthMode() error {
if c.ClusterAuthMode != "" && c.ClusterAuthMode != api.ClusterAuthPassthrough && c.ClusterAuthMode != api.ClusterAuthKubeconfig {
return fmt.Errorf("invalid cluster_auth_mode %q: must be %q or %q", c.ClusterAuthMode, api.ClusterAuthPassthrough, api.ClusterAuthKubeconfig)
}
return nil
}
33 changes: 27 additions & 6 deletions pkg/http/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import (
"context"
"fmt"
"net/http"
"slices"
"strings"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"k8s.io/klog/v2"
"k8s.io/utils/strings/slices"

"github.com/containers/kubernetes-mcp-server/pkg/config"
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/oauth"
)

// write401 sends a 401/Unauthorized response with WWW-Authenticate header.
Expand Down Expand Up @@ -48,11 +50,16 @@ func write401(w http.ResponseWriter, wwwAuthenticateHeader, errorType, message s
// - The token is then validated against the OIDC Provider.
//
// see TestAuthorizationOidcToken
func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oidc.Provider) func(http.Handler) http.Handler {
func AuthorizationMiddleware(staticConfig *config.StaticConfig, oauthState *oauth.State) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if slices.Contains(infraPaths, r.URL.Path) ||
slices.Contains(WellKnownEndpoints, r.URL.EscapedPath()) {
// Skip auth for infrastructure endpoints (health, metrics) and well-known endpoints
// Use prefix matching per endpoint to handle sub-paths like /.well-known/oauth-protected-resource/sse
requestPath := r.URL.EscapedPath()
isWellKnown := !strings.Contains(requestPath, "..") && slices.ContainsFunc(WellKnownEndpoints, func(ep string) bool {
return requestPath == ep || strings.HasPrefix(requestPath, ep+"/")
})
if slices.Contains(infraPaths, r.URL.Path) || isWellKnown {
next.ServeHTTP(w, r)
return
}
Expand Down Expand Up @@ -86,15 +93,29 @@ func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oi
}
// Online OIDC provider validation
if err == nil {
err = claims.ValidateWithProvider(r.Context(), staticConfig.OAuthAudience, oidcProvider)
snapshot := oauthState.Load()
if snapshot == nil || snapshot.OIDCProvider == nil {
// Provider was configured (authorization_url set) but is unavailable — reject
if staticConfig.AuthorizationURL != "" {
klog.V(1).Infof("Authentication rejected - OIDC provider unavailable: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
write401(w, wwwAuthenticateHeader, "temporarily_unavailable", "OIDC provider is not available")
return
}
// No provider configured — offline validation only
} else {
err = claims.ValidateWithProvider(r.Context(), staticConfig.OAuthAudience, snapshot.OIDCProvider)
}
}
if err != nil {
klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
write401(w, wwwAuthenticateHeader, "invalid_token", "Unauthorized: Invalid token")
return
}

next.ServeHTTP(w, r)
// Store the validated Authorization header in context for MCP handlers
// This is necessary because SSE transport doesn't propagate HTTP headers to MCP requests
ctx := context.WithValue(r.Context(), internalk8s.OAuthAuthorizationHeader, authHeader)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Expand Down
11 changes: 6 additions & 5 deletions pkg/http/authorization_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,14 +294,15 @@ func (s *AuthorizationSuite) TestAuthorizationUnauthorizedTokenExchangeFailure()
s.Require().NotNil(s.mcpClient.Session, "Expected session for valid authentication")
s.Require().NotNil(s.mcpClient.Session.InitializeResult(), "Expected initial request to not be nil")
})
s.Run("Call tool exchanges token VALID OIDC EXCHANGE Authorization header", func() {
s.Run("Call tool returns error when token exchange fails", func() {
toolResult, err := s.mcpClient.Session.CallTool(s.T().Context(), &mcp.CallToolParams{
Name: "events_list",
Arguments: map[string]any{},
})
s.Require().NoError(err, "Expected no error calling tool") // TODO: Should error
s.Require().NotNil(toolResult, "Expected tool result to not be nil") // Should be nil
s.Regexp("token exchange failed:[^:]+: status code 401", s.logBuffer.String())
// When token exchange is explicitly configured and fails,
// the error should propagate rather than silently passing through
s.Require().Error(err, "Expected tool call to fail when token exchange fails")
s.Require().Nil(toolResult, "Expected no tool result when token exchange fails")
})
})
s.mcpClient.Close()
Expand Down Expand Up @@ -424,7 +425,7 @@ func (s *AuthorizationSuite) TestAuthorizationOidcTokenExchange() {
})
s.Require().NoError(err, "Expected no error calling tool")
s.Require().NotNil(toolResult, "Expected tool result to not be nil")
s.Contains(s.logBuffer.String(), "token exchanged successfully")
// Token exchange is verified by the successful tool call with STS configured
})
})
s.mcpClient.Close()
Expand Down
9 changes: 4 additions & 5 deletions pkg/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@ import (
"syscall"
"time"

"github.com/coreos/go-oidc/v3/oidc"

"k8s.io/klog/v2"

"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
"github.com/containers/kubernetes-mcp-server/pkg/oauth"
)

// tlsErrorFilterWriter filters out noisy TLS handshake errors from health checks
Expand Down Expand Up @@ -104,11 +103,11 @@ func statsHandler(mcpServer *mcp.Server) http.HandlerFunc {
}
}

func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.StaticConfig, oidcProvider *oidc.Provider, httpClient *http.Client) error {
func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.StaticConfig, oauthState *oauth.State) error {
mux := http.NewServeMux()

wrappedMux := RequestMiddleware(
AuthorizationMiddleware(staticConfig, oidcProvider)(
AuthorizationMiddleware(staticConfig, oauthState)(
MaxBodyMiddleware(staticConfig.HTTP.MaxBodyBytes)(mux),
),
)
Expand Down Expand Up @@ -144,7 +143,7 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat
})
mux.HandleFunc(statsEndpoint, statsHandler(mcpServer))
mux.Handle(metricsEndpoint, mcpServer.GetMetrics().PrometheusHandler())
mux.Handle("/.well-known/", WellKnownHandler(staticConfig, httpClient))
mux.Handle("/.well-known/", WellKnownHandler(staticConfig, oauthState))

ctx, cancel := context.WithCancel(ctx)
defer cancel()
Expand Down
18 changes: 11 additions & 7 deletions pkg/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ import (

"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
"github.com/containers/kubernetes-mcp-server/pkg/oauth"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"github.com/stretchr/testify/suite"
"golang.org/x/sync/errgroup"
"k8s.io/klog/v2"
"k8s.io/klog/v2/textlogger"

"github.com/containers/kubernetes-mcp-server/pkg/config"
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
)

type BaseHttpSuite struct {
Expand All @@ -36,6 +36,7 @@ type BaseHttpSuite struct {
StaticConfig *config.StaticConfig
mcpServer *mcp.Server
OidcProvider *oidc.Provider
OAuthState *oauth.State
timeoutCancel context.CancelFunc
StopServer context.CancelFunc
WaitForShutdown func() error
Expand All @@ -55,7 +56,8 @@ func (s *BaseHttpSuite) StartServer() {
s.Require().NoError(err, "Expected no error getting random port address")
s.StaticConfig.Port = strconv.Itoa(tcpAddr.Port)

provider, err := kubernetes.NewProvider(s.StaticConfig, kubernetes.WithTokenExchange(s.OidcProvider, nil))
s.OAuthState = oauth.NewState(oauth.SnapshotFromConfig(s.StaticConfig, s.OidcProvider, nil))
provider, err := kubernetes.NewProvider(s.StaticConfig, kubernetes.WithTokenExchange(s.OAuthState))
s.Require().NoError(err, "Expected no error creating kubernetes target provider")
s.mcpServer, err = mcp.NewServer(mcp.Configuration{StaticConfig: s.StaticConfig}, provider)
s.Require().NoError(err, "Expected no error creating MCP server")
Expand All @@ -64,7 +66,7 @@ func (s *BaseHttpSuite) StartServer() {
timeoutCtx, s.timeoutCancel = context.WithTimeout(s.T().Context(), 10*time.Second)
group, gc := errgroup.WithContext(timeoutCtx)
cancelCtx, s.StopServer = context.WithCancel(gc)
group.Go(func() error { return Serve(cancelCtx, s.mcpServer, s.StaticConfig, s.OidcProvider, nil) })
group.Go(func() error { return Serve(cancelCtx, s.mcpServer, s.StaticConfig, s.OAuthState) })
s.WaitForShutdown = group.Wait
s.Require().NoError(test.WaitForServer(tcpAddr), "HTTP server did not start in time")
s.Require().NoError(test.WaitForHealthz(tcpAddr), "HTTP server /healthz endpoint did not respond with non-404 in time")
Expand All @@ -90,6 +92,7 @@ type httpContext struct {
WaitForShutdown func() error
StaticConfig *config.StaticConfig
OidcProvider *oidc.Provider
OAuthState *oauth.State
}

func (c *httpContext) beforeEach(t *testing.T) {
Expand Down Expand Up @@ -117,7 +120,8 @@ func (c *httpContext) beforeEach(t *testing.T) {
t.Fatalf("Failed to close random port listener: %v", randomPortErr)
}
c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
provider, err := kubernetes.NewProvider(c.StaticConfig, kubernetes.WithTokenExchange(c.OidcProvider, nil))
c.OAuthState = oauth.NewState(oauth.SnapshotFromConfig(c.StaticConfig, c.OidcProvider, nil))
provider, err := kubernetes.NewProvider(c.StaticConfig, kubernetes.WithTokenExchange(c.OAuthState))
if err != nil {
t.Fatalf("Failed to create kubernetes target provider: %v", err)
}
Expand All @@ -129,7 +133,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
timeoutCtx, c.timeoutCancel = context.WithTimeout(t.Context(), 10*time.Second)
group, gc := errgroup.WithContext(timeoutCtx)
cancelCtx, c.StopServer = context.WithCancel(gc)
group.Go(func() error { return Serve(cancelCtx, mcpServer, c.StaticConfig, c.OidcProvider, nil) })
group.Go(func() error { return Serve(cancelCtx, mcpServer, c.StaticConfig, c.OAuthState) })
c.WaitForShutdown = group.Wait
// Wait for HTTP server to start (using net)
for i := 0; i < 10; i++ {
Expand Down
Loading