Skip to content

Commit 7767161

Browse files
authored
feat(auth): introduce OIDC token verification if authorization-url is specified (176)
Pass correct audience --- Validate server and authorization url via url.Parse --- Import go-oidc/v3 --- Wire initialized oidc provider if authorization url is set --- Wire oidc issuer validation
1 parent 5c75327 commit 7767161

File tree

8 files changed

+278
-55
lines changed

8 files changed

+278
-55
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.1
44

55
require (
66
github.com/BurntSushi/toml v1.5.0
7+
github.com/coreos/go-oidc/v3 v3.14.1
78
github.com/fsnotify/fsnotify v1.9.0
89
github.com/mark3labs/mcp-go v0.33.0
910
github.com/pkg/errors v0.9.1
@@ -51,6 +52,7 @@ require (
5152
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
5253
github.com/go-errors/errors v1.4.2 // indirect
5354
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
55+
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
5456
github.com/go-logr/logr v1.4.2 // indirect
5557
github.com/go-openapi/jsonpointer v0.21.0 // indirect
5658
github.com/go-openapi/jsonreference v0.20.2 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
4444
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
4545
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
4646
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
47+
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
48+
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
4749
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
4850
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
4951
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -92,6 +94,8 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI
9294
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
9395
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
9496
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
97+
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
98+
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
9599
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
96100
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
97101
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=

pkg/http/authorization.go

Lines changed: 111 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
package http
22

33
import (
4+
"context"
45
"encoding/base64"
56
"encoding/json"
67
"fmt"
78
"net/http"
8-
"slices"
99
"strings"
1010
"time"
1111

12+
"github.com/coreos/go-oidc/v3/oidc"
1213
"k8s.io/klog/v2"
1314

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

35+
audience := Audience
36+
if serverURL != "" {
37+
audience = serverURL
38+
}
39+
3440
authHeader := r.Header.Get("Authorization")
3541
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
3642
klog.V(1).Infof("Authentication failed - missing or invalid bearer token: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
3743

38-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience=%s, error="invalid_token"`, Audience))
44+
if serverURL == "" {
45+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
46+
} else {
47+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
48+
}
3949
http.Error(w, "Unauthorized: Bearer token required", http.StatusUnauthorized)
4050
return
4151
}
4252

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

45-
audience := Audience
46-
if serverURL != "" {
47-
audience = serverURL
48-
}
49-
50-
err := validateJWTToken(token, audience)
55+
// Validate the token offline for simple sanity check
56+
// Because missing expected audience and expired tokens must be
57+
// rejected already.
58+
claims, err := validateJWTToken(token, audience)
5159
if err != nil {
5260
klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
5361

54-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience=%s, error="invalid_token"`, Audience))
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+
}
5567
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
5668
return
5769
}
5870

59-
// Validate token using Kubernetes TokenReview API
60-
_, _, err = mcpServer.VerifyToken(r.Context(), token, Audience)
71+
oidcProvider := mcpServer.GetOIDCProvider()
72+
if oidcProvider != nil {
73+
// If OIDC Provider is configured, this token must be validated against it.
74+
if err := validateTokenWithOIDC(r.Context(), oidcProvider, token, audience); err != nil {
75+
klog.V(1).Infof("Authentication failed - OIDC token validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
76+
77+
if serverURL == "" {
78+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience))
79+
} else {
80+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint))
81+
}
82+
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
83+
return
84+
}
85+
}
86+
87+
// Scopes are likely to be used for authorization.
88+
scopes := claims.GetScopes()
89+
klog.V(2).Infof("JWT token validated - Scopes: %v", scopes)
90+
91+
// Now, there are a couple of options:
92+
// 1. If there is no authorization url configured for this MCP Server,
93+
// that means this token will be used against the Kubernetes API Server.
94+
// So that we need to validate the token using Kubernetes TokenReview API beforehand.
95+
// 2. If there is an authorization url configured for this MCP Server,
96+
// that means up to this point, the token is validated against the OIDC Provider already.
97+
// 2. a. If this is the only token in the headers, this validated token
98+
// is supposed to be used against the Kubernetes API Server as well. Therefore,
99+
// TokenReview request must succeed.
100+
// 2. b. If this is not the only token in the headers, the token in here is used
101+
// only for authentication and authorization. Therefore, we need to send TokenReview request
102+
// with the other token in the headers (TODO: still need to validate aud and exp of this token separately).
103+
_, _, err = mcpServer.VerifyTokenAPIServer(r.Context(), token, audience)
61104
if err != nil {
62105
klog.V(1).Infof("Authentication failed - token validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
63106

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

74121
type JWTClaims struct {
75-
Issuer string `json:"iss"`
76-
Audience []string `json:"aud"`
77-
ExpiresAt int64 `json:"exp"`
122+
Issuer string `json:"iss"`
123+
Audience any `json:"aud"`
124+
ExpiresAt int64 `json:"exp"`
125+
Scope string `json:"scope,omitempty"`
126+
}
127+
128+
func (c *JWTClaims) GetScopes() []string {
129+
if c.Scope == "" {
130+
return nil
131+
}
132+
return strings.Fields(c.Scope)
78133
}
79134

80-
// validateJWTToken validates basic JWT claims without signature verification
81-
func validateJWTToken(token, audience string) error {
135+
func (c *JWTClaims) ContainsAudience(audience string) bool {
136+
switch aud := c.Audience.(type) {
137+
case string:
138+
return aud == audience
139+
case []interface{}:
140+
for _, a := range aud {
141+
if str, ok := a.(string); ok && str == audience {
142+
return true
143+
}
144+
}
145+
case []string:
146+
for _, a := range aud {
147+
if a == audience {
148+
return true
149+
}
150+
}
151+
}
152+
return false
153+
}
154+
155+
// validateJWTToken validates basic JWT claims without signature verification and returns the claims
156+
func validateJWTToken(token, audience string) (*JWTClaims, error) {
82157
parts := strings.Split(token, ".")
83158
if len(parts) != 3 {
84-
return fmt.Errorf("invalid JWT token format")
159+
return nil, fmt.Errorf("invalid JWT token format")
85160
}
86161

87162
claims, err := parseJWTClaims(parts[1])
88163
if err != nil {
89-
return fmt.Errorf("failed to parse JWT claims: %v", err)
164+
return nil, fmt.Errorf("failed to parse JWT claims: %v", err)
90165
}
91166

92167
if claims.ExpiresAt > 0 && time.Now().Unix() > claims.ExpiresAt {
93-
return fmt.Errorf("token expired")
168+
return nil, fmt.Errorf("token expired")
94169
}
95170

96-
if !slices.Contains(claims.Audience, audience) {
97-
return fmt.Errorf("token audience mismatch: %v", claims.Audience)
171+
if !claims.ContainsAudience(audience) {
172+
return nil, fmt.Errorf("token audience mismatch: %v", claims.Audience)
98173
}
99174

100-
return nil
175+
return claims, nil
101176
}
102177

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

119194
return &claims, nil
120195
}
196+
197+
func validateTokenWithOIDC(ctx context.Context, provider *oidc.Provider, token, audience string) error {
198+
verifier := provider.Verifier(&oidc.Config{
199+
ClientID: audience,
200+
})
201+
202+
_, err := verifier.Verify(ctx, token)
203+
if err != nil {
204+
return fmt.Errorf("JWT token verification failed: %v", err)
205+
}
206+
207+
return nil
208+
}

0 commit comments

Comments
 (0)