diff --git a/Makefile b/Makefile index 04ff1ac0..3d4b1017 100644 --- a/Makefile +++ b/Makefile @@ -153,3 +153,4 @@ local-env-teardown: ## Tear down the local Kind cluster # Include build configuration files -include build/*.mk +-include build/openshift/*.mk diff --git a/build/openshift/keycloak-acm.mk b/build/openshift/keycloak-acm.mk new file mode 100644 index 00000000..ce0a131e --- /dev/null +++ b/build/openshift/keycloak-acm.mk @@ -0,0 +1,89 @@ +# Keycloak ACM Integration for OpenShift +# +# This file contains targets for setting up Keycloak with V1 token exchange +# for ACM multi-cluster environments on OpenShift. +# +# Prerequisites: +# - OpenShift 4.19+ or 4.20+ cluster +# - ACM installed +# - Cluster-admin access +# +# Initial Setup (Hub Only): +# make keycloak-acm-setup-hub # Deploy Keycloak and configure hub realm +# make keycloak-acm-generate-toml # Generate MCP server configuration +# +# Environment Variables: +# HUB_KUBECONFIG - Path to hub cluster kubeconfig (default: $KUBECONFIG) +# KEYCLOAK_URL - Keycloak URL (auto-detected from route if not set) +# ADMIN_USER - Keycloak admin username (default: admin) +# ADMIN_PASSWORD - Keycloak admin password (default: admin) + +##@ Keycloak ACM Integration + +.PHONY: keycloak-acm-setup-hub +keycloak-acm-setup-hub: ## Deploy Keycloak on OpenShift with V1 token exchange for ACM hub + @echo "===========================================" + @echo "Keycloak ACM Hub Setup" + @echo "===========================================" + @echo "" + @echo "This will:" + @echo " 1. Enable TechPreviewNoUpgrade feature gate (if needed)" + @echo " 2. Deploy Keycloak with V1 token exchange features" + @echo " 3. Create hub realm with mcp user and clients" + @echo " 4. Configure same-realm token exchange" + @echo " 5. Fix CA trust for cross-realm token exchange" + @echo " 6. Create RBAC for mcp user" + @echo " 7. Save configuration to .keycloak-config/" + @echo "" + @bash ./hack/keycloak-acm/setup-hub.sh + @echo "" + @echo "✅ Hub Keycloak setup complete!" + @echo "" + @echo "Configuration saved to: .keycloak-config/hub-config.env" + @echo "" + @echo "Next steps:" + @echo " 1. Run: make keycloak-acm-generate-toml" + @echo " 2. Start MCP server with: ./kubernetes-mcp-server --config acm-kubeconfig.toml" + +.PHONY: keycloak-acm-generate-toml +keycloak-acm-generate-toml: ## Generate acm-kubeconfig.toml from saved Keycloak configuration + @echo "===========================================" + @echo "Generating MCP Server Configuration" + @echo "===========================================" + @echo "" + @bash ./hack/keycloak-acm/generate-toml.sh + @echo "" + @echo "Next: Start MCP server with: ./kubernetes-mcp-server --port 8080 --config acm-kubeconfig.toml" + +.PHONY: keycloak-acm-status +keycloak-acm-status: ## Show Keycloak ACM configuration status + @echo "===========================================" + @echo "Keycloak ACM Configuration Status" + @echo "===========================================" + @echo "" + @if [ -f .keycloak-config/hub-config.env ]; then \ + echo "✅ Hub configuration found:"; \ + echo ""; \ + source .keycloak-config/hub-config.env && \ + echo " Keycloak URL: $$KEYCLOAK_URL"; \ + echo " Hub Realm: $$HUB_REALM"; \ + echo " MCP User: $$MCP_USERNAME"; \ + echo ""; \ + kubectl get pods -n keycloak -l app=keycloak 2>/dev/null && echo "" || echo " ⚠️ Keycloak pod not found"; \ + kubectl get route keycloak -n keycloak -o jsonpath='{.spec.host}' 2>/dev/null && echo "" || echo " ⚠️ Keycloak route not found"; \ + else \ + echo "❌ Hub configuration not found"; \ + echo " Run: make keycloak-acm-setup-hub"; \ + fi + @echo "" + @if [ -f acm-kubeconfig.toml ]; then \ + echo "✅ MCP configuration found: acm-kubeconfig.toml"; \ + echo ""; \ + echo "Configured clusters:"; \ + grep '^\[cluster_provider_configs.acm-kubeconfig.clusters' acm-kubeconfig.toml | \ + sed 's/\[cluster_provider_configs.acm-kubeconfig.clusters."\(.*\)"\]/ - \1/'; \ + else \ + echo "❌ MCP configuration not found"; \ + echo " Run: make keycloak-acm-generate-toml"; \ + fi + @echo "" diff --git a/dev/config/openshift/keycloak/README.md b/dev/config/openshift/keycloak/README.md new file mode 100644 index 00000000..9a17eacf --- /dev/null +++ b/dev/config/openshift/keycloak/README.md @@ -0,0 +1,195 @@ +# ACM Keycloak Declarative Configuration + +This directory contains declarative JSON configuration files for setting up Keycloak for ACM (Advanced Cluster Management) multi-realm token exchange. + +## Architecture + +- **Hub Realm**: Central realm where users authenticate +- **Managed Cluster Realms**: One realm per managed cluster +- **Token Exchange**: V1 token exchange using `subject_issuer` parameter + - Same-realm: `mcp-sts` → `mcp-server` within hub realm + - Cross-realm: Hub realm token → Managed cluster realm token + +## Directory Structure + +``` +dev/acm/config/keycloak/ +├── realm/ +│ ├── hub-realm-create.json # Hub realm configuration +│ └── managed-realm-create.json # Template for managed cluster realms +├── clients/ +│ ├── mcp-server.json # OAuth client (confidential) +│ ├── mcp-client.json # Browser OAuth client (public) +│ └── mcp-sts.json # STS client for token exchange +├── client-scopes/ +│ ├── openid.json # OpenID Connect scope +│ └── mcp-server.json # MCP audience scope +├── mappers/ +│ ├── mcp-server-audience-mapper.json # Adds mcp-server to aud claim +│ └── sub-claim-mapper.json # Maps user ID to sub claim +├── users/ +│ └── mcp.json # Test user (mcp/mcp) +└── identity-providers/ + └── hub-realm-idp-template.json # IDP config for cross-realm trust +``` + +## Configuration Files + +### Hub Realm (`realm/hub-realm-create.json`) + +- Realm name: `hub` +- User registration: disabled +- Password reset: enabled +- Brute force protection: enabled +- Token lifespans configured for security + +### Clients + +#### `mcp-server` (Confidential Client) +- Used by MCP server for OAuth authentication +- Direct access grants enabled (password flow) +- Service accounts enabled +- Default scopes: `openid`, `profile`, `email`, `mcp-server` + +#### `mcp-client` (Public Client) +- Used by browser-based tools (e.g., MCP Inspector) +- PKCE enabled for security +- Authorization code flow only +- No service accounts + +#### `mcp-sts` (STS Client) +- Used for token exchange operations +- Service accounts only (no user login) +- No redirect URIs (not for browser flows) + +### Client Scopes + +#### `openid` +- Standard OpenID Connect scope +- Provides basic user claims (sub, iss, aud, exp, iat) + +#### `mcp-server` +- Custom audience scope +- Adds `mcp-server` to the `aud` claim in access tokens +- Required for token validation + +### Protocol Mappers + +#### `mcp-server-audience` +- Type: `oidc-audience-mapper` +- Adds `mcp-server` to the audience claim +- Applied to `mcp-server` client scope + +#### `sub` +- Type: `oidc-sub-mapper` +- Maps user ID to `sub` claim +- Used for federated identity linking + +### Users + +#### `mcp` User +- Username: `mcp` +- Password: `mcp` +- Email: `mcp@example.com` +- Full name: MCP User +- Used for testing and development + +### Identity Provider + +#### Hub Realm IDP Template +- Provider: `oidc` (generic OIDC, not keycloak-oidc) +- Trust email: enabled +- Store token: disabled +- Sync mode: IMPORT (create local users) +- Signature validation: enabled via JWKS URL + +## Variable Substitution + +JSON templates use `${VARIABLE_NAME}` placeholders that are replaced at runtime: + +- `${KEYCLOAK_URL}`: Base Keycloak URL (e.g., `https://keycloak-keycloak.apps.example.com`) +- `${HUB_CLIENT_SECRET}`: Secret for mcp-server client in hub realm +- `${MANAGED_REALM}`: Name of managed cluster realm (e.g., `managed-cluster-one`) + +## Usage + +These JSON files are applied via the Keycloak Admin REST API using the setup scripts: + +1. **Hub Setup**: `hack/acm/acm-keycloak-setup-hub-declarative.sh` + - Creates hub realm + - Creates clients (mcp-server, mcp-client, mcp-sts) + - Creates client scopes (openid, mcp-server) + - Adds protocol mappers + - Creates test user + - Configures same-realm token exchange permissions + +2. **Managed Cluster Registration**: `hack/acm/acm-register-managed-cluster-declarative.sh` + - Creates managed cluster realm + - Registers identity provider (hub realm) + - Creates federated user link + - Configures cross-realm token exchange permissions + +## Token Exchange Configuration + +### Same-Realm Token Exchange (Hub) + +Allows `mcp-sts` client to exchange tokens for `mcp-server` audience within the hub realm. + +**Steps** (applied by setup script): +1. Enable management permissions on `mcp-server` client +2. Get token-exchange permission ID +3. Create client policy allowing `mcp-sts` +4. Link policy to token-exchange permission + +**Test Command**: +```bash +source .keycloak-config/hub-config.env +./hack/acm/test-same-realm-token-exchange.sh +``` + +### Cross-Realm Token Exchange (Hub → Managed) + +Allows exchanging hub realm token for managed cluster realm token. + +**Steps** (applied by setup script): +1. Create identity provider in managed realm pointing to hub realm +2. Create federated identity link (hub user → managed user via `sub` claim) +3. Enable fine-grained permissions on IDP +4. Create client policy allowing hub realm's `mcp-sts` +5. Link policy to token-exchange permission on IDP + +**Test Command**: +```bash +source .keycloak-config/hub-config.env +source .keycloak-config/clusters/managed-cluster-one.env +./hack/acm/test-cross-realm-token-exchange.sh +``` + +## Keycloak Admin API Endpoints + +Configuration is applied using these endpoints: + +- **Realm**: `POST /admin/realms` +- **Clients**: `POST /admin/realms/{realm}/clients` +- **Client Scopes**: `POST /admin/realms/{realm}/client-scopes` +- **Protocol Mappers**: `POST /admin/realms/{realm}/client-scopes/{scope-id}/protocol-mappers/models` +- **Users**: `POST /admin/realms/{realm}/users` +- **Identity Providers**: `POST /admin/realms/{realm}/identity-provider/instances` +- **Client Permissions**: `PUT /admin/realms/{realm}/clients/{client-id}/management/permissions` +- **Authorization Policies**: `POST /admin/realms/{realm}/clients/{client-id}/authz/resource-server/policy/client` + +## Benefits of Declarative Approach + +1. **Version Control**: Configuration as code +2. **Repeatability**: Same configuration every time +3. **Testability**: Easy to test in different environments +4. **Documentation**: Self-documenting via JSON structure +5. **Validation**: JSON schema validation possible +6. **Idempotency**: Can reapply without side effects +7. **Debugging**: Easy to compare configurations + +## References + +- Keycloak Admin REST API: https://www.keycloak.org/docs-api/26.0/rest-api/index.html +- Token Exchange: https://www.keycloak.org/docs/latest/securing_apps/#_token-exchange +- Identity Brokering: https://www.keycloak.org/docs/latest/server_admin/#_identity_broker diff --git a/dev/config/openshift/keycloak/client-scopes/mcp-server.json b/dev/config/openshift/keycloak/client-scopes/mcp-server.json new file mode 100644 index 00000000..565f30be --- /dev/null +++ b/dev/config/openshift/keycloak/client-scopes/mcp-server.json @@ -0,0 +1,9 @@ +{ + "name": "mcp-server", + "description": "MCP Server audience scope", + "protocol": "openid-connect", + "attributes": { + "display.on.consent.screen": "false", + "include.in.token.scope": "true" + } +} diff --git a/dev/config/openshift/keycloak/client-scopes/openid.json b/dev/config/openshift/keycloak/client-scopes/openid.json new file mode 100644 index 00000000..437348b1 --- /dev/null +++ b/dev/config/openshift/keycloak/client-scopes/openid.json @@ -0,0 +1,10 @@ +{ + "name": "openid", + "description": "OpenID Connect scope", + "protocol": "openid-connect", + "attributes": { + "display.on.consent.screen": "true", + "include.in.token.scope": "true", + "consent.screen.text": "${openidScopeConsentText}" + } +} diff --git a/dev/config/openshift/keycloak/clients/mcp-client.json b/dev/config/openshift/keycloak/clients/mcp-client.json new file mode 100644 index 00000000..ef0b5512 --- /dev/null +++ b/dev/config/openshift/keycloak/clients/mcp-client.json @@ -0,0 +1,27 @@ +{ + "clientId": "mcp-client", + "name": "MCP Client", + "description": "Public OAuth client for browser-based authentication (inspector)", + "enabled": true, + "publicClient": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "protocol": "openid-connect", + "redirectUris": ["*"], + "webOrigins": ["*"], + "fullScopeAllowed": false, + "defaultClientScopes": ["openid", "profile", "email", "mcp-server"], + "optionalClientScopes": [], + "attributes": { + "pkce.code.challenge.method": "S256", + "oauth2.device.authorization.grant.enabled": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "display.on.consent.screen": "false" + } +} diff --git a/dev/config/openshift/keycloak/clients/mcp-server.json b/dev/config/openshift/keycloak/clients/mcp-server.json new file mode 100644 index 00000000..99358d9e --- /dev/null +++ b/dev/config/openshift/keycloak/clients/mcp-server.json @@ -0,0 +1,41 @@ +{ + "clientId": "mcp-server", + "name": "MCP Server", + "description": "OAuth client for MCP server authentication", + "enabled": true, + "publicClient": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": false, + "protocol": "openid-connect", + "redirectUris": ["*"], + "webOrigins": ["*"], + "fullScopeAllowed": false, + "defaultClientScopes": ["openid", "profile", "email", "mcp-server"], + "optionalClientScopes": [], + "attributes": { + "oauth2.device.authorization.grant.enabled": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false", + "client.secret.creation.time": "0", + "display.on.consent.screen": "false", + "saml.artifact.binding": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "saml.assertion.signature": "false", + "saml.client.signature": "false", + "saml.encrypt": "false", + "saml.authnstatement": "false", + "saml.onetimeuse.condition": "false", + "saml_force_name_id_format": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "exclude.session.state.from.auth.response": "false", + "tls.client.certificate.bound.access.tokens": "false", + "access.token.lifespan": "300" + } +} diff --git a/dev/config/openshift/keycloak/clients/mcp-sts.json b/dev/config/openshift/keycloak/clients/mcp-sts.json new file mode 100644 index 00000000..09181b6f --- /dev/null +++ b/dev/config/openshift/keycloak/clients/mcp-sts.json @@ -0,0 +1,26 @@ +{ + "clientId": "mcp-sts", + "name": "MCP STS", + "description": "Security Token Service client for token exchange (same-realm and cross-realm)", + "enabled": true, + "publicClient": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": false, + "protocol": "openid-connect", + "redirectUris": [], + "webOrigins": [], + "fullScopeAllowed": false, + "defaultClientScopes": ["openid", "profile", "email"], + "optionalClientScopes": [], + "attributes": { + "oauth2.device.authorization.grant.enabled": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "display.on.consent.screen": "false" + } +} diff --git a/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json b/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json new file mode 100644 index 00000000..6b708e98 --- /dev/null +++ b/dev/config/openshift/keycloak/identity-providers/hub-realm-idp-template.json @@ -0,0 +1,23 @@ +{ + "alias": "hub-realm", + "displayName": "Hub Realm", + "providerId": "oidc", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": true, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "issuer": "${KEYCLOAK_URL}/realms/hub", + "validateSignature": "true", + "useJwksUrl": "true", + "jwksUrl": "${KEYCLOAK_URL}/realms/hub/protocol/openid-connect/certs", + "clientId": "mcp-server", + "clientSecret": "${HUB_CLIENT_SECRET}", + "clientAuthMethod": "client_secret_post", + "syncMode": "IMPORT" + } +} diff --git a/dev/config/openshift/keycloak/mappers/mcp-server-audience-mapper.json b/dev/config/openshift/keycloak/mappers/mcp-server-audience-mapper.json new file mode 100644 index 00000000..35b505fd --- /dev/null +++ b/dev/config/openshift/keycloak/mappers/mcp-server-audience-mapper.json @@ -0,0 +1,12 @@ +{ + "name": "mcp-server-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "mcp-server", + "id.token.claim": "false", + "access.token.claim": "true", + "lightweight.claim": "false" + } +} diff --git a/dev/config/openshift/keycloak/mappers/sub-claim-mapper.json b/dev/config/openshift/keycloak/mappers/sub-claim-mapper.json new file mode 100644 index 00000000..26fc401d --- /dev/null +++ b/dev/config/openshift/keycloak/mappers/sub-claim-mapper.json @@ -0,0 +1,11 @@ +{ + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } +} diff --git a/dev/config/openshift/keycloak/realm/hub-realm-create.json b/dev/config/openshift/keycloak/realm/hub-realm-create.json new file mode 100644 index 00000000..e375407d --- /dev/null +++ b/dev/config/openshift/keycloak/realm/hub-realm-create.json @@ -0,0 +1,74 @@ +{ + "realm": "hub", + "enabled": true, + "displayName": "Hub Realm", + "displayNameHtml": "
Hub Realm
", + "loginTheme": "keycloak", + "accountTheme": "keycloak.v2", + "adminTheme": "keycloak.v2", + "emailTheme": "keycloak", + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "defaultSignatureAlgorithm": "RS256", + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [], + "authenticatorConfig": [], + "requiredActions": [], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaInterval": "5", + "cibaAuthRequestedUserHint": "login_hint", + "parRequestUriLifespan": "60", + "frontendUrl": "", + "acr.loa.map": "{}" + }, + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/dev/config/openshift/keycloak/realm/managed-realm-create.json b/dev/config/openshift/keycloak/realm/managed-realm-create.json new file mode 100644 index 00000000..952936fa --- /dev/null +++ b/dev/config/openshift/keycloak/realm/managed-realm-create.json @@ -0,0 +1,74 @@ +{ + "realm": "managed-cluster-one", + "enabled": true, + "displayName": "Managed Cluster Realm", + "displayNameHtml": "
Managed Cluster
", + "loginTheme": "keycloak", + "accountTheme": "keycloak.v2", + "adminTheme": "keycloak.v2", + "emailTheme": "keycloak", + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "defaultSignatureAlgorithm": "RS256", + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [], + "authenticatorConfig": [], + "requiredActions": [], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaInterval": "5", + "cibaAuthRequestedUserHint": "login_hint", + "parRequestUriLifespan": "60", + "frontendUrl": "", + "acr.loa.map": "{}" + }, + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/dev/config/openshift/keycloak/users/mcp.json b/dev/config/openshift/keycloak/users/mcp.json new file mode 100644 index 00000000..286ff52f --- /dev/null +++ b/dev/config/openshift/keycloak/users/mcp.json @@ -0,0 +1,18 @@ +{ + "username": "mcp", + "enabled": true, + "emailVerified": true, + "firstName": "MCP", + "lastName": "User", + "email": "mcp@example.com", + "credentials": [ + { + "type": "password", + "value": "mcp", + "temporary": false + } + ], + "realmRoles": ["offline_access", "uma_authorization"], + "clientRoles": {}, + "groups": [] +} diff --git a/hack/keycloak-acm/fix-ca-trust.sh b/hack/keycloak-acm/fix-ca-trust.sh new file mode 100755 index 00000000..562e6804 --- /dev/null +++ b/hack/keycloak-acm/fix-ca-trust.sh @@ -0,0 +1,237 @@ +#!/bin/bash +set -euo pipefail + +# Fix Keycloak CA Trust for Same-Instance Cross-Realm Token Exchange +# +# This script configures Keycloak to trust the OpenShift router CA certificate, +# enabling JWKS signature validation for cross-realm token exchange. +# +# Based on: SINGLE_KEYCLOAK_SUCCESS.md + +echo "===========================================" +echo "Fixing Keycloak CA Trust" +echo "===========================================" +echo "" +echo "This will configure Keycloak to trust the OpenShift router CA certificate" +echo "for same-instance cross-realm JWKS validation." +echo "" + +# Check if running on OpenShift +if ! kubectl get route -n keycloak keycloak >/dev/null 2>&1; then + echo "❌ Error: Not running on OpenShift (no route found)" + echo "This script is designed for OpenShift clusters with OpenShift routes" + exit 1 +fi + +echo "Step 1: Extracting OpenShift router CA certificate..." + +# Try different sources for the router CA +ROUTER_CA="" + +# Method 1: Try router-ca from openshift-ingress-operator namespace +if ROUTER_CA=$(kubectl get secret router-ca -n openshift-ingress-operator -o jsonpath='{.data.tls\.crt}' 2>/dev/null | base64 -d); then + if [ -n "$ROUTER_CA" ]; then + echo " ✅ Found router CA in openshift-ingress-operator/router-ca" + fi +fi + +# Method 2: Try router-certs-default from openshift-ingress namespace +if [ -z "$ROUTER_CA" ]; then + if ROUTER_CA=$(kubectl get secret router-certs-default -n openshift-ingress -o jsonpath='{.data.tls\.crt}' 2>/dev/null | base64 -d); then + if [ -n "$ROUTER_CA" ]; then + echo " ✅ Found router CA in openshift-ingress/router-certs-default" + fi + fi +fi + +# Method 3: Extract from ingress controller +if [ -z "$ROUTER_CA" ]; then + INGRESS_CA=$(kubectl get ingresscontroller default -n openshift-ingress-operator -o jsonpath='{.spec.defaultCertificate.name}' 2>/dev/null) + if [ -n "$INGRESS_CA" ]; then + if ROUTER_CA=$(kubectl get secret "$INGRESS_CA" -n openshift-ingress -o jsonpath='{.data.tls\.crt}' 2>/dev/null | base64 -d); then + if [ -n "$ROUTER_CA" ]; then + echo " ✅ Found router CA in openshift-ingress/$INGRESS_CA" + fi + fi + fi +fi + +# Verify we found a CA +if [ -z "$ROUTER_CA" ]; then + echo "❌ Error: Could not find OpenShift router CA certificate" + echo "" + echo "Tried:" + echo " - secret/router-ca in openshift-ingress-operator" + echo " - secret/router-certs-default in openshift-ingress" + echo " - ingress controller default certificate" + exit 1 +fi + +# Verify it's a valid certificate +if ! echo "$ROUTER_CA" | openssl x509 -noout -text >/dev/null 2>&1; then + echo "❌ Error: Invalid CA certificate format" + exit 1 +fi + +# Show certificate info +echo "" +echo "Router CA Certificate Details:" +echo "$ROUTER_CA" | openssl x509 -noout -subject -issuer -dates | sed 's/^/ /' +echo "" + +echo "Step 2: Creating router-ca ConfigMap in keycloak namespace..." + +# Create temporary file +TEMP_CA=$(mktemp) +echo "$ROUTER_CA" > "$TEMP_CA" + +# Create or update ConfigMap +kubectl create configmap router-ca -n keycloak \ + --from-file=router-ca.crt="$TEMP_CA" \ + --dry-run=client -o yaml | kubectl apply -f - + +rm -f "$TEMP_CA" + +echo " ✅ ConfigMap router-ca created/updated" +echo "" + +echo "Step 3: Checking Keycloak deployment..." + +if ! kubectl get deployment keycloak -n keycloak >/dev/null 2>&1; then + echo "❌ Error: Keycloak deployment not found in keycloak namespace" + exit 1 +fi + +echo " ✅ Keycloak deployment found" +echo "" + +echo "Step 4: Patching Keycloak deployment with KC_TRUSTSTORE_PATHS..." + +# Check if already patched +CURRENT_TRUSTSTORE=$(kubectl get deployment keycloak -n keycloak -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="KC_TRUSTSTORE_PATHS")].value}' 2>/dev/null || echo "") + +if [ "$CURRENT_TRUSTSTORE" = "/ca-certs/router-ca.crt" ]; then + echo " ℹ️ KC_TRUSTSTORE_PATHS already configured" + + # Check if volume mount exists + VOLUME_MOUNT=$(kubectl get deployment keycloak -n keycloak -o jsonpath='{.spec.template.spec.containers[0].volumeMounts[?(@.name=="router-ca")].mountPath}' 2>/dev/null || echo "") + + if [ -n "$VOLUME_MOUNT" ]; then + echo " ✅ Volume mount already configured" + echo "" + echo "===========================================" + echo "✅ Keycloak CA Trust Already Configured!" + echo "===========================================" + exit 0 + fi +fi + +# Create patch JSON +PATCH_JSON=$(cat <<'EOF' +{ + "spec": { + "template": { + "spec": { + "containers": [ + { + "name": "keycloak", + "env": [ + { + "name": "KC_TRUSTSTORE_PATHS", + "value": "/ca-certs/router-ca.crt" + } + ], + "volumeMounts": [ + { + "name": "router-ca", + "mountPath": "/ca-certs", + "readOnly": true + } + ] + } + ], + "volumes": [ + { + "name": "router-ca", + "configMap": { + "name": "router-ca" + } + } + ] + } + } + } +} +EOF +) + +# Apply strategic merge patch +kubectl patch deployment keycloak -n keycloak --type=strategic --patch "$PATCH_JSON" + +echo " ✅ Deployment patched" +echo "" + +echo "Step 5: Waiting for Keycloak to restart..." + +# Wait for rollout +if kubectl rollout status deployment/keycloak -n keycloak --timeout=5m; then + echo " ✅ Keycloak rollout complete" +else + echo " ⚠️ Rollout taking longer than expected" + echo " Check status with: kubectl rollout status deployment/keycloak -n keycloak" +fi + +echo "" +echo "Step 6: Verifying Keycloak is ready..." + +# Wait for pod to be ready +for i in {1..30}; do + if kubectl get pods -n keycloak -l app=keycloak -o jsonpath='{.items[0].status.conditions[?(@.type=="Ready")].status}' 2>/dev/null | grep -q "True"; then + echo " ✅ Keycloak pod is ready" + break + fi + if [ $i -eq 30 ]; then + echo " ⚠️ Keycloak pod not ready after 5 minutes" + echo " Check logs with: make keycloak-logs" + exit 1 + fi + sleep 10 +done + +echo "" + +# Get Keycloak URL +KEYCLOAK_URL="https://$(kubectl get route keycloak -n keycloak -o jsonpath='{.spec.host}')" + +# Check Keycloak health +if curl -sk "$KEYCLOAK_URL/health/ready" | grep -q '"status":"UP"'; then + echo " ✅ Keycloak health check passed" +else + echo " ⚠️ Keycloak health check did not return UP" + echo " URL: $KEYCLOAK_URL/health/ready" +fi + +echo "" +echo "===========================================" +echo "✅ Keycloak CA Trust Fixed!" +echo "===========================================" +echo "" +echo "What this enables:" +echo " ✅ Cross-realm JWKS signature validation" +echo " ✅ validateSignature=true in IDP configuration" +echo " ✅ Proper TLS trust for same-instance token exchange" +echo "" +echo "Keycloak Configuration:" +echo " KC_TRUSTSTORE_PATHS: /ca-certs/router-ca.crt" +echo " ConfigMap: router-ca (keycloak namespace)" +echo " Router CA: Imported into JVM truststore" +echo "" +echo "Next steps:" +if [ -f ".keycloak-config/hub-config.env" ]; then + echo " ✅ Hub realm already configured" + echo " → Register managed clusters: make keycloak-acm-register-managed-declarative CLUSTER_NAME=... MANAGED_KUBECONFIG=..." +else + echo " 1. Setup hub realm: make keycloak-acm-setup-hub-declarative" + echo " 2. Register managed clusters: make keycloak-acm-register-managed-declarative CLUSTER_NAME=... MANAGED_KUBECONFIG=..." +fi +echo "" diff --git a/hack/keycloak-acm/generate-toml.sh b/hack/keycloak-acm/generate-toml.sh new file mode 100755 index 00000000..c28b58b7 --- /dev/null +++ b/hack/keycloak-acm/generate-toml.sh @@ -0,0 +1,184 @@ +#!/bin/bash +set -eo pipefail + +# Generate acm-kubeconfig.toml from Keycloak configuration files +# +# This script reads from: +# - .keycloak-config/hub-config.env +# - .keycloak-config/clusters/*.env +# +# And generates: acm-kubeconfig.toml + +OUTPUT_FILE="acm-kubeconfig.toml" + +echo "===========================================" +echo "Generating acm-kubeconfig.toml" +echo "===========================================" +echo "" + +# Check if hub config exists +if [ ! -f ".keycloak-config/hub-config.env" ]; then + echo "❌ Error: Hub configuration not found" + echo " Run: make keycloak-acm-setup-hub" + exit 1 +fi + +# Load hub config +source .keycloak-config/hub-config.env + +# Detect hub kubeconfig +if [ -n "${HUB_KUBECONFIG:-}" ]; then + HUB_KUBECONFIG_PATH="$HUB_KUBECONFIG" +elif [ -n "${KUBECONFIG:-}" ]; then + HUB_KUBECONFIG_PATH="$KUBECONFIG" +else + HUB_KUBECONFIG_PATH="$HOME/.kube/config" +fi + +# Detect context name from kubeconfig +if [ -f "$HUB_KUBECONFIG_PATH" ]; then + CONTEXT_NAME=$(kubectl --kubeconfig="$HUB_KUBECONFIG_PATH" config current-context 2>/dev/null || echo "admin") +else + CONTEXT_NAME="admin" +fi + +echo "Configuration:" +echo " Hub Kubeconfig: $HUB_KUBECONFIG_PATH" +echo " Context Name: $CONTEXT_NAME" +echo " Keycloak URL: $KEYCLOAK_URL" +echo " Hub Realm: $HUB_REALM" +echo "" + +# Count managed clusters +CLUSTER_COUNT=$(ls -1 .keycloak-config/clusters/*.env 2>/dev/null | wc -l) +echo " Managed Clusters: $CLUSTER_COUNT" +echo "" + +# Generate TOML file +cat > "$OUTPUT_FILE" < "$CA_FILE" 2>/dev/null; then + echo " ✅ Keycloak CA extracted to $CA_FILE" + cat >> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" <> "$OUTPUT_FILE" +echo "" >> "$OUTPUT_FILE" + +# Always add local-cluster (hub itself) with same-realm token exchange +echo "Adding local-cluster (hub itself)..." +cat >> "$OUTPUT_FILE" <> "$OUTPUT_FILE" </dev/null 2>&1; then + echo "✅ Namespace $KEYCLOAK_NAMESPACE already exists" +else + oc create namespace "$KEYCLOAK_NAMESPACE" + echo "✅ Namespace $KEYCLOAK_NAMESPACE created" +fi + +# Deploy PostgreSQL +echo "" +echo "Deploying PostgreSQL..." + +# Check if PostgreSQL secret already exists +if oc get secret postgresql-credentials -n "$KEYCLOAK_NAMESPACE" >/dev/null 2>&1; then + echo " Using existing PostgreSQL credentials" + POSTGRESQL_PASSWORD=$(oc get secret postgresql-credentials -n "$KEYCLOAK_NAMESPACE" -o jsonpath='{.data.POSTGRESQL_PASSWORD}' | base64 -d) +else + echo " Generating new PostgreSQL credentials" + POSTGRESQL_PASSWORD="$(openssl rand -base64 24 | tr -d '=+/' | cut -c1-24)" +fi + +cat </dev/null || echo "000") + if [ "$STATUS" = "200" ]; then + echo "✅ Keycloak HTTP endpoint ready" + break + fi + echo " Attempt $i/30: Waiting (status: $STATUS)..." + sleep 5 +done + +if [ "$STATUS" != "200" ]; then + echo "❌ Keycloak endpoint not responding" + exit 1 +fi + +#============================================================================= +# STEP 2: Configure OpenShift Authentication CR +#============================================================================= +echo "" +echo "========================================" +echo "STEP 2: Configuring OpenShift Authentication" +echo "========================================" +echo "" + +echo "Enabling TechPreviewNoUpgrade feature gate..." +CURRENT_FEATURE_SET=$(oc get featuregate cluster -o jsonpath='{.spec.featureSet}' 2>/dev/null || echo "") +if [ "$CURRENT_FEATURE_SET" != "TechPreviewNoUpgrade" ]; then + echo " Enabling TechPreviewNoUpgrade..." + oc patch featuregate cluster --type=merge -p='{"spec":{"featureSet":"TechPreviewNoUpgrade"}}' + echo " ✅ Feature gate enabled" + echo " ⚠️ Control plane will restart (10-15 minutes)" + echo " ⚠️ Waiting 2 minutes for initial rollout..." + sleep 120 +else + echo " ✅ TechPreviewNoUpgrade already enabled" +fi + +echo "" +echo "Waiting for kube-apiserver..." +for i in $(seq 1 30); do + if oc wait --for=condition=Available --timeout=10s clusteroperator/kube-apiserver 2>/dev/null; then + echo " ✅ kube-apiserver is ready" + break + fi + echo " Waiting for kube-apiserver (attempt $i/30)..." + sleep 10 +done + +echo "" +echo "Configuring OIDC provider CA certificate..." +kubectl get configmap -n openshift-config-managed default-ingress-cert -o jsonpath='{.data.ca-bundle\.crt}' > /tmp/keycloak-ca.crt +echo " Extracted OpenShift ingress CA ($(wc -l < /tmp/keycloak-ca.crt) lines)" +oc delete configmap keycloak-oidc-ca -n openshift-config 2>/dev/null || true +oc create configmap keycloak-oidc-ca -n openshift-config --from-file=ca-bundle.crt=/tmp/keycloak-ca.crt +echo " ✅ CA certificate configmap created" + +echo "" +echo "Configuring OIDC provider..." +ISSUER_URL="$KEYCLOAK_URL/realms/$HUB_REALM" +echo " Issuer URL: $ISSUER_URL" +echo " Audiences: openshift, $CLIENT_ID" + +CURRENT_ISSUER=$(oc get authentication.config.openshift.io/cluster -o jsonpath='{.spec.oidcProviders[0].issuer.issuerURL}' 2>/dev/null || echo "") +if [ "$CURRENT_ISSUER" = "$ISSUER_URL" ]; then + echo " ✅ OIDC provider already configured" +else + if [ -n "$CURRENT_ISSUER" ]; then + echo " Updating existing OIDC provider..." + printf '[{"op":"replace","path":"/spec/oidcProviders/0/issuer/issuerURL","value":"%s"},{"op":"replace","path":"/spec/oidcProviders/0/issuer/audiences","value":["openshift","%s"]}]' "$ISSUER_URL" "$CLIENT_ID" > /tmp/oidc-patch.json + else + echo " Creating new OIDC provider..." + printf '[{"op":"remove","path":"/spec/webhookTokenAuthenticator"},{"op":"replace","path":"/spec/type","value":"OIDC"},{"op":"add","path":"/spec/oidcProviders","value":[{"name":"keycloak","issuer":{"issuerURL":"%s","audiences":["openshift","%s"],"issuerCertificateAuthority":{"name":"keycloak-oidc-ca"}},"claimMappings":{"username":{"claim":"preferred_username","prefixPolicy":"NoPrefix"}}}]}]' "$ISSUER_URL" "$CLIENT_ID" > /tmp/oidc-patch.json + fi + oc patch authentication.config.openshift.io/cluster --type=json -p="$(cat /tmp/oidc-patch.json)" + echo " ✅ Authentication CR configured" + echo "" + echo " ⚠️ IMPORTANT: kube-apiserver will now roll out with OIDC configuration" + echo " This takes 10-15 minutes as each master node updates sequentially." + echo "" + echo " You can monitor the rollout with:" + echo " oc get co kube-apiserver -w" + echo "" + echo " The MCP server will not be able to authenticate until the rollout completes." + echo " Wait until all conditions show: Available=True, Progressing=False, Degraded=False" + echo "" +fi + +#============================================================================= +# STEP 3: Create Hub Realm +#============================================================================= +echo "" +echo "========================================" +echo "STEP 3: Creating Hub Realm" +echo "========================================" +echo "" + +# Get admin token +echo "Getting admin token..." +ADMIN_TOKEN=$(curl -sk -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=$ADMIN_USER" \ + -d "password=$ADMIN_PASSWORD" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" | jq -r '.access_token') + +if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then + echo "❌ Failed to get admin token" + exit 1 +fi +echo "✅ Got admin token" + +# Create hub realm +echo "" +echo "Creating hub realm..." +EXISTING_REALM=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM" \ + -H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null) + +# Check if realm exists by looking for the .realm field (not just valid JSON) +if echo "$EXISTING_REALM" | jq -e '.realm' > /dev/null 2>&1; then + echo " ✅ Hub realm already exists: $HUB_REALM" +else + echo " Creating hub realm..." + curl -sk -X POST "$KEYCLOAK_URL/admin/realms" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"realm\": \"$HUB_REALM\", + \"enabled\": true, + \"displayName\": \"Hub Cluster Realm\", + \"accessTokenLifespan\": 3600 + }" > /dev/null + echo " ✅ Created hub realm: $HUB_REALM" +fi + +# Create client scopes (openid and mcp-server) +echo "" +echo "Creating client scopes..." + +# Check if client-scopes endpoint is ready +SCOPES_RESPONSE=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + +# Check if response is valid JSON array (not an error object) +# We check if it's an array by attempting to get its length +SCOPES_COUNT=$(echo "$SCOPES_RESPONSE" | jq 'if type == "array" then length else -1 end' 2>/dev/null || echo "-1") + +if [ "$SCOPES_COUNT" = "-1" ]; then + echo " ⚠️ Realm may not be fully ready, waiting 5 seconds..." + sleep 5 + SCOPES_RESPONSE=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + + # Check again after retry + SCOPES_COUNT=$(echo "$SCOPES_RESPONSE" | jq 'if type == "array" then length else -1 end' 2>/dev/null || echo "-1") + if [ "$SCOPES_COUNT" = "-1" ]; then + echo " ❌ Failed to get client scopes from Keycloak" + echo " Response: $SCOPES_RESPONSE" + exit 1 + fi +fi + +# Create openid scope +OPENID_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "openid") | .id // empty') + +if [ -z "$OPENID_SCOPE_UUID" ] || [ "$OPENID_SCOPE_UUID" = "null" ]; then + curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "openid", + "description": "OpenID Connect scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + } + }' > /dev/null + + # Wait a moment for scope to be created, then fetch UUID + sleep 2 + SCOPES_RESPONSE=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + OPENID_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "openid") | .id // empty') + echo " ✅ Created openid scope" +else + echo " ✅ openid scope already exists" +fi + +# Create mcp-server scope (for audience validation) +MCP_SERVER_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "mcp-server") | .id // empty') + +if [ -z "$MCP_SERVER_SCOPE_UUID" ] || [ "$MCP_SERVER_SCOPE_UUID" = "null" ]; then + curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "mcp-server", + "description": "MCP Server audience scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + } + }' > /dev/null + + # Wait and fetch UUID + sleep 2 + SCOPES_RESPONSE=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/client-scopes" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + MCP_SERVER_SCOPE_UUID=$(echo "$SCOPES_RESPONSE" | jq -r '.[] | select(.name == "mcp-server") | .id // empty') + echo " ✅ Created mcp-server scope" +else + echo " ✅ mcp-server scope already exists" +fi + +# Create mcp-server client +echo "" +echo "Creating mcp-server client..." +CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=$CLIENT_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id // empty') + +if [ -z "$CLIENT_UUID" ] || [ "$CLIENT_UUID" = "null" ]; then + CLIENT_SECRET=$(openssl rand -hex 32) + + curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"$CLIENT_ID\", + \"enabled\": true, + \"protocol\": \"openid-connect\", + \"publicClient\": false, + \"directAccessGrantsEnabled\": true, + \"serviceAccountsEnabled\": true, + \"standardFlowEnabled\": true, + \"secret\": \"$CLIENT_SECRET\", + \"redirectUris\": [\"http://localhost:*\", \"https://*\"], + \"webOrigins\": [\"*\"], + \"attributes\": { + \"token.exchange.grant.enabled\": \"true\" + } + }" > /dev/null + + CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=$CLIENT_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id') + + echo " ✅ Created client: $CLIENT_ID" + echo " 📝 Client Secret: $CLIENT_SECRET" +else + echo " ✅ Client already exists: $CLIENT_UUID" + CLIENT_SECRET=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/client-secret" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.value') + echo " 📝 Client Secret: $CLIENT_SECRET" +fi + +# Add scopes to mcp-server client +echo "" +echo "Adding scopes to mcp-server client..." +curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/default-client-scopes/$OPENID_SCOPE_UUID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 +curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/default-client-scopes/$MCP_SERVER_SCOPE_UUID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 +echo "✅ Scopes added (openid, mcp-server)" + +# Add sub claim mapper +echo "" +echo "Creating sub claim mapper..." +EXISTING_SUB_MAPPER=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/protocol-mappers/models" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.name == "sub") | .id // empty') + +if [ -z "$EXISTING_SUB_MAPPER" ]; then + curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/protocol-mappers/models" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }' > /dev/null + echo " ✅ Created sub claim mapper" +else + echo " ✅ sub claim mapper already exists" +fi + +# Create mcp-client (public OAuth client for inspector/browser flow) +echo "" +echo "Creating mcp-client (public OAuth client)..." +MCP_CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=mcp-client" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id // empty') + +if [ -z "$MCP_CLIENT_UUID" ] || [ "$MCP_CLIENT_UUID" = "null" ]; then + curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "mcp-client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "directAccessGrantsEnabled": true, + "standardFlowEnabled": true, + "redirectUris": ["http://localhost:*"], + "webOrigins": ["*"] + }' > /dev/null + + MCP_CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=mcp-client" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id') + + # Add scopes to mcp-client + curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$MCP_CLIENT_UUID/default-client-scopes/$OPENID_SCOPE_UUID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 + curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$MCP_CLIENT_UUID/default-client-scopes/$MCP_SERVER_SCOPE_UUID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 + + # Add audience mapper to include mcp-server in aud claim + curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$MCP_CLIENT_UUID/protocol-mappers/models" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "mcp-server-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "mcp-server", + "id.token.claim": "true", + "access.token.claim": "true" + } + }' > /dev/null 2>&1 + + echo " ✅ Created mcp-client (public OAuth client)" +else + echo " ✅ mcp-client already exists" +fi + +# Create mcp-sts client (for token exchange) +echo "" +echo "Creating mcp-sts client (for token exchange)..." +STS_CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=mcp-sts" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id // empty') + +if [ -z "$STS_CLIENT_UUID" ] || [ "$STS_CLIENT_UUID" = "null" ]; then + STS_CLIENT_SECRET=$(openssl rand -hex 32) + + curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"mcp-sts\", + \"enabled\": true, + \"protocol\": \"openid-connect\", + \"publicClient\": false, + \"directAccessGrantsEnabled\": true, + \"serviceAccountsEnabled\": true, + \"standardFlowEnabled\": false, + \"secret\": \"$STS_CLIENT_SECRET\", + \"attributes\": { + \"token.exchange.grant.enabled\": \"true\" + } + }" > /dev/null + + STS_CLIENT_UUID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=mcp-sts" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id') + + # Add scopes to mcp-sts + curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/default-client-scopes/$OPENID_SCOPE_UUID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 + curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/default-client-scopes/$MCP_SERVER_SCOPE_UUID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null 2>&1 + + echo " ✅ Created mcp-sts client" + echo " 📝 STS Client Secret: $STS_CLIENT_SECRET" +else + echo " ✅ mcp-sts client already exists" + STS_CLIENT_SECRET=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$STS_CLIENT_UUID/client-secret" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.value') + echo " 📝 STS Client Secret: $STS_CLIENT_SECRET" +fi + +# Create test user +echo "" +echo "Creating test user..." +EXISTING_USER=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users?username=$MCP_USERNAME&exact=true" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + +USER_ID=$(echo "$EXISTING_USER" | jq -r '.[0].id // empty') + +if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then + curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"$MCP_USERNAME\", + \"enabled\": true, + \"emailVerified\": true, + \"email\": \"$MCP_USERNAME@example.com\", + \"firstName\": \"MCP\", + \"lastName\": \"User\", + \"requiredActions\": [] + }" > /dev/null + + USER_ID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users?username=$MCP_USERNAME&exact=true" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id') + + # Set password + curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/users/$USER_ID/reset-password" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"type\": \"password\", + \"value\": \"$MCP_PASSWORD\", + \"temporary\": false + }" > /dev/null + + echo " ✅ Created user: $MCP_USERNAME / $MCP_PASSWORD" +else + echo " ✅ User already exists: $MCP_USERNAME" +fi + +# Save configuration +echo "" +echo "Saving configuration..." +mkdir -p .keycloak-config +cat > .keycloak-config/hub-config.env < /dev/null + +# Get the token-exchange permission ID +MCP_SERVER_PERMS=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$CLIENT_UUID/management/permissions" \ + -H "Authorization: Bearer $ADMIN_TOKEN") +TOKEN_EXCHANGE_PERM_ID=$(echo "$MCP_SERVER_PERMS" | jq -r '.scopePermissions."token-exchange"') + +# Get realm-management client ID +REALM_MGMT_ID=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients?clientId=realm-management" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id') + +# Create client policy for mcp-sts +POLICY_RESPONSE=$(curl -sk -X POST "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$REALM_MGMT_ID/authz/resource-server/policy/client" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"type\": \"client\", + \"logic\": \"POSITIVE\", + \"decisionStrategy\": \"UNANIMOUS\", + \"name\": \"allow-mcp-sts-to-exchange-to-mcp-server\", + \"description\": \"Allow mcp-sts client to perform token exchange to mcp-server audience\", + \"clients\": [\"$STS_CLIENT_UUID\"] + }" 2>/dev/null) + +STS_POLICY_ID=$(echo "$POLICY_RESPONSE" | jq -r '.id // empty') + +if [ -z "$STS_POLICY_ID" ] || [ "$STS_POLICY_ID" = "null" ]; then + # Policy might already exist, try to find it + ALL_POLICIES=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$REALM_MGMT_ID/authz/resource-server/policy?type=client" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + STS_POLICY_ID=$(echo "$ALL_POLICIES" | jq -r '.[] | select(.name == "allow-mcp-sts-to-exchange-to-mcp-server") | .id') +fi + +# Link policy to token-exchange permission +CURRENT_PERM=$(curl -sk -X GET "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$REALM_MGMT_ID/authz/resource-server/permission/$TOKEN_EXCHANGE_PERM_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN") + +UPDATED_PERM=$(echo "$CURRENT_PERM" | jq --arg policy_id "$STS_POLICY_ID" '. + {policies: [$policy_id]}') + +curl -sk -X PUT "$KEYCLOAK_URL/admin/realms/$HUB_REALM/clients/$REALM_MGMT_ID/authz/resource-server/permission/$TOKEN_EXCHANGE_PERM_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$UPDATED_PERM" > /dev/null + +echo " ✅ Same-realm token exchange configured (mcp-sts → mcp-server)" + +# Step 13: Create RBAC for mcp user on hub cluster +echo "" +echo "Step 13: Creating RBAC for mcp user on hub cluster..." + +oc apply -f - </dev/null | base64 -d); then + if [ -n "$ROUTER_CA" ]; then + echo " ✅ Found router CA in openshift-ingress-operator/router-ca" + fi +fi + +if [ -z "$ROUTER_CA" ]; then + if ROUTER_CA=$(oc get secret router-certs-default -n openshift-ingress -o jsonpath='{.data.tls\.crt}' 2>/dev/null | base64 -d); then + if [ -n "$ROUTER_CA" ]; then + echo " ✅ Found router CA in openshift-ingress/router-certs-default" + fi + fi +fi + +if [ -z "$ROUTER_CA" ]; then + echo " ⚠️ Could not find router CA certificate, cross-realm token exchange may fail" +else + # Create ConfigMap + TEMP_CA=$(mktemp) + echo "$ROUTER_CA" > "$TEMP_CA" + + oc create configmap router-ca -n keycloak \ + --from-file=router-ca.crt="$TEMP_CA" \ + --dry-run=client -o yaml | oc apply -f - + + rm -f "$TEMP_CA" + + echo " ✅ ConfigMap router-ca created in keycloak namespace" + + # Check if Keycloak deployment needs patching + CURRENT_TRUSTSTORE=$(oc get deployment keycloak -n keycloak -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="KC_TRUSTSTORE_PATHS")].value}' 2>/dev/null || echo "") + + if [ "$CURRENT_TRUSTSTORE" = "/ca-certs/router-ca.crt" ]; then + echo " ✅ Keycloak already configured with CA trust" + else + # Patch Keycloak deployment + PATCH_JSON=$(cat <<'EOF' +{ + "spec": { + "template": { + "spec": { + "containers": [ + { + "name": "keycloak", + "env": [ + { + "name": "KC_TRUSTSTORE_PATHS", + "value": "/ca-certs/router-ca.crt" + } + ], + "volumeMounts": [ + { + "name": "router-ca", + "mountPath": "/ca-certs", + "readOnly": true + } + ] + } + ], + "volumes": [ + { + "name": "router-ca", + "configMap": { + "name": "router-ca" + } + } + ] + } + } + } +} +EOF +) + + oc patch deployment keycloak -n keycloak --type=strategic --patch "$PATCH_JSON" + echo " ✅ Keycloak deployment patched with CA trust" + echo " ⏳ Waiting for Keycloak to restart..." + + oc rollout status deployment/keycloak -n keycloak --timeout=5m + echo " ✅ Keycloak restarted with CA trust" + fi +fi + +echo "" +echo "==========================================" +echo "✅ Hub Keycloak Setup Complete!" +echo "==========================================" +echo "" +echo "Configuration Summary:" +echo " Keycloak URL: $KEYCLOAK_URL" +echo " Hub Realm: $KEYCLOAK_URL/realms/$HUB_REALM" +echo "" +echo " Clients created:" +echo " - mcp-server (confidential): $CLIENT_SECRET" +echo " - mcp-client (public OAuth): for browser/inspector flow" +echo " - mcp-sts (STS): $STS_CLIENT_SECRET" +echo "" +echo " Test User: $MCP_USERNAME / $MCP_PASSWORD" +echo " Admin: $ADMIN_USER / $ADMIN_PASSWORD" +echo "" +echo " V1 Features: token-exchange:v1,admin-fine-grained-authz:v1" +echo " openid Scope: ✅ Configured on all clients" +echo " sub Claim Mapper: ✅ Configured" +echo " Token Exchange: ✅ Enabled" +echo " Same-Realm Exchange: ✅ Configured (mcp-sts → mcp-server)" +echo "" +echo "Next Steps:" +echo " 1. Wait for cluster-bot to be ready" +echo " 2. Register cluster-bot with:" +echo " CLUSTER_NAME=cluster-bot MANAGED_KUBECONFIG=/path/to/kubeconfig \\" +echo " ./hack/acm/acm-register-managed-cluster.sh" +echo "" +echo "Test authentication:" +echo " curl -sk -X POST \"$KEYCLOAK_URL/realms/$HUB_REALM/protocol/openid-connect/token\" \\" +echo " -d \"grant_type=password\" -d \"client_id=$CLIENT_ID\" \\" +echo " -d \"client_secret=$CLIENT_SECRET\" -d \"username=$MCP_USERNAME\" \\" +echo " -d \"password=$MCP_PASSWORD\" -d \"scope=openid $CLIENT_ID\"" +echo ""