Skip to content

Commit 7aa07fe

Browse files
authored
Merge pull request #16 from ardaguclu/sync-downstream
Sync downstream
2 parents 0a3ca57 + 624ae97 commit 7aa07fe

File tree

19 files changed

+2138
-1405
lines changed

19 files changed

+2138
-1405
lines changed

.tekton/openshift-mcp-server-pull-request.yaml

Lines changed: 587 additions & 587 deletions
Large diffs are not rendered by default.

.tekton/openshift-mcp-server-push.yaml

Lines changed: 587 additions & 587 deletions
Large diffs are not rendered by default.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/spf13/afero v1.14.0
1313
github.com/spf13/cobra v1.9.1
1414
github.com/spf13/pflag v1.0.7
15+
golang.org/x/oauth2 v0.30.0
1516
golang.org/x/sync v0.16.0
1617
helm.sh/helm/v3 v3.18.4
1718
k8s.io/api v0.33.3
@@ -116,7 +117,6 @@ require (
116117
go.yaml.in/yaml/v3 v3.0.4 // indirect
117118
golang.org/x/crypto v0.40.0 // indirect
118119
golang.org/x/net v0.42.0 // indirect
119-
golang.org/x/oauth2 v0.30.0 // indirect
120120
golang.org/x/sys v0.34.0 // indirect
121121
golang.org/x/term v0.33.0 // indirect
122122
golang.org/x/text v0.27.0 // indirect

pkg/mcp/mock_server_test.go renamed to internal/test/mock_server.go

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
package mcp
1+
package test
22

33
import (
44
"encoding/json"
55
"errors"
66
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
710
v1 "k8s.io/api/core/v1"
811
apierrors "k8s.io/apimachinery/pkg/api/errors"
912
"k8s.io/apimachinery/pkg/runtime"
1013
"k8s.io/apimachinery/pkg/runtime/serializer"
1114
"k8s.io/apimachinery/pkg/util/httpstream"
1215
"k8s.io/apimachinery/pkg/util/httpstream/spdy"
1316
"k8s.io/client-go/rest"
14-
"net/http"
15-
"net/http/httptest"
17+
"k8s.io/client-go/tools/clientcmd/api"
1618
)
1719

1820
type MockServer struct {
@@ -51,7 +53,26 @@ func (m *MockServer) Handle(handler http.Handler) {
5153
m.restHandlers = append(m.restHandlers, handler.ServeHTTP)
5254
}
5355

54-
func writeObject(w http.ResponseWriter, obj runtime.Object) {
56+
func (m *MockServer) Config() *rest.Config {
57+
return m.config
58+
}
59+
60+
func (m *MockServer) KubeConfig() *api.Config {
61+
fakeConfig := api.NewConfig()
62+
fakeConfig.Clusters["fake"] = api.NewCluster()
63+
fakeConfig.Clusters["fake"].Server = m.config.Host
64+
fakeConfig.Clusters["fake"].CertificateAuthorityData = m.config.CAData
65+
fakeConfig.AuthInfos["fake"] = api.NewAuthInfo()
66+
fakeConfig.AuthInfos["fake"].ClientKeyData = m.config.KeyData
67+
fakeConfig.AuthInfos["fake"].ClientCertificateData = m.config.CertData
68+
fakeConfig.Contexts["fake-context"] = api.NewContext()
69+
fakeConfig.Contexts["fake-context"].Cluster = "fake"
70+
fakeConfig.Contexts["fake-context"].AuthInfo = "fake"
71+
fakeConfig.CurrentContext = "fake-context"
72+
return fakeConfig
73+
}
74+
75+
func WriteObject(w http.ResponseWriter, obj runtime.Object) {
5576
w.Header().Set("Content-Type", runtime.ContentTypeJSON)
5677
if err := json.NewEncoder(w).Encode(obj); err != nil {
5778
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -63,11 +84,11 @@ type streamAndReply struct {
6384
replySent <-chan struct{}
6485
}
6586

66-
type streamContext struct {
67-
conn io.Closer
68-
stdinStream io.ReadCloser
69-
stdoutStream io.WriteCloser
70-
stderrStream io.WriteCloser
87+
type StreamContext struct {
88+
Closer io.Closer
89+
StdinStream io.ReadCloser
90+
StdoutStream io.WriteCloser
91+
StderrStream io.WriteCloser
7192
writeStatus func(status *apierrors.StatusError) error
7293
}
7394

@@ -87,20 +108,20 @@ func v4WriteStatusFunc(stream io.Writer) func(status *apierrors.StatusError) err
87108
return err
88109
}
89110
}
90-
func createHTTPStreams(w http.ResponseWriter, req *http.Request, opts *StreamOptions) (*streamContext, error) {
111+
func CreateHTTPStreams(w http.ResponseWriter, req *http.Request, opts *StreamOptions) (*StreamContext, error) {
91112
_, err := httpstream.Handshake(req, w, []string{"v4.channel.k8s.io"})
92113
if err != nil {
93114
return nil, err
94115
}
95116

96117
upgrader := spdy.NewResponseUpgrader()
97118
streamCh := make(chan streamAndReply)
98-
conn := upgrader.UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error {
119+
connection := upgrader.UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error {
99120
streamCh <- streamAndReply{Stream: stream, replySent: replySent}
100121
return nil
101122
})
102-
ctx := &streamContext{
103-
conn: conn,
123+
ctx := &StreamContext{
124+
Closer: connection,
104125
}
105126

106127
// wait for stream
@@ -128,13 +149,13 @@ WaitForStreams:
128149
ctx.writeStatus = v4WriteStatusFunc(stream)
129150
case v1.StreamTypeStdout:
130151
replyChan <- struct{}{}
131-
ctx.stdoutStream = stream
152+
ctx.StdoutStream = stream
132153
case v1.StreamTypeStdin:
133154
replyChan <- struct{}{}
134-
ctx.stdinStream = stream
155+
ctx.StdinStream = stream
135156
case v1.StreamTypeStderr:
136157
replyChan <- struct{}{}
137-
ctx.stderrStream = stream
158+
ctx.StderrStream = stream
138159
default:
139160
// add other stream ...
140161
return nil, errors.New("unimplemented stream type")

pkg/config/config.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,33 @@ type StaticConfig struct {
1919
// When true, expose only tools annotated with readOnlyHint=true
2020
ReadOnly bool `toml:"read_only,omitempty"`
2121
// When true, disable tools annotated with destructiveHint=true
22-
DisableDestructive bool `toml:"disable_destructive,omitempty"`
23-
EnabledTools []string `toml:"enabled_tools,omitempty"`
24-
DisabledTools []string `toml:"disabled_tools,omitempty"`
25-
RequireOAuth bool `toml:"require_oauth,omitempty"`
26-
AuthorizationURL string `toml:"authorization_url,omitempty"`
27-
JwksURL string `toml:"jwks_url,omitempty"`
22+
DisableDestructive bool `toml:"disable_destructive,omitempty"`
23+
EnabledTools []string `toml:"enabled_tools,omitempty"`
24+
DisabledTools []string `toml:"disabled_tools,omitempty"`
25+
26+
// Authorization-related fields
27+
// RequireOAuth indicates whether the server requires OAuth for authentication.
28+
RequireOAuth bool `toml:"require_oauth,omitempty"`
29+
// OAuthAudience is the valid audience for the OAuth tokens, used for offline JWT claim validation.
30+
OAuthAudience string `toml:"oauth_audience,omitempty"`
31+
// ValidateToken indicates whether the server should validate the token against the Kubernetes API Server using TokenReview.
32+
ValidateToken bool `toml:"validate_token,omitempty"`
33+
// AuthorizationURL is the URL of the OIDC authorization server.
34+
// It is used for token validation and for STS token exchange.
35+
AuthorizationURL string `toml:"authorization_url,omitempty"`
36+
// DisableDynamicClientRegistration indicates whether dynamic client registration is disabled.
37+
// If true, the .well-known endpoints will not expose the registration endpoint.
38+
DisableDynamicClientRegistration bool `toml:"disable_dynamic_client_registration,omitempty"`
39+
// OAuthScopes are the supported **client** scopes requested during the **client/frontend** OAuth flow.
40+
OAuthScopes []string `toml:"oauth_scopes,omitempty"`
41+
// StsClientId is the OAuth client ID used for backend token exchange
42+
StsClientId string `toml:"sts_client_id,omitempty"`
43+
// StsClientSecret is the OAuth client secret used for backend token exchange
44+
StsClientSecret string `toml:"sts_client_secret,omitempty"`
45+
// StsAudience is the audience for the STS token exchange.
46+
StsAudience string `toml:"sts_audience,omitempty"`
47+
// StsScopes is the scopes for the STS token exchange.
48+
StsScopes []string `toml:"sts_scopes,omitempty"`
2849
CertificateAuthority string `toml:"certificate_authority,omitempty"`
2950
ServerURL string `toml:"server_url,omitempty"`
3051
}

pkg/http/authorization.go

Lines changed: 114 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,93 +9,131 @@ import (
99
"github.com/coreos/go-oidc/v3/oidc"
1010
"github.com/go-jose/go-jose/v4"
1111
"github.com/go-jose/go-jose/v4/jwt"
12+
"golang.org/x/oauth2"
13+
authenticationapiv1 "k8s.io/api/authentication/v1"
1214
"k8s.io/klog/v2"
15+
"k8s.io/utils/strings/slices"
1316

17+
"github.com/containers/kubernetes-mcp-server/pkg/config"
1418
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
1519
)
1620

17-
const (
18-
Audience = "kubernetes-mcp-server"
19-
)
21+
type KubernetesApiTokenVerifier interface {
22+
// KubernetesApiVerifyToken TODO: clarify proper implementation
23+
KubernetesApiVerifyToken(ctx context.Context, token, audience string) (*authenticationapiv1.UserInfo, []string, error)
24+
}
2025

21-
// AuthorizationMiddleware validates the OAuth flow using Kubernetes TokenReview API
22-
func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *oidc.Provider, mcpServer *mcp.Server) func(http.Handler) http.Handler {
26+
// AuthorizationMiddleware validates the OAuth flow for protected resources.
27+
//
28+
// The flow is skipped for unprotected resources, such as health checks and well-known endpoints.
29+
//
30+
// There are several auth scenarios supported by this middleware:
31+
//
32+
// 1. requireOAuth is false:
33+
//
34+
// - The OAuth flow is skipped, and the server is effectively unprotected.
35+
// - The request is passed to the next handler without any validation.
36+
//
37+
// see TestAuthorizationRequireOAuthFalse
38+
//
39+
// 2. requireOAuth is set to true, server is protected:
40+
//
41+
// 2.1. Raw Token Validation (oidcProvider is nil):
42+
// - The token is validated offline for basic sanity checks (expiration).
43+
// - If OAuthAudience is set, the token is validated against the audience.
44+
// - If ValidateToken is set, the token is then used against the Kubernetes API Server for TokenReview.
45+
//
46+
// see TestAuthorizationRawToken
47+
//
48+
// 2.2. OIDC Provider Validation (oidcProvider is not nil):
49+
// - The token is validated offline for basic sanity checks (audience and expiration).
50+
// - If OAuthAudience is set, the token is validated against the audience.
51+
// - The token is then validated against the OIDC Provider.
52+
// - If ValidateToken is set, the token is then used against the Kubernetes API Server for TokenReview.
53+
//
54+
// see TestAuthorizationOidcToken
55+
//
56+
// 2.3. OIDC Token Exchange (oidcProvider is not nil, StsClientId and StsAudience are set):
57+
// - The token is validated offline for basic sanity checks (audience and expiration).
58+
// - If OAuthAudience is set, the token is validated against the audience.
59+
// - The token is then validated against the OIDC Provider.
60+
// - If the token is valid, an external account token exchange is performed using
61+
// the OIDC Provider to obtain a new token with the specified audience and scopes.
62+
// - If ValidateToken is set, the exchanged token is then used against the Kubernetes API Server for TokenReview.
63+
//
64+
// see TestAuthorizationOidcTokenExchange
65+
func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oidc.Provider, verifier KubernetesApiTokenVerifier) func(http.Handler) http.Handler {
2366
return func(next http.Handler) http.Handler {
2467
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25-
if r.URL.Path == healthEndpoint || r.URL.Path == oauthProtectedResourceEndpoint {
68+
if r.URL.Path == healthEndpoint || slices.Contains(WellKnownEndpoints, r.URL.EscapedPath()) {
2669
next.ServeHTTP(w, r)
2770
return
2871
}
29-
if !requireOAuth {
72+
if !staticConfig.RequireOAuth {
3073
next.ServeHTTP(w, r)
3174
return
3275
}
3376

34-
audience := Audience
35-
if serverURL != "" {
36-
audience = serverURL
77+
wwwAuthenticateHeader := "Bearer realm=\"Kubernetes MCP Server\""
78+
if staticConfig.OAuthAudience != "" {
79+
wwwAuthenticateHeader += fmt.Sprintf(`, audience="%s"`, staticConfig.OAuthAudience)
3780
}
3881

3982
authHeader := r.Header.Get("Authorization")
4083
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
4184
klog.V(1).Infof("Authentication failed - missing or invalid bearer token: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
4285

43-
if serverURL == "" {
44-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="missing_token"`, audience))
45-
} else {
46-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="missing_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
47-
}
86+
w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+", error=\"missing_token\"")
4887
http.Error(w, "Unauthorized: Bearer token required", http.StatusUnauthorized)
4988
return
5089
}
5190

5291
token := strings.TrimPrefix(authHeader, "Bearer ")
5392

54-
// Validate the token offline for simple sanity check
55-
// Because missing expected audience and expired tokens must be
56-
// rejected already.
5793
claims, err := ParseJWTClaims(token)
58-
if err == nil && claims != nil {
59-
err = claims.Validate(r.Context(), audience, oidcProvider)
94+
if err == nil && claims == nil {
95+
// Impossible case, but just in case
96+
err = fmt.Errorf("failed to parse JWT claims from token")
6097
}
61-
if err != nil {
62-
klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
63-
64-
if serverURL == "" {
65-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
66-
} else {
67-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
98+
// Offline validation
99+
if err == nil {
100+
err = claims.ValidateOffline(staticConfig.OAuthAudience)
101+
}
102+
// Online OIDC provider validation
103+
if err == nil {
104+
err = claims.ValidateWithProvider(r.Context(), staticConfig.OAuthAudience, oidcProvider)
105+
}
106+
// Scopes propagation, they are likely to be used for authorization.
107+
if err == nil {
108+
scopes := claims.GetScopes()
109+
klog.V(2).Infof("JWT token validated - Scopes: %v", scopes)
110+
r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes))
111+
}
112+
// Token exchange with OIDC provider
113+
sts := NewFromConfig(staticConfig, oidcProvider)
114+
// TODO: Maybe the token had already been exchanged, if it has the right audience and scopes, we can skip this step.
115+
if err == nil && sts.IsEnabled() {
116+
var exchangedToken *oauth2.Token
117+
// If the token is valid, we can exchange it for a new token with the specified audience and scopes.
118+
exchangedToken, err = sts.ExternalAccountTokenExchange(r.Context(), &oauth2.Token{
119+
AccessToken: claims.Token,
120+
TokenType: "Bearer",
121+
})
122+
if err == nil {
123+
// Replace the original token with the exchanged token
124+
token = exchangedToken.AccessToken
125+
claims, err = ParseJWTClaims(token)
126+
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // TODO: Implement test to verify, THIS IS A CRITICAL PART
68127
}
69-
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
70-
return
71128
}
72-
73-
// Scopes are likely to be used for authorization.
74-
scopes := claims.GetScopes()
75-
klog.V(2).Infof("JWT token validated - Scopes: %v", scopes)
76-
r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes))
77-
78-
// Now, there are a couple of options:
79-
// 1. If there is no authorization url configured for this MCP Server,
80-
// that means this token will be used against the Kubernetes API Server.
81-
// So that we need to validate the token using Kubernetes TokenReview API beforehand.
82-
// 2. If there is an authorization url configured for this MCP Server,
83-
// that means up to this point, the token is validated against the OIDC Provider already.
84-
// 2. a. If this is the only token in the headers, this validated token
85-
// is supposed to be used against the Kubernetes API Server as well. Therefore,
86-
// TokenReview request must succeed.
87-
// 2. b. If this is not the only token in the headers, the token in here is used
88-
// only for authentication and authorization. Therefore, we need to send TokenReview request
89-
// with the other token in the headers (TODO: still need to validate aud and exp of this token separately).
90-
_, _, err = mcpServer.VerifyTokenAPIServer(r.Context(), token, audience)
129+
// Kubernetes API Server TokenReview validation
130+
if err == nil && staticConfig.ValidateToken {
131+
err = claims.ValidateWithKubernetesApi(r.Context(), staticConfig.OAuthAudience, verifier)
132+
}
91133
if err != nil {
92-
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)
134+
klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
93135

94-
if serverURL == "" {
95-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
96-
} else {
97-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
98-
}
136+
w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+", error=\"invalid_token\"")
99137
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
100138
return
101139
}
@@ -134,16 +172,24 @@ func (c *JWTClaims) GetScopes() []string {
134172
return strings.Fields(c.Scope)
135173
}
136174

137-
// Validate Checks if the JWT claims are valid and if the audience matches the expected one.
138-
func (c *JWTClaims) Validate(ctx context.Context, audience string, provider *oidc.Provider) error {
139-
if err := c.Claims.Validate(jwt.Expected{AnyAudience: jwt.Audience{audience}}); err != nil {
175+
// ValidateOffline Checks if the JWT claims are valid and if the audience matches the expected one.
176+
func (c *JWTClaims) ValidateOffline(audience string) error {
177+
expected := jwt.Expected{}
178+
if audience != "" {
179+
expected.AnyAudience = jwt.Audience{audience}
180+
}
181+
if err := c.Validate(expected); err != nil {
140182
return fmt.Errorf("JWT token validation error: %v", err)
141183
}
184+
return nil
185+
}
186+
187+
// ValidateWithProvider validates the JWT claims against the OIDC provider.
188+
func (c *JWTClaims) ValidateWithProvider(ctx context.Context, audience string, provider *oidc.Provider) error {
142189
if provider != nil {
143190
verifier := provider.Verifier(&oidc.Config{
144191
ClientID: audience,
145192
})
146-
147193
_, err := verifier.Verify(ctx, c.Token)
148194
if err != nil {
149195
return fmt.Errorf("OIDC token validation error: %v", err)
@@ -152,6 +198,16 @@ func (c *JWTClaims) Validate(ctx context.Context, audience string, provider *oid
152198
return nil
153199
}
154200

201+
func (c *JWTClaims) ValidateWithKubernetesApi(ctx context.Context, audience string, verifier KubernetesApiTokenVerifier) error {
202+
if verifier != nil {
203+
_, _, err := verifier.KubernetesApiVerifyToken(ctx, c.Token, audience)
204+
if err != nil {
205+
return fmt.Errorf("kubernetes API token validation error: %v", err)
206+
}
207+
}
208+
return nil
209+
}
210+
155211
func ParseJWTClaims(token string) (*JWTClaims, error) {
156212
tkn, err := jwt.ParseSigned(token, allSignatureAlgorithms)
157213
if err != nil {

0 commit comments

Comments
 (0)