Skip to content

Commit cfc42b3

Browse files
authored
test(auth): complete test scenarios for raw token and oidc (#248)
Signed-off-by: Marc Nuri <[email protected]>
1 parent 43744f2 commit cfc42b3

File tree

7 files changed

+155
-33
lines changed

7 files changed

+155
-33
lines changed

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

internal/test/mock_server.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"k8s.io/apimachinery/pkg/util/httpstream"
1515
"k8s.io/apimachinery/pkg/util/httpstream/spdy"
1616
"k8s.io/client-go/rest"
17+
"k8s.io/client-go/tools/clientcmd/api"
1718
)
1819

1920
type MockServer struct {
@@ -56,6 +57,21 @@ func (m *MockServer) Config() *rest.Config {
5657
return m.config
5758
}
5859

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+
5975
func WriteObject(w http.ResponseWriter, obj runtime.Object) {
6076
w.Header().Set("Content-Type", runtime.ContentTypeJSON)
6177
if err := json.NewEncoder(w).Encode(obj); err != nil {

pkg/http/authorization.go

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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+
authenticationapiv1 "k8s.io/api/authentication/v1"
1213
"k8s.io/klog/v2"
1314
"k8s.io/utils/strings/slices"
1415

@@ -19,8 +20,37 @@ const (
1920
Audience = "mcp-server"
2021
)
2122

22-
// AuthorizationMiddleware validates the OAuth flow using Kubernetes TokenReview API
23-
func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *oidc.Provider, mcpServer *mcp.Server) func(http.Handler) http.Handler {
23+
type KubernetesApiTokenVerifier interface {
24+
// KubernetesApiVerifyToken TODO: clarify proper implementation
25+
KubernetesApiVerifyToken(ctx context.Context, token, audience string) (*authenticationapiv1.UserInfo, []string, error)
26+
}
27+
28+
// AuthorizationMiddleware validates the OAuth flow for protected resources.
29+
//
30+
// The flow is skipped for unprotected resources, such as health checks and well-known endpoints.
31+
//
32+
// There are several auth scenarios
33+
//
34+
// 1. requireOAuth is false:
35+
//
36+
// - The OAuth flow is skipped, and the server is effectively unprotected.
37+
// - The request is passed to the next handler without any validation.
38+
//
39+
// see TestAuthorizationRequireOAuthFalse
40+
//
41+
// 2. requireOAuth is set to true, server is protected:
42+
//
43+
// 2.1. Raw Token Validation (oidcProvider is nil):
44+
// - The token is validated offline for basic sanity checks (audience and expiration).
45+
// - The token is then used against the Kubernetes API Server for TokenReview.
46+
//
47+
// 2.2. OIDC Provider Validation (oidcProvider is not nil):
48+
// - The token is validated offline for basic sanity checks (audience and expiration).
49+
// - The token is then validated against the OIDC Provider.
50+
// - The token is then used against the Kubernetes API Server for TokenReview.
51+
//
52+
// 2.3. OIDC Token Exchange (oidcProvider is not nil and xxx):
53+
func AuthorizationMiddleware(requireOAuth bool, oidcProvider *oidc.Provider, verifier KubernetesApiTokenVerifier) func(http.Handler) http.Handler {
2454
return func(next http.Handler) http.Handler {
2555
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2656
if r.URL.Path == healthEndpoint || slices.Contains(WellKnownEndpoints, r.URL.EscapedPath()) {
@@ -38,32 +68,21 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *
3868
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
3969
klog.V(1).Infof("Authentication failed - missing or invalid bearer token: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
4070

41-
if serverURL == "" {
42-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="missing_token"`, audience))
43-
} else {
44-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="missing_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
45-
}
71+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="missing_token"`, audience))
4672
http.Error(w, "Unauthorized: Bearer token required", http.StatusUnauthorized)
4773
return
4874
}
4975

5076
token := strings.TrimPrefix(authHeader, "Bearer ")
5177

52-
// Validate the token offline for simple sanity check
53-
// Because missing expected audience and expired tokens must be
54-
// rejected already.
5578
claims, err := ParseJWTClaims(token)
5679
if err == nil && claims != nil {
5780
err = claims.Validate(r.Context(), audience, oidcProvider)
5881
}
5982
if err != nil {
6083
klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
6184

62-
if serverURL == "" {
63-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
64-
} else {
65-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
66-
}
85+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
6786
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
6887
return
6988
}
@@ -85,15 +104,11 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *
85104
// 2. b. If this is not the only token in the headers, the token in here is used
86105
// only for authentication and authorization. Therefore, we need to send TokenReview request
87106
// with the other token in the headers (TODO: still need to validate aud and exp of this token separately).
88-
_, _, err = mcpServer.VerifyTokenAPIServer(r.Context(), token, audience)
107+
_, _, err = verifier.KubernetesApiVerifyToken(r.Context(), token, audience)
89108
if err != nil {
90109
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)
91110

92-
if serverURL == "" {
93-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
94-
} else {
95-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
96-
}
111+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
97112
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
98113
return
99114
}

pkg/http/http.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat
2828
mux := http.NewServeMux()
2929

3030
wrappedMux := RequestMiddleware(
31-
AuthorizationMiddleware(staticConfig.RequireOAuth, staticConfig.ServerURL, oidcProvider, mcpServer)(mux),
31+
AuthorizationMiddleware(staticConfig.RequireOAuth, oidcProvider, mcpServer)(mux),
3232
)
3333

3434
httpServer := &http.Server{

pkg/http/http_test.go

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ import (
1919
"testing"
2020
"time"
2121

22+
"github.com/containers/kubernetes-mcp-server/internal/test"
2223
"github.com/coreos/go-oidc/v3/oidc"
2324
"github.com/coreos/go-oidc/v3/oidc/oidctest"
2425
"golang.org/x/sync/errgroup"
2526
"k8s.io/client-go/tools/clientcmd"
26-
"k8s.io/client-go/tools/clientcmd/api"
2727
"k8s.io/klog/v2"
2828
"k8s.io/klog/v2/textlogger"
2929

@@ -33,6 +33,7 @@ import (
3333

3434
type httpContext struct {
3535
klogState klog.State
36+
mockServer *test.MockServer
3637
LogBuffer bytes.Buffer
3738
HttpAddress string // HTTP server address
3839
timeoutCancel context.CancelFunc // Release resources if test completes before the timeout
@@ -42,21 +43,31 @@ type httpContext struct {
4243
OidcProvider *oidc.Provider
4344
}
4445

46+
const tokenReviewSuccessful = `
47+
{
48+
"kind": "TokenReview",
49+
"apiVersion": "authentication.k8s.io/v1",
50+
"spec": {"token": "valid-token"},
51+
"status": {
52+
"authenticated": true,
53+
"user": {
54+
"username": "test-user",
55+
"groups": ["system:authenticated"]
56+
}
57+
}
58+
}`
59+
4560
func (c *httpContext) beforeEach(t *testing.T) {
4661
t.Helper()
4762
http.DefaultClient.Timeout = 10 * time.Second
4863
if c.StaticConfig == nil {
4964
c.StaticConfig = &config.StaticConfig{}
5065
}
66+
c.mockServer = test.NewMockServer()
5167
// Fake Kubernetes configuration
52-
fakeConfig := api.NewConfig()
53-
fakeConfig.Clusters["fake"] = api.NewCluster()
54-
fakeConfig.Clusters["fake"].Server = "https://example.com"
55-
fakeConfig.Contexts["fake-context"] = api.NewContext()
56-
fakeConfig.Contexts["fake-context"].Cluster = "fake"
57-
fakeConfig.CurrentContext = "fake-context"
68+
mockKubeConfig := c.mockServer.KubeConfig()
5869
kubeConfig := filepath.Join(t.TempDir(), "config")
59-
_ = clientcmd.WriteToFile(*fakeConfig, kubeConfig)
70+
_ = clientcmd.WriteToFile(*mockKubeConfig, kubeConfig)
6071
_ = os.Setenv("KUBECONFIG", kubeConfig)
6172
// Capture logging
6273
c.klogState = klog.CaptureState()
@@ -100,6 +111,7 @@ func (c *httpContext) beforeEach(t *testing.T) {
100111

101112
func (c *httpContext) afterEach(t *testing.T) {
102113
t.Helper()
114+
c.mockServer.Close()
103115
c.StopServer()
104116
err := c.WaitForShutdown()
105117
if err != nil {
@@ -546,3 +558,81 @@ func TestAuthorizationUnauthorized(t *testing.T) {
546558
})
547559
})
548560
}
561+
562+
// TestAuthorizationRequireOAuthFalse tests the scenario where OAuth is not required.
563+
func TestAuthorizationRequireOAuthFalse(t *testing.T) {
564+
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: false}}, func(ctx *httpContext) {
565+
resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
566+
if err != nil {
567+
t.Fatalf("Failed to get protected endpoint: %v", err)
568+
}
569+
t.Cleanup(func() { _ = resp.Body.Close() })
570+
t.Run("Protected resource with MISSING Authorization header returns 200 - OK)", func(t *testing.T) {
571+
if resp.StatusCode != http.StatusOK {
572+
t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
573+
}
574+
})
575+
})
576+
}
577+
578+
func TestAuthorizationRawToken(t *testing.T) {
579+
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}}, func(ctx *httpContext) {
580+
ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
581+
if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
582+
w.Header().Set("Content-Type", "application/json")
583+
_, _ = w.Write([]byte(tokenReviewSuccessful))
584+
return
585+
}
586+
}))
587+
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
588+
if err != nil {
589+
t.Fatalf("Failed to create request: %v", err)
590+
}
591+
req.Header.Set("Authorization", "Bearer "+tokenBasicNotExpired)
592+
resp, err := http.DefaultClient.Do(req)
593+
if err != nil {
594+
t.Fatalf("Failed to get protected endpoint: %v", err)
595+
}
596+
t.Cleanup(func() { _ = resp.Body.Close() })
597+
t.Run("Protected resource with VALID Authorization header returns 200 - OK", func(t *testing.T) {
598+
if resp.StatusCode != http.StatusOK {
599+
t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
600+
}
601+
})
602+
})
603+
}
604+
605+
func TestAuthorizationOidcToken(t *testing.T) {
606+
key, oidcProvider, httpServer := NewOidcTestServer(t)
607+
t.Cleanup(httpServer.Close)
608+
rawClaims := `{
609+
"iss": "` + httpServer.URL + `",
610+
"exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `,
611+
"aud": "mcp-server"
612+
}`
613+
validOidcToken := oidctest.SignIDToken(key, "test-oidc-key-id", oidc.RS256, rawClaims)
614+
testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}, OidcProvider: oidcProvider}, func(ctx *httpContext) {
615+
ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
616+
if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
617+
w.Header().Set("Content-Type", "application/json")
618+
_, _ = w.Write([]byte(tokenReviewSuccessful))
619+
return
620+
}
621+
}))
622+
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
623+
if err != nil {
624+
t.Fatalf("Failed to create request: %v", err)
625+
}
626+
req.Header.Set("Authorization", "Bearer "+validOidcToken)
627+
resp, err := http.DefaultClient.Do(req)
628+
if err != nil {
629+
t.Fatalf("Failed to get protected endpoint: %v", err)
630+
}
631+
t.Cleanup(func() { _ = resp.Body.Close() })
632+
t.Run("Protected resource with VALID OIDC Authorization header returns 200 - OK", func(t *testing.T) {
633+
if resp.StatusCode != http.StatusOK {
634+
t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
635+
}
636+
})
637+
})
638+
}

pkg/kubernetes/token.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kubernetes
33
import (
44
"context"
55
"fmt"
6+
67
authenticationv1api "k8s.io/api/authentication/v1"
78
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
89
)

pkg/mcp/mcp.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,9 @@ func (s *Server) ServeHTTP(httpServer *http.Server) *server.StreamableHTTPServer
122122
return server.NewStreamableHTTPServer(s.server, options...)
123123
}
124124

125-
// VerifyTokenAPIServer verifies the given token with the audience by
125+
// KubernetesApiVerifyToken verifies the given token with the audience by
126126
// sending an TokenReview request to API Server.
127-
func (s *Server) VerifyTokenAPIServer(ctx context.Context, token string, audience string) (*authenticationapiv1.UserInfo, []string, error) {
127+
func (s *Server) KubernetesApiVerifyToken(ctx context.Context, token string, audience string) (*authenticationapiv1.UserInfo, []string, error) {
128128
if s.k == nil {
129129
return nil, nil, fmt.Errorf("kubernetes manager is not initialized")
130130
}

0 commit comments

Comments
 (0)