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..ce0a131e
--- /dev/null
+++ b/build/openshift/keycloak-acm.mk
@@ -0,0 +1,89 @@
+# 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 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 acm-kubeconfig.toml"
+
+.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 \
+ echo "✅ Hub configuration found:"; \
+ echo ""; \
+ source .keycloak-config/hub-config.env && \
+ echo " Keycloak URL: $$KEYCLOAK_URL"; \
+ echo " Hub Realm: $$HUB_REALM"; \
+ echo " MCP User: $$MCP_USERNAME"; \
+ echo ""; \
+ kubectl get pods -n keycloak -l app=keycloak 2>/dev/null && echo "" || echo " ⚠️ Keycloak pod not found"; \
+ kubectl get route keycloak -n keycloak -o jsonpath='{.spec.host}' 2>/dev/null && echo "" || echo " ⚠️ Keycloak route not found"; \
+ else \
+ echo "❌ Hub configuration not found"; \
+ echo " Run: make keycloak-acm-setup-hub"; \
+ fi
+ @echo ""
+ @if [ -f acm-kubeconfig.toml ]; then \
+ echo "✅ MCP configuration found: acm-kubeconfig.toml"; \
+ echo ""; \
+ echo "Configured clusters:"; \
+ grep '^\[cluster_provider_configs.acm-kubeconfig.clusters' acm-kubeconfig.toml | \
+ sed 's/\[cluster_provider_configs.acm-kubeconfig.clusters."\(.*\)"\]/ - \1/'; \
+ else \
+ echo "❌ MCP configuration not found"; \
+ echo " Run: make keycloak-acm-generate-toml"; \
+ fi
+ @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/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/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/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..c28b58b7
--- /dev/null
+++ b/hack/keycloak-acm/generate-toml.sh
@@ -0,0 +1,184 @@
+#!/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: acm-kubeconfig.toml
+
+OUTPUT_FILE="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" </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
+
+cat </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"
+else
+ echo " ✅ mcp-server scope already exists"
+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"
+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"
+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 ""