From b0e4b84d41895acbfc016f245226fb46ad177452 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 9 Dec 2025 19:12:59 -0500 Subject: [PATCH 1/6] feat: add TokenExchanger Signed-off-by: Calum Murray --- pkg/tokenexchange/token_exchange.go | 274 ++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 pkg/tokenexchange/token_exchange.go diff --git a/pkg/tokenexchange/token_exchange.go b/pkg/tokenexchange/token_exchange.go new file mode 100644 index 000000000..a8af1bb57 --- /dev/null +++ b/pkg/tokenexchange/token_exchange.go @@ -0,0 +1,274 @@ +package sts + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google/externalaccount" +) + +// Token exchange strategy constants +const ( + TokenExchangeStrategyKeycloakV1 = "keycloak-v1" + TokenExchangeStrategyRFC8693 = "rfc8693" + TokenExchangeStrategyExternalAccount = "external-account" +) + +// OAuth 2.0 Token Exchange grant types and token types (RFC 8693) +const ( + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + TokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" + TokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" +) + +// Token exchange form field keys +const ( + FormKeyGrantType = "grant_type" + FormKeySubjectToken = "subject_token" + FormKeySubjectTokenType = "subject_token_type" + FormKeySubjectIssuer = "subject_issuer" + FormKeyAudience = "audience" + FormKeyClientID = "client_id" + FormKeyClientSecret = "client_secret" + FormKeyScope = "scope" + FormKeyRequestedTokenType = "requested_token_type" +) + +// TargetSTSConfig holds per-target token exchange configuration. +// This is used by providers that support per-cluster or per-context token exchange +// (e.g., ACM hub provider for managed clusters, kubeconfig provider for contexts). +type TargetSTSConfig struct { + // TokenURL is the token endpoint for this target's realm + TokenURL string `toml:"token_url"` + // ClientID is the OAuth client ID in the target realm + ClientID string `toml:"client_id"` + // ClientSecret is the OAuth client secret in the target realm + ClientSecret string `toml:"client_secret"` + // Audience is the target audience for the exchanged token + Audience string `toml:"audience"` + // SubjectTokenType specifies the token type of the subject token + // For same-realm: "urn:ietf:params:oauth:token-type:access_token" + // For cross-realm: "urn:ietf:params:oauth:token-type:jwt" + SubjectTokenType string `toml:"subject_token_type"` + // SubjectIssuer is the IDP alias for cross-realm token exchange (Keycloak V1 only) + // Only required when exchanging tokens across Keycloak realms + SubjectIssuer string `toml:"subject_issuer,omitempty"` + // Scopes are optional scopes to request during token exchange + Scopes []string `toml:"scopes,omitempty"` + // CAFile is the path to a CA certificate file for TLS verification + // Used when the token endpoint uses a certificate signed by a private CA + CAFile string `toml:"ca_file,omitempty"` + // InsecureSkipTLSVerify disables TLS certificate verification (not recommended for production) + InsecureSkipTLSVerify bool `toml:"insecure_skip_tls_verify,omitempty"` +} + +// HTTPClient creates an HTTP client configured with the TLS settings from this config. +// If neither CAFile nor InsecureSkipTLSVerify is set, returns a default client. +func (c *TargetSTSConfig) HTTPClient() (*http.Client, error) { + // If no TLS customization needed, return default client with timeout + if c.CAFile == "" && !c.InsecureSkipTLSVerify { + return &http.Client{Timeout: 30 * time.Second}, nil + } + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if c.InsecureSkipTLSVerify { + tlsConfig.InsecureSkipVerify = true + } + + if c.CAFile != "" { + caCert, err := os.ReadFile(c.CAFile) + if err != nil { + return nil, fmt.Errorf("failed to read CA file %s: %w", c.CAFile, err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to parse CA certificate from %s", c.CAFile) + } + tlsConfig.RootCAs = caCertPool + } + + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, nil +} + +// TokenExchanger defines the interface for token exchange strategies +type TokenExchanger interface { + Exchange(ctx context.Context, httpClient *http.Client, cfg TargetSTSConfig, subjectToken string) (*oauth2.Token, error) +} + +// tokenExchangeResponse represents the OAuth 2.0 token exchange response +type tokenExchangeResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + IssuedTokenType string `json:"issued_token_type,omitempty"` +} + +// GetTokenExchanger returns a TokenExchanger for the given strategy +func GetTokenExchanger(strategy string) (TokenExchanger, error) { + switch strategy { + case TokenExchangeStrategyKeycloakV1: + return &keycloakV1Exchanger{}, nil + case TokenExchangeStrategyRFC8693: + return &rfc8693Exchanger{}, nil + case TokenExchangeStrategyExternalAccount: + return &externalAccountExchanger{}, nil + default: + return nil, fmt.Errorf("unknown token exchange strategy: %s", strategy) + } +} + +// staticSubjectTokenSupplier implements externalaccount.SubjectTokenSupplier +type staticSubjectTokenSupplier struct { + token string +} + +func (s *staticSubjectTokenSupplier) SubjectToken(_ context.Context, _ externalaccount.SupplierOptions) (string, error) { + return s.token, nil +} + +var _ externalaccount.SubjectTokenSupplier = &staticSubjectTokenSupplier{} + +// keycloakV1Exchanger implements Keycloak V1 token exchange with subject_issuer support +type keycloakV1Exchanger struct{} + +func (e *keycloakV1Exchanger) Exchange(ctx context.Context, httpClient *http.Client, cfg TargetSTSConfig, subjectToken string) (*oauth2.Token, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + + data := url.Values{} + data.Set(FormKeyGrantType, GrantTypeTokenExchange) + data.Set(FormKeySubjectToken, subjectToken) + data.Set(FormKeySubjectTokenType, cfg.SubjectTokenType) + data.Set(FormKeyAudience, cfg.Audience) + data.Set(FormKeyClientID, cfg.ClientID) + + if cfg.ClientSecret != "" { + data.Set(FormKeyClientSecret, cfg.ClientSecret) + } + + // subject_issuer is the key differentiator for Keycloak V1 cross-realm exchange + if cfg.SubjectIssuer != "" { + data.Set(FormKeySubjectIssuer, cfg.SubjectIssuer) + } + + if len(cfg.Scopes) > 0 { + data.Set(FormKeyScope, strings.Join(cfg.Scopes, " ")) + } + + return doTokenExchange(ctx, httpClient, cfg.TokenURL, data) +} + +// rfc8693Exchanger implements standard RFC 8693 token exchange +type rfc8693Exchanger struct{} + +func (e *rfc8693Exchanger) Exchange(ctx context.Context, httpClient *http.Client, cfg TargetSTSConfig, subjectToken string) (*oauth2.Token, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + + data := url.Values{} + data.Set(FormKeyGrantType, GrantTypeTokenExchange) + data.Set(FormKeySubjectToken, subjectToken) + data.Set(FormKeySubjectTokenType, cfg.SubjectTokenType) + data.Set(FormKeyAudience, cfg.Audience) + data.Set(FormKeyRequestedTokenType, TokenTypeAccessToken) + data.Set(FormKeyClientID, cfg.ClientID) + + if cfg.ClientSecret != "" { + data.Set(FormKeyClientSecret, cfg.ClientSecret) + } + + if len(cfg.Scopes) > 0 { + data.Set(FormKeyScope, strings.Join(cfg.Scopes, " ")) + } + + return doTokenExchange(ctx, httpClient, cfg.TokenURL, data) +} + +// externalAccountExchanger wraps the Google externalaccount library +type externalAccountExchanger struct{} + +func (e *externalAccountExchanger) Exchange(ctx context.Context, httpClient *http.Client, cfg TargetSTSConfig, subjectToken string) (*oauth2.Token, error) { + if httpClient != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + } + + ts, err := externalaccount.NewTokenSource(ctx, externalaccount.Config{ + TokenURL: cfg.TokenURL, + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Audience: cfg.Audience, + SubjectTokenType: cfg.SubjectTokenType, + SubjectTokenSupplier: &staticSubjectTokenSupplier{token: subjectToken}, + Scopes: cfg.Scopes, + }) + if err != nil { + return nil, fmt.Errorf("failed to create external account token source: %w", err) + } + + return ts.Token() +} + +// doTokenExchange performs the HTTP POST for token exchange +func doTokenExchange(ctx context.Context, httpClient *http.Client, tokenURL string, data url.Values) (*oauth2.Token, error) { + req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create token exchange request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token exchange request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read token exchange response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp tokenExchangeResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse token exchange response: %w", err) + } + + token := &oauth2.Token{ + AccessToken: tokenResp.AccessToken, + TokenType: tokenResp.TokenType, + RefreshToken: tokenResp.RefreshToken, + } + + if tokenResp.ExpiresIn > 0 { + token.Expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + } + + return token, nil +} From 1d60ed90177f48e55705a6907d695bab46d7bd59 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 9 Dec 2025 19:13:33 -0500 Subject: [PATCH 2/6] feat: use TokenExchanger to handle v1 keycloak token exchange Signed-off-by: Calum Murray --- pkg/config/config.go | 6 ++ pkg/http/authorization.go | 60 ++++++++++++------ pkg/kubernetes/provider.go | 1 + pkg/kubernetes/provider_acm_hub.go | 61 ++++++++++++++++++ pkg/kubernetes/provider_kubeconfig.go | 90 ++++++++++++++++++++++++++- pkg/kubernetes/provider_single.go | 12 ++++ pkg/kubernetes/token.go | 12 ++++ pkg/mcp/mcp.go | 23 +++++-- 8 files changed, 238 insertions(+), 27 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 81bec2b7c..cf391b1f4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,6 +16,12 @@ const ( ClusterProviderDisabled = "disabled" ) +// ContextKey is a type for context keys to avoid collisions +type ContextKey string + +// TokenScopesContextKey is the context key for storing token scopes +const TokenScopesContextKey = ContextKey("TokenScopesContextKey") + // StaticConfig is the configuration for the server. // It allows to configure server specific settings and tools to be enabled or disabled. type StaticConfig struct { diff --git a/pkg/http/authorization.go b/pkg/http/authorization.go index 19f617099..a2d9a95ae 100644 --- a/pkg/http/authorization.go +++ b/pkg/http/authorization.go @@ -18,7 +18,6 @@ import ( "k8s.io/utils/strings/slices" "github.com/containers/kubernetes-mcp-server/pkg/config" - "github.com/containers/kubernetes-mcp-server/pkg/mcp" ) type KubernetesApiTokenVerifier interface { @@ -26,6 +25,13 @@ type KubernetesApiTokenVerifier interface { KubernetesApiVerifyToken(ctx context.Context, cluster, token, audience string) (*authenticationapiv1.UserInfo, []string, error) // GetTargetParameterName returns the parameter name used for target identification in MCP requests GetTargetParameterName() string + // HasTargetTokenExchange returns true if per-target token exchange is configured for the given target. + // When true, ExchangeTokenForTarget should be used instead of global STS exchange. + HasTargetTokenExchange(target string) bool + // ExchangeTokenForTarget exchanges the given token for a target-specific token. + // This is used for per-cluster token exchange (e.g., cross-realm Keycloak exchange). + // Returns the exchanged token, or an error if exchange fails. + ExchangeTokenForTarget(ctx context.Context, target, token string) (*oauth2.Token, error) } // extractTargetFromRequest extracts cluster parameter from MCP request body @@ -151,37 +157,51 @@ func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oi if err == nil { scopes := claims.GetScopes() klog.V(2).Infof("JWT token validated - Scopes: %v", scopes) - r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes)) + r = r.WithContext(context.WithValue(r.Context(), config.TokenScopesContextKey, scopes)) } - // Token exchange with OIDC provider - sts := NewFromConfig(staticConfig, oidcProvider) - // TODO: Maybe the token had already been exchanged, if it has the right audience and scopes, we can skip this step. - if err == nil && sts.IsEnabled() { - var exchangedToken *oauth2.Token - // If the token is valid, we can exchange it for a new token with the specified audience and scopes. + + // Extract target early so we can check for per-target token exchange + var target string + if err == nil { + targetParameterName := verifier.GetTargetParameterName() + target, _ = extractTargetFromRequest(r, targetParameterName) + } + + // Token exchange: per-target exchange takes priority over global STS + if err == nil { ctx := r.Context() if httpClient != nil { ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) } - exchangedToken, err = sts.ExternalAccountTokenExchange(ctx, &oauth2.Token{ - AccessToken: claims.Token, - TokenType: "Bearer", - }) - if err == nil { - // Replace the original token with the exchanged token + + var exchangedToken *oauth2.Token + if verifier.HasTargetTokenExchange(target) { + // Per-target token exchange (e.g., cross-realm Keycloak exchange) + klog.V(2).Infof("Using per-target token exchange for target: %s", target) + exchangedToken, err = verifier.ExchangeTokenForTarget(ctx, target, claims.Token) + } else { + // Fall back to global STS exchange if configured + // TODO: Maybe the token had already been exchanged, if it has the right audience and scopes, we can skip this step. + sts := NewFromConfig(staticConfig, oidcProvider) + if sts.IsEnabled() { + exchangedToken, err = sts.ExternalAccountTokenExchange(ctx, &oauth2.Token{ + AccessToken: claims.Token, + TokenType: "Bearer", + }) + } + } + + // Replace the original token with the exchanged token + if err == nil && exchangedToken != nil { token = exchangedToken.AccessToken claims, err = ParseJWTClaims(token) r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // TODO: Implement test to verify, THIS IS A CRITICAL PART } } + // Kubernetes API Server TokenReview validation if err == nil && staticConfig.ValidateToken { - targetParameterName := verifier.GetTargetParameterName() - cluster, clusterErr := extractTargetFromRequest(r, targetParameterName) - if clusterErr != nil { - klog.V(2).Infof("Failed to extract cluster from request, using default: %v", clusterErr) - } - err = claims.ValidateWithKubernetesApi(r.Context(), staticConfig.OAuthAudience, cluster, verifier) + err = claims.ValidateWithKubernetesApi(r.Context(), staticConfig.OAuthAudience, target, verifier) } 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) diff --git a/pkg/kubernetes/provider.go b/pkg/kubernetes/provider.go index 092c7de82..bdce4dcd6 100644 --- a/pkg/kubernetes/provider.go +++ b/pkg/kubernetes/provider.go @@ -14,6 +14,7 @@ type Provider interface { // See: https://github.com/containers/kubernetes-mcp-server/pull/372#discussion_r2421592315 Openshift TokenVerifier + TokenExchanger GetTargets(ctx context.Context) ([]string, error) GetDerivedKubernetes(ctx context.Context, target string) (*Kubernetes, error) GetDefaultTarget() string diff --git a/pkg/kubernetes/provider_acm_hub.go b/pkg/kubernetes/provider_acm_hub.go index 6db2faa09..40c7c619d 100644 --- a/pkg/kubernetes/provider_acm_hub.go +++ b/pkg/kubernetes/provider_acm_hub.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "golang.org/x/oauth2" authenticationv1api "k8s.io/api/authentication/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -21,6 +22,7 @@ import ( "github.com/BurntSushi/toml" "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/containers/kubernetes-mcp-server/pkg/sts" ) const ( @@ -42,6 +44,15 @@ type ACMProviderConfig struct { // The CA file for the cluster proxy addon ClusterProxyAddonCAFile string `toml:"cluster_proxy_addon_ca_file,omitempty"` + + // TokenExchangeStrategy specifies which token exchange protocol to use + // Valid values: "keycloak-v1", "rfc8693", "external-account" + // Default: "" (no per-cluster token exchange) + TokenExchangeStrategy string `toml:"token_exchange_strategy,omitempty"` + + // Clusters holds per-cluster token exchange configuration + // The key is the cluster name (e.g., "my-managed-cluster") + Clusters map[string]sts.TargetSTSConfig `toml:"clusters,omitempty"` } func (c *ACMProviderConfig) Validate() error { @@ -114,6 +125,10 @@ type acmHubClusterProvider struct { // Resource version from last list operation to use for watch lastResourceVersion string + + // Per-cluster token exchange configuration + tokenExchanger sts.TokenExchanger + clusterSTSConfigs map[string]sts.TargetSTSConfig } var _ Provider = &acmHubClusterProvider{} @@ -259,6 +274,17 @@ func newACMClusterProvider(m *Manager, cfg *ACMProviderConfig, watchKubeConfig b skipTLSVerify: cfg.ClusterProxyAddonSkipTLSVerify, } + // Initialize per-cluster token exchange if strategy is configured + if cfg.TokenExchangeStrategy != "" { + tokenExchanger, err := sts.GetTokenExchanger(cfg.TokenExchangeStrategy) + if err != nil { + return nil, fmt.Errorf("failed to initialize token exchanger: %w", err) + } + provider.tokenExchanger = tokenExchanger + provider.clusterSTSConfigs = cfg.Clusters + klog.V(2).Infof("Per-cluster token exchange enabled with strategy: %s", cfg.TokenExchangeStrategy) + } + ctx := context.Background() if err := provider.refreshClusters(ctx); err != nil { klog.Warningf("Failed to discover managed clusters: %v", err) @@ -511,3 +537,38 @@ func (p *acmHubClusterProvider) initializeManager(m *Manager) error { return nil } + +// HasTargetTokenExchange returns true if per-target token exchange is configured for the given target. +func (p *acmHubClusterProvider) HasTargetTokenExchange(target string) bool { + if p.tokenExchanger == nil || p.clusterSTSConfigs == nil { + return false + } + _, ok := p.clusterSTSConfigs[target] + return ok +} + +// ExchangeTokenForTarget exchanges the given token for a target-specific token. +func (p *acmHubClusterProvider) ExchangeTokenForTarget(ctx context.Context, target, token string) (*oauth2.Token, error) { + if p.tokenExchanger == nil { + return nil, fmt.Errorf("token exchanger not configured") + } + + stsCfg, ok := p.clusterSTSConfigs[target] + if !ok { + return nil, fmt.Errorf("no token exchange configuration for target %s", target) + } + + // Create HTTP client with TLS config from target's STS config + httpClient, err := stsCfg.HTTPClient() + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for target %s: %w", target, err) + } + + exchangedToken, err := p.tokenExchanger.Exchange(ctx, httpClient, stsCfg, token) + if err != nil { + return nil, fmt.Errorf("token exchange failed for target %s: %w", target, err) + } + + klog.V(3).Infof("Successfully exchanged token for target %s", target) + return exchangedToken, nil +} diff --git a/pkg/kubernetes/provider_kubeconfig.go b/pkg/kubernetes/provider_kubeconfig.go index 9ab055c8c..131b0c567 100644 --- a/pkg/kubernetes/provider_kubeconfig.go +++ b/pkg/kubernetes/provider_kubeconfig.go @@ -4,27 +4,64 @@ import ( "context" "errors" "fmt" + "net/http" + "time" + + "github.com/BurntSushi/toml" + "golang.org/x/oauth2" "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/containers/kubernetes-mcp-server/pkg/sts" authenticationv1api "k8s.io/api/authentication/v1" + "k8s.io/klog/v2" ) // KubeConfigTargetParameterName is the parameter name used to specify // the kubeconfig context when using the kubeconfig cluster provider strategy. const KubeConfigTargetParameterName = "context" +// KubeConfigProviderConfig holds kubeconfig-specific configuration +type KubeConfigProviderConfig struct { + // TokenExchangeStrategy specifies which token exchange protocol to use + // Valid values: "keycloak-v1", "rfc8693", "external-account" + // Default: "" (no per-context token exchange) + TokenExchangeStrategy string `toml:"token_exchange_strategy,omitempty"` + + // Contexts holds per-context token exchange configuration + // The key is the context name from the kubeconfig file + Contexts map[string]sts.TargetSTSConfig `toml:"contexts,omitempty"` +} + +func (c *KubeConfigProviderConfig) Validate() error { + return nil +} + +func parseKubeConfigProviderConfig(_ context.Context, primitive toml.Primitive, md toml.MetaData) (config.ProviderConfig, error) { + cfg := &KubeConfigProviderConfig{} + if err := md.PrimitiveDecode(primitive, cfg); err != nil { + return nil, err + } + return cfg, nil +} + // kubeConfigClusterProvider implements Provider for managing multiple // Kubernetes clusters using different contexts from a kubeconfig file. // It lazily initializes managers for each context as they are requested. type kubeConfigClusterProvider struct { defaultContext string managers map[string]*Manager + + // Per-context token exchange configuration + tokenExchanger sts.TokenExchanger + contextSTSConfigs map[string]sts.TargetSTSConfig + httpClient *http.Client } var _ Provider = &kubeConfigClusterProvider{} func init() { RegisterProvider(config.ClusterProviderKubeConfig, newKubeConfigClusterProvider) + config.RegisterProviderConfig(config.ClusterProviderKubeConfig, parseKubeConfigProviderConfig) } // newKubeConfigClusterProvider creates a provider that manages multiple clusters @@ -57,10 +94,30 @@ func newKubeConfigClusterProvider(cfg *config.StaticConfig) (Provider, error) { allClusterManagers[name] = nil } - return &kubeConfigClusterProvider{ + provider := &kubeConfigClusterProvider{ defaultContext: rawConfig.CurrentContext, managers: allClusterManagers, - }, nil + } + + // Initialize per-context token exchange if configured + providerCfg, ok := cfg.GetProviderConfig(config.ClusterProviderKubeConfig) + if ok { + kubeConfigCfg := providerCfg.(*KubeConfigProviderConfig) + if kubeConfigCfg.TokenExchangeStrategy != "" { + tokenExchanger, err := sts.GetTokenExchanger(kubeConfigCfg.TokenExchangeStrategy) + if err != nil { + return nil, fmt.Errorf("failed to initialize token exchanger: %w", err) + } + provider.tokenExchanger = tokenExchanger + provider.contextSTSConfigs = kubeConfigCfg.Contexts + provider.httpClient = &http.Client{ + Timeout: 30 * time.Second, + } + klog.V(2).Infof("Per-context token exchange enabled with strategy: %s", kubeConfigCfg.TokenExchangeStrategy) + } + } + + return provider, nil } func (p *kubeConfigClusterProvider) managerForContext(context string) (*Manager, error) { @@ -129,3 +186,32 @@ func (p *kubeConfigClusterProvider) Close() { m.Close() } + +// HasTargetTokenExchange returns true if per-target token exchange is configured for the given context. +func (p *kubeConfigClusterProvider) HasTargetTokenExchange(target string) bool { + if p.tokenExchanger == nil || p.contextSTSConfigs == nil { + return false + } + _, ok := p.contextSTSConfigs[target] + return ok +} + +// ExchangeTokenForTarget exchanges the given token for a target-specific token. +func (p *kubeConfigClusterProvider) ExchangeTokenForTarget(ctx context.Context, target, token string) (*oauth2.Token, error) { + if p.tokenExchanger == nil { + return nil, fmt.Errorf("token exchanger not configured") + } + + stsCfg, ok := p.contextSTSConfigs[target] + if !ok { + return nil, fmt.Errorf("no token exchange configuration for context %s", target) + } + + exchangedToken, err := p.tokenExchanger.Exchange(ctx, p.httpClient, stsCfg, token) + if err != nil { + return nil, fmt.Errorf("token exchange failed for context %s: %w", target, err) + } + + klog.V(3).Infof("Successfully exchanged token for context %s", target) + return exchangedToken, nil +} diff --git a/pkg/kubernetes/provider_single.go b/pkg/kubernetes/provider_single.go index 3693d6396..577525c5a 100644 --- a/pkg/kubernetes/provider_single.go +++ b/pkg/kubernetes/provider_single.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" + "golang.org/x/oauth2" + "github.com/containers/kubernetes-mcp-server/pkg/config" authenticationv1api "k8s.io/api/authentication/v1" ) @@ -92,3 +94,13 @@ func (p *singleClusterProvider) WatchTargets(watch func() error) { func (p *singleClusterProvider) Close() { p.manager.Close() } + +// HasTargetTokenExchange returns false for single cluster provider (no per-target exchange). +func (p *singleClusterProvider) HasTargetTokenExchange(_ string) bool { + return false +} + +// ExchangeTokenForTarget is not supported for single cluster provider. +func (p *singleClusterProvider) ExchangeTokenForTarget(_ context.Context, _, _ string) (*oauth2.Token, error) { + return nil, fmt.Errorf("per-target token exchange not supported with %s strategy", p.strategy) +} diff --git a/pkg/kubernetes/token.go b/pkg/kubernetes/token.go index f81c3a88f..919457994 100644 --- a/pkg/kubernetes/token.go +++ b/pkg/kubernetes/token.go @@ -3,9 +3,21 @@ package kubernetes import ( "context" + "golang.org/x/oauth2" authenticationv1api "k8s.io/api/authentication/v1" ) type TokenVerifier interface { VerifyToken(ctx context.Context, cluster, token, audience string) (*authenticationv1api.UserInfo, []string, error) } + +// TokenExchanger provides per-target token exchange capabilities. +// This is used for scenarios like cross-realm Keycloak token exchange +// where a hub token needs to be exchanged for a managed cluster token. +type TokenExchanger interface { + // HasTargetTokenExchange returns true if per-target token exchange is configured for the given target. + HasTargetTokenExchange(target string) bool + // ExchangeTokenForTarget exchanges the given token for a target-specific token. + // Returns the exchanged token, or an error if exchange fails. + ExchangeTokenForTarget(ctx context.Context, target, token string) (*oauth2.Token, error) +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index f64d41045..4bca53afc 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -9,6 +9,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "golang.org/x/oauth2" authenticationapiv1 "k8s.io/api/authentication/v1" "k8s.io/klog/v2" "k8s.io/utils/ptr" @@ -21,10 +22,6 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/version" ) -type ContextKey string - -const TokenScopesContextKey = ContextKey("TokenScopesContextKey") - type Configuration struct { *config.StaticConfig listOutput output.Output @@ -192,6 +189,22 @@ func (s *Server) GetTargetParameterName() string { return s.p.GetTargetParameterName() } +// HasTargetTokenExchange returns true if per-target token exchange is configured for the given target. +func (s *Server) HasTargetTokenExchange(target string) bool { + if s.p == nil { + return false + } + return s.p.HasTargetTokenExchange(target) +} + +// ExchangeTokenForTarget exchanges the given token for a target-specific token. +func (s *Server) ExchangeTokenForTarget(ctx context.Context, target, token string) (*oauth2.Token, error) { + if s.p == nil { + return nil, fmt.Errorf("kubernetes cluster provider is not initialized") + } + return s.p.ExchangeTokenForTarget(ctx, target, token) +} + func (s *Server) GetEnabledTools() []string { return s.enabledTools } @@ -255,7 +268,7 @@ func toolCallLoggingMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFu func toolScopedAuthorizationMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFunc { return func(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) { - scopes, ok := ctx.Value(TokenScopesContextKey).([]string) + scopes, ok := ctx.Value(config.TokenScopesContextKey).([]string) if !ok { return NewTextResult("", fmt.Errorf("authorization failed: Access denied: Tool '%s' requires scope 'mcp:%s' but no scope is available", ctr.Params.Name, ctr.Params.Name)), nil } From 2b2fcffb9bf0809642dc1d29b2dbac22c6c398ae Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 9 Dec 2025 19:14:38 -0500 Subject: [PATCH 3/6] fix: keycloak v1 token exchange is configured correctly Signed-off-by: Calum Murray --- .gitignore | 2 + dev/config/keycloak/deployment.yaml | 2 +- .../hub-realm-idp-template.json | 8 ++- hack/keycloak-acm/generate-toml.sh | 27 +++++++- hack/keycloak-acm/register-managed-cluster.sh | 61 ++++++++++++++----- hack/keycloak-acm/setup-hub.sh | 11 +++- 6 files changed, 91 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 12b624e76..ddf69c4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ kubernetes-mcp-server-linux-arm64 kubernetes-mcp-server-windows-amd64.exe kubernetes-mcp-server-windows-arm64.exe +.keycloak-config + python/.venv/ python/build/ python/dist/ diff --git a/dev/config/keycloak/deployment.yaml b/dev/config/keycloak/deployment.yaml index efcb7e0f9..fc6743852 100644 --- a/dev/config/keycloak/deployment.yaml +++ b/dev/config/keycloak/deployment.yaml @@ -24,7 +24,7 @@ spec: containers: - name: keycloak image: quay.io/keycloak/keycloak:26.4 - args: ["start-dev"] + args: ["start-dev", "--features=token-exchange:v1,admin-fine-grained-authz:v1"] env: - name: KC_BOOTSTRAP_ADMIN_USERNAME value: "admin" diff --git a/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json b/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json index 6b708e982..94107ae7d 100644 --- a/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json +++ b/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json @@ -5,7 +5,7 @@ "enabled": true, "updateProfileFirstLoginMode": "on", "trustEmail": true, - "storeToken": false, + "storeToken": true, "addReadTokenRoleOnCreate": false, "authenticateByDefault": false, "linkOnly": false, @@ -15,9 +15,13 @@ "validateSignature": "true", "useJwksUrl": "true", "jwksUrl": "${KEYCLOAK_URL}/realms/hub/protocol/openid-connect/certs", + "tokenUrl": "${KEYCLOAK_URL}/realms/hub/protocol/openid-connect/token", + "authorizationUrl": "${KEYCLOAK_URL}/realms/hub/protocol/openid-connect/auth", + "userInfoUrl": "${KEYCLOAK_URL}/realms/hub/protocol/openid-connect/userinfo", "clientId": "mcp-server", "clientSecret": "${HUB_CLIENT_SECRET}", "clientAuthMethod": "client_secret_post", - "syncMode": "IMPORT" + "syncMode": "IMPORT", + "disableUserInfo": "false" } } diff --git a/hack/keycloak-acm/generate-toml.sh b/hack/keycloak-acm/generate-toml.sh index 7b5014d15..a6ceb85ce 100755 --- a/hack/keycloak-acm/generate-toml.sh +++ b/hack/keycloak-acm/generate-toml.sh @@ -56,8 +56,12 @@ echo " Keycloak URL: $KEYCLOAK_URL" echo " Hub Realm: $HUB_REALM" echo "" -# Count managed clusters -CLUSTER_COUNT=$(ls -1 .keycloak-config/clusters/*.env 2>/dev/null | wc -l) +# Count managed clusters (handle case where directory is empty or doesn't exist) +if [ -d ".keycloak-config/clusters" ]; then + CLUSTER_COUNT=$(find .keycloak-config/clusters -maxdepth 1 -name "*.env" -type f 2>/dev/null | wc -l | tr -d ' ') +else + CLUSTER_COUNT=0 +fi echo " Managed Clusters: $CLUSTER_COUNT" echo "" @@ -122,6 +126,8 @@ cat >> "$OUTPUT_FILE" <> "$OUTPUT_FILE" # Always add local-cluster (hub itself) with same-realm token exchange echo "Adding local-cluster (hub itself)..." +if [ -f "$CA_FILE" ]; then cat >> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" echo "audience = \"mcp-server\"" >> "$OUTPUT_FILE" echo "subject_token_type = \"urn:ietf:params:oauth:token-type:jwt\"" >> "$OUTPUT_FILE" + if [ -f "$CA_FILE" ]; then + echo "ca_file = \"$CA_FILE\"" >> "$OUTPUT_FILE" + fi echo "" >> "$OUTPUT_FILE" done fi diff --git a/hack/keycloak-acm/register-managed-cluster.sh b/hack/keycloak-acm/register-managed-cluster.sh index bab51d959..47d75743b 100755 --- a/hack/keycloak-acm/register-managed-cluster.sh +++ b/hack/keycloak-acm/register-managed-cluster.sh @@ -358,20 +358,53 @@ if [ -z "$HUB_USER_ID" ]; then exit 1 fi -# Get the service account user for mcp-server client (this is the user used in token exchange) -SERVICE_ACCOUNT_USER=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$MANAGED_CLIENT_UUID/service-account-user" \ +echo " ✅ Found hub user: $MCP_USERNAME (ID: $HUB_USER_ID)" + +# For V1 cross-realm token exchange, we need a regular user in the managed realm +# with a federated identity link to the hub user. The sub claim from the hub token +# must match the userId in the federated identity link. + +# Check if user already exists in managed realm +MANAGED_USERS=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/users?username=$MCP_USERNAME" \ -H "Authorization: Bearer $ADMIN_TOKEN") -MANAGED_USER_ID=$(echo "$SERVICE_ACCOUNT_USER" | jq -r '.id // empty') +MANAGED_USER_ID=$(echo "$MANAGED_USERS" | jq -r '.[0].id // empty') if [ -z "$MANAGED_USER_ID" ] || [ "$MANAGED_USER_ID" = "null" ]; then - echo " ❌ Service account for mcp-server client not found" - exit 1 + # Create user in managed realm + echo " Creating user $MCP_USERNAME in managed realm..." + USER_CREATE_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/users" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"$MCP_USERNAME\", + \"enabled\": true, + \"email\": \"$MCP_USERNAME@example.com\", + \"firstName\": \"MCP\", + \"lastName\": \"User\" + }") + USER_CREATE_CODE=$(echo "$USER_CREATE_RESPONSE" | tail -c 4) + + if [ "$USER_CREATE_CODE" = "201" ]; then + echo " ✅ User $MCP_USERNAME created in managed realm" + else + echo " ⚠️ User creation returned HTTP $USER_CREATE_CODE" + fi + + # Get the new user ID + MANAGED_USERS=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/users?username=$MCP_USERNAME" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + MANAGED_USER_ID=$(echo "$MANAGED_USERS" | jq -r '.[0].id // empty') +else + echo " ✅ User $MCP_USERNAME already exists in managed realm (ID: $MANAGED_USER_ID)" fi -SERVICE_ACCOUNT_USERNAME=$(echo "$SERVICE_ACCOUNT_USER" | jq -r '.username // empty') -echo " ✅ Found service account: $SERVICE_ACCOUNT_USERNAME (ID: $MANAGED_USER_ID)" +if [ -z "$MANAGED_USER_ID" ] || [ "$MANAGED_USER_ID" = "null" ]; then + echo " ❌ Failed to get managed realm user ID" + exit 1 +fi -# Create federated identity link between hub user and managed service account +# Create federated identity link between managed user and hub user +# The userId here is the hub user's ID - this is how Keycloak matches the sub claim FED_IDENTITY_JSON="{ \"identityProvider\": \"$IDP_ALIAS\", \"userId\": \"$HUB_USER_ID\", @@ -385,7 +418,7 @@ FED_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/re FED_CODE=$(echo "$FED_RESPONSE" | tail -c 4) if [ "$FED_CODE" = "204" ] || [ "$FED_CODE" = "409" ]; then - echo " ✅ Federated identity link created (hub user: $MCP_USERNAME/$HUB_USER_ID → managed service account: $SERVICE_ACCOUNT_USERNAME/$MANAGED_USER_ID)" + echo " ✅ Federated identity link created (hub: $MCP_USERNAME/$HUB_USER_ID → managed: $MCP_USERNAME/$MANAGED_USER_ID)" else echo " ⚠️ Federated identity link returned HTTP $FED_CODE" fi @@ -522,13 +555,13 @@ else echo " ✅ CA certificate ConfigMap created" fi -# Step 3: Create RBAC for service-account-mcp-server user +# Step 3: Create RBAC for mcp user (the user from token exchange) echo "" -echo "Step 3: Creating RBAC for service-account-mcp-server user..." -kubectl --kubeconfig="$MANAGED_KUBECONFIG" create clusterrolebinding svc-acct-mcp-server-admin \ - --clusterrole=cluster-admin --user=service-account-mcp-server \ +echo "Step 3: Creating RBAC for $MCP_USERNAME user..." +kubectl --kubeconfig="$MANAGED_KUBECONFIG" create clusterrolebinding mcp-user-admin \ + --clusterrole=cluster-admin --user="$MCP_USERNAME" \ --dry-run=client -o yaml | kubectl --kubeconfig="$MANAGED_KUBECONFIG" apply -f - -echo " ✅ RBAC created" +echo " ✅ RBAC created for $MCP_USERNAME" # Step 4: Configure OIDC provider echo "" diff --git a/hack/keycloak-acm/setup-hub.sh b/hack/keycloak-acm/setup-hub.sh index ecf349282..8fdbc9eda 100755 --- a/hack/keycloak-acm/setup-hub.sh +++ b/hack/keycloak-acm/setup-hub.sh @@ -408,7 +408,16 @@ curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/defa -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/default-client-scopes/$MCP_SERVER_SCOPE_UUID" \ -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 -echo "✅ Scopes added (openid, mcp-server)" + +# Add basic scope (contains sub claim mapper) - important for token exchange +BASIC_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "basic") | .id // empty') +if [ -n "$BASIC_SCOPE_UUID" ] && [ "$BASIC_SCOPE_UUID" != "null" ]; then + curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/default-client-scopes/$BASIC_SCOPE_UUID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 + echo "✅ Scopes added (openid, mcp-server, basic)" +else + echo "✅ Scopes added (openid, mcp-server)" +fi # Add sub claim mapper echo "" From 99543ff09e72d298179b6f5f4ed4ec1ed65b0d40 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 9 Dec 2025 19:28:59 -0500 Subject: [PATCH 4/6] cleanup: move remaining sts package references to tokenexchange Signed-off-by: Calum Murray --- pkg/kubernetes/provider_acm_hub.go | 10 +++++----- pkg/kubernetes/provider_kubeconfig.go | 10 +++++----- pkg/tokenexchange/token_exchange.go | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/kubernetes/provider_acm_hub.go b/pkg/kubernetes/provider_acm_hub.go index 40c7c619d..77283d381 100644 --- a/pkg/kubernetes/provider_acm_hub.go +++ b/pkg/kubernetes/provider_acm_hub.go @@ -22,7 +22,7 @@ import ( "github.com/BurntSushi/toml" "github.com/containers/kubernetes-mcp-server/pkg/config" - "github.com/containers/kubernetes-mcp-server/pkg/sts" + "github.com/containers/kubernetes-mcp-server/pkg/tokenexchange" ) const ( @@ -52,7 +52,7 @@ type ACMProviderConfig struct { // Clusters holds per-cluster token exchange configuration // The key is the cluster name (e.g., "my-managed-cluster") - Clusters map[string]sts.TargetSTSConfig `toml:"clusters,omitempty"` + Clusters map[string]tokenexchange.TargetSTSConfig `toml:"clusters,omitempty"` } func (c *ACMProviderConfig) Validate() error { @@ -127,8 +127,8 @@ type acmHubClusterProvider struct { lastResourceVersion string // Per-cluster token exchange configuration - tokenExchanger sts.TokenExchanger - clusterSTSConfigs map[string]sts.TargetSTSConfig + tokenExchanger tokenexchange.TokenExchanger + clusterSTSConfigs map[string]tokenexchange.TargetSTSConfig } var _ Provider = &acmHubClusterProvider{} @@ -276,7 +276,7 @@ func newACMClusterProvider(m *Manager, cfg *ACMProviderConfig, watchKubeConfig b // Initialize per-cluster token exchange if strategy is configured if cfg.TokenExchangeStrategy != "" { - tokenExchanger, err := sts.GetTokenExchanger(cfg.TokenExchangeStrategy) + tokenExchanger, err := tokenexchange.GetTokenExchanger(cfg.TokenExchangeStrategy) if err != nil { return nil, fmt.Errorf("failed to initialize token exchanger: %w", err) } diff --git a/pkg/kubernetes/provider_kubeconfig.go b/pkg/kubernetes/provider_kubeconfig.go index 131b0c567..68139111d 100644 --- a/pkg/kubernetes/provider_kubeconfig.go +++ b/pkg/kubernetes/provider_kubeconfig.go @@ -11,7 +11,7 @@ import ( "golang.org/x/oauth2" "github.com/containers/kubernetes-mcp-server/pkg/config" - "github.com/containers/kubernetes-mcp-server/pkg/sts" + "github.com/containers/kubernetes-mcp-server/pkg/tokenexchange" authenticationv1api "k8s.io/api/authentication/v1" "k8s.io/klog/v2" ) @@ -29,7 +29,7 @@ type KubeConfigProviderConfig struct { // Contexts holds per-context token exchange configuration // The key is the context name from the kubeconfig file - Contexts map[string]sts.TargetSTSConfig `toml:"contexts,omitempty"` + Contexts map[string]tokenexchange.TargetSTSConfig `toml:"contexts,omitempty"` } func (c *KubeConfigProviderConfig) Validate() error { @@ -52,8 +52,8 @@ type kubeConfigClusterProvider struct { managers map[string]*Manager // Per-context token exchange configuration - tokenExchanger sts.TokenExchanger - contextSTSConfigs map[string]sts.TargetSTSConfig + tokenExchanger tokenexchange.TokenExchanger + contextSTSConfigs map[string]tokenexchange.TargetSTSConfig httpClient *http.Client } @@ -104,7 +104,7 @@ func newKubeConfigClusterProvider(cfg *config.StaticConfig) (Provider, error) { if ok { kubeConfigCfg := providerCfg.(*KubeConfigProviderConfig) if kubeConfigCfg.TokenExchangeStrategy != "" { - tokenExchanger, err := sts.GetTokenExchanger(kubeConfigCfg.TokenExchangeStrategy) + tokenExchanger, err := tokenexchange.GetTokenExchanger(kubeConfigCfg.TokenExchangeStrategy) if err != nil { return nil, fmt.Errorf("failed to initialize token exchanger: %w", err) } diff --git a/pkg/tokenexchange/token_exchange.go b/pkg/tokenexchange/token_exchange.go index a8af1bb57..c60dbc2b4 100644 --- a/pkg/tokenexchange/token_exchange.go +++ b/pkg/tokenexchange/token_exchange.go @@ -1,4 +1,4 @@ -package sts +package tokenexchange import ( "context" From b7ce31b075e08382d7476ec18dc14ac3484846b0 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Tue, 9 Dec 2025 20:03:44 -0500 Subject: [PATCH 5/6] fix(snyk): pass security check by removing user config option for insecureskiptlsverify Signed-off-by: Calum Murray --- pkg/tokenexchange/token_exchange.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/pkg/tokenexchange/token_exchange.go b/pkg/tokenexchange/token_exchange.go index c60dbc2b4..12e4291f3 100644 --- a/pkg/tokenexchange/token_exchange.go +++ b/pkg/tokenexchange/token_exchange.go @@ -68,15 +68,13 @@ type TargetSTSConfig struct { // CAFile is the path to a CA certificate file for TLS verification // Used when the token endpoint uses a certificate signed by a private CA CAFile string `toml:"ca_file,omitempty"` - // InsecureSkipTLSVerify disables TLS certificate verification (not recommended for production) - InsecureSkipTLSVerify bool `toml:"insecure_skip_tls_verify,omitempty"` } // HTTPClient creates an HTTP client configured with the TLS settings from this config. -// If neither CAFile nor InsecureSkipTLSVerify is set, returns a default client. +// If CAFile is not set, returns a default client. func (c *TargetSTSConfig) HTTPClient() (*http.Client, error) { // If no TLS customization needed, return default client with timeout - if c.CAFile == "" && !c.InsecureSkipTLSVerify { + if c.CAFile == "" { return &http.Client{Timeout: 30 * time.Second}, nil } @@ -84,10 +82,6 @@ func (c *TargetSTSConfig) HTTPClient() (*http.Client, error) { MinVersion: tls.VersionTLS12, } - if c.InsecureSkipTLSVerify { - tlsConfig.InsecureSkipVerify = true - } - if c.CAFile != "" { caCert, err := os.ReadFile(c.CAFile) if err != nil { From 21547fdb53eae26680ff3c654a23c65111487aef Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Thu, 11 Dec 2025 10:55:40 -0500 Subject: [PATCH 6/6] fix: keycloak token exchange config Signed-off-by: Calum Murray --- build/openshift/keycloak-acm.mk | 2 +- .../hub-realm-idp-template.json | 2 +- hack/keycloak-acm/register-managed-cluster.sh | 30 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/build/openshift/keycloak-acm.mk b/build/openshift/keycloak-acm.mk index d7d3cf4b5..69724076e 100644 --- a/build/openshift/keycloak-acm.mk +++ b/build/openshift/keycloak-acm.mk @@ -82,7 +82,7 @@ keycloak-acm-register-managed-cluster: ## Register managed cluster with ACM and @echo " 4. Configure cross-realm token exchange" @echo " 5. Enable TechPreviewNoUpgrade on managed cluster" @echo " 6. Configure OIDC authentication" - @echo " 7. Create RBAC for service-account-mcp-server" + @echo " 7. Create RBAC for mcp user" @echo "" @echo "⏳ Total time: ~25-30 minutes (rollouts happen in background)" @echo "" diff --git a/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json b/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json index 94107ae7d..f4ab85f3e 100644 --- a/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json +++ b/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json @@ -22,6 +22,6 @@ "clientSecret": "${HUB_CLIENT_SECRET}", "clientAuthMethod": "client_secret_post", "syncMode": "IMPORT", - "disableUserInfo": "false" + "disableUserInfo": "true" } } diff --git a/hack/keycloak-acm/register-managed-cluster.sh b/hack/keycloak-acm/register-managed-cluster.sh index 47d75743b..ea4bf3720 100755 --- a/hack/keycloak-acm/register-managed-cluster.sh +++ b/hack/keycloak-acm/register-managed-cluster.sh @@ -422,6 +422,36 @@ if [ "$FED_CODE" = "204" ] || [ "$FED_CODE" = "409" ]; then else echo " ⚠️ Federated identity link returned HTTP $FED_CODE" fi + +# Remove any federated identity link from service account user to prevent +# "More results found" error during token exchange. The mcp-server client's +# service account should NOT have a federated identity link. +echo " Checking for service account federated identity..." +SA_USER=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$MANAGED_CLIENT_UUID/service-account-user" \ + -H "Authorization: Bearer $ADMIN_TOKEN") +SA_USER_ID=$(echo "$SA_USER" | jq -r '.id // empty') + +if [ -n "$SA_USER_ID" ] && [ "$SA_USER_ID" != "null" ]; then + # Check if service account has a federated identity link + SA_FED=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/users/$SA_USER_ID/federated-identity" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + SA_FED_IDP=$(echo "$SA_FED" | jq -r '.[] | select(.identityProvider == "'"$IDP_ALIAS"'") | .identityProvider // empty') + + if [ -n "$SA_FED_IDP" ]; then + echo " Removing federated identity from service account..." + curl $CURL_OPTS -X DELETE "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/users/$SA_USER_ID/federated-identity/$IDP_ALIAS" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null + echo " ✅ Removed federated identity from service account" + else + echo " ✅ Service account has no conflicting federated identity" + fi +fi + +# Clear Keycloak user cache to ensure federated identity changes take effect immediately +echo " Clearing Keycloak user cache..." +curl $CURL_OPTS -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clear-user-cache" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null +echo " ✅ User cache cleared" echo "" # Step 9: Configure cross-realm token exchange permissions