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 ""