# Bitnami Keycloak Helm Chart Values # Host: id.forteapps.net # 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 hostname: id.forteapps.net 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": ["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 secret syncer Job - apiVersion: v1 kind: ServiceAccount metadata: name: keycloak-secret-syncer namespace: keycloak # -- ClusterRole granting access to secrets and namespaces - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: keycloak-secret-syncer rules: - apiGroups: [""] resources: ["secrets"] verbs: ["get", "create", "update", "patch"] - apiGroups: [""] resources: ["namespaces"] verbs: ["get", "list"] # -- ClusterRoleBinding for the syncer ServiceAccount - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: keycloak-secret-syncer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: keycloak-secret-syncer subjects: - kind: ServiceAccount name: keycloak-secret-syncer namespace: keycloak # -- PostSync Job: extracts Keycloak client secrets into K8s Secrets - apiVersion: batch/v1 kind: Job metadata: name: keycloak-secret-syncer namespace: keycloak annotations: argocd.argoproj.io/hook: PostSync argocd.argoproj.io/hook-delete-policy: BeforeHookCreation spec: backoffLimit: 3 template: spec: serviceAccountName: keycloak-secret-syncer restartPolicy: Never containers: - name: syncer 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" # Read admin credentials from the keycloak-credentials secret ADMIN_USER="admin" ADMIN_PASS=$(cat /secrets/admin-password) # Authenticate to Keycloak Admin API 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 # Get all clients in the realm echo "Fetching clients from realm '${REALM}'..." CLIENTS=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients") # Filter clients with k8s.secret.sync=true SYNC_CLIENTS=$(echo "$CLIENTS" | jq -c '[.[] | select(.attributes["k8s.secret.sync"] == "true")]') COUNT=$(echo "$SYNC_CLIENTS" | jq 'length') echo "Found ${COUNT} client(s) with sync enabled" 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" # 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 Secret JSON manifest # Args: namespace, name, id-key, secret-key, b64-id, b64-secret build_manifest() { local ns="$1" name="$2" id_key="$3" secret_key="$4" b64_id="$5" b64_secret="$6" cat < secret '${TARGET_NS}/${TARGET_NAME}' (keys: ${ID_KEY}, ${SECRET_KEY})" # Get the client secret from Keycloak SECRET_VALUE=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${CLIENT_UUID}/client-secret" \ | jq -r '.value') if [ -z "$SECRET_VALUE" ] || [ "$SECRET_VALUE" = "null" ]; then echo " WARNING: No secret found for client '${CLIENT_ID}', skipping" continue fi B64_CLIENT_ID=$(printf '%s' "$CLIENT_ID" | base64 | tr -d '\n') B64_SECRET=$(printf '%s' "$SECRET_VALUE" | base64 | tr -d '\n') # 1. Write to target namespace (if it exists) NS_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ --cacert "$CA_CERT" \ -H "Authorization: Bearer ${SA_TOKEN}" \ "${K8S_API}/api/v1/namespaces/${TARGET_NS}") if [ "$NS_STATUS" = "200" ]; then MANIFEST=$(build_manifest "$TARGET_NS" "$TARGET_NAME" "$ID_KEY" "$SECRET_KEY" "$B64_CLIENT_ID" "$B64_SECRET") upsert_secret "$TARGET_NS" "$TARGET_NAME" "$MANIFEST" || exit 1 else echo " WARNING: Namespace '${TARGET_NS}' does not exist, skipping target" fi # 2. Always write a central copy to the secrets namespace CENTRAL_MANIFEST=$(build_manifest "$CENTRAL_NS" "$TARGET_NAME" "$ID_KEY" "$SECRET_KEY" "$B64_CLIENT_ID" "$B64_SECRET") upsert_secret "$CENTRAL_NS" "$TARGET_NAME" "$CENTRAL_MANIFEST" || exit 1 done echo "Secret sync 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