# Bitnami Keycloak Helm Chart Values # Chart version: 25.2.0 image: repository: bitnamilegacy/keycloak production: true proxyHeaders: xforwarded auth: adminUser: admin existingSecret: keycloak-credentials passwordSecretKey: admin-password ingress: enabled: true tls: true ingressClassName: traefik annotations: cert-manager.io/cluster-issuer: letsencrypt-prod metrics: enabled: true prometheusRule: namespace: monitoring enabled: true resources: requests: cpu: 250m memory: 512Mi limits: cpu: 500m memory: 1Gi postgresql: enabled: true image: repository: bitnamilegacy/postgresql auth: existingSecret: keycloak-credentials secretKeys: adminPasswordKey: postgres-password userPasswordKey: password username: bn_keycloak database: bitnami_keycloak primary: persistence: size: 8Gi keycloakConfigCli: enabled: true image: repository: bitnamilegacy/keycloak-config-cli configuration: forte-realm.json: | { "realm": "forte", "enabled": true, "displayName": "Forte", "sslRequired": "external", "registrationAllowed": false, "loginWithEmailAllowed": true, "resetPasswordAllowed": true, "rememberMe": true, "clients": [ { "clientId": "gitea", "name": "Gitea", "enabled": true, "protocol": "openid-connect", "clientAuthenticatorType": "client-secret", "standardFlowEnabled": true, "directAccessGrantsEnabled": false, "publicClient": false, "redirectUris": ["https://git.forteapps.net/*"], "webOrigins": ["https://git.forteapps.net"], "defaultClientScopes": ["basic", "openid", "email", "profile"], "attributes": { "k8s.secret.sync": "true", "k8s.secret.namespace": "gitea", "k8s.secret.name": "gitea-oidc-credentials", "k8s.secret.client-id-key": "key", "k8s.secret.client-secret-key": "secret" }, "protocolMappers": [ { "name": "email_verified", "protocol": "openid-connect", "protocolMapper": "oidc-hardcoded-claim-mapper", "config": { "claim.name": "email_verified", "claim.value": "true", "jsonType.label": "boolean", "id.token.claim": "true", "access.token.claim": "true", "userinfo.token.claim": "true" } } ] } ] } extraDeploy: # -- ServiceAccount for the client registrar CronJob - apiVersion: v1 kind: ServiceAccount metadata: name: keycloak-client-registrar namespace: keycloak # -- ClusterRole granting access to secrets and namespaces - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: keycloak-client-registrar rules: - apiGroups: [""] resources: ["secrets"] verbs: ["get", "list", "create", "update", "patch"] - apiGroups: [""] resources: ["namespaces"] verbs: ["get", "list"] # -- ClusterRoleBinding for the registrar ServiceAccount - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: keycloak-client-registrar roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: keycloak-client-registrar subjects: - kind: ServiceAccount name: keycloak-client-registrar namespace: keycloak # -- CronJob: registers Keycloak clients and syncs secrets - apiVersion: batch/v1 kind: CronJob metadata: name: keycloak-client-registrar namespace: keycloak spec: schedule: "*/2 * * * *" concurrencyPolicy: Forbid successfulJobsHistoryLimit: 1 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 3 template: spec: serviceAccountName: keycloak-client-registrar restartPolicy: Never containers: - name: registrar image: alpine:3.20 command: ["/bin/sh", "-c"] args: - | set -e apk add --no-cache curl jq > /dev/null 2>&1 KEYCLOAK_URL="http://keycloak:80" REALM="forte" K8S_API="https://kubernetes.default.svc" SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" CENTRAL_NS="secrets" # --- Authenticate to Keycloak Admin API --- ADMIN_USER="admin" ADMIN_PASS=$(cat /secrets/admin-password) echo "Authenticating to Keycloak..." TOKEN=$(curl -sf -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ -d "client_id=admin-cli" \ -d "username=${ADMIN_USER}" \ -d "password=${ADMIN_PASS}" \ -d "grant_type=password" | jq -r '.access_token') if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then echo "ERROR: Failed to authenticate to Keycloak" exit 1 fi # --- Helper functions --- # Upsert a K8s Secret: try PUT (update), fall back to POST (create) upsert_secret() { local ns="$1" name="$2" manifest="$3" local code code=$(curl -sf -o /dev/null -w "%{http_code}" \ --cacert "$CA_CERT" \ -H "Authorization: Bearer ${SA_TOKEN}" \ -H "Content-Type: application/json" \ -X PUT -d "$manifest" \ "${K8S_API}/api/v1/namespaces/${ns}/secrets/${name}") if [ "$code" = "200" ]; then echo " Updated secret '${ns}/${name}'" elif [ "$code" = "404" ]; then code=$(curl -sf -o /dev/null -w "%{http_code}" \ --cacert "$CA_CERT" \ -H "Authorization: Bearer ${SA_TOKEN}" \ -H "Content-Type: application/json" \ -X POST -d "$manifest" \ "${K8S_API}/api/v1/namespaces/${ns}/secrets") if [ "$code" = "201" ]; then echo " Created secret '${ns}/${name}'" else echo " ERROR: Failed to create secret '${ns}/${name}' (HTTP ${code})" return 1 fi else echo " ERROR: Failed to update secret '${ns}/${name}' (HTTP ${code})" return 1 fi } # Build a credential Secret JSON manifest build_credential_secret() { local ns="$1" name="$2" id_key="$3" secret_key="$4" b64_id="$5" b64_secret="$6" cat < '${TARGET_NS}/${TARGET_NAME}' (keys: ${ID_KEY}, ${SECRET_KEY})" sync_credentials "$CLIENT_ID" "$CLIENT_UUID" "$TARGET_NS" "$TARGET_NAME" "$ID_KEY" "$SECRET_KEY" done # ============================================= # NEW PATH — self-service config Secrets # ============================================= echo "" echo "=== Self-service: config Secrets with label keycloak.forteapps.net/client-config=true ===" CONFIG_SECRETS=$(curl -sf \ --cacert "$CA_CERT" \ -H "Authorization: Bearer ${SA_TOKEN}" \ "${K8S_API}/api/v1/namespaces/keycloak/secrets?labelSelector=keycloak.forteapps.net/client-config=true") CONFIG_COUNT=$(echo "$CONFIG_SECRETS" | jq '.items | length') echo "Found ${CONFIG_COUNT} config Secret(s) to process" echo "$CONFIG_SECRETS" | jq -c '.items[]' | while read -r CONFIG_SECRET; do CONFIG_NAME=$(echo "$CONFIG_SECRET" | jq -r '.metadata.name') SOURCE_NS=$(echo "$CONFIG_SECRET" | jq -r '.metadata.annotations["keycloak.forteapps.net/source-namespace"] // .metadata.labels["keycloak.forteapps.net/source-namespace"] // "unknown"') # Decode client.json from the Secret data CLIENT_JSON_B64=$(echo "$CONFIG_SECRET" | jq -r '.data["client.json"] // empty') if [ -z "$CLIENT_JSON_B64" ]; then echo "WARNING: Config Secret '${CONFIG_NAME}' missing client.json field, skipping" continue fi CLIENT_JSON=$(printf '%s' "$CLIENT_JSON_B64" | base64 -d) CLIENT_ID=$(echo "$CLIENT_JSON" | jq -r '.clientId') echo "Processing self-service client '${CLIENT_ID}' from config '${CONFIG_NAME}'" # Compute config hash for change detection CONFIG_HASH=$(printf '%s' "$CLIENT_JSON" | sha256sum | cut -d' ' -f1) EXISTING_HASH=$(echo "$CONFIG_SECRET" | jq -r '.metadata.annotations["keycloak.forteapps.net/config-hash"] // ""') # Extract secret delivery config from client.json CRED_NS=$(echo "$CLIENT_JSON" | jq -r '.secret.namespace // "'"${SOURCE_NS}"'"') CRED_NAME=$(echo "$CLIENT_JSON" | jq -r '.secret.name // "'"${CLIENT_ID}"'-oidc-credentials"') CRED_ID_KEY=$(echo "$CLIENT_JSON" | jq -r '.secret.keys.clientId // "client-id"') CRED_SECRET_KEY=$(echo "$CLIENT_JSON" | jq -r '.secret.keys.clientSecret // "client-secret"') # Check if credential Secret already exists in target namespace CRED_EXISTS=$(curl -sf -o /dev/null -w "%{http_code}" \ --cacert "$CA_CERT" \ -H "Authorization: Bearer ${SA_TOKEN}" \ "${K8S_API}/api/v1/namespaces/${CRED_NS}/secrets/${CRED_NAME}") # Skip if hash matches and credential Secret exists if [ "$CONFIG_HASH" = "$EXISTING_HASH" ] && [ "$CRED_EXISTS" = "200" ]; then echo " No changes detected, skipping" continue fi # Build Keycloak client representation (strip our secret delivery config) KC_CLIENT=$(echo "$CLIENT_JSON" | jq '{ clientId: .clientId, name: .name, enabled: true, protocol: "openid-connect", clientAuthenticatorType: "client-secret", standardFlowEnabled: true, directAccessGrantsEnabled: false, publicClient: false, redirectUris: .redirectUris, webOrigins: .webOrigins, defaultClientScopes: .defaultClientScopes, protocolMappers: (.protocolMappers // []) }') # Check if client already exists EXISTING=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=${CLIENT_ID}" \ | jq -r '.[0].id // empty') if [ -n "$EXISTING" ]; then echo " Updating existing Keycloak client (uuid: ${EXISTING})" HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${TOKEN}" \ -H "Content-Type: application/json" \ -X PUT -d "$KC_CLIENT" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${EXISTING}") if [ "$HTTP_CODE" != "204" ] && [ "$HTTP_CODE" != "200" ]; then echo " ERROR: Failed to update client '${CLIENT_ID}' (HTTP ${HTTP_CODE})" annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/sync-status" "error" continue fi CLIENT_UUID="$EXISTING" else echo " Creating new Keycloak client '${CLIENT_ID}'" HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${TOKEN}" \ -H "Content-Type: application/json" \ -X POST -d "$KC_CLIENT" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients") if [ "$HTTP_CODE" != "201" ]; then echo " ERROR: Failed to create client '${CLIENT_ID}' (HTTP ${HTTP_CODE})" annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/sync-status" "error" continue fi # Fetch the newly created client's UUID CLIENT_UUID=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=${CLIENT_ID}" \ | jq -r '.[0].id') fi # Sync credentials to target namespace sync_credentials "$CLIENT_ID" "$CLIENT_UUID" "$CRED_NS" "$CRED_NAME" "$CRED_ID_KEY" "$CRED_SECRET_KEY" # Annotate config Secret with hash and sync status annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/config-hash" "$CONFIG_HASH" annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/sync-status" "synced" TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") annotate_secret "keycloak" "$CONFIG_NAME" "keycloak.forteapps.net/last-sync" "$TIMESTAMP" echo " Synced successfully" done echo "" echo "Client registrar run complete" volumeMounts: - name: keycloak-credentials mountPath: /secrets readOnly: true resources: requests: cpu: 50m memory: 64Mi limits: cpu: 200m memory: 128Mi volumes: - name: keycloak-credentials secret: secretName: keycloak-credentials items: - key: admin-password path: admin-password