#!/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_REALM # IPA_BIND_DN cn=Directory Manager # IPA_BIND_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 </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 </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 </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 /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 </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