#!/bin/bash # nextcloud-configure.sh — point Nextcloud at FreeIPA for accounts and at # Keycloak for logins. # # Run AFTER the stack is up and ./keycloak-configure.sh has provisioned the # "nextcloud" OIDC client. Safe to re-run. # # The two backends do different jobs, and both are needed: # # user_ldap binds FreeIPA and OWNS the account list. Users, display names, # mail and group membership come from IPA and are read-only here, # which is what makes IPA the source of truth rather than a thing # Nextcloud happens to sync from once. # user_oidc performs the actual login against Keycloak, so password policy, # MFA and session control live in one place for every app. # # The join between them is the username. Nextcloud's internal username is # pinned to the IPA `uid` (ldapExpertUsernameAttr), and the OIDC provider maps # `preferred_username` onto it with unique-uid disabled — otherwise user_oidc # hashes the claim into an ID of its own and every SSO login creates a SECOND, # empty account beside the LDAP one. With them aligned, user_oidc's # soft_auto_provision updates the existing LDAP user instead. # # Required env (from .env): # IPA_SERVER / IPA_HOSTNAME, IPA_DOMAIN, NC_PUBLIC_URL, KC_REALM # NC_LDAP_BIND_DN + NC_LDAP_BIND_PASSWORD (falls back to IPA_BIND_* / DM) # Optional: # NC_LDAP_GROUP restrict Nextcloud accounts to members of this IPA group # KC_PUBLIC_URL externally reachable Keycloak base URL (default: derived) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" [[ -f .env ]] && set -a && source .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_DOMAIN:?IPA_DOMAIN is required}" IPA_SERVER="${IPA_SERVER:-${IPA_HOSTNAME:?set IPA_HOSTNAME or IPA_SERVER}}" NC_PUBLIC_URL="${NC_PUBLIC_URL:?NC_PUBLIC_URL is required (see .env.example)}" NC_PUBLIC_URL="${NC_PUBLIC_URL%/}" KC_REALM="${KC_REALM:-freeipa}" IPA_BASEDN="dc=${IPA_DOMAIN/./,dc=}" LDAP_BIND_DN="${NC_LDAP_BIND_DN:-${IPA_BIND_DN:-cn=Directory Manager}}" LDAP_BIND_PW="${NC_LDAP_BIND_PASSWORD:-${IPA_BIND_PASSWORD:-${IPA_DM_PASSWORD:-}}}" [[ -z "$LDAP_BIND_PW" ]] && { error "No LDAP bind password (set NC_LDAP_BIND_PASSWORD)."; exit 1; } if [[ "$LDAP_BIND_DN" == "cn=Directory Manager" ]]; then warn "Binding LDAP as Directory Manager. For production create a read-only" warn " sysaccount in FreeIPA and set NC_LDAP_BIND_DN / NC_LDAP_BIND_PASSWORD." fi LDAP_SCHEME="ldap"; LDAP_PORT=389 [[ "${IPA_USE_LDAPS:-false}" == "true" ]] && LDAP_SCHEME="ldaps" && LDAP_PORT=636 # Keycloak's externally reachable base. Nextcloud redirects the BROWSER here, so # it must be an address the user's machine can resolve — not keycloak:8080. if [[ -z "${KC_PUBLIC_URL:-}" ]]; then # Default: same origin as Nextcloud, since both sit behind the ansipa gateway. KC_PUBLIC_URL="${NC_PUBLIC_URL%/nextcloud}/auth" fi KC_PUBLIC_URL="${KC_PUBLIC_URL%/}" DISCOVERY_URI="${KC_PUBLIC_URL}/realms/${KC_REALM}/.well-known/openid-configuration" occ() { docker compose exec -T -u www-data nextcloud php occ "$@"; } # ─── Wait for the installer to finish ──────────────────────────────────────── info "Waiting for Nextcloud to finish installing..." for i in $(seq 1 60); do if occ status 2>/dev/null | grep -q 'installed: true'; then break; fi [[ $i -eq 60 ]] && { error "Nextcloud not installed after 5 minutes."; exit 1; } sleep 5 done log "Nextcloud is installed." # ─── Apps ──────────────────────────────────────────────────────────────────── log "Enabling user_ldap and user_oidc..." occ app:enable user_ldap >/dev/null 2>&1 || warn " could not enable user_ldap" # user_oidc is not bundled; install pulls it from the app store on first run. if ! occ app:list 2>/dev/null | grep -q 'user_oidc'; then occ app:install user_oidc >/dev/null 2>&1 \ || { error " could not install user_oidc (no app store access?)." error " Install it from the Apps page, then re-run this script."; exit 1; } fi occ app:enable user_oidc >/dev/null 2>&1 || warn " could not enable user_oidc" log " apps ready." # ─── LDAP backend (FreeIPA owns the accounts) ──────────────────────────────── log "Configuring the FreeIPA LDAP backend..." # Which config ID we own is recorded in a state file rather than recovered by # parsing `ldap:show-config`, whose table output is not a stable interface. If # the recorded ID has since been deleted in the UI, fall through and make a new # one — re-running must never silently configure a config that no longer exists. LDAP_STATE="$SCRIPT_DIR/.nextcloud-ldap-config" LDAP_CFG="" if [[ -f "$LDAP_STATE" ]]; then _saved=$(tr -d '[:space:]' < "$LDAP_STATE") if [[ -n "$_saved" ]] && occ ldap:show-config "$_saved" >/dev/null 2>&1; then LDAP_CFG="$_saved" log " reusing LDAP config $LDAP_CFG" else warn " recorded LDAP config '${_saved}' is gone — creating a fresh one." fi fi if [[ -z "$LDAP_CFG" ]]; then LDAP_CFG=$(occ ldap:create-empty-config 2>/dev/null | grep -oE 's[0-9]+' | head -1 || true) [[ -z "$LDAP_CFG" ]] && { error "Could not create an LDAP configuration."; exit 1; } echo "$LDAP_CFG" > "$LDAP_STATE" log " created LDAP config $LDAP_CFG" fi set_ldap() { occ ldap:set-config "$LDAP_CFG" "$1" "$2" >/dev/null 2>&1 \ || warn " ldap:set-config $1 failed"; } set_ldap ldapHost "${LDAP_SCHEME}://${IPA_SERVER}" set_ldap ldapPort "$LDAP_PORT" set_ldap ldapAgentName "$LDAP_BIND_DN" set_ldap ldapAgentPassword "$LDAP_BIND_PW" set_ldap ldapBase "$IPA_BASEDN" set_ldap ldapBaseUsers "cn=users,cn=accounts,$IPA_BASEDN" set_ldap ldapBaseGroups "cn=groups,cn=accounts,$IPA_BASEDN" # Only enabled, non-expired posix accounts. IPA marks disabled users with # nsAccountLock=TRUE, so exclude them — otherwise a user disabled in IPA keeps # a working Nextcloud account, which defeats the whole point of central identity. _user_filter="(&(objectClass=posixAccount)(!(nsAccountLock=TRUE)))" if [[ -n "${NC_LDAP_GROUP:-}" ]]; then _user_filter="(&(objectClass=posixAccount)(!(nsAccountLock=TRUE))(memberOf=cn=${NC_LDAP_GROUP},cn=groups,cn=accounts,${IPA_BASEDN}))" log " restricting accounts to IPA group '${NC_LDAP_GROUP}'" fi set_ldap ldapUserFilter "$_user_filter" set_ldap ldapUserFilterMode "1" # 1 = raw filter, don't let the UI rewrite it set_ldap ldapLoginFilter "(&${_user_filter}(uid=%uid))" set_ldap ldapLoginFilterMode "1" set_ldap ldapUserDisplayName "displayName" set_ldap ldapEmailAttribute "mail" set_ldap ldapQuotaAttribute "" set_ldap ldapUserFilterObjectclass "posixAccount" # THE critical setting: make Nextcloud's internal username the IPA uid. # Left at its default, Nextcloud invents a UUID-based internal name and the # OIDC preferred_username will never line up with it. set_ldap ldapExpertUsernameAttr "uid" set_ldap ldapGroupFilter "(objectClass=groupOfNames)" set_ldap ldapGroupFilterMode "1" set_ldap ldapGroupDisplayName "cn" set_ldap ldapGroupMemberAssocAttr "member" set_ldap ldapNestedGroups "0" set_ldap turnOnPasswordChange "0" # passwords change in IPA, not here set_ldap hasMemberOfFilterSupport "1" set_ldap useMemberOfToDetectMembership "1" set_ldap ldapConfigurationActive "1" if occ ldap:test-config "$LDAP_CFG" 2>&1 | grep -qi 'valid\|successful'; then log " LDAP bind OK." else warn " LDAP test-config did not report success — check the bind DN/password." warn " (run: docker compose exec -u www-data nextcloud php occ ldap:test-config $LDAP_CFG)" fi # ─── OIDC login via Keycloak ───────────────────────────────────────────────── log "Configuring the Keycloak OIDC provider..." CLIENT_SECRET="" if [[ -f .oidc-secrets ]]; then CLIENT_SECRET=$(grep '^nextcloud=' .oidc-secrets | cut -d= -f2- || true) fi if [[ -z "$CLIENT_SECRET" ]]; then error "No 'nextcloud' client secret in .oidc-secrets." error "Run ./keycloak-configure.sh first (with NC_PUBLIC_URL set in .env)." exit 1 fi # soft_auto_provision (default on, set explicitly here so it survives someone # turning auto_provision off later): when the OIDC subject already exists in # another backend — our LDAP one — update that account rather than making a # duplicate in the user_oidc backend. occ config:system:set user_oidc auto_provision --value=true --type=boolean >/dev/null occ config:system:set user_oidc soft_auto_provision --value=true --type=boolean >/dev/null # --unique-uid=0 keeps the user ID as the raw claim instead of a hash of # (claim + provider name); with the hash on, SSO logins never match LDAP users. occ user_oidc:provider keycloak \ --clientid="nextcloud" \ --clientsecret="$CLIENT_SECRET" \ --discoveryuri="$DISCOVERY_URI" \ --scope="openid email profile groups" \ --mapping-uid="preferred_username" \ --mapping-display-name="name" \ --mapping-email="email" \ --unique-uid=0 \ --group-provisioning=1 \ >/dev/null 2>&1 \ && log " provider 'keycloak' registered." \ || warn " user_oidc:provider failed — check 'occ user_oidc:provider --help' for this app version." # ─── Instance settings ─────────────────────────────────────────────────────── occ config:system:set overwrite.cli.url --value="$NC_PUBLIC_URL" >/dev/null occ config:system:set default_phone_region --value="${NC_PHONE_REGION:-DE}" >/dev/null # Nudge people to the SSO button but keep the password form reachable at # ?direct=1 — the break-glass local admin still needs a way in if Keycloak is # down, which is exactly when you cannot fix it through Keycloak. occ config:system:set hide_login_form --value=false --type=boolean >/dev/null # Best-effort: this command only exists on newer Nextcloud releases, and the # LDAP backend populates itself on demand anyway, so a miss here is harmless. occ user:sync-account-data >/dev/null 2>&1 || true cat <