Dotfiles/setup/modules/FreeipaAnsible/image/keycloak-configure.sh

273 lines
12 KiB
Bash
Executable File

#!/bin/bash
# keycloak-configure.sh — wire Keycloak to FreeIPA via LDAP user federation
#
# Run this AFTER both FreeIPA and Keycloak are fully up.
# Reads settings from environment variables or a .env file in the same directory.
#
# Required env vars:
# IPA_SERVER FreeIPA server FQDN
# IPA_DOMAIN FreeIPA domain
# IPA_DM_PASSWORD Directory Manager password (used as LDAP bind credential
# unless IPA_BIND_DN / IPA_BIND_PASSWORD override it)
# KC_ADMIN_PASSWORD Keycloak admin password
#
# Optional env vars (defaults shown):
# KC_URL http://localhost:8080
# KC_ADMIN admin
# KC_REALM freeipa (realm to create)
# KC_REALM_DISPLAY <IPA_DOMAIN>
# IPA_REALM <IPA_DOMAIN uppercased>
# IPA_BIND_DN cn=Directory Manager
# IPA_BIND_PASSWORD <IPA_DM_PASSWORD>
# IPA_USE_LDAPS false
# IPA_LDAP_PORT 389 (or 636 if LDAPS)
# SYNC_FULL_PERIOD 604800 (1 week)
# SYNC_CHANGED_PERIOD 86400 (1 day)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ -f "$SCRIPT_DIR/.env" ]] && set -a && source "$SCRIPT_DIR/.env" && set +a
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*" >&2; }
info() { echo -e "${CYAN}[i]${NC} $*"; }
: "${IPA_SERVER:?IPA_SERVER is required}"
: "${IPA_DOMAIN:?IPA_DOMAIN is required}"
: "${IPA_DM_PASSWORD:?IPA_DM_PASSWORD is required}"
: "${KC_ADMIN_PASSWORD:?KC_ADMIN_PASSWORD is required}"
KC_URL="${KC_URL:-http://localhost:8080}"
KC_ADMIN="${KC_ADMIN:-admin}"
KC_REALM="${KC_REALM:-freeipa}"
KC_REALM_DISPLAY="${KC_REALM_DISPLAY:-$IPA_DOMAIN}"
IPA_REALM="${IPA_REALM:-${IPA_DOMAIN^^}}"
IPA_BIND_DN="${IPA_BIND_DN:-cn=Directory Manager}"
IPA_BIND_PASSWORD="${IPA_BIND_PASSWORD:-$IPA_DM_PASSWORD}"
IPA_USE_LDAPS="${IPA_USE_LDAPS:-false}"
IPA_LDAP_SCHEME="ldap"
IPA_LDAP_PORT=389
[[ "$IPA_USE_LDAPS" == "true" ]] && IPA_LDAP_SCHEME="ldaps" && IPA_LDAP_PORT=636
IPA_LDAP_URL="${IPA_LDAP_URL:-${IPA_LDAP_SCHEME}://${IPA_SERVER}:${IPA_LDAP_PORT}}"
IPA_BASEDN="dc=${IPA_DOMAIN/./,dc=}"
SYNC_FULL_PERIOD="${SYNC_FULL_PERIOD:-604800}"
SYNC_CHANGED_PERIOD="${SYNC_CHANGED_PERIOD:-86400}"
# ─── Helpers ──────────────────────────────────────────────────────────────────
kc_token() {
curl -sf -X POST \
"$KC_URL/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=admin-cli&grant_type=password" \
-d "username=$KC_ADMIN" \
--data-urlencode "password=$KC_ADMIN_PASSWORD" \
| jq -r '.access_token'
}
kc_get() { curl -sf -H "Authorization: Bearer $TOKEN" "$KC_URL$1"; }
kc_post() { curl -sf -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d "$2" "$KC_URL$1"; }
kc_put() { curl -sf -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d "$2" "$KC_URL$1"; }
kc_status() { curl -sf -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" "$KC_URL$1"; }
# ─── Wait for Keycloak ────────────────────────────────────────────────────────
info "Waiting for Keycloak at $KC_URL..."
for i in $(seq 1 60); do
curl -sf "$KC_URL/health/ready" &>/dev/null && break
[[ $i -eq 60 ]] && { error "Keycloak not ready after 120s."; exit 1; }
sleep 2
done
log "Keycloak is ready."
# ─── Authenticate ─────────────────────────────────────────────────────────────
TOKEN=$(kc_token)
[[ -z "$TOKEN" || "$TOKEN" == "null" ]] && { error "Failed to obtain Keycloak token."; exit 1; }
log "Admin token obtained."
# ─── Create realm ─────────────────────────────────────────────────────────────
REALM_STATUS=$(kc_status "/admin/realms/$KC_REALM")
if [[ "$REALM_STATUS" == "200" ]]; then
warn "Realm '$KC_REALM' already exists — updating."
kc_put "/admin/realms/$KC_REALM" \
"{\"realm\":\"$KC_REALM\",\"displayName\":\"$KC_REALM_DISPLAY\",\"enabled\":true,
\"ssoSessionMaxLifespan\":36000,\"accessTokenLifespan\":300}" >/dev/null
else
kc_post "/admin/realms" \
"{\"realm\":\"$KC_REALM\",\"displayName\":\"$KC_REALM_DISPLAY\",\"enabled\":true,
\"ssoSessionMaxLifespan\":36000,\"accessTokenLifespan\":300}" >/dev/null
log "Realm '$KC_REALM' created."
fi
TOKEN=$(kc_token)
# ─── LDAP user federation ─────────────────────────────────────────────────────
log "Configuring FreeIPA LDAP user federation..."
LDAP_COMPONENT=$(cat <<JSON
{
"name": "freeipa-ldap",
"providerId": "ldap",
"providerType": "org.keycloak.storage.UserStorageProvider",
"config": {
"enabled": ["true"],
"priority": ["1"],
"editMode": ["READ_ONLY"],
"syncRegistrations": ["false"],
"vendor": ["rhds"],
"usernameLDAPAttribute": ["uid"],
"rdnLDAPAttribute": ["uid"],
"uuidLDAPAttribute": ["ipaUniqueID"],
"userObjectClasses": ["inetOrgPerson, organizationalPerson"],
"connectionUrl": ["$IPA_LDAP_URL"],
"usersDn": ["cn=users,cn=accounts,$IPA_BASEDN"],
"authType": ["simple"],
"bindDn": ["$IPA_BIND_DN"],
"bindCredential": ["$IPA_BIND_PASSWORD"],
"searchScope": ["1"],
"validatePasswordPolicy": ["false"],
"trustEmail": ["false"],
"useTruststoreSpi": ["ldapsOnly"],
"connectionPooling": ["true"],
"pagination": ["true"],
"batchSizeForSync": ["1000"],
"fullSyncPeriod": ["$SYNC_FULL_PERIOD"],
"changedSyncPeriod": ["$SYNC_CHANGED_PERIOD"],
"importEnabled": ["true"],
"cachePolicy": ["DEFAULT"],
"kerberosRealm": ["$IPA_REALM"],
"serverPrincipal": ["HTTP/$IPA_SERVER@$IPA_REALM"],
"useKerberosForPasswordAuthentication": ["false"],
"allowKerberosAuthentication": ["false"],
"debug": ["false"]
}
}
JSON
)
EXISTING_ID=$(kc_get "/admin/realms/$KC_REALM/components?type=org.keycloak.storage.UserStorageProvider&name=freeipa-ldap" \
| jq -r '.[0].id // empty')
if [[ -n "$EXISTING_ID" ]]; then
warn "LDAP provider already exists (id=$EXISTING_ID) — updating."
kc_put "/admin/realms/$KC_REALM/components/$EXISTING_ID" "$LDAP_COMPONENT" >/dev/null
LDAP_ID="$EXISTING_ID"
else
LDAP_ID=$(kc_post "/admin/realms/$KC_REALM/components" "$LDAP_COMPONENT" \
| jq -r '.id // empty')
# Keycloak returns 201 with Location header, not a body with id — extract from header or re-query
if [[ -z "$LDAP_ID" ]]; then
LDAP_ID=$(kc_get "/admin/realms/$KC_REALM/components?type=org.keycloak.storage.UserStorageProvider&name=freeipa-ldap" \
| jq -r '.[0].id')
fi
log "LDAP provider created (id=$LDAP_ID)."
fi
# ─── Attribute mappers ────────────────────────────────────────────────────────
log "Adding LDAP attribute mappers..."
add_mapper() {
local name="$1" type="$2" ldap_attr="$3" user_attr="$4"
local payload
payload=$(cat <<JSON
{
"name": "$name",
"providerId": "$type",
"providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
"parentId": "$LDAP_ID",
"config": {
"ldap.attribute": ["$ldap_attr"],
"user.model.attribute": ["$user_attr"],
"read.only": ["true"],
"always.read.value.from.ldap": ["false"],
"is.mandatory.in.ldap": ["false"]
}
}
JSON
)
local exists
exists=$(kc_get "/admin/realms/$KC_REALM/components?parent=$LDAP_ID&name=$name" \
| jq -r '.[0].id // empty')
if [[ -z "$exists" ]]; then
kc_post "/admin/realms/$KC_REALM/components" "$payload" >/dev/null
log " mapper: $name"
else
warn " mapper '$name' already exists — skipping."
fi
}
add_mapper "email" "user-attribute-ldap-mapper" "mail" "email"
add_mapper "first-name" "user-attribute-ldap-mapper" "givenName" "firstName"
add_mapper "last-name" "user-attribute-ldap-mapper" "sn" "lastName"
add_mapper "uid-number" "user-attribute-ldap-mapper" "uidNumber" "uidNumber"
# Group mapper (maps IPA groups to Keycloak groups)
GROUP_MAPPER=$(cat <<JSON
{
"name": "freeipa-groups",
"providerId": "group-ldap-mapper",
"providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
"parentId": "$LDAP_ID",
"config": {
"groups.dn": ["cn=groups,cn=accounts,$IPA_BASEDN"],
"group.name.ldap.attribute": ["cn"],
"group.object.classes": ["groupOfNames"],
"preserve.group.inheritance": ["false"],
"membership.ldap.attribute": ["member"],
"membership.attribute.type": ["DN"],
"mode": ["READ_ONLY"],
"user.roles.retrieve.strategy": ["LOAD_GROUPS_BY_MEMBER_ATTRIBUTE"],
"mapped.group.attributes": [""],
"drop.non.existing.groups.during.sync": ["false"]
}
}
JSON
)
exists=$(kc_get "/admin/realms/$KC_REALM/components?parent=$LDAP_ID&name=freeipa-groups" \
| jq -r '.[0].id // empty')
if [[ -z "$exists" ]]; then
kc_post "/admin/realms/$KC_REALM/components" "$GROUP_MAPPER" >/dev/null
log " mapper: freeipa-groups"
fi
# ─── Trigger initial sync ──────────────────────────────────────────────────────
log "Triggering initial user sync..."
SYNC_RESULT=$(kc_post "/admin/realms/$KC_REALM/user-storage/$LDAP_ID/sync?action=triggerFullSync" "" 2>/dev/null || echo "{}")
ADDED=$(echo "$SYNC_RESULT" | jq -r '.added // 0')
UPDATED=$(echo "$SYNC_RESULT" | jq -r '.updated // 0')
log "Sync complete: $ADDED added, $UPDATED updated."
# ─── Enable email login ────────────────────────────────────────────────────────
kc_put "/admin/realms/$KC_REALM" \
'{"loginWithEmailAllowed":true,"duplicateEmailsAllowed":false}' >/dev/null
log "Email login enabled on realm '$KC_REALM'."
# ─── Summary ─────────────────────────────────────────────────────────────────
cat <<EOF
${GREEN}Keycloak ↔ FreeIPA configuration complete.${NC}
Keycloak URL: $KC_URL
Realm: $KC_REALM (display: $KC_REALM_DISPLAY)
LDAP provider: $IPA_LDAP_URL
Users DN: cn=users,cn=accounts,$IPA_BASEDN
Groups DN: cn=groups,cn=accounts,$IPA_BASEDN
Sync schedule: full=${SYNC_FULL_PERIOD}s / changed=${SYNC_CHANGED_PERIOD}s
Admin console: $KC_URL/admin/$KC_REALM/console
User login: $KC_URL/realms/$KC_REALM/account
Next steps:
• Verify users are visible: Admin console → Users
• Set up client applications (OIDC/SAML) in this realm
• For production: switch Keycloak to 'start' mode with a TLS cert
• For Kerberos/SPNEGO: supply an HTTP service keytab and set
allowKerberosAuthentication=true in the LDAP provider config
EOF