-
Couldn't load subscription status.
- Fork 164
Introduce OIDC token verification if authorization-url is specified #176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
bca8709
Pass correct audience
ardaguclu db1965c
Validate server and authorization url via url.Parse
ardaguclu d944d9f
Import go-oidc/v3
ardaguclu 48094e4
Wire initialized oidc provider if authorization url is set
ardaguclu e96cda1
Wire oidc issuer validation
ardaguclu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
|
|
@@ -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() | ||
| 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 | ||
| } | ||
|
|
@@ -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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
@@ -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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 ^^