diff --git a/Makefile b/Makefile
index 04ff1ac0..3d4b1017 100644
--- a/Makefile
+++ b/Makefile
@@ -153,3 +153,4 @@ local-env-teardown: ## Tear down the local Kind cluster
# Include build configuration files
-include build/*.mk
+-include build/openshift/*.mk
diff --git a/build/openshift/keycloak-acm.mk b/build/openshift/keycloak-acm.mk
new file mode 100644
index 00000000..d7d3cf4b
--- /dev/null
+++ b/build/openshift/keycloak-acm.mk
@@ -0,0 +1,185 @@
+# Keycloak ACM Integration for OpenShift
+#
+# This file contains targets for setting up Keycloak with V1 token exchange
+# for ACM multi-cluster environments on OpenShift.
+#
+# Prerequisites:
+# - OpenShift 4.19+ or 4.20+ cluster
+# - ACM installed
+# - Cluster-admin access
+#
+# Initial Setup (Hub Only):
+# make keycloak-acm-setup-hub # Deploy Keycloak and configure hub realm
+# make keycloak-acm-generate-toml # Generate MCP server configuration
+#
+# Environment Variables:
+# HUB_KUBECONFIG - Path to hub cluster kubeconfig (default: $KUBECONFIG)
+# KEYCLOAK_URL - Keycloak URL (auto-detected from route if not set)
+# ADMIN_USER - Keycloak admin username (default: admin)
+# ADMIN_PASSWORD - Keycloak admin password (default: admin)
+
+##@ Keycloak ACM Integration
+
+.PHONY: keycloak-acm-setup-hub
+keycloak-acm-setup-hub: ## Deploy Keycloak on OpenShift with V1 token exchange for ACM hub
+ @echo "==========================================="
+ @echo "Keycloak ACM Hub Setup"
+ @echo "==========================================="
+ @echo ""
+ @echo "This will:"
+ @echo " 1. Enable TechPreviewNoUpgrade feature gate (if needed)"
+ @echo " 2. Deploy Keycloak with V1 token exchange features"
+ @echo " 3. Create hub realm with mcp user and clients"
+ @echo " 4. Configure same-realm token exchange"
+ @echo " 5. Fix CA trust for cross-realm token exchange"
+ @echo " 6. Create RBAC for mcp user"
+ @echo " 7. Save configuration to .keycloak-config/"
+ @echo ""
+ @bash ./hack/keycloak-acm/setup-hub.sh
+ @echo ""
+ @echo "✅ Hub Keycloak setup complete!"
+ @echo ""
+ @echo "Configuration saved to: .keycloak-config/hub-config.env"
+ @echo ""
+ @echo "Next steps:"
+ @echo " 1. Run: make keycloak-acm-generate-toml"
+ @echo " 2. Start MCP server with: ./kubernetes-mcp-server --config acm-kubeconfig.toml"
+
+.PHONY: keycloak-acm-generate-toml
+keycloak-acm-generate-toml: ## Generate _output/acm-kubeconfig.toml from saved Keycloak configuration
+ @echo "==========================================="
+ @echo "Generating MCP Server Configuration"
+ @echo "==========================================="
+ @echo ""
+ @bash ./hack/keycloak-acm/generate-toml.sh
+ @echo ""
+ @echo "Next: Start MCP server with: ./kubernetes-mcp-server --port 8080 --config _output/acm-kubeconfig.toml"
+
+.PHONY: keycloak-acm-register-managed-cluster
+keycloak-acm-register-managed-cluster: ## Register managed cluster with ACM and configure OIDC (requires: CLUSTER_NAME, MANAGED_KUBECONFIG)
+ @if [ -z "$(CLUSTER_NAME)" ]; then \
+ echo "Error: CLUSTER_NAME is required"; \
+ echo "Usage: make keycloak-acm-register-managed-cluster CLUSTER_NAME=my-cluster MANAGED_KUBECONFIG=/path/to/kubeconfig"; \
+ exit 1; \
+ fi
+ @if [ -z "$(MANAGED_KUBECONFIG)" ]; then \
+ echo "Error: MANAGED_KUBECONFIG is required"; \
+ echo "Usage: make keycloak-acm-register-managed-cluster CLUSTER_NAME=my-cluster MANAGED_KUBECONFIG=/path/to/kubeconfig"; \
+ exit 1; \
+ fi
+ @if [ ! -f "$(MANAGED_KUBECONFIG)" ]; then \
+ echo "Error: Kubeconfig file not found: $(MANAGED_KUBECONFIG)"; \
+ exit 1; \
+ fi
+ @echo "==========================================="
+ @echo "Managed Cluster Setup: $(CLUSTER_NAME)"
+ @echo "==========================================="
+ @echo ""
+ @echo "This will:"
+ @echo " 1. Create ACM ManagedCluster resource"
+ @echo " 2. Apply ACM import manifests (starts cluster-proxy agents)"
+ @echo " 3. Create managed cluster realm in Keycloak"
+ @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 ""
+ @echo "⏳ Total time: ~25-30 minutes (rollouts happen in background)"
+ @echo ""
+ @HUB_KUBECONFIG="$${HUB_KUBECONFIG:-$$KUBECONFIG}" && \
+ if [ -z "$$HUB_KUBECONFIG" ]; then \
+ echo "Error: HUB_KUBECONFIG not set and KUBECONFIG is empty"; \
+ echo "Either set KUBECONFIG to hub cluster or pass HUB_KUBECONFIG=..."; \
+ exit 1; \
+ fi && \
+ CLUSTER_NAME="$(CLUSTER_NAME)" \
+ HUB_KUBECONFIG="$$HUB_KUBECONFIG" \
+ MANAGED_KUBECONFIG="$(MANAGED_KUBECONFIG)" \
+ bash ./hack/keycloak-acm/register-managed-cluster.sh
+
+.PHONY: keycloak-acm-apply-import-manifests
+keycloak-acm-apply-import-manifests: ## [Optional] Re-apply ACM import manifests to managed cluster (already included in registration)
+ @if [ -z "$(CLUSTER_NAME)" ]; then \
+ echo "Error: CLUSTER_NAME is required"; \
+ echo "Usage: make keycloak-acm-apply-import-manifests CLUSTER_NAME=my-cluster MANAGED_KUBECONFIG=/path/to/kubeconfig"; \
+ exit 1; \
+ fi
+ @if [ -z "$(MANAGED_KUBECONFIG)" ]; then \
+ echo "Error: MANAGED_KUBECONFIG is required"; \
+ echo "Usage: make keycloak-acm-apply-import-manifests CLUSTER_NAME=my-cluster MANAGED_KUBECONFIG=/path/to/kubeconfig"; \
+ exit 1; \
+ fi
+ @if [ ! -f "$(MANAGED_KUBECONFIG)" ]; then \
+ echo "Error: Kubeconfig file not found: $(MANAGED_KUBECONFIG)"; \
+ exit 1; \
+ fi
+ @HUB_KUBECONFIG="$${HUB_KUBECONFIG:-$$KUBECONFIG}" && \
+ if [ -z "$$HUB_KUBECONFIG" ]; then \
+ echo "Error: HUB_KUBECONFIG not set and KUBECONFIG is empty"; \
+ echo "Either set KUBECONFIG to hub cluster or pass HUB_KUBECONFIG=..."; \
+ exit 1; \
+ fi && \
+ CLUSTER_NAME="$(CLUSTER_NAME)" \
+ HUB_KUBECONFIG="$$HUB_KUBECONFIG" \
+ MANAGED_KUBECONFIG="$(MANAGED_KUBECONFIG)" \
+ bash ./hack/keycloak-acm/apply-import-manifests.sh
+
+.PHONY: keycloak-acm-status
+keycloak-acm-status: ## Show Keycloak ACM configuration status
+ @echo "==========================================="
+ @echo "Keycloak ACM Configuration Status"
+ @echo "==========================================="
+ @echo ""
+ @if [ -f .keycloak-config/hub-config.env ]; then \
+ source .keycloak-config/hub-config.env; \
+ POD_STATUS=$$(kubectl get pods -n keycloak -l app=keycloak -o jsonpath='{.items[0].metadata.name} ({.items[0].status.phase})' 2>/dev/null || echo "No pod found"); \
+ echo "Pod: $$POD_STATUS"; \
+ echo ""; \
+ KEYCLOAK_ROUTE=$$(kubectl get route keycloak -n keycloak -o jsonpath='{.spec.host}' 2>/dev/null); \
+ if [ -n "$$KEYCLOAK_ROUTE" ]; then \
+ echo "Route: https://$$KEYCLOAK_ROUTE"; \
+ else \
+ echo "Route: $$KEYCLOAK_URL"; \
+ fi; \
+ echo ""; \
+ echo "Admin Console:"; \
+ echo " URL: $$KEYCLOAK_URL/admin"; \
+ echo " Username: $$ADMIN_USER"; \
+ echo " Password: $$ADMIN_PASSWORD"; \
+ echo ""; \
+ echo "Hub Realm: $$HUB_REALM"; \
+ echo " MCP User: $$MCP_USERNAME"; \
+ echo " Client ID: $$CLIENT_ID"; \
+ echo ""; \
+ echo "OIDC Endpoints ($$HUB_REALM realm):"; \
+ echo " Discovery: $$KEYCLOAK_URL/realms/$$HUB_REALM/.well-known/openid-configuration"; \
+ echo " Token: $$KEYCLOAK_URL/realms/$$HUB_REALM/protocol/openid-connect/token"; \
+ echo " Authorize: $$KEYCLOAK_URL/realms/$$HUB_REALM/protocol/openid-connect/auth"; \
+ echo ""; \
+ else \
+ echo "❌ Hub configuration not found"; \
+ echo " Run: make keycloak-acm-setup-hub"; \
+ fi
+ @echo ""
+ @if [ -f .keycloak-config/clusters ]; then \
+ echo "Managed Clusters:"; \
+ for cluster_env in .keycloak-config/clusters/*.env; do \
+ if [ -f "$$cluster_env" ]; then \
+ source "$$cluster_env"; \
+ echo " - $$CLUSTER_NAME (realm: $$MANAGED_REALM)"; \
+ fi; \
+ done; \
+ echo ""; \
+ fi
+ @if [ -f _output/acm-kubeconfig.toml ]; then \
+ echo "✅ MCP configuration: _output/acm-kubeconfig.toml"; \
+ echo ""; \
+ echo "Configured clusters in TOML:"; \
+ grep '^\[cluster_provider_configs.acm-kubeconfig.clusters' _output/acm-kubeconfig.toml 2>/dev/null | \
+ sed 's/\[cluster_provider_configs.acm-kubeconfig.clusters."\(.*\)"\]/ - \1/' || echo " (none)"; \
+ else \
+ echo "❌ MCP configuration not found"; \
+ echo " Run: make keycloak-acm-generate-toml"; \
+ fi
+ @echo ""
+ @echo "==========================================="
diff --git a/dev/config/openshift/keycloak/README.md b/dev/config/openshift/keycloak/README.md
new file mode 100644
index 00000000..9a17eacf
--- /dev/null
+++ b/dev/config/openshift/keycloak/README.md
@@ -0,0 +1,195 @@
+# ACM Keycloak Declarative Configuration
+
+This directory contains declarative JSON configuration files for setting up Keycloak for ACM (Advanced Cluster Management) multi-realm token exchange.
+
+## Architecture
+
+- **Hub Realm**: Central realm where users authenticate
+- **Managed Cluster Realms**: One realm per managed cluster
+- **Token Exchange**: V1 token exchange using `subject_issuer` parameter
+ - Same-realm: `mcp-sts` → `mcp-server` within hub realm
+ - Cross-realm: Hub realm token → Managed cluster realm token
+
+## Directory Structure
+
+```
+dev/acm/config/keycloak/
+├── realm/
+│ ├── hub-realm-create.json # Hub realm configuration
+│ └── managed-realm-create.json # Template for managed cluster realms
+├── clients/
+│ ├── mcp-server.json # OAuth client (confidential)
+│ ├── mcp-client.json # Browser OAuth client (public)
+│ └── mcp-sts.json # STS client for token exchange
+├── client-scopes/
+│ ├── openid.json # OpenID Connect scope
+│ └── mcp-server.json # MCP audience scope
+├── mappers/
+│ ├── mcp-server-audience-mapper.json # Adds mcp-server to aud claim
+│ └── sub-claim-mapper.json # Maps user ID to sub claim
+├── users/
+│ └── mcp.json # Test user (mcp/mcp)
+└── identity-providers/
+ └── hub-realm-idp-template.json # IDP config for cross-realm trust
+```
+
+## Configuration Files
+
+### Hub Realm (`realm/hub-realm-create.json`)
+
+- Realm name: `hub`
+- User registration: disabled
+- Password reset: enabled
+- Brute force protection: enabled
+- Token lifespans configured for security
+
+### Clients
+
+#### `mcp-server` (Confidential Client)
+- Used by MCP server for OAuth authentication
+- Direct access grants enabled (password flow)
+- Service accounts enabled
+- Default scopes: `openid`, `profile`, `email`, `mcp-server`
+
+#### `mcp-client` (Public Client)
+- Used by browser-based tools (e.g., MCP Inspector)
+- PKCE enabled for security
+- Authorization code flow only
+- No service accounts
+
+#### `mcp-sts` (STS Client)
+- Used for token exchange operations
+- Service accounts only (no user login)
+- No redirect URIs (not for browser flows)
+
+### Client Scopes
+
+#### `openid`
+- Standard OpenID Connect scope
+- Provides basic user claims (sub, iss, aud, exp, iat)
+
+#### `mcp-server`
+- Custom audience scope
+- Adds `mcp-server` to the `aud` claim in access tokens
+- Required for token validation
+
+### Protocol Mappers
+
+#### `mcp-server-audience`
+- Type: `oidc-audience-mapper`
+- Adds `mcp-server` to the audience claim
+- Applied to `mcp-server` client scope
+
+#### `sub`
+- Type: `oidc-sub-mapper`
+- Maps user ID to `sub` claim
+- Used for federated identity linking
+
+### Users
+
+#### `mcp` User
+- Username: `mcp`
+- Password: `mcp`
+- Email: `mcp@example.com`
+- Full name: MCP User
+- Used for testing and development
+
+### Identity Provider
+
+#### Hub Realm IDP Template
+- Provider: `oidc` (generic OIDC, not keycloak-oidc)
+- Trust email: enabled
+- Store token: disabled
+- Sync mode: IMPORT (create local users)
+- Signature validation: enabled via JWKS URL
+
+## Variable Substitution
+
+JSON templates use `${VARIABLE_NAME}` placeholders that are replaced at runtime:
+
+- `${KEYCLOAK_URL}`: Base Keycloak URL (e.g., `https://keycloak-keycloak.apps.example.com`)
+- `${HUB_CLIENT_SECRET}`: Secret for mcp-server client in hub realm
+- `${MANAGED_REALM}`: Name of managed cluster realm (e.g., `managed-cluster-one`)
+
+## Usage
+
+These JSON files are applied via the Keycloak Admin REST API using the setup scripts:
+
+1. **Hub Setup**: `hack/acm/acm-keycloak-setup-hub-declarative.sh`
+ - Creates hub realm
+ - Creates clients (mcp-server, mcp-client, mcp-sts)
+ - Creates client scopes (openid, mcp-server)
+ - Adds protocol mappers
+ - Creates test user
+ - Configures same-realm token exchange permissions
+
+2. **Managed Cluster Registration**: `hack/acm/acm-register-managed-cluster-declarative.sh`
+ - Creates managed cluster realm
+ - Registers identity provider (hub realm)
+ - Creates federated user link
+ - Configures cross-realm token exchange permissions
+
+## Token Exchange Configuration
+
+### Same-Realm Token Exchange (Hub)
+
+Allows `mcp-sts` client to exchange tokens for `mcp-server` audience within the hub realm.
+
+**Steps** (applied by setup script):
+1. Enable management permissions on `mcp-server` client
+2. Get token-exchange permission ID
+3. Create client policy allowing `mcp-sts`
+4. Link policy to token-exchange permission
+
+**Test Command**:
+```bash
+source .keycloak-config/hub-config.env
+./hack/acm/test-same-realm-token-exchange.sh
+```
+
+### Cross-Realm Token Exchange (Hub → Managed)
+
+Allows exchanging hub realm token for managed cluster realm token.
+
+**Steps** (applied by setup script):
+1. Create identity provider in managed realm pointing to hub realm
+2. Create federated identity link (hub user → managed user via `sub` claim)
+3. Enable fine-grained permissions on IDP
+4. Create client policy allowing hub realm's `mcp-sts`
+5. Link policy to token-exchange permission on IDP
+
+**Test Command**:
+```bash
+source .keycloak-config/hub-config.env
+source .keycloak-config/clusters/managed-cluster-one.env
+./hack/acm/test-cross-realm-token-exchange.sh
+```
+
+## Keycloak Admin API Endpoints
+
+Configuration is applied using these endpoints:
+
+- **Realm**: `POST /admin/realms`
+- **Clients**: `POST /admin/realms/{realm}/clients`
+- **Client Scopes**: `POST /admin/realms/{realm}/client-scopes`
+- **Protocol Mappers**: `POST /admin/realms/{realm}/client-scopes/{scope-id}/protocol-mappers/models`
+- **Users**: `POST /admin/realms/{realm}/users`
+- **Identity Providers**: `POST /admin/realms/{realm}/identity-provider/instances`
+- **Client Permissions**: `PUT /admin/realms/{realm}/clients/{client-id}/management/permissions`
+- **Authorization Policies**: `POST /admin/realms/{realm}/clients/{client-id}/authz/resource-server/policy/client`
+
+## Benefits of Declarative Approach
+
+1. **Version Control**: Configuration as code
+2. **Repeatability**: Same configuration every time
+3. **Testability**: Easy to test in different environments
+4. **Documentation**: Self-documenting via JSON structure
+5. **Validation**: JSON schema validation possible
+6. **Idempotency**: Can reapply without side effects
+7. **Debugging**: Easy to compare configurations
+
+## References
+
+- Keycloak Admin REST API: https://www.keycloak.org/docs-api/26.0/rest-api/index.html
+- Token Exchange: https://www.keycloak.org/docs/latest/securing_apps/#_token-exchange
+- Identity Brokering: https://www.keycloak.org/docs/latest/server_admin/#_identity_broker
diff --git a/dev/config/openshift/keycloak/client-scopes/mcp-server.json b/dev/config/openshift/keycloak/client-scopes/mcp-server.json
new file mode 100644
index 00000000..565f30be
--- /dev/null
+++ b/dev/config/openshift/keycloak/client-scopes/mcp-server.json
@@ -0,0 +1,9 @@
+{
+ "name": "mcp-server",
+ "description": "MCP Server audience scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "display.on.consent.screen": "false",
+ "include.in.token.scope": "true"
+ }
+}
diff --git a/dev/config/openshift/keycloak/client-scopes/openid.json b/dev/config/openshift/keycloak/client-scopes/openid.json
new file mode 100644
index 00000000..437348b1
--- /dev/null
+++ b/dev/config/openshift/keycloak/client-scopes/openid.json
@@ -0,0 +1,10 @@
+{
+ "name": "openid",
+ "description": "OpenID Connect scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "display.on.consent.screen": "true",
+ "include.in.token.scope": "true",
+ "consent.screen.text": "${openidScopeConsentText}"
+ }
+}
diff --git a/dev/config/openshift/keycloak/clients/mcp-client.json b/dev/config/openshift/keycloak/clients/mcp-client.json
new file mode 100644
index 00000000..ef0b5512
--- /dev/null
+++ b/dev/config/openshift/keycloak/clients/mcp-client.json
@@ -0,0 +1,27 @@
+{
+ "clientId": "mcp-client",
+ "name": "MCP Client",
+ "description": "Public OAuth client for browser-based authentication (inspector)",
+ "enabled": true,
+ "publicClient": true,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "authorizationServicesEnabled": false,
+ "protocol": "openid-connect",
+ "redirectUris": ["*"],
+ "webOrigins": ["*"],
+ "fullScopeAllowed": false,
+ "defaultClientScopes": ["openid", "profile", "email", "mcp-server"],
+ "optionalClientScopes": [],
+ "attributes": {
+ "pkce.code.challenge.method": "S256",
+ "oauth2.device.authorization.grant.enabled": "false",
+ "oidc.ciba.grant.enabled": "false",
+ "backchannel.logout.session.required": "false",
+ "backchannel.logout.revoke.offline.tokens": "false",
+ "display.on.consent.screen": "false"
+ }
+}
diff --git a/dev/config/openshift/keycloak/clients/mcp-server.json b/dev/config/openshift/keycloak/clients/mcp-server.json
new file mode 100644
index 00000000..99358d9e
--- /dev/null
+++ b/dev/config/openshift/keycloak/clients/mcp-server.json
@@ -0,0 +1,41 @@
+{
+ "clientId": "mcp-server",
+ "name": "MCP Server",
+ "description": "OAuth client for MCP server authentication",
+ "enabled": true,
+ "publicClient": false,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": true,
+ "authorizationServicesEnabled": false,
+ "protocol": "openid-connect",
+ "redirectUris": ["*"],
+ "webOrigins": ["*"],
+ "fullScopeAllowed": false,
+ "defaultClientScopes": ["openid", "profile", "email", "mcp-server"],
+ "optionalClientScopes": [],
+ "attributes": {
+ "oauth2.device.authorization.grant.enabled": "false",
+ "oidc.ciba.grant.enabled": "false",
+ "backchannel.logout.session.required": "true",
+ "backchannel.logout.revoke.offline.tokens": "false",
+ "client.secret.creation.time": "0",
+ "display.on.consent.screen": "false",
+ "saml.artifact.binding": "false",
+ "saml.server.signature": "false",
+ "saml.server.signature.keyinfo.ext": "false",
+ "saml.assertion.signature": "false",
+ "saml.client.signature": "false",
+ "saml.encrypt": "false",
+ "saml.authnstatement": "false",
+ "saml.onetimeuse.condition": "false",
+ "saml_force_name_id_format": "false",
+ "saml.multivalued.roles": "false",
+ "saml.force.post.binding": "false",
+ "exclude.session.state.from.auth.response": "false",
+ "tls.client.certificate.bound.access.tokens": "false",
+ "access.token.lifespan": "300"
+ }
+}
diff --git a/dev/config/openshift/keycloak/clients/mcp-sts.json b/dev/config/openshift/keycloak/clients/mcp-sts.json
new file mode 100644
index 00000000..09181b6f
--- /dev/null
+++ b/dev/config/openshift/keycloak/clients/mcp-sts.json
@@ -0,0 +1,26 @@
+{
+ "clientId": "mcp-sts",
+ "name": "MCP STS",
+ "description": "Security Token Service client for token exchange (same-realm and cross-realm)",
+ "enabled": true,
+ "publicClient": false,
+ "consentRequired": false,
+ "standardFlowEnabled": false,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": true,
+ "authorizationServicesEnabled": false,
+ "protocol": "openid-connect",
+ "redirectUris": [],
+ "webOrigins": [],
+ "fullScopeAllowed": false,
+ "defaultClientScopes": ["openid", "profile", "email"],
+ "optionalClientScopes": [],
+ "attributes": {
+ "oauth2.device.authorization.grant.enabled": "false",
+ "oidc.ciba.grant.enabled": "false",
+ "backchannel.logout.session.required": "false",
+ "backchannel.logout.revoke.offline.tokens": "false",
+ "display.on.consent.screen": "false"
+ }
+}
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
new file mode 100644
index 00000000..6b708e98
--- /dev/null
+++ b/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json
@@ -0,0 +1,23 @@
+{
+ "alias": "hub-realm",
+ "displayName": "Hub Realm",
+ "providerId": "oidc",
+ "enabled": true,
+ "updateProfileFirstLoginMode": "on",
+ "trustEmail": true,
+ "storeToken": false,
+ "addReadTokenRoleOnCreate": false,
+ "authenticateByDefault": false,
+ "linkOnly": false,
+ "firstBrokerLoginFlowAlias": "first broker login",
+ "config": {
+ "issuer": "${KEYCLOAK_URL}/realms/hub",
+ "validateSignature": "true",
+ "useJwksUrl": "true",
+ "jwksUrl": "${KEYCLOAK_URL}/realms/hub/protocol/openid-connect/certs",
+ "clientId": "mcp-server",
+ "clientSecret": "${HUB_CLIENT_SECRET}",
+ "clientAuthMethod": "client_secret_post",
+ "syncMode": "IMPORT"
+ }
+}
diff --git a/dev/config/openshift/keycloak/keycloak.yaml b/dev/config/openshift/keycloak/keycloak.yaml
new file mode 100644
index 00000000..147fb9ca
--- /dev/null
+++ b/dev/config/openshift/keycloak/keycloak.yaml
@@ -0,0 +1,97 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: keycloak
+ labels:
+ app: keycloak
+spec:
+ ports:
+ - name: https
+ port: 8443
+ targetPort: 8443
+ - name: http
+ port: 8080
+ targetPort: 8080
+ selector:
+ app: keycloak
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: keycloak
+ labels:
+ app: keycloak
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: keycloak
+ template:
+ metadata:
+ labels:
+ app: keycloak
+ spec:
+ containers:
+ - name: keycloak
+ image: quay.io/keycloak/keycloak:KEYCLOAK_VERSION_PLACEHOLDER
+ args:
+ - start-dev
+ - --features=token-exchange:v1,admin-fine-grained-authz:v1
+ env:
+ - name: KC_DB
+ value: postgres
+ - name: KC_DB_URL
+ value: jdbc:postgresql://postgresql:5432/keycloak
+ - name: KC_DB_USERNAME
+ valueFrom:
+ secretKeyRef:
+ name: postgresql-credentials
+ key: POSTGRESQL_USER
+ - name: KC_DB_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: postgresql-credentials
+ key: POSTGRESQL_PASSWORD
+ - name: KC_BOOTSTRAP_ADMIN_USERNAME
+ value: "ADMIN_USER_PLACEHOLDER"
+ - name: KC_BOOTSTRAP_ADMIN_PASSWORD
+ value: "ADMIN_PASSWORD_PLACEHOLDER"
+ - name: KC_PROXY_HEADERS
+ value: "xforwarded"
+ - name: KC_HTTP_ENABLED
+ value: "true"
+ - name: KC_HOSTNAME_STRICT
+ value: "false"
+ ports:
+ - name: http
+ containerPort: 8080
+ - name: https
+ containerPort: 8443
+ readinessProbe:
+ httpGet:
+ path: /realms/master
+ port: 8080
+ initialDelaySeconds: 60
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /realms/master
+ port: 8080
+ initialDelaySeconds: 90
+ periodSeconds: 30
+---
+apiVersion: route.openshift.io/v1
+kind: Route
+metadata:
+ name: keycloak
+ labels:
+ app: keycloak
+spec:
+ to:
+ kind: Service
+ name: keycloak
+ port:
+ targetPort: http
+ tls:
+ termination: edge
+ insecureEdgeTerminationPolicy: Redirect
diff --git a/dev/config/openshift/keycloak/mappers/mcp-server-audience-mapper.json b/dev/config/openshift/keycloak/mappers/mcp-server-audience-mapper.json
new file mode 100644
index 00000000..35b505fd
--- /dev/null
+++ b/dev/config/openshift/keycloak/mappers/mcp-server-audience-mapper.json
@@ -0,0 +1,12 @@
+{
+ "name": "mcp-server-audience",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.client.audience": "mcp-server",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "lightweight.claim": "false"
+ }
+}
diff --git a/dev/config/openshift/keycloak/mappers/sub-claim-mapper.json b/dev/config/openshift/keycloak/mappers/sub-claim-mapper.json
new file mode 100644
index 00000000..26fc401d
--- /dev/null
+++ b/dev/config/openshift/keycloak/mappers/sub-claim-mapper.json
@@ -0,0 +1,11 @@
+{
+ "name": "sub",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-sub-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+}
diff --git a/dev/config/openshift/keycloak/postgresql.yaml b/dev/config/openshift/keycloak/postgresql.yaml
new file mode 100644
index 00000000..18c4743c
--- /dev/null
+++ b/dev/config/openshift/keycloak/postgresql.yaml
@@ -0,0 +1,85 @@
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: postgresql-data
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+---
+apiVersion: v1
+kind: Secret
+metadata:
+ name: postgresql-credentials
+type: Opaque
+stringData:
+ POSTGRESQL_DATABASE: keycloak
+ POSTGRESQL_USER: keycloak
+ POSTGRESQL_PASSWORD: POSTGRESQL_PASSWORD_PLACEHOLDER
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: postgresql
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: postgresql
+ template:
+ metadata:
+ labels:
+ app: postgresql
+ spec:
+ containers:
+ - name: postgresql
+ image: registry.redhat.io/rhel9/postgresql-16:latest
+ ports:
+ - containerPort: 5432
+ name: postgresql
+ envFrom:
+ - secretRef:
+ name: postgresql-credentials
+ volumeMounts:
+ - name: postgresql-data
+ mountPath: /var/lib/pgsql/data
+ resources:
+ requests:
+ memory: "256Mi"
+ cpu: "250m"
+ limits:
+ memory: "512Mi"
+ cpu: "500m"
+ livenessProbe:
+ tcpSocket:
+ port: 5432
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ readinessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - pg_isready -U keycloak
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ volumes:
+ - name: postgresql-data
+ persistentVolumeClaim:
+ claimName: postgresql-data
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: postgresql
+spec:
+ ports:
+ - port: 5432
+ targetPort: 5432
+ name: postgresql
+ selector:
+ app: postgresql
+ type: ClusterIP
diff --git a/dev/config/openshift/keycloak/realm/hub-realm-create.json b/dev/config/openshift/keycloak/realm/hub-realm-create.json
new file mode 100644
index 00000000..e375407d
--- /dev/null
+++ b/dev/config/openshift/keycloak/realm/hub-realm-create.json
@@ -0,0 +1,74 @@
+{
+ "realm": "hub",
+ "enabled": true,
+ "displayName": "Hub Realm",
+ "displayNameHtml": "
Hub Realm
",
+ "loginTheme": "keycloak",
+ "accountTheme": "keycloak.v2",
+ "adminTheme": "keycloak.v2",
+ "emailTheme": "keycloak",
+ "sslRequired": "external",
+ "registrationAllowed": false,
+ "registrationEmailAsUsername": false,
+ "rememberMe": true,
+ "verifyEmail": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": true,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": true,
+ "permanentLockout": false,
+ "maxFailureWaitSeconds": 900,
+ "minimumQuickLoginWaitSeconds": 60,
+ "waitIncrementSeconds": 60,
+ "quickLoginCheckMilliSeconds": 1000,
+ "maxDeltaTimeSeconds": 43200,
+ "failureFactor": 30,
+ "defaultSignatureAlgorithm": "RS256",
+ "offlineSessionMaxLifespanEnabled": false,
+ "offlineSessionMaxLifespan": 5184000,
+ "clientSessionIdleTimeout": 0,
+ "clientSessionMaxLifespan": 0,
+ "clientOfflineSessionIdleTimeout": 0,
+ "clientOfflineSessionMaxLifespan": 0,
+ "accessTokenLifespan": 300,
+ "accessTokenLifespanForImplicitFlow": 900,
+ "ssoSessionIdleTimeout": 1800,
+ "ssoSessionMaxLifespan": 36000,
+ "ssoSessionIdleTimeoutRememberMe": 0,
+ "ssoSessionMaxLifespanRememberMe": 0,
+ "accessCodeLifespan": 60,
+ "accessCodeLifespanUserAction": 300,
+ "accessCodeLifespanLogin": 1800,
+ "actionTokenGeneratedByAdminLifespan": 43200,
+ "actionTokenGeneratedByUserLifespan": 300,
+ "oauth2DeviceCodeLifespan": 600,
+ "oauth2DevicePollingInterval": 5,
+ "internationalizationEnabled": false,
+ "supportedLocales": [],
+ "authenticationFlows": [],
+ "authenticatorConfig": [],
+ "requiredActions": [],
+ "browserFlow": "browser",
+ "registrationFlow": "registration",
+ "directGrantFlow": "direct grant",
+ "resetCredentialsFlow": "reset credentials",
+ "clientAuthenticationFlow": "clients",
+ "dockerAuthenticationFlow": "docker auth",
+ "attributes": {
+ "cibaBackchannelTokenDeliveryMode": "poll",
+ "cibaExpiresIn": "120",
+ "cibaInterval": "5",
+ "cibaAuthRequestedUserHint": "login_hint",
+ "parRequestUriLifespan": "60",
+ "frontendUrl": "",
+ "acr.loa.map": "{}"
+ },
+ "userManagedAccessAllowed": false,
+ "clientProfiles": {
+ "profiles": []
+ },
+ "clientPolicies": {
+ "policies": []
+ }
+}
diff --git a/dev/config/openshift/keycloak/realm/managed-realm-create.json b/dev/config/openshift/keycloak/realm/managed-realm-create.json
new file mode 100644
index 00000000..952936fa
--- /dev/null
+++ b/dev/config/openshift/keycloak/realm/managed-realm-create.json
@@ -0,0 +1,74 @@
+{
+ "realm": "managed-cluster-one",
+ "enabled": true,
+ "displayName": "Managed Cluster Realm",
+ "displayNameHtml": "Managed Cluster
",
+ "loginTheme": "keycloak",
+ "accountTheme": "keycloak.v2",
+ "adminTheme": "keycloak.v2",
+ "emailTheme": "keycloak",
+ "sslRequired": "external",
+ "registrationAllowed": false,
+ "registrationEmailAsUsername": false,
+ "rememberMe": true,
+ "verifyEmail": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": true,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": true,
+ "permanentLockout": false,
+ "maxFailureWaitSeconds": 900,
+ "minimumQuickLoginWaitSeconds": 60,
+ "waitIncrementSeconds": 60,
+ "quickLoginCheckMilliSeconds": 1000,
+ "maxDeltaTimeSeconds": 43200,
+ "failureFactor": 30,
+ "defaultSignatureAlgorithm": "RS256",
+ "offlineSessionMaxLifespanEnabled": false,
+ "offlineSessionMaxLifespan": 5184000,
+ "clientSessionIdleTimeout": 0,
+ "clientSessionMaxLifespan": 0,
+ "clientOfflineSessionIdleTimeout": 0,
+ "clientOfflineSessionMaxLifespan": 0,
+ "accessTokenLifespan": 300,
+ "accessTokenLifespanForImplicitFlow": 900,
+ "ssoSessionIdleTimeout": 1800,
+ "ssoSessionMaxLifespan": 36000,
+ "ssoSessionIdleTimeoutRememberMe": 0,
+ "ssoSessionMaxLifespanRememberMe": 0,
+ "accessCodeLifespan": 60,
+ "accessCodeLifespanUserAction": 300,
+ "accessCodeLifespanLogin": 1800,
+ "actionTokenGeneratedByAdminLifespan": 43200,
+ "actionTokenGeneratedByUserLifespan": 300,
+ "oauth2DeviceCodeLifespan": 600,
+ "oauth2DevicePollingInterval": 5,
+ "internationalizationEnabled": false,
+ "supportedLocales": [],
+ "authenticationFlows": [],
+ "authenticatorConfig": [],
+ "requiredActions": [],
+ "browserFlow": "browser",
+ "registrationFlow": "registration",
+ "directGrantFlow": "direct grant",
+ "resetCredentialsFlow": "reset credentials",
+ "clientAuthenticationFlow": "clients",
+ "dockerAuthenticationFlow": "docker auth",
+ "attributes": {
+ "cibaBackchannelTokenDeliveryMode": "poll",
+ "cibaExpiresIn": "120",
+ "cibaInterval": "5",
+ "cibaAuthRequestedUserHint": "login_hint",
+ "parRequestUriLifespan": "60",
+ "frontendUrl": "",
+ "acr.loa.map": "{}"
+ },
+ "userManagedAccessAllowed": false,
+ "clientProfiles": {
+ "profiles": []
+ },
+ "clientPolicies": {
+ "policies": []
+ }
+}
diff --git a/dev/config/openshift/keycloak/users/mcp.json b/dev/config/openshift/keycloak/users/mcp.json
new file mode 100644
index 00000000..286ff52f
--- /dev/null
+++ b/dev/config/openshift/keycloak/users/mcp.json
@@ -0,0 +1,18 @@
+{
+ "username": "mcp",
+ "enabled": true,
+ "emailVerified": true,
+ "firstName": "MCP",
+ "lastName": "User",
+ "email": "mcp@example.com",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "mcp",
+ "temporary": false
+ }
+ ],
+ "realmRoles": ["offline_access", "uma_authorization"],
+ "clientRoles": {},
+ "groups": []
+}
diff --git a/hack/keycloak-acm/apply-import-manifests.sh b/hack/keycloak-acm/apply-import-manifests.sh
new file mode 100755
index 00000000..6692fc8f
--- /dev/null
+++ b/hack/keycloak-acm/apply-import-manifests.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+# Apply ACM Import Manifests to Managed Cluster
+# This script extracts import manifests from the hub cluster and applies them to the managed cluster
+# to start the klusterlet agents required for cluster-proxy functionality.
+#
+# Required environment variables:
+# CLUSTER_NAME - Name of the managed cluster (e.g., managed-cluster-one)
+# HUB_KUBECONFIG - Path to hub cluster kubeconfig
+# MANAGED_KUBECONFIG - Path to managed cluster kubeconfig
+
+set -e
+
+# Validate required environment variables
+if [ -z "$CLUSTER_NAME" ]; then
+ echo "Error: CLUSTER_NAME environment variable is required"
+ exit 1
+fi
+
+if [ -z "$HUB_KUBECONFIG" ]; then
+ echo "Error: HUB_KUBECONFIG environment variable is required"
+ exit 1
+fi
+
+if [ -z "$MANAGED_KUBECONFIG" ]; then
+ echo "Error: MANAGED_KUBECONFIG environment variable is required"
+ exit 1
+fi
+
+echo "=========================================="
+echo "Applying Import Manifests"
+echo "=========================================="
+echo "Cluster Name: $CLUSTER_NAME"
+echo "Hub Kubeconfig: $HUB_KUBECONFIG"
+echo "Managed Kubeconfig: $MANAGED_KUBECONFIG"
+echo ""
+
+# Create temporary directory for manifests
+TEMP_DIR=$(mktemp -d)
+trap "rm -rf $TEMP_DIR" EXIT
+
+echo "Step 1: Extracting import manifests from hub cluster..."
+kubectl --kubeconfig="$HUB_KUBECONFIG" get secret ${CLUSTER_NAME}-import -n ${CLUSTER_NAME} -o jsonpath='{.data.crds\.yaml}' | base64 -d > "$TEMP_DIR/crds.yaml"
+kubectl --kubeconfig="$HUB_KUBECONFIG" get secret ${CLUSTER_NAME}-import -n ${CLUSTER_NAME} -o jsonpath='{.data.import\.yaml}' | base64 -d > "$TEMP_DIR/import.yaml"
+echo " ✅ Manifests extracted to $TEMP_DIR"
+echo " - crds.yaml: $(wc -l < $TEMP_DIR/crds.yaml) lines"
+echo " - import.yaml: $(wc -l < $TEMP_DIR/import.yaml) lines"
+echo ""
+
+echo "Step 2: Applying CRDs to managed cluster..."
+kubectl --kubeconfig="$MANAGED_KUBECONFIG" apply -f "$TEMP_DIR/crds.yaml"
+echo " ✅ CRDs applied"
+echo ""
+
+echo "Step 3: Applying import resources to managed cluster..."
+kubectl --kubeconfig="$MANAGED_KUBECONFIG" apply -f "$TEMP_DIR/import.yaml"
+echo " ✅ Import resources applied"
+echo ""
+
+echo "Step 4: Waiting for klusterlet agents to start (30 seconds)..."
+sleep 30
+echo ""
+
+echo "Step 5: Verifying klusterlet pods..."
+echo ""
+echo "Klusterlet Agent Pods:"
+kubectl --kubeconfig="$MANAGED_KUBECONFIG" get pods -n open-cluster-management-agent
+echo ""
+echo "Klusterlet Addon Pods (including cluster-proxy):"
+kubectl --kubeconfig="$MANAGED_KUBECONFIG" get pods -n open-cluster-management-agent-addon
+echo ""
+
+# Check if cluster-proxy agent is running
+PROXY_PODS=$(kubectl --kubeconfig="$MANAGED_KUBECONFIG" get pods -n open-cluster-management-agent-addon -l component=cluster-proxy-proxy-agent --no-headers 2>/dev/null | wc -l)
+if [ "$PROXY_PODS" -gt 0 ]; then
+ echo "✅ SUCCESS: Cluster-proxy agent pods are running!"
+else
+ echo "⚠️ WARNING: Cluster-proxy agent pods not found. They may still be starting."
+ echo " Run the following command to check again:"
+ echo " kubectl --kubeconfig=$MANAGED_KUBECONFIG get pods -n open-cluster-management-agent-addon -l component=cluster-proxy-proxy-agent"
+fi
+echo ""
+echo "=========================================="
+echo "Import manifests applied successfully"
+echo "=========================================="
diff --git a/hack/keycloak-acm/fix-ca-trust.sh b/hack/keycloak-acm/fix-ca-trust.sh
new file mode 100755
index 00000000..562e6804
--- /dev/null
+++ b/hack/keycloak-acm/fix-ca-trust.sh
@@ -0,0 +1,237 @@
+#!/bin/bash
+set -euo pipefail
+
+# Fix Keycloak CA Trust for Same-Instance Cross-Realm Token Exchange
+#
+# This script configures Keycloak to trust the OpenShift router CA certificate,
+# enabling JWKS signature validation for cross-realm token exchange.
+#
+# Based on: SINGLE_KEYCLOAK_SUCCESS.md
+
+echo "==========================================="
+echo "Fixing Keycloak CA Trust"
+echo "==========================================="
+echo ""
+echo "This will configure Keycloak to trust the OpenShift router CA certificate"
+echo "for same-instance cross-realm JWKS validation."
+echo ""
+
+# Check if running on OpenShift
+if ! kubectl get route -n keycloak keycloak >/dev/null 2>&1; then
+ echo "❌ Error: Not running on OpenShift (no route found)"
+ echo "This script is designed for OpenShift clusters with OpenShift routes"
+ exit 1
+fi
+
+echo "Step 1: Extracting OpenShift router CA certificate..."
+
+# Try different sources for the router CA
+ROUTER_CA=""
+
+# Method 1: Try router-ca from openshift-ingress-operator namespace
+if ROUTER_CA=$(kubectl get secret router-ca -n openshift-ingress-operator -o jsonpath='{.data.tls\.crt}' 2>/dev/null | base64 -d); then
+ if [ -n "$ROUTER_CA" ]; then
+ echo " ✅ Found router CA in openshift-ingress-operator/router-ca"
+ fi
+fi
+
+# Method 2: Try router-certs-default from openshift-ingress namespace
+if [ -z "$ROUTER_CA" ]; then
+ if ROUTER_CA=$(kubectl get secret router-certs-default -n openshift-ingress -o jsonpath='{.data.tls\.crt}' 2>/dev/null | base64 -d); then
+ if [ -n "$ROUTER_CA" ]; then
+ echo " ✅ Found router CA in openshift-ingress/router-certs-default"
+ fi
+ fi
+fi
+
+# Method 3: Extract from ingress controller
+if [ -z "$ROUTER_CA" ]; then
+ INGRESS_CA=$(kubectl get ingresscontroller default -n openshift-ingress-operator -o jsonpath='{.spec.defaultCertificate.name}' 2>/dev/null)
+ if [ -n "$INGRESS_CA" ]; then
+ if ROUTER_CA=$(kubectl get secret "$INGRESS_CA" -n openshift-ingress -o jsonpath='{.data.tls\.crt}' 2>/dev/null | base64 -d); then
+ if [ -n "$ROUTER_CA" ]; then
+ echo " ✅ Found router CA in openshift-ingress/$INGRESS_CA"
+ fi
+ fi
+ fi
+fi
+
+# Verify we found a CA
+if [ -z "$ROUTER_CA" ]; then
+ echo "❌ Error: Could not find OpenShift router CA certificate"
+ echo ""
+ echo "Tried:"
+ echo " - secret/router-ca in openshift-ingress-operator"
+ echo " - secret/router-certs-default in openshift-ingress"
+ echo " - ingress controller default certificate"
+ exit 1
+fi
+
+# Verify it's a valid certificate
+if ! echo "$ROUTER_CA" | openssl x509 -noout -text >/dev/null 2>&1; then
+ echo "❌ Error: Invalid CA certificate format"
+ exit 1
+fi
+
+# Show certificate info
+echo ""
+echo "Router CA Certificate Details:"
+echo "$ROUTER_CA" | openssl x509 -noout -subject -issuer -dates | sed 's/^/ /'
+echo ""
+
+echo "Step 2: Creating router-ca ConfigMap in keycloak namespace..."
+
+# Create temporary file
+TEMP_CA=$(mktemp)
+echo "$ROUTER_CA" > "$TEMP_CA"
+
+# Create or update ConfigMap
+kubectl create configmap router-ca -n keycloak \
+ --from-file=router-ca.crt="$TEMP_CA" \
+ --dry-run=client -o yaml | kubectl apply -f -
+
+rm -f "$TEMP_CA"
+
+echo " ✅ ConfigMap router-ca created/updated"
+echo ""
+
+echo "Step 3: Checking Keycloak deployment..."
+
+if ! kubectl get deployment keycloak -n keycloak >/dev/null 2>&1; then
+ echo "❌ Error: Keycloak deployment not found in keycloak namespace"
+ exit 1
+fi
+
+echo " ✅ Keycloak deployment found"
+echo ""
+
+echo "Step 4: Patching Keycloak deployment with KC_TRUSTSTORE_PATHS..."
+
+# Check if already patched
+CURRENT_TRUSTSTORE=$(kubectl get deployment keycloak -n keycloak -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="KC_TRUSTSTORE_PATHS")].value}' 2>/dev/null || echo "")
+
+if [ "$CURRENT_TRUSTSTORE" = "/ca-certs/router-ca.crt" ]; then
+ echo " ℹ️ KC_TRUSTSTORE_PATHS already configured"
+
+ # Check if volume mount exists
+ VOLUME_MOUNT=$(kubectl get deployment keycloak -n keycloak -o jsonpath='{.spec.template.spec.containers[0].volumeMounts[?(@.name=="router-ca")].mountPath}' 2>/dev/null || echo "")
+
+ if [ -n "$VOLUME_MOUNT" ]; then
+ echo " ✅ Volume mount already configured"
+ echo ""
+ echo "==========================================="
+ echo "✅ Keycloak CA Trust Already Configured!"
+ echo "==========================================="
+ exit 0
+ fi
+fi
+
+# Create patch JSON
+PATCH_JSON=$(cat <<'EOF'
+{
+ "spec": {
+ "template": {
+ "spec": {
+ "containers": [
+ {
+ "name": "keycloak",
+ "env": [
+ {
+ "name": "KC_TRUSTSTORE_PATHS",
+ "value": "/ca-certs/router-ca.crt"
+ }
+ ],
+ "volumeMounts": [
+ {
+ "name": "router-ca",
+ "mountPath": "/ca-certs",
+ "readOnly": true
+ }
+ ]
+ }
+ ],
+ "volumes": [
+ {
+ "name": "router-ca",
+ "configMap": {
+ "name": "router-ca"
+ }
+ }
+ ]
+ }
+ }
+ }
+}
+EOF
+)
+
+# Apply strategic merge patch
+kubectl patch deployment keycloak -n keycloak --type=strategic --patch "$PATCH_JSON"
+
+echo " ✅ Deployment patched"
+echo ""
+
+echo "Step 5: Waiting for Keycloak to restart..."
+
+# Wait for rollout
+if kubectl rollout status deployment/keycloak -n keycloak --timeout=5m; then
+ echo " ✅ Keycloak rollout complete"
+else
+ echo " ⚠️ Rollout taking longer than expected"
+ echo " Check status with: kubectl rollout status deployment/keycloak -n keycloak"
+fi
+
+echo ""
+echo "Step 6: Verifying Keycloak is ready..."
+
+# Wait for pod to be ready
+for i in {1..30}; do
+ if kubectl get pods -n keycloak -l app=keycloak -o jsonpath='{.items[0].status.conditions[?(@.type=="Ready")].status}' 2>/dev/null | grep -q "True"; then
+ echo " ✅ Keycloak pod is ready"
+ break
+ fi
+ if [ $i -eq 30 ]; then
+ echo " ⚠️ Keycloak pod not ready after 5 minutes"
+ echo " Check logs with: make keycloak-logs"
+ exit 1
+ fi
+ sleep 10
+done
+
+echo ""
+
+# Get Keycloak URL
+KEYCLOAK_URL="https://$(kubectl get route keycloak -n keycloak -o jsonpath='{.spec.host}')"
+
+# Check Keycloak health
+if curl -sk "$KEYCLOAK_URL/health/ready" | grep -q '"status":"UP"'; then
+ echo " ✅ Keycloak health check passed"
+else
+ echo " ⚠️ Keycloak health check did not return UP"
+ echo " URL: $KEYCLOAK_URL/health/ready"
+fi
+
+echo ""
+echo "==========================================="
+echo "✅ Keycloak CA Trust Fixed!"
+echo "==========================================="
+echo ""
+echo "What this enables:"
+echo " ✅ Cross-realm JWKS signature validation"
+echo " ✅ validateSignature=true in IDP configuration"
+echo " ✅ Proper TLS trust for same-instance token exchange"
+echo ""
+echo "Keycloak Configuration:"
+echo " KC_TRUSTSTORE_PATHS: /ca-certs/router-ca.crt"
+echo " ConfigMap: router-ca (keycloak namespace)"
+echo " Router CA: Imported into JVM truststore"
+echo ""
+echo "Next steps:"
+if [ -f ".keycloak-config/hub-config.env" ]; then
+ echo " ✅ Hub realm already configured"
+ echo " → Register managed clusters: make keycloak-acm-register-managed-declarative CLUSTER_NAME=... MANAGED_KUBECONFIG=..."
+else
+ echo " 1. Setup hub realm: make keycloak-acm-setup-hub-declarative"
+ echo " 2. Register managed clusters: make keycloak-acm-register-managed-declarative CLUSTER_NAME=... MANAGED_KUBECONFIG=..."
+fi
+echo ""
diff --git a/hack/keycloak-acm/generate-toml.sh b/hack/keycloak-acm/generate-toml.sh
new file mode 100755
index 00000000..7b5014d1
--- /dev/null
+++ b/hack/keycloak-acm/generate-toml.sh
@@ -0,0 +1,189 @@
+#!/bin/bash
+set -eo pipefail
+
+# Generate acm-kubeconfig.toml from Keycloak configuration files
+#
+# This script reads from:
+# - .keycloak-config/hub-config.env
+# - .keycloak-config/clusters/*.env
+#
+# And generates: _output/acm-kubeconfig.toml
+
+# Get script directory and repo root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+# Create _output directory if it doesn't exist
+mkdir -p "$REPO_ROOT/_output"
+
+OUTPUT_FILE="$REPO_ROOT/_output/acm-kubeconfig.toml"
+
+echo "==========================================="
+echo "Generating acm-kubeconfig.toml"
+echo "==========================================="
+echo ""
+
+# Check if hub config exists
+if [ ! -f ".keycloak-config/hub-config.env" ]; then
+ echo "❌ Error: Hub configuration not found"
+ echo " Run: make keycloak-acm-setup-hub"
+ exit 1
+fi
+
+# Load hub config
+source .keycloak-config/hub-config.env
+
+# Detect hub kubeconfig
+if [ -n "${HUB_KUBECONFIG:-}" ]; then
+ HUB_KUBECONFIG_PATH="$HUB_KUBECONFIG"
+elif [ -n "${KUBECONFIG:-}" ]; then
+ HUB_KUBECONFIG_PATH="$KUBECONFIG"
+else
+ HUB_KUBECONFIG_PATH="$HOME/.kube/config"
+fi
+
+# Detect context name from kubeconfig
+if [ -f "$HUB_KUBECONFIG_PATH" ]; then
+ CONTEXT_NAME=$(kubectl --kubeconfig="$HUB_KUBECONFIG_PATH" config current-context 2>/dev/null || echo "admin")
+else
+ CONTEXT_NAME="admin"
+fi
+
+echo "Configuration:"
+echo " Hub Kubeconfig: $HUB_KUBECONFIG_PATH"
+echo " Context Name: $CONTEXT_NAME"
+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)
+echo " Managed Clusters: $CLUSTER_COUNT"
+echo ""
+
+# Generate TOML file
+cat > "$OUTPUT_FILE" < "$CA_FILE" 2>/dev/null; then
+ echo " ✅ Keycloak CA extracted to $CA_FILE"
+ cat >> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE"
+echo "" >> "$OUTPUT_FILE"
+
+# Always add local-cluster (hub itself) with same-realm token exchange
+echo "Adding local-cluster (hub itself)..."
+cat >> "$OUTPUT_FILE" <> "$OUTPUT_FILE"
+ echo "[cluster_provider_configs.acm-kubeconfig.clusters.\"$CLUSTER_NAME\"]" >> "$OUTPUT_FILE"
+ echo "token_url = \"$KEYCLOAK_URL/realms/$MANAGED_REALM/protocol/openid-connect/token\"" >> "$OUTPUT_FILE"
+ echo "client_id = \"$CLUSTER_CLIENT_ID\"" >> "$OUTPUT_FILE"
+ echo "client_secret = \"$CLUSTER_CLIENT_SECRET\"" >> "$OUTPUT_FILE"
+ 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"
+ echo "" >> "$OUTPUT_FILE"
+ done
+fi
+
+echo ""
+echo "==========================================="
+echo "✅ Generated: $OUTPUT_FILE"
+echo "==========================================="
+echo ""
+echo "Configuration includes:"
+echo " ✅ Hub OAuth configuration"
+echo " ✅ Hub client credentials"
+echo " ✅ local-cluster (hub itself - same-realm token exchange)"
+echo " ✅ $CLUSTER_COUNT managed cluster(s) (cross-realm token exchange)"
+echo ""
+echo "Next steps:"
+echo " 1. Review the generated file: cat $OUTPUT_FILE"
+echo " 2. Update certificate_authority path if using self-signed certs"
+echo " 3. Update kubeconfig path if needed"
+echo " 4. Test with: npx @modelcontextprotocol/inspector@latest ./kubernetes-mcp-server --config $OUTPUT_FILE"
+echo ""
diff --git a/hack/keycloak-acm/register-managed-cluster.sh b/hack/keycloak-acm/register-managed-cluster.sh
new file mode 100755
index 00000000..bab51d95
--- /dev/null
+++ b/hack/keycloak-acm/register-managed-cluster.sh
@@ -0,0 +1,640 @@
+#!/bin/bash
+set -euo pipefail
+
+# ACM Managed Cluster Registration Script (Declarative)
+# This script registers a managed cluster realm and configures cross-realm token exchange
+#
+# Required environment variables:
+# CLUSTER_NAME - Name of the managed cluster (e.g., managed-cluster-one)
+# HUB_KUBECONFIG - Path to hub cluster kubeconfig
+# MANAGED_KUBECONFIG - Path to managed cluster kubeconfig
+#
+# Optional environment variables:
+# KEYCLOAK_CA_CERT - Path to CA certificate for HTTPS verification (optional)
+
+# Validate required variables
+: "${CLUSTER_NAME:?Error: CLUSTER_NAME environment variable is required}"
+: "${HUB_KUBECONFIG:?Error: HUB_KUBECONFIG environment variable is required}"
+: "${MANAGED_KUBECONFIG:?Error: MANAGED_KUBECONFIG environment variable is required}"
+
+# Get the directory where this script is located
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+KEYCLOAK_CONFIG_DIR="$REPO_ROOT/dev/config/openshift/keycloak"
+HUB_CONFIG_ENV="$REPO_ROOT/.keycloak-config/hub-config.env"
+CLUSTER_CONFIG_DIR="$REPO_ROOT/.keycloak-config/clusters"
+
+# Load hub configuration
+if [ ! -f "$HUB_CONFIG_ENV" ]; then
+ echo "❌ Hub configuration not found at $HUB_CONFIG_ENV"
+ echo "Please run ./hack/acm/acm-keycloak-setup-hub-declarative.sh first"
+ exit 1
+fi
+
+source "$HUB_CONFIG_ENV"
+
+# Set curl options based on CA cert availability
+CURL_OPTS="-sk"
+if [ -n "${KEYCLOAK_CA_CERT:-}" ]; then
+ CURL_OPTS="--cacert $KEYCLOAK_CA_CERT"
+fi
+
+MANAGED_REALM="$CLUSTER_NAME"
+IDP_ALIAS="hub-realm"
+
+echo "========================================="
+echo "ACM Managed Cluster Registration (Declarative)"
+echo "========================================="
+echo "Cluster Name: $CLUSTER_NAME"
+echo "Managed Realm: $MANAGED_REALM"
+echo "Hub Realm: $HUB_REALM"
+echo "Keycloak URL: $KEYCLOAK_URL"
+echo ""
+
+#=============================================================================
+# ACM ManagedCluster Registration
+#=============================================================================
+echo "========================================="
+echo "ACM ManagedCluster Registration"
+echo "========================================="
+echo ""
+
+# Step 2: Create ManagedCluster in ACM
+echo "Step 2: Creating ManagedCluster in ACM..."
+cat </dev/null; then
+ echo " ⚠️ Warning: Import secret not found yet, waiting longer..."
+ sleep 30
+fi
+
+echo ""
+
+# Step 3: Apply import manifests to managed cluster
+echo "Step 3: Applying ACM import manifests to managed cluster..."
+
+# Extract manifests
+TEMP_DIR=$(mktemp -d)
+trap "rm -rf $TEMP_DIR" EXIT
+
+kubectl --kubeconfig="$HUB_KUBECONFIG" get secret ${CLUSTER_NAME}-import -n ${CLUSTER_NAME} \
+ -o jsonpath='{.data.crds\.yaml}' | base64 -d > "$TEMP_DIR/crds.yaml"
+kubectl --kubeconfig="$HUB_KUBECONFIG" get secret ${CLUSTER_NAME}-import -n ${CLUSTER_NAME} \
+ -o jsonpath='{.data.import\.yaml}' | base64 -d > "$TEMP_DIR/import.yaml"
+
+echo " Applying CRDs to managed cluster..."
+kubectl --kubeconfig="$MANAGED_KUBECONFIG" apply -f "$TEMP_DIR/crds.yaml"
+
+echo " Applying import resources to managed cluster..."
+kubectl --kubeconfig="$MANAGED_KUBECONFIG" apply -f "$TEMP_DIR/import.yaml"
+
+echo " ✅ Import manifests applied"
+echo ""
+echo " Waiting for klusterlet agents to start (30 seconds)..."
+sleep 30
+
+# Verify klusterlet pods are starting
+echo " Klusterlet agent pods:"
+kubectl --kubeconfig="$MANAGED_KUBECONFIG" get pods -n open-cluster-management-agent 2>/dev/null || echo " (starting...)"
+echo ""
+echo " Klusterlet addon pods (including cluster-proxy):"
+kubectl --kubeconfig="$MANAGED_KUBECONFIG" get pods -n open-cluster-management-agent-addon 2>/dev/null || echo " (starting...)"
+echo ""
+
+# Check if cluster-proxy agent is running or starting
+PROXY_PODS=$(kubectl --kubeconfig="$MANAGED_KUBECONFIG" get pods -n open-cluster-management-agent-addon \
+ -l component=cluster-proxy-proxy-agent --no-headers 2>/dev/null | wc -l)
+if [ "$PROXY_PODS" -gt 0 ]; then
+ echo " ✅ Cluster-proxy agent pods detected"
+else
+ echo " ⚠️ Cluster-proxy agent pods not yet running (may take a few minutes)"
+fi
+
+echo ""
+
+#=============================================================================
+# Keycloak Realm Configuration
+#=============================================================================
+echo "========================================="
+echo "Keycloak Realm Configuration"
+echo "========================================="
+echo ""
+
+# Get admin token (do this right before Keycloak operations to avoid expiration)
+echo "Step 4: Getting Keycloak admin access token..."
+echo " Keycloak URL: $KEYCLOAK_URL"
+echo " Checking Keycloak availability..."
+
+# Test if Keycloak is reachable
+if ! curl $CURL_OPTS -sf "$KEYCLOAK_URL/realms/master" > /dev/null 2>&1; then
+ echo " ⚠️ Keycloak not yet reachable, waiting 30 seconds..."
+ sleep 30
+
+ if ! curl $CURL_OPTS -sf "$KEYCLOAK_URL/realms/master" > /dev/null 2>&1; then
+ echo " ❌ Keycloak still not reachable at $KEYCLOAK_URL"
+ echo " Please ensure Keycloak is running: make keycloak-status"
+ exit 1
+ fi
+fi
+
+echo " ✅ Keycloak is reachable"
+echo " Requesting admin token..."
+
+RESPONSE=$(curl $CURL_OPTS -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "username=$ADMIN_USER" \
+ -d "password=$ADMIN_PASSWORD" \
+ -d "grant_type=password" \
+ -d "client_id=admin-cli")
+
+ADMIN_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token // empty' 2>/dev/null)
+
+if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then
+ echo " ❌ Failed to get access token"
+ echo " Response: $RESPONSE"
+ echo ""
+ echo " Please check:"
+ echo " - Keycloak admin credentials in .keycloak-config/hub-config.env"
+ echo " - ADMIN_USER: $ADMIN_USER"
+ echo " - Keycloak status: make keycloak-status"
+ exit 1
+fi
+
+echo " ✅ Admin token obtained"
+echo ""
+
+# Step 5: Create managed cluster realm
+echo "Step 5: Creating managed cluster realm..."
+REALM_JSON=$(cat "$KEYCLOAK_CONFIG_DIR/realm/managed-realm-create.json" | sed "s/\"managed-cluster-one\"/\"$MANAGED_REALM\"/")
+REALM_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$REALM_JSON")
+
+REALM_CODE=$(echo "$REALM_RESPONSE" | tail -c 4)
+
+if [ "$REALM_CODE" = "201" ]; then
+ echo " ✅ Managed realm created"
+elif [ "$REALM_CODE" = "409" ]; then
+ echo " ✅ Managed realm already exists"
+else
+ echo " ❌ Failed to create managed realm (HTTP $REALM_CODE)"
+ exit 1
+fi
+echo ""
+
+# Step 3: Create client scopes
+echo "Step 3: Creating client scopes in managed realm..."
+
+SCOPE_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d @"$KEYCLOAK_CONFIG_DIR/client-scopes/openid.json")
+
+SCOPE_CODE=$(echo "$SCOPE_RESPONSE" | tail -c 4)
+if [ "$SCOPE_CODE" = "201" ] || [ "$SCOPE_CODE" = "409" ]; then
+ echo " ✅ openid scope created/exists"
+fi
+
+SCOPE_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d @"$KEYCLOAK_CONFIG_DIR/client-scopes/mcp-server.json")
+
+SCOPE_CODE=$(echo "$SCOPE_RESPONSE" | tail -c 4)
+if [ "$SCOPE_CODE" = "201" ] || [ "$SCOPE_CODE" = "409" ]; then
+ echo " ✅ mcp-server scope created/exists"
+fi
+echo ""
+
+# Step 4: Add protocol mappers to mcp-server scope
+echo "Step 4: Adding protocol mappers..."
+
+SCOPES_LIST=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+MCP_SERVER_SCOPE_ID=$(echo "$SCOPES_LIST" | jq -r '.[] | select(.name == "mcp-server") | .id // empty')
+
+MAPPER_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/client-scopes/$MCP_SERVER_SCOPE_ID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d @"$KEYCLOAK_CONFIG_DIR/mappers/mcp-server-audience-mapper.json")
+
+MAPPER_CODE=$(echo "$MAPPER_RESPONSE" | tail -c 4)
+if [ "$MAPPER_CODE" = "201" ] || [ "$MAPPER_CODE" = "409" ]; then
+ echo " ✅ mcp-server audience mapper added"
+fi
+echo ""
+
+# Step 5: Create mcp-server client in managed realm
+echo "Step 5: Creating mcp-server client in managed realm..."
+
+CLIENT_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d @"$KEYCLOAK_CONFIG_DIR/clients/mcp-server.json")
+
+CLIENT_CODE=$(echo "$CLIENT_RESPONSE" | tail -c 4)
+if [ "$CLIENT_CODE" = "201" ] || [ "$CLIENT_CODE" = "409" ]; then
+ echo " ✅ mcp-server client created/exists"
+fi
+echo ""
+
+# Step 6: Get managed mcp-server client UUID and secret
+echo "Step 6: Retrieving managed cluster client details..."
+
+CLIENTS_LIST=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+
+MANAGED_CLIENT_UUID=$(echo "$CLIENTS_LIST" | jq -r '.[] | select(.clientId == "mcp-server") | .id')
+MANAGED_SECRET_RESPONSE=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$MANAGED_CLIENT_UUID/client-secret" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+MANAGED_CLIENT_SECRET=$(echo "$MANAGED_SECRET_RESPONSE" | jq -r '.value')
+
+echo " ✅ Managed mcp-server UUID: $MANAGED_CLIENT_UUID"
+echo ""
+
+# Step 6a: Add protocol mapper for sub claim (required for token exchange)
+echo "Step 6a: Adding protocol mapper for sub claim..."
+
+# Check if user-id mapper already exists
+MAPPERS=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$MANAGED_CLIENT_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+
+USER_ID_MAPPER=$(echo "$MAPPERS" | jq -r '.[] | select(.name == "user-id") | .id')
+
+if [ -z "$USER_ID_MAPPER" ] || [ "$USER_ID_MAPPER" = "null" ]; then
+ MAPPER_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$MANAGED_CLIENT_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "user-id",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "config": {
+ "user.attribute": "id",
+ "claim.name": "sub",
+ "jsonType.label": "String",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }')
+
+ MAPPER_CODE=$(echo "$MAPPER_RESPONSE" | tail -c 4)
+ if [ "$MAPPER_CODE" = "201" ]; then
+ echo " ✅ Created user-id mapper for sub claim"
+ else
+ echo " ⚠️ Failed to create user-id mapper (code: $MAPPER_CODE)"
+ fi
+else
+ echo " ✅ user-id mapper already exists"
+fi
+echo ""
+
+# Step 7: Create identity provider pointing to hub realm
+echo "Step 7: Creating identity provider (hub realm)..."
+
+IDP_JSON=$(cat "$KEYCLOAK_CONFIG_DIR/identity-providers/hub-realm-idp-template.json" | \
+ sed "s|\${KEYCLOAK_URL}|$KEYCLOAK_URL|g" | \
+ sed "s|\${HUB_CLIENT_SECRET}|$CLIENT_SECRET|g" | \
+ sed "s/\"hub-realm\"/\"$IDP_ALIAS\"/")
+
+IDP_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/identity-provider/instances" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$IDP_JSON")
+
+IDP_CODE=$(echo "$IDP_RESPONSE" | tail -c 4)
+
+if [ "$IDP_CODE" = "201" ]; then
+ echo " ✅ Identity provider created"
+elif [ "$IDP_CODE" = "409" ]; then
+ echo " ✅ Identity provider already exists"
+ # Update existing IDP
+ curl $CURL_OPTS -X PUT "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/identity-provider/instances/$IDP_ALIAS" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$IDP_JSON" > /dev/null
+ echo " ✅ Identity provider updated"
+else
+ echo " ❌ Failed to create identity provider (HTTP $IDP_CODE)"
+ exit 1
+fi
+echo ""
+
+# Step 8: Create federated identity link
+echo "Step 8: Creating federated identity link..."
+
+# Get hub user ID
+HUB_USERS=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users?username=$MCP_USERNAME" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+HUB_USER_ID=$(echo "$HUB_USERS" | jq -r '.[0].id // empty')
+
+if [ -z "$HUB_USER_ID" ]; then
+ echo " ❌ Hub user $MCP_USERNAME not found"
+ 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" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+MANAGED_USER_ID=$(echo "$SERVICE_ACCOUNT_USER" | jq -r '.id // empty')
+
+if [ -z "$MANAGED_USER_ID" ] || [ "$MANAGED_USER_ID" = "null" ]; then
+ echo " ❌ Service account for mcp-server client not found"
+ exit 1
+fi
+
+SERVICE_ACCOUNT_USERNAME=$(echo "$SERVICE_ACCOUNT_USER" | jq -r '.username // empty')
+echo " ✅ Found service account: $SERVICE_ACCOUNT_USERNAME (ID: $MANAGED_USER_ID)"
+
+# Create federated identity link between hub user and managed service account
+FED_IDENTITY_JSON="{
+ \"identityProvider\": \"$IDP_ALIAS\",
+ \"userId\": \"$HUB_USER_ID\",
+ \"userName\": \"$MCP_USERNAME\"
+}"
+
+FED_RESPONSE=$(curl $CURL_OPTS -w "%{http_code}" -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/users/$MANAGED_USER_ID/federated-identity/$IDP_ALIAS" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$FED_IDENTITY_JSON")
+
+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)"
+else
+ echo " ⚠️ Federated identity link returned HTTP $FED_CODE"
+fi
+echo ""
+
+# Step 9: Configure cross-realm token exchange permissions
+echo "Step 9: Configuring cross-realm token exchange permissions..."
+
+# Enable fine-grained permissions on IDP
+curl $CURL_OPTS -X PUT "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/identity-provider/instances/$IDP_ALIAS/management/permissions" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"enabled": true}' > /dev/null
+
+# Get IDP permissions
+IDP_PERMS=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/identity-provider/instances/$IDP_ALIAS/management/permissions" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+TOKEN_EXCHANGE_PERM_ID=$(echo "$IDP_PERMS" | jq -r '.scopePermissions."token-exchange"')
+
+# Get realm-management client ID
+REALM_MGMT_ID=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients?clientId=realm-management" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id')
+
+# Create client policy for managed mcp-server
+POLICY_RESPONSE=$(curl $CURL_OPTS -X POST "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$REALM_MGMT_ID/authz/resource-server/policy/client" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"type\": \"client\",
+ \"logic\": \"POSITIVE\",
+ \"decisionStrategy\": \"UNANIMOUS\",
+ \"name\": \"allow-mcp-server-token-exchange\",
+ \"description\": \"Allow hub realm mcp-server to exchange to managed cluster\",
+ \"clients\": [\"$MANAGED_CLIENT_UUID\"]
+ }" 2>/dev/null)
+
+POLICY_ID=$(echo "$POLICY_RESPONSE" | jq -r '.id // empty')
+
+# If policy creation failed, try to find existing policy
+if [ -z "$POLICY_ID" ] || [ "$POLICY_ID" = "null" ]; then
+ ALL_POLICIES=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$REALM_MGMT_ID/authz/resource-server/policy?type=client" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+ POLICY_ID=$(echo "$ALL_POLICIES" | jq -r '.[] | select(.name == "allow-mcp-server-token-exchange") | .id')
+fi
+
+# Link policy to token-exchange permission
+CURRENT_PERM=$(curl $CURL_OPTS -X GET "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$REALM_MGMT_ID/authz/resource-server/permission/$TOKEN_EXCHANGE_PERM_ID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+
+UPDATED_PERM=$(echo "$CURRENT_PERM" | jq --arg policy_id "$POLICY_ID" '. + {policies: [$policy_id]}')
+
+curl $CURL_OPTS -X PUT "$KEYCLOAK_URL/admin/realms/$MANAGED_REALM/clients/$REALM_MGMT_ID/authz/resource-server/permission/$TOKEN_EXCHANGE_PERM_ID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$UPDATED_PERM" > /dev/null
+
+echo " ✅ Cross-realm token exchange configured"
+echo ""
+
+# Step 10: Save configuration
+echo "Step 10: Saving configuration..."
+
+mkdir -p "$CLUSTER_CONFIG_DIR"
+cat > "$CLUSTER_CONFIG_DIR/$CLUSTER_NAME.env" </dev/null || echo "")
+if [ "$CURRENT_FEATURE_SET" != "TechPreviewNoUpgrade" ]; then
+ kubectl --kubeconfig="$MANAGED_KUBECONFIG" patch featuregate cluster \
+ --type=merge -p='{"spec":{"featureSet":"TechPreviewNoUpgrade"}}'
+ echo " ✅ TechPreviewNoUpgrade enabled"
+ echo " ⚠️ Control plane will restart (10-15 minutes)"
+ echo " ⚠️ Waiting 2 minutes for initial rollout..."
+ sleep 120
+else
+ echo " ✅ TechPreviewNoUpgrade already enabled"
+fi
+
+echo ""
+echo "Waiting for kube-apiserver on managed cluster..."
+for i in $(seq 1 30); do
+ if kubectl --kubeconfig="$MANAGED_KUBECONFIG" wait --for=condition=Available --timeout=10s clusteroperator/kube-apiserver 2>/dev/null; then
+ echo " ✅ kube-apiserver is ready"
+ break
+ fi
+ echo " Waiting for kube-apiserver (attempt $i/30)..."
+ sleep 10
+done
+
+# Step 2: Create Keycloak CA certificate ConfigMap
+echo ""
+echo "Step 2: Creating Keycloak CA certificate ConfigMap..."
+KEYCLOAK_CA=$(kubectl --kubeconfig="$HUB_KUBECONFIG" get configmap router-ca -n keycloak \
+ -o jsonpath='{.data.router-ca\.crt}' 2>/dev/null || \
+ kubectl --kubeconfig="$HUB_KUBECONFIG" get configmap -n openshift-config-managed default-ingress-cert \
+ -o jsonpath='{.data.ca-bundle\.crt}' 2>/dev/null)
+
+if [ -z "$KEYCLOAK_CA" ]; then
+ echo " ⚠️ Could not extract Keycloak CA certificate"
+ echo " You may need to manually create ConfigMap: keycloak-oidc-ca in openshift-config"
+else
+ echo "$KEYCLOAK_CA" | kubectl --kubeconfig="$MANAGED_KUBECONFIG" create configmap keycloak-oidc-ca \
+ -n openshift-config --from-file=ca-bundle.crt=/dev/stdin --dry-run=client -o yaml | \
+ kubectl --kubeconfig="$MANAGED_KUBECONFIG" apply -f -
+ echo " ✅ CA certificate ConfigMap created"
+fi
+
+# Step 3: Create RBAC for service-account-mcp-server user
+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 \
+ --dry-run=client -o yaml | kubectl --kubeconfig="$MANAGED_KUBECONFIG" apply -f -
+echo " ✅ RBAC created"
+
+# Step 4: Configure OIDC provider
+echo ""
+echo "Step 4: Configuring OIDC provider..."
+ISSUER_URL="$KEYCLOAK_URL/realms/$MANAGED_REALM"
+
+CURRENT_ISSUER=$(kubectl --kubeconfig="$MANAGED_KUBECONFIG" get authentication.config.openshift.io/cluster \
+ -o jsonpath='{.spec.oidcProviders[0].issuer.issuerURL}' 2>/dev/null || echo "")
+
+if [ "$CURRENT_ISSUER" = "$ISSUER_URL" ]; then
+ echo " ✅ OIDC provider already configured"
+else
+ if [ -n "$CURRENT_ISSUER" ]; then
+ echo " Updating existing OIDC provider..."
+ printf '[{"op":"replace","path":"/spec/oidcProviders/0/issuer/issuerURL","value":"%s"},{"op":"replace","path":"/spec/oidcProviders/0/issuer/audiences","value":["mcp-server"]}]' "$ISSUER_URL" > /tmp/oidc-patch-$CLUSTER_NAME.json
+ else
+ echo " Creating new OIDC provider..."
+ printf '[{"op":"remove","path":"/spec/webhookTokenAuthenticator"},{"op":"replace","path":"/spec/type","value":"OIDC"},{"op":"add","path":"/spec/oidcProviders","value":[{"name":"keycloak","issuer":{"issuerURL":"%s","audiences":["mcp-server"],"issuerCertificateAuthority":{"name":"keycloak-oidc-ca"}},"claimMappings":{"username":{"claim":"preferred_username","prefixPolicy":"NoPrefix"}}}]}]' "$ISSUER_URL" > /tmp/oidc-patch-$CLUSTER_NAME.json
+ fi
+
+ kubectl --kubeconfig="$MANAGED_KUBECONFIG" patch authentication.config.openshift.io/cluster \
+ --type=json -p="$(cat /tmp/oidc-patch-$CLUSTER_NAME.json)"
+ echo " ✅ OIDC provider configured"
+ echo ""
+ echo " Verifying kube-apiserver operator picked up OIDC configuration..."
+
+ # Get current revision before verification
+ BEFORE_REV=$(kubectl --kubeconfig="$MANAGED_KUBECONFIG" get kubeapiserver cluster \
+ -o jsonpath='{.status.latestAvailableRevision}' 2>/dev/null || echo "0")
+
+ # Wait up to 2 minutes for a new revision to be created
+ echo " Waiting for new kube-apiserver revision to be created..."
+ for i in $(seq 1 24); do
+ sleep 5
+ CURRENT_REV=$(kubectl --kubeconfig="$MANAGED_KUBECONFIG" get kubeapiserver cluster \
+ -o jsonpath='{.status.latestAvailableRevision}' 2>/dev/null || echo "0")
+ if [ "$CURRENT_REV" -gt "$BEFORE_REV" ]; then
+ echo " ✅ New revision $CURRENT_REV created"
+ break
+ fi
+ if [ $i -eq 24 ]; then
+ echo " ⚠️ No new revision created after 2 minutes"
+ echo " Applying workaround: remove/re-add OIDC provider to force reconciliation..."
+
+ # Remove OIDC provider
+ printf '[{"op":"replace","path":"/spec/oidcProviders","value":[]}]' > /tmp/oidc-remove-$CLUSTER_NAME.json
+ kubectl --kubeconfig="$MANAGED_KUBECONFIG" patch authentication.config.openshift.io/cluster \
+ --type=json -p="$(cat /tmp/oidc-remove-$CLUSTER_NAME.json)"
+ sleep 10
+
+ # Re-add OIDC provider
+ kubectl --kubeconfig="$MANAGED_KUBECONFIG" patch authentication.config.openshift.io/cluster \
+ --type=json -p="$(cat /tmp/oidc-patch-$CLUSTER_NAME.json)"
+ echo " ✅ OIDC provider re-applied"
+
+ # Wait for new revision again
+ for j in $(seq 1 12); do
+ sleep 5
+ CURRENT_REV=$(kubectl --kubeconfig="$MANAGED_KUBECONFIG" get kubeapiserver cluster \
+ -o jsonpath='{.status.latestAvailableRevision}' 2>/dev/null || echo "0")
+ if [ "$CURRENT_REV" -gt "$BEFORE_REV" ]; then
+ echo " ✅ New revision $CURRENT_REV created after workaround"
+ break
+ fi
+ done
+ fi
+ done
+
+ echo ""
+ echo " ⚠️ IMPORTANT: kube-apiserver will now roll out with OIDC configuration"
+ echo " This takes 10-15 minutes as each master node updates sequentially."
+ echo ""
+ echo " You can monitor the rollout with:"
+ echo " kubectl --kubeconfig=$MANAGED_KUBECONFIG get co kube-apiserver -w"
+ echo ""
+ echo " Wait until: Available=True, Progressing=False, Degraded=False"
+fi
+
+echo ""
+echo "========================================="
+echo "✅ Managed Cluster Registration Complete!"
+echo "========================================="
+echo ""
+echo "Cluster: $CLUSTER_NAME"
+echo "Managed Realm: $MANAGED_REALM"
+echo "Identity Provider: $IDP_ALIAS (hub realm)"
+echo "Federated User: $MCP_USERNAME (hub: $HUB_USER_ID → managed: $MANAGED_USER_ID)"
+echo "ACM ManagedCluster: Created"
+echo "ACM Import Manifests: Applied"
+echo "Cluster-Proxy Agents: Starting"
+echo "Cross-Realm Exchange: Configured"
+echo "OIDC Authentication: Configured (rolling out)"
+echo ""
+echo "Configuration saved to: $CLUSTER_CONFIG_DIR/$CLUSTER_NAME.env"
+echo ""
+echo "⚠️ IMPORTANT: Wait for rollouts to complete:"
+echo " 1. Feature gate rollout: ~10-15 minutes"
+echo " 2. OIDC rollout: ~10-15 minutes"
+echo " 3. Cluster-proxy agents: ~2-5 minutes"
+echo " Total: ~25-30 minutes"
+echo ""
+echo "Monitor rollout status:"
+echo " kubectl --kubeconfig=$MANAGED_KUBECONFIG get co kube-apiserver -w"
+echo " kubectl --kubeconfig=$MANAGED_KUBECONFIG get pods -n open-cluster-management-agent-addon -w"
+echo ""
+echo "After rollout completes:"
+echo " 1. Run: make keycloak-acm-generate-toml"
+echo " 2. Start MCP server: ./kubernetes-mcp-server --port 8080 --config _output/acm-kubeconfig.toml"
+echo ""
diff --git a/hack/keycloak-acm/setup-hub.sh b/hack/keycloak-acm/setup-hub.sh
new file mode 100755
index 00000000..ecf34928
--- /dev/null
+++ b/hack/keycloak-acm/setup-hub.sh
@@ -0,0 +1,839 @@
+#!/bin/bash
+# Setup Keycloak instance and hub realm for ACM multi-cluster
+# This script:
+# 1. Deploys Keycloak (PostgreSQL + Keycloak instance)
+# 2. Configures OpenShift Authentication CR to use Keycloak as OIDC provider
+# 3. Creates hub realm with all V1 token exchange requirements
+
+set -e
+
+# Get script directory and repo root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+KEYCLOAK_CONFIG_DIR="$REPO_ROOT/dev/config/openshift/keycloak"
+
+# Configuration
+HUB_REALM="${HUB_REALM:-hub}"
+CLIENT_ID="${CLIENT_ID:-mcp-server}"
+MCP_USERNAME="${MCP_USERNAME:-mcp}"
+MCP_PASSWORD="${MCP_PASSWORD:-mcp}"
+KEYCLOAK_VERSION="${KEYCLOAK_VERSION:-26.4}"
+KEYCLOAK_NAMESPACE="${KEYCLOAK_NAMESPACE:-keycloak}"
+
+# Keycloak admin credentials (consistent with deployment)
+ADMIN_USER="admin"
+ADMIN_PASSWORD="admin"
+
+echo "=========================================="
+echo "Keycloak Hub Setup for ACM"
+echo "=========================================="
+echo "Hub Realm: $HUB_REALM"
+echo "Client: $CLIENT_ID"
+echo "Keycloak Version: $KEYCLOAK_VERSION"
+echo ""
+echo "This script will:"
+echo " 1. Deploy Keycloak (PostgreSQL + Keycloak instance)"
+echo " 2. Configure OpenShift Authentication CR"
+echo " 3. Create hub realm with V1 token exchange support"
+echo ""
+
+#=============================================================================
+# STEP 1: Deploy Keycloak
+#=============================================================================
+echo "========================================"
+echo "STEP 1: Deploying Keycloak"
+echo "========================================"
+echo ""
+
+# Create namespace
+echo "Creating namespace..."
+if oc get namespace "$KEYCLOAK_NAMESPACE" >/dev/null 2>&1; then
+ echo "✅ Namespace $KEYCLOAK_NAMESPACE already exists"
+else
+ oc create namespace "$KEYCLOAK_NAMESPACE"
+ echo "✅ Namespace $KEYCLOAK_NAMESPACE created"
+fi
+
+# Deploy PostgreSQL
+echo ""
+echo "Deploying PostgreSQL..."
+
+# Check if PostgreSQL secret already exists
+if oc get secret postgresql-credentials -n "$KEYCLOAK_NAMESPACE" >/dev/null 2>&1; then
+ echo " Using existing PostgreSQL credentials"
+ POSTGRESQL_PASSWORD=$(oc get secret postgresql-credentials -n "$KEYCLOAK_NAMESPACE" -o jsonpath='{.data.POSTGRESQL_PASSWORD}' | base64 -d)
+else
+ echo " Generating new PostgreSQL credentials"
+ POSTGRESQL_PASSWORD="$(openssl rand -base64 24 | tr -d '=+/' | cut -c1-24)"
+fi
+
+sed "s/POSTGRESQL_PASSWORD_PLACEHOLDER/$POSTGRESQL_PASSWORD/" "$KEYCLOAK_CONFIG_DIR/postgresql.yaml" | \
+ oc apply -n "$KEYCLOAK_NAMESPACE" -f -
+
+echo "✅ PostgreSQL deployment created"
+
+echo ""
+echo "Waiting for PostgreSQL to be ready..."
+oc wait --for=condition=ready pod -l app=postgresql -n "$KEYCLOAK_NAMESPACE" --timeout=300s
+echo "✅ PostgreSQL is ready"
+
+# Deploy Keycloak
+echo ""
+echo "Deploying Keycloak with V1 features enabled..."
+
+sed -e "s/KEYCLOAK_VERSION_PLACEHOLDER/$KEYCLOAK_VERSION/" \
+ -e "s/ADMIN_USER_PLACEHOLDER/$ADMIN_USER/" \
+ -e "s/ADMIN_PASSWORD_PLACEHOLDER/$ADMIN_PASSWORD/" \
+ "$KEYCLOAK_CONFIG_DIR/keycloak.yaml" | \
+ oc apply -n "$KEYCLOAK_NAMESPACE" -f -
+
+echo "✅ Keycloak deployment created with V1 features: token-exchange:v1,admin-fine-grained-authz:v1"
+
+echo ""
+echo "Waiting for Keycloak pod to be ready..."
+oc wait --for=condition=ready pod -l app=keycloak -n "$KEYCLOAK_NAMESPACE" --timeout=300s
+echo "✅ Keycloak pod is ready"
+
+# Get Keycloak URL
+echo ""
+echo "Getting Keycloak route..."
+KEYCLOAK_ROUTE=$(oc get route keycloak -n "$KEYCLOAK_NAMESPACE" -o jsonpath='{.spec.host}')
+KEYCLOAK_URL="https://$KEYCLOAK_ROUTE"
+echo "✅ Keycloak URL: $KEYCLOAK_URL"
+
+# Wait for Keycloak HTTP endpoint
+echo ""
+echo "Waiting for Keycloak HTTP endpoint..."
+for i in $(seq 1 30); do
+ STATUS=$(curl -sk -o /dev/null -w "%{http_code}" "$KEYCLOAK_URL/realms/master" 2>/dev/null || echo "000")
+ if [ "$STATUS" = "200" ]; then
+ echo "✅ Keycloak HTTP endpoint ready"
+ break
+ fi
+ echo " Attempt $i/30: Waiting (status: $STATUS)..."
+ sleep 5
+done
+
+if [ "$STATUS" != "200" ]; then
+ echo "❌ Keycloak endpoint not responding"
+ exit 1
+fi
+
+#=============================================================================
+# STEP 2: Configure OpenShift Authentication CR
+#=============================================================================
+echo ""
+echo "========================================"
+echo "STEP 2: Configuring OpenShift Authentication"
+echo "========================================"
+echo ""
+
+echo "Enabling TechPreviewNoUpgrade feature gate..."
+CURRENT_FEATURE_SET=$(oc get featuregate cluster -o jsonpath='{.spec.featureSet}' 2>/dev/null || echo "")
+if [ "$CURRENT_FEATURE_SET" != "TechPreviewNoUpgrade" ]; then
+ echo " Enabling TechPreviewNoUpgrade..."
+ oc patch featuregate cluster --type=merge -p='{"spec":{"featureSet":"TechPreviewNoUpgrade"}}'
+ echo " ✅ Feature gate enabled"
+ echo " ⚠️ Control plane will restart (10-15 minutes)"
+ echo " ⚠️ Waiting 2 minutes for initial rollout..."
+ sleep 120
+else
+ echo " ✅ TechPreviewNoUpgrade already enabled"
+fi
+
+echo ""
+echo "Waiting for kube-apiserver..."
+for i in $(seq 1 30); do
+ if oc wait --for=condition=Available --timeout=10s clusteroperator/kube-apiserver 2>/dev/null; then
+ echo " ✅ kube-apiserver is ready"
+ break
+ fi
+ echo " Waiting for kube-apiserver (attempt $i/30)..."
+ sleep 10
+done
+
+echo ""
+echo "Configuring OIDC provider CA certificate..."
+kubectl get configmap -n openshift-config-managed default-ingress-cert -o jsonpath='{.data.ca-bundle\.crt}' > /tmp/keycloak-ca.crt
+echo " Extracted OpenShift ingress CA ($(wc -l < /tmp/keycloak-ca.crt) lines)"
+oc delete configmap keycloak-oidc-ca -n openshift-config 2>/dev/null || true
+oc create configmap keycloak-oidc-ca -n openshift-config --from-file=ca-bundle.crt=/tmp/keycloak-ca.crt
+echo " ✅ CA certificate configmap created"
+
+echo ""
+echo "Configuring OIDC provider..."
+ISSUER_URL="$KEYCLOAK_URL/realms/$HUB_REALM"
+echo " Issuer URL: $ISSUER_URL"
+echo " Audiences: openshift, $CLIENT_ID"
+
+CURRENT_ISSUER=$(oc get authentication.config.openshift.io/cluster -o jsonpath='{.spec.oidcProviders[0].issuer.issuerURL}' 2>/dev/null || echo "")
+if [ "$CURRENT_ISSUER" = "$ISSUER_URL" ]; then
+ echo " ✅ OIDC provider already configured"
+else
+ if [ -n "$CURRENT_ISSUER" ]; then
+ echo " Updating existing OIDC provider..."
+ printf '[{"op":"replace","path":"/spec/oidcProviders/0/issuer/issuerURL","value":"%s"},{"op":"replace","path":"/spec/oidcProviders/0/issuer/audiences","value":["openshift","%s"]}]' "$ISSUER_URL" "$CLIENT_ID" > /tmp/oidc-patch.json
+ else
+ echo " Creating new OIDC provider..."
+ printf '[{"op":"remove","path":"/spec/webhookTokenAuthenticator"},{"op":"replace","path":"/spec/type","value":"OIDC"},{"op":"add","path":"/spec/oidcProviders","value":[{"name":"keycloak","issuer":{"issuerURL":"%s","audiences":["openshift","%s"],"issuerCertificateAuthority":{"name":"keycloak-oidc-ca"}},"claimMappings":{"username":{"claim":"preferred_username","prefixPolicy":"NoPrefix"}}}]}]' "$ISSUER_URL" "$CLIENT_ID" > /tmp/oidc-patch.json
+ fi
+ oc patch authentication.config.openshift.io/cluster --type=json -p="$(cat /tmp/oidc-patch.json)"
+ echo " ✅ Authentication CR configured"
+ echo ""
+ echo " ⚠️ IMPORTANT: kube-apiserver will now roll out with OIDC configuration"
+ echo " This takes 10-15 minutes as each master node updates sequentially."
+ echo ""
+ echo " You can monitor the rollout with:"
+ echo " oc get co kube-apiserver -w"
+ echo ""
+ echo " The MCP server will not be able to authenticate until the rollout completes."
+ echo " Wait until all conditions show: Available=True, Progressing=False, Degraded=False"
+ echo ""
+fi
+
+#=============================================================================
+# STEP 3: Create Hub Realm
+#=============================================================================
+echo ""
+echo "========================================"
+echo "STEP 3: Creating Hub Realm"
+echo "========================================"
+echo ""
+
+# Get admin token
+echo "Getting admin token..."
+ADMIN_TOKEN=$(curl -sk -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "username=$ADMIN_USER" \
+ -d "password=$ADMIN_PASSWORD" \
+ -d "grant_type=password" \
+ -d "client_id=admin-cli" | jq -r '.access_token')
+
+if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then
+ echo "❌ Failed to get admin token"
+ exit 1
+fi
+echo "✅ Got admin token"
+
+# Create hub realm
+echo ""
+echo "Creating hub realm..."
+EXISTING_REALM=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null)
+
+# Check if realm exists by looking for the .realm field (not just valid JSON)
+if echo "$EXISTING_REALM" | jq -e '.realm' > /dev/null 2>&1; then
+ echo " ✅ Hub realm already exists: $HUB_REALM"
+else
+ echo " Creating hub realm..."
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"realm\": \"$HUB_REALM\",
+ \"enabled\": true,
+ \"displayName\": \"Hub Cluster Realm\",
+ \"accessTokenLifespan\": 3600
+ }" > /dev/null
+ echo " ✅ Created hub realm: $HUB_REALM"
+fi
+
+# Create client scopes (openid and mcp-server)
+echo ""
+echo "Creating client scopes..."
+
+# Check if client-scopes endpoint is ready
+SCOPES_RESPONSE=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+
+# Check if response is valid JSON array (not an error object)
+# We check if it's an array by attempting to get its length
+SCOPES_COUNT=$(echo "$SCOPES_RESPONSE" | jq 'if type == "array" then length else -1 end' 2>/dev/null || echo "-1")
+
+if [ "$SCOPES_COUNT" = "-1" ]; then
+ echo " ⚠️ Realm may not be fully ready, waiting 5 seconds..."
+ sleep 5
+ SCOPES_RESPONSE=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+
+ # Check again after retry
+ SCOPES_COUNT=$(echo "$SCOPES_RESPONSE" | jq 'if type == "array" then length else -1 end' 2>/dev/null || echo "-1")
+ if [ "$SCOPES_COUNT" = "-1" ]; then
+ echo " ❌ Failed to get client scopes from Keycloak"
+ echo " Response: $SCOPES_RESPONSE"
+ exit 1
+ fi
+fi
+
+# Create openid scope
+OPENID_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "openid") | .id // empty')
+
+if [ -z "$OPENID_SCOPE_UUID" ] || [ "$OPENID_SCOPE_UUID" = "null" ]; then
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "openid",
+ "description": "OpenID Connect scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "false"
+ }
+ }' > /dev/null
+
+ # Wait a moment for scope to be created, then fetch UUID
+ sleep 2
+ SCOPES_RESPONSE=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+ OPENID_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "openid") | .id // empty')
+ echo " ✅ Created openid scope"
+else
+ echo " ✅ openid scope already exists"
+fi
+
+# Create mcp-server scope (for audience validation)
+MCP_SERVER_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "mcp-server") | .id // empty')
+
+if [ -z "$MCP_SERVER_SCOPE_UUID" ] || [ "$MCP_SERVER_SCOPE_UUID" = "null" ]; then
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "mcp-server",
+ "description": "MCP Server audience scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "false"
+ }
+ }' > /dev/null
+
+ # Wait and fetch UUID
+ sleep 2
+ SCOPES_RESPONSE=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+ MCP_SERVER_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "mcp-server") | .id // empty')
+ echo " ✅ Created mcp-server scope"
+
+ # Add audience mapper to mcp-server scope
+ echo " Adding mcp-server-audience mapper..."
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes/$MCP_SERVER_SCOPE_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "mcp-server-audience",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "config": {
+ "included.client.audience": "mcp-server",
+ "id.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }' > /dev/null 2>&1
+ echo " ✅ Added mcp-server-audience mapper"
+else
+ echo " ✅ mcp-server scope already exists"
+
+ # Check and add audience mapper if missing
+ MAPPERS=$(curl -sk "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes/$MCP_SERVER_SCOPE_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+ AUDIENCE_MAPPER=$(echo "$MAPPERS" | jq -r '.[] | select(.name == "mcp-server-audience") | .id')
+
+ if [ -z "$AUDIENCE_MAPPER" ] || [ "$AUDIENCE_MAPPER" = "null" ]; then
+ echo " Adding mcp-server-audience mapper..."
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes/$MCP_SERVER_SCOPE_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "mcp-server-audience",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "config": {
+ "included.client.audience": "mcp-server",
+ "id.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }' > /dev/null 2>&1
+ echo " ✅ Added mcp-server-audience mapper"
+ else
+ echo " ✅ mcp-server-audience mapper already configured"
+ fi
+fi
+
+# Create mcp-server client
+echo ""
+echo "Creating mcp-server client..."
+CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=$CLIENT_ID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id // empty')
+
+if [ -z "$CLIENT_UUID" ] || [ "$CLIENT_UUID" = "null" ]; then
+ CLIENT_SECRET=$(openssl rand -hex 32)
+
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"clientId\": \"$CLIENT_ID\",
+ \"enabled\": true,
+ \"protocol\": \"openid-connect\",
+ \"publicClient\": false,
+ \"directAccessGrantsEnabled\": true,
+ \"serviceAccountsEnabled\": true,
+ \"standardFlowEnabled\": true,
+ \"secret\": \"$CLIENT_SECRET\",
+ \"redirectUris\": [\"http://localhost:*\", \"https://*\"],
+ \"webOrigins\": [\"*\"],
+ \"attributes\": {
+ \"token.exchange.grant.enabled\": \"true\"
+ }
+ }" > /dev/null
+
+ CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=$CLIENT_ID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id')
+
+ echo " ✅ Created client: $CLIENT_ID"
+ echo " 📝 Client Secret: $CLIENT_SECRET"
+else
+ echo " ✅ Client already exists: $CLIENT_UUID"
+ CLIENT_SECRET=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/client-secret" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.value')
+ echo " 📝 Client Secret: $CLIENT_SECRET"
+fi
+
+# Add scopes to mcp-server client
+echo ""
+echo "Adding scopes to mcp-server client..."
+curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/default-client-scopes/$OPENID_SCOPE_UUID" \
+ -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 sub claim mapper
+echo ""
+echo "Creating sub claim mapper..."
+EXISTING_SUB_MAPPER=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.name == "sub") | .id // empty')
+
+if [ -z "$EXISTING_SUB_MAPPER" ]; then
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "sub",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-sub-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }' > /dev/null
+ echo " ✅ Created sub claim mapper"
+else
+ echo " ✅ sub claim mapper already exists"
+fi
+
+# Create mcp-client (public OAuth client for inspector/browser flow)
+echo ""
+echo "Creating mcp-client (public OAuth client)..."
+MCP_CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=mcp-client" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id // empty')
+
+if [ -z "$MCP_CLIENT_UUID" ] || [ "$MCP_CLIENT_UUID" = "null" ]; then
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "clientId": "mcp-client",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": true,
+ "directAccessGrantsEnabled": true,
+ "standardFlowEnabled": true,
+ "redirectUris": ["http://localhost:*"],
+ "webOrigins": ["*"]
+ }' > /dev/null
+
+ MCP_CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=mcp-client" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id')
+
+ # Add scopes to mcp-client
+ curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$MCP_CLIENT_UUID/default-client-scopes/$OPENID_SCOPE_UUID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1
+ curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$MCP_CLIENT_UUID/default-client-scopes/$MCP_SERVER_SCOPE_UUID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1
+
+ # Add audience mapper to include mcp-server in aud claim
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$MCP_CLIENT_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "mcp-server-audience",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.client.audience": "mcp-server",
+ "id.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }' > /dev/null 2>&1
+
+ echo " ✅ Created mcp-client (public OAuth client)"
+else
+ echo " ✅ mcp-client already exists"
+fi
+
+# Create mcp-sts client (for token exchange)
+echo ""
+echo "Creating mcp-sts client (for token exchange)..."
+STS_CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=mcp-sts" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id // empty')
+
+if [ -z "$STS_CLIENT_UUID" ] || [ "$STS_CLIENT_UUID" = "null" ]; then
+ STS_CLIENT_SECRET=$(openssl rand -hex 32)
+
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"clientId\": \"mcp-sts\",
+ \"enabled\": true,
+ \"protocol\": \"openid-connect\",
+ \"publicClient\": false,
+ \"directAccessGrantsEnabled\": true,
+ \"serviceAccountsEnabled\": true,
+ \"standardFlowEnabled\": false,
+ \"secret\": \"$STS_CLIENT_SECRET\",
+ \"attributes\": {
+ \"token.exchange.grant.enabled\": \"true\"
+ }
+ }" > /dev/null
+
+ STS_CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=mcp-sts" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id')
+
+ # Add scopes to mcp-sts
+ curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/default-client-scopes/$OPENID_SCOPE_UUID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1
+ curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/default-client-scopes/$MCP_SERVER_SCOPE_UUID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1
+
+ echo " ✅ Created mcp-sts client"
+ echo " 📝 STS Client Secret: $STS_CLIENT_SECRET"
+
+ # Add user-id protocol mapper for sub claim (required for token exchange)
+ echo " Adding user-id protocol mapper..."
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "user-id",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "config": {
+ "user.attribute": "id",
+ "claim.name": "sub",
+ "jsonType.label": "String",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }' > /dev/null 2>&1
+ echo " ✅ Added user-id mapper for sub claim"
+else
+ echo " ✅ mcp-sts client already exists"
+ STS_CLIENT_SECRET=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/client-secret" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.value')
+ echo " 📝 STS Client Secret: $STS_CLIENT_SECRET"
+
+ # Check and add user-id mapper if missing
+ MAPPERS=$(curl -sk "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+ USER_ID_MAPPER=$(echo "$MAPPERS" | jq -r '.[] | select(.name == "user-id") | .id')
+
+ if [ -z "$USER_ID_MAPPER" ] || [ "$USER_ID_MAPPER" = "null" ]; then
+ echo " Adding user-id protocol mapper..."
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/protocol-mappers/models" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "user-id",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "config": {
+ "user.attribute": "id",
+ "claim.name": "sub",
+ "jsonType.label": "String",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }' > /dev/null 2>&1
+ echo " ✅ Added user-id mapper for sub claim"
+ else
+ echo " ✅ user-id mapper already configured"
+ fi
+fi
+
+# Create test user
+echo ""
+echo "Creating test user..."
+EXISTING_USER=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users?username=$MCP_USERNAME&exact=true" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+
+USER_ID=$(echo "$EXISTING_USER" | jq -r '.[0].id // empty')
+
+if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
+ curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"username\": \"$MCP_USERNAME\",
+ \"enabled\": true,
+ \"emailVerified\": true,
+ \"email\": \"$MCP_USERNAME@example.com\",
+ \"firstName\": \"MCP\",
+ \"lastName\": \"User\",
+ \"requiredActions\": []
+ }" > /dev/null
+
+ USER_ID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users?username=$MCP_USERNAME&exact=true" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id')
+
+ # Set password
+ curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users/$USER_ID/reset-password" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"type\": \"password\",
+ \"value\": \"$MCP_PASSWORD\",
+ \"temporary\": false
+ }" > /dev/null
+
+ echo " ✅ Created user: $MCP_USERNAME / $MCP_PASSWORD"
+else
+ echo " ✅ User already exists: $MCP_USERNAME"
+fi
+
+# Save configuration
+echo ""
+echo "Saving configuration..."
+mkdir -p .keycloak-config
+cat > .keycloak-config/hub-config.env < /dev/null
+
+# Get the token-exchange permission ID
+MCP_SERVER_PERMS=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/management/permissions" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+TOKEN_EXCHANGE_PERM_ID=$(echo "$MCP_SERVER_PERMS" | jq -r '.scopePermissions."token-exchange"')
+
+# Get realm-management client ID
+REALM_MGMT_ID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=realm-management" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id')
+
+# Create client policy for mcp-sts
+POLICY_RESPONSE=$(curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$REALM_MGMT_ID/authz/resource-server/policy/client" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"type\": \"client\",
+ \"logic\": \"POSITIVE\",
+ \"decisionStrategy\": \"UNANIMOUS\",
+ \"name\": \"allow-mcp-sts-to-exchange-to-mcp-server\",
+ \"description\": \"Allow mcp-sts client to perform token exchange to mcp-server audience\",
+ \"clients\": [\"$STS_CLIENT_UUID\"]
+ }" 2>/dev/null)
+
+STS_POLICY_ID=$(echo "$POLICY_RESPONSE" | jq -r '.id // empty')
+
+if [ -z "$STS_POLICY_ID" ] || [ "$STS_POLICY_ID" = "null" ]; then
+ # Policy might already exist, try to find it
+ ALL_POLICIES=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$REALM_MGMT_ID/authz/resource-server/policy?type=client" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+ STS_POLICY_ID=$(echo "$ALL_POLICIES" | jq -r '.[] | select(.name == "allow-mcp-sts-to-exchange-to-mcp-server") | .id')
+fi
+
+# Link policy to token-exchange permission
+CURRENT_PERM=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$REALM_MGMT_ID/authz/resource-server/permission/$TOKEN_EXCHANGE_PERM_ID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN")
+
+UPDATED_PERM=$(echo "$CURRENT_PERM" | jq --arg policy_id "$STS_POLICY_ID" '. + {policies: [$policy_id]}')
+
+curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$REALM_MGMT_ID/authz/resource-server/permission/$TOKEN_EXCHANGE_PERM_ID" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "$UPDATED_PERM" > /dev/null
+
+echo " ✅ Same-realm token exchange configured (mcp-sts → mcp-server)"
+
+# Step 13: Create RBAC for mcp user on hub cluster
+echo ""
+echo "Step 13: Creating RBAC for mcp user on hub cluster..."
+
+oc apply -f - </dev/null | base64 -d); then
+ if [ -n "$ROUTER_CA" ]; then
+ echo " ✅ Found router CA in openshift-ingress-operator/router-ca"
+ fi
+fi
+
+if [ -z "$ROUTER_CA" ]; then
+ if ROUTER_CA=$(oc get secret router-certs-default -n openshift-ingress -o jsonpath='{.data.tls\.crt}' 2>/dev/null | base64 -d); then
+ if [ -n "$ROUTER_CA" ]; then
+ echo " ✅ Found router CA in openshift-ingress/router-certs-default"
+ fi
+ fi
+fi
+
+if [ -z "$ROUTER_CA" ]; then
+ echo " ⚠️ Could not find router CA certificate, cross-realm token exchange may fail"
+else
+ # Create ConfigMap
+ TEMP_CA=$(mktemp)
+ echo "$ROUTER_CA" > "$TEMP_CA"
+
+ oc create configmap router-ca -n keycloak \
+ --from-file=router-ca.crt="$TEMP_CA" \
+ --dry-run=client -o yaml | oc apply -f -
+
+ rm -f "$TEMP_CA"
+
+ echo " ✅ ConfigMap router-ca created in keycloak namespace"
+
+ # Check if Keycloak deployment needs patching
+ CURRENT_TRUSTSTORE=$(oc get deployment keycloak -n keycloak -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="KC_TRUSTSTORE_PATHS")].value}' 2>/dev/null || echo "")
+
+ if [ "$CURRENT_TRUSTSTORE" = "/ca-certs/router-ca.crt" ]; then
+ echo " ✅ Keycloak already configured with CA trust"
+ else
+ # Patch Keycloak deployment
+ PATCH_JSON=$(cat <<'EOF'
+{
+ "spec": {
+ "template": {
+ "spec": {
+ "containers": [
+ {
+ "name": "keycloak",
+ "env": [
+ {
+ "name": "KC_TRUSTSTORE_PATHS",
+ "value": "/ca-certs/router-ca.crt"
+ }
+ ],
+ "volumeMounts": [
+ {
+ "name": "router-ca",
+ "mountPath": "/ca-certs",
+ "readOnly": true
+ }
+ ]
+ }
+ ],
+ "volumes": [
+ {
+ "name": "router-ca",
+ "configMap": {
+ "name": "router-ca"
+ }
+ }
+ ]
+ }
+ }
+ }
+}
+EOF
+)
+
+ oc patch deployment keycloak -n keycloak --type=strategic --patch "$PATCH_JSON"
+ echo " ✅ Keycloak deployment patched with CA trust"
+ echo " ⏳ Waiting for Keycloak to restart..."
+
+ oc rollout status deployment/keycloak -n keycloak --timeout=5m
+ echo " ✅ Keycloak restarted with CA trust"
+ fi
+fi
+
+echo ""
+echo "=========================================="
+echo "✅ Hub Keycloak Setup Complete!"
+echo "=========================================="
+echo ""
+echo "Configuration Summary:"
+echo " Keycloak URL: $KEYCLOAK_URL"
+echo " Hub Realm: $KEYCLOAK_URL/realms/$HUB_REALM"
+echo ""
+echo " Clients created:"
+echo " - mcp-server (confidential): $CLIENT_SECRET"
+echo " - mcp-client (public OAuth): for browser/inspector flow"
+echo " - mcp-sts (STS): $STS_CLIENT_SECRET"
+echo ""
+echo " Test User: $MCP_USERNAME / $MCP_PASSWORD"
+echo " Admin: $ADMIN_USER / $ADMIN_PASSWORD"
+echo ""
+echo " V1 Features: token-exchange:v1,admin-fine-grained-authz:v1"
+echo " openid Scope: ✅ Configured on all clients"
+echo " sub Claim Mapper: ✅ Configured"
+echo " Token Exchange: ✅ Enabled"
+echo " Same-Realm Exchange: ✅ Configured (mcp-sts → mcp-server)"
+echo ""
+echo "Next Steps:"
+echo " 1. Wait for cluster-bot to be ready"
+echo " 2. Register cluster-bot with:"
+echo " CLUSTER_NAME=cluster-bot MANAGED_KUBECONFIG=/path/to/kubeconfig \\"
+echo " ./hack/acm/acm-register-managed-cluster.sh"
+echo ""
+echo "Test authentication:"
+echo " curl -sk -X POST \"$KEYCLOAK_URL/realms/$HUB_REALM/protocol/openid-connect/token\" \\"
+echo " -d \"grant_type=password\" -d \"client_id=$CLIENT_ID\" \\"
+echo " -d \"client_secret=$CLIENT_SECRET\" -d \"username=$MCP_USERNAME\" \\"
+echo " -d \"password=$MCP_PASSWORD\" -d \"scope=openid $CLIENT_ID\""
+echo ""