Skip to content
Open
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
2 changes: 1 addition & 1 deletion build/openshift/keycloak-acm.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
2 changes: 1 addition & 1 deletion dev/config/keycloak/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"enabled": true,
"updateProfileFirstLoginMode": "on",
"trustEmail": true,
"storeToken": false,
"storeToken": true,
"addReadTokenRoleOnCreate": false,
"authenticateByDefault": false,
"linkOnly": false,
Expand All @@ -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": "true"
}
}
27 changes: 25 additions & 2 deletions hack/keycloak-acm/generate-toml.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand Down Expand Up @@ -122,6 +126,8 @@ cat >> "$OUTPUT_FILE" <<EOF
[cluster_provider_configs.acm-kubeconfig]
context_name = "$CONTEXT_NAME"
cluster_proxy_addon_skip_tls_verify = true
# Token exchange strategy: keycloak-v1, rfc8693, or external-account
token_exchange_strategy = "keycloak-v1"

EOF

Expand All @@ -131,6 +137,7 @@ echo "" >> "$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" <<EOF
# Cluster: local-cluster (hub itself - same-realm token exchange)
[cluster_provider_configs.acm-kubeconfig.clusters."local-cluster"]
Expand All @@ -139,8 +146,21 @@ client_id = "$STS_CLIENT_ID"
client_secret = "$STS_CLIENT_SECRET"
audience = "mcp-server"
subject_token_type = "urn:ietf:params:oauth:token-type:access_token"
ca_file = "$CA_FILE"

EOF
else
cat >> "$OUTPUT_FILE" <<EOF
# Cluster: local-cluster (hub itself - same-realm token exchange)
[cluster_provider_configs.acm-kubeconfig.clusters."local-cluster"]
token_url = "$KEYCLOAK_URL/realms/$HUB_REALM/protocol/openid-connect/token"
client_id = "$STS_CLIENT_ID"
client_secret = "$STS_CLIENT_SECRET"
audience = "mcp-server"
subject_token_type = "urn:ietf:params:oauth:token-type:access_token"

EOF
fi

# Add other managed clusters
if [ "$CLUSTER_COUNT" -gt 0 ]; then
Expand All @@ -166,6 +186,9 @@ if [ "$CLUSTER_COUNT" -gt 0 ]; then
echo "subject_issuer = \"$CLUSTER_IDP_ALIAS\"" >> "$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
Expand Down
91 changes: 77 additions & 14 deletions hack/keycloak-acm/register-managed-cluster.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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\": \"[email protected]\",
\"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\",
Expand All @@ -385,10 +418,40 @@ 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

# 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
Expand Down Expand Up @@ -522,13 +585,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 ""
Expand Down
11 changes: 10 additions & 1 deletion hack/keycloak-acm/setup-hub.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
6 changes: 6 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
60 changes: 40 additions & 20 deletions pkg/http/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,20 @@ 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 {
// KubernetesApiVerifyToken TODO: clarify proper implementation
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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pkg/kubernetes/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading