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

440 lines
20 KiB
Bash
Executable File

#!/bin/bash
# keycloak-configure.sh — wire Keycloak to FreeIPA, and provision OIDC clients
#
# Two jobs:
# 1. LDAP user federation, so FreeIPA stays the single source of truth for
# accounts and groups and Keycloak only ever mirrors it (READ_ONLY).
# 2. A confidential OIDC client per relying party (Nextcloud, Proxmox VE/PBS,
# OPNsense), with a "groups" claim so those apps can authorize on IPA
# group membership. Secrets land in ./.oidc-secrets (0600, gitignored).
#
# CheckMK is deliberately NOT here: the Community/Raw edition supports neither
# SAML nor OIDC, so it binds FreeIPA over LDAP directly — see
# ./checkmk-ldap-configure.sh.
#
# Run this AFTER both FreeIPA and Keycloak are fully up. Safe to re-run: every
# object is created-or-updated, and existing clients keep their secrets.
# 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
# ANSI-C quoting ($'...') so these hold real escape characters. With plain
# single quotes they are the literal text \033[0;32m, which `echo -e` renders
# but the summary heredocs below (plain `cat`) would print raw.
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}"
# Must include the relative path Keycloak is served under: docker-compose.yml
# sets KC_HTTP_RELATIVE_PATH=/auth so the gateway can proxy it at a native
# subpath, which moves EVERY endpoint (admin API, realms, token) under /auth.
KC_URL="${KC_URL:-http://localhost:8080/auth}"
KC_URL="${KC_URL%/}"
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 ────────────────────────────────────────────────────────
# Probe the master realm, not /health/ready: the health endpoints live on the
# separate management port 9000 and are NOT under KC_HTTP_RELATIVE_PATH, so
# "$KC_URL/health/ready" 404s forever on this deployment. /realms/master
# answers 200 on the serving port as soon as Keycloak is actually usable.
info "Waiting for Keycloak at $KC_URL..."
for i in $(seq 1 60); do
curl -sf "$KC_URL/realms/master" &>/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'."
TOKEN=$(kc_token)
# ─── Group membership in tokens ───────────────────────────────────────────────
# Relying parties authorize on FreeIPA group membership (Nextcloud quota groups,
# Proxmox permissions, ...), so every token needs a "groups" claim. The LDAP
# group mapper above only imports groups INTO Keycloak — a protocol mapper is
# what actually puts them in the ID token / userinfo response.
#
# full.path=false emits bare names ("usr_nextcloud") rather than "/usr_nextcloud",
# because that is what the consumers below match against.
log "Adding realm-wide 'groups' claim mapper..."
GROUPS_SCOPE_ID=$(kc_get "/admin/realms/$KC_REALM/client-scopes" \
| jq -r '.[] | select(.name=="groups") | .id // empty' | head -1)
if [[ -z "$GROUPS_SCOPE_ID" ]]; then
kc_post "/admin/realms/$KC_REALM/client-scopes" '{
"name": "groups",
"protocol": "openid-connect",
"attributes": {"include.in.token.scope":"true","display.on.consent.screen":"false"}
}' >/dev/null 2>&1 || true
GROUPS_SCOPE_ID=$(kc_get "/admin/realms/$KC_REALM/client-scopes" \
| jq -r '.[] | select(.name=="groups") | .id // empty' | head -1)
fi
if [[ -n "$GROUPS_SCOPE_ID" ]]; then
HAS_GM=$(kc_get "/admin/realms/$KC_REALM/client-scopes/$GROUPS_SCOPE_ID/protocol-mappers/models" \
| jq -r '.[] | select(.name=="groups") | .id // empty' | head -1)
if [[ -z "$HAS_GM" ]]; then
kc_post "/admin/realms/$KC_REALM/client-scopes/$GROUPS_SCOPE_ID/protocol-mappers/models" '{
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"config": {
"full.path": "false",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"claim.name": "groups"
}
}' >/dev/null 2>&1 || true
fi
# Hand the scope to every new client automatically.
kc_put "/admin/realms/$KC_REALM/default-default-client-scopes/$GROUPS_SCOPE_ID" "" >/dev/null 2>&1 || true
log " 'groups' client scope ready."
else
warn " could not create the 'groups' client scope — add it by hand if apps need group claims."
fi
# ─── OIDC clients ─────────────────────────────────────────────────────────────
# One confidential client per relying party. Secrets are written to
# .oidc-secrets (mode 0600, gitignored) so nextcloud-configure.sh and the
# Proxmox/OPNsense setup steps can pick them up without a trip through the UI.
#
# Clients whose base URL is not configured in .env are skipped — set PVE_URL,
# PBS_URL or OPNSENSE_URL and re-run to add them later. Re-running is safe: an
# existing client is updated in place and keeps its secret.
SECRETS_FILE="$SCRIPT_DIR/.oidc-secrets"
kc_client() {
local client_id="$1" name="$2" root_url="$3" redirect="$4"
local payload existing cid secret
payload=$(jq -n \
--arg id "$client_id" --arg name "$name" \
--arg root "$root_url" --arg redir "$redirect" \
'{
clientId: $id, name: $name, enabled: true, protocol: "openid-connect",
publicClient: false, bearerOnly: false,
standardFlowEnabled: true, directAccessGrantsEnabled: false,
serviceAccountsEnabled: false, implicitFlowEnabled: false,
rootUrl: $root, baseUrl: $root,
redirectUris: [$redir], webOrigins: ["+"],
attributes: {"post.logout.redirect.uris": $root + "/*"}
}')
existing=$(kc_get "/admin/realms/$KC_REALM/clients?clientId=$client_id" \
| jq -r '.[0].id // empty')
if [[ -n "$existing" ]]; then
kc_put "/admin/realms/$KC_REALM/clients/$existing" "$payload" >/dev/null
cid="$existing"
warn " client '$client_id' already existed — updated (secret unchanged)."
else
kc_post "/admin/realms/$KC_REALM/clients" "$payload" >/dev/null
cid=$(kc_get "/admin/realms/$KC_REALM/clients?clientId=$client_id" \
| jq -r '.[0].id // empty')
log " client '$client_id' created."
fi
[[ -z "$cid" ]] && { error " could not resolve client '$client_id'."; return 1; }
secret=$(kc_get "/admin/realms/$KC_REALM/clients/$cid/client-secret" \
| jq -r '.value // empty')
[[ -z "$secret" ]] && { error " could not read secret for '$client_id'."; return 1; }
# Rewrite this client's line in place, keeping the others.
touch "$SECRETS_FILE"; chmod 600 "$SECRETS_FILE"
grep -v "^${client_id}=" "$SECRETS_FILE" > "$SECRETS_FILE.tmp" 2>/dev/null || true
mv "$SECRETS_FILE.tmp" "$SECRETS_FILE"
echo "${client_id}=${secret}" >> "$SECRETS_FILE"
chmod 600 "$SECRETS_FILE"
}
log "Provisioning OIDC clients..."
# Nextcloud — user_oidc's callback is always <base>/apps/user_oidc/code
NC_PUBLIC_URL="${NC_PUBLIC_URL:-}"
if [[ -n "$NC_PUBLIC_URL" ]]; then
NC_PUBLIC_URL="${NC_PUBLIC_URL%/}"
kc_client "nextcloud" "Nextcloud" "$NC_PUBLIC_URL" "$NC_PUBLIC_URL/apps/user_oidc/code" || true
else
warn " NC_PUBLIC_URL unset — skipping the Nextcloud client."
fi
# Proxmox VE / PBS — the OpenID realm posts back to the web UI root.
if [[ -n "${PVE_URL:-}" ]]; then
kc_client "proxmox-ve" "Proxmox VE" "${PVE_URL%/}" "${PVE_URL%/}/*" || true
else
warn " PVE_URL unset — skipping the Proxmox VE client."
fi
if [[ -n "${PBS_URL:-}" ]]; then
kc_client "proxmox-bs" "Proxmox Backup Server" "${PBS_URL%/}" "${PBS_URL%/}/*" || true
else
warn " PBS_URL unset — skipping the Proxmox Backup Server client."
fi
if [[ -n "${OPNSENSE_URL:-}" ]]; then
kc_client "opnsense" "OPNsense" "${OPNSENSE_URL%/}" "${OPNSENSE_URL%/}/*" || true
else
warn " OPNSENSE_URL unset — skipping the OPNsense client."
fi
[[ -f "$SECRETS_FILE" ]] && log "Client secrets written to $SECRETS_FILE (mode 0600)."
# ─── 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
OIDC clients: $(ls "$SECRETS_FILE" &>/dev/null && cut -d= -f1 "$SECRETS_FILE" | tr '\n' ' ' || echo "(none — set NC_PUBLIC_URL / PVE_URL / PBS_URL / OPNSENSE_URL)")
Client secrets: $SECRETS_FILE
Discovery URL (give this to relying parties):
$KC_URL/realms/$KC_REALM/.well-known/openid-configuration
Next steps:
• Verify users are visible: Admin console → Users
• Run ./nextcloud-configure.sh to point Nextcloud at IPA LDAP + this realm
• CheckMK Community edition has no OIDC/SAML — run ./checkmk-ldap-configure.sh
to bind it straight to FreeIPA instead
• Proxmox VE / PBS: add an OpenID Connect realm with the discovery URL above
and the proxmox-ve / proxmox-bs client secret
• 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