Skip to content
Merged
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: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.24.1

require (
github.com/BurntSushi/toml v1.5.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/fsnotify/fsnotify v1.9.0
github.com/mark3labs/mcp-go v0.33.0
github.com/pkg/errors v0.9.1
Expand Down Expand Up @@ -51,6 +52,7 @@ require (
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
Expand Down Expand Up @@ -92,6 +94,8 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
Expand Down
134 changes: 111 additions & 23 deletions pkg/http/authorization.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package http

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"time"

"github.com/coreos/go-oidc/v3/oidc"
"k8s.io/klog/v2"

"github.com/manusa/kubernetes-mcp-server/pkg/mcp"
Expand All @@ -31,37 +32,83 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, mcpServer *mcp
return
}

audience := Audience
if serverURL != "" {
audience = serverURL
}

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

w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience=%s, error="invalid_token"`, Audience))
if serverURL == "" {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
} else {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
}
http.Error(w, "Unauthorized: Bearer token required", http.StatusUnauthorized)
return
}

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

audience := Audience
if serverURL != "" {
audience = serverURL
}

err := validateJWTToken(token, audience)
// Validate the token offline for simple sanity check
// Because missing expected audience and expired tokens must be
// rejected already.
claims, err := validateJWTToken(token, audience)
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)

w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience=%s, error="invalid_token"`, Audience))
if serverURL == "" {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
} else {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
}
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
return
}

// Validate token using Kubernetes TokenReview API
_, _, err = mcpServer.VerifyToken(r.Context(), token, Audience)
oidcProvider := mcpServer.GetOIDCProvider()
if oidcProvider != nil {
// If OIDC Provider is configured, this token must be validated against it.
if err := validateTokenWithOIDC(r.Context(), oidcProvider, token, audience); err != nil {
klog.V(1).Infof("Authentication failed - OIDC token validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)

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

// Scopes are likely to be used for authorization.
scopes := claims.GetScopes()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the foundational work for scoped based authorization. This can also be used for logging.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will probably need clarification and an enumeration of different scenarios in a specific issue or user story

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. Once we have a clear picture about the role of the scopes, we'll need this ^^

klog.V(2).Infof("JWT token validated - Scopes: %v", scopes)

// Now, there are a couple of options:
// 1. If there is no authorization url configured for this MCP Server,
// that means this token will be used against the Kubernetes API Server.
// So that we need to validate the token using Kubernetes TokenReview API beforehand.
// 2. If there is an authorization url configured for this MCP Server,
// that means up to this point, the token is validated against the OIDC Provider already.
// 2. a. If this is the only token in the headers, this validated token
// is supposed to be used against the Kubernetes API Server as well. Therefore,
// TokenReview request must succeed.
// 2. b. If this is not the only token in the headers, the token in here is used
// only for authentication and authorization. Therefore, we need to send TokenReview request
// with the other token in the headers (TODO: still need to validate aud and exp of this token separately).
_, _, err = mcpServer.VerifyTokenAPIServer(r.Context(), token, audience)
if err != nil {
klog.V(1).Infof("Authentication failed - token validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)

w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience=%s, error="invalid_token"`, Audience))
if serverURL == "" {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
} else {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
}
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
return
}
Expand All @@ -72,32 +119,60 @@ func AuthorizationMiddleware(requireOAuth bool, serverURL string, mcpServer *mcp
}

type JWTClaims struct {
Issuer string `json:"iss"`
Audience []string `json:"aud"`
ExpiresAt int64 `json:"exp"`
Issuer string `json:"iss"`
Audience any `json:"aud"`
ExpiresAt int64 `json:"exp"`
Scope string `json:"scope,omitempty"`
}

func (c *JWTClaims) GetScopes() []string {
if c.Scope == "" {
return nil
}
return strings.Fields(c.Scope)
}

// validateJWTToken validates basic JWT claims without signature verification
func validateJWTToken(token, audience string) error {
func (c *JWTClaims) ContainsAudience(audience string) bool {
switch aud := c.Audience.(type) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

audience is relatively arbitrary fields. So that we have to support different data structures.

case string:
return aud == audience
case []interface{}:
for _, a := range aud {
if str, ok := a.(string); ok && str == audience {
return true
}
}
case []string:
for _, a := range aud {
if a == audience {
return true
}
}
}
return false
}

// validateJWTToken validates basic JWT claims without signature verification and returns the claims
func validateJWTToken(token, audience string) (*JWTClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return fmt.Errorf("invalid JWT token format")
return nil, fmt.Errorf("invalid JWT token format")
}

claims, err := parseJWTClaims(parts[1])
if err != nil {
return fmt.Errorf("failed to parse JWT claims: %v", err)
return nil, fmt.Errorf("failed to parse JWT claims: %v", err)
}

if claims.ExpiresAt > 0 && time.Now().Unix() > claims.ExpiresAt {
return fmt.Errorf("token expired")
return nil, fmt.Errorf("token expired")
}

if !slices.Contains(claims.Audience, audience) {
return fmt.Errorf("token audience mismatch: %v", claims.Audience)
if !claims.ContainsAudience(audience) {
return nil, fmt.Errorf("token audience mismatch: %v", claims.Audience)
}

return nil
return claims, nil
}

func parseJWTClaims(payload string) (*JWTClaims, error) {
Expand All @@ -118,3 +193,16 @@ func parseJWTClaims(payload string) (*JWTClaims, error) {

return &claims, nil
}

func validateTokenWithOIDC(ctx context.Context, provider *oidc.Provider, token, audience string) error {
verifier := provider.Verifier(&oidc.Config{
ClientID: audience,
})

_, err := verifier.Verify(ctx, token)
if err != nil {
return fmt.Errorf("JWT token verification failed: %v", err)
}

return nil
}
Loading
Loading