#!/usr/bin/env bash # ============================================================= # E-Cosplay Keycloak — idempotent sync script # ============================================================= # Run by the keycloak-init container on every `docker compose up`. # Fully idempotent: re-running only applies missing config. # # Re-sync after editing this file: # docker compose up -d keycloak-init --force-recreate # # Required env vars (set in docker-compose.yml): # KC_SERVER, KC_ADMIN, KC_ADMIN_PASSWORD # SMTP_HOST, SMTP_PORT, SMTP_FROM, SMTP_FROM_DISPLAY_NAME, # SMTP_USER, SMTP_PASSWORD # LOGIN_THEME # ECOSPLAY_GROUPS (space-separated list) # ADMIN_USER_USERNAME, ADMIN_USER_PASSWORD, # ADMIN_USER_FIRSTNAME, ADMIN_USER_LASTNAME # SYNC_BOT_CLIENT, SYNC_BOT_SECRET # ============================================================= set -euo pipefail KC=/opt/keycloak/bin/kcadm.sh log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } info() { printf ' %s\n' "$*"; } warn() { printf ' \033[1;33m! %s\033[0m\n' "$*"; } # ------------------------------------------------------------- # Wait for Keycloak and authenticate # ------------------------------------------------------------- # Two authentication paths: # 1. sync-bot service account (preferred, used after bootstrap). # 2. Default bootstrap admin (used on first run, before sync-bot # has been created and granted the admin role). # ------------------------------------------------------------- KC_LOGIN_MODE=none login_as_sync_bot() { [ -n "${SYNC_BOT_SECRET:-}" ] || return 1 $KC config credentials \ --server "$KC_SERVER" \ --realm master \ --client "$SYNC_BOT_CLIENT" \ --secret "$SYNC_BOT_SECRET" >/dev/null 2>&1 } login_as_admin() { $KC config credentials \ --server "$KC_SERVER" \ --realm master \ --user "$KC_ADMIN" \ --password "$KC_ADMIN_PASSWORD" >/dev/null 2>&1 } log "Authenticating to Keycloak at ${KC_SERVER}" while true; do if login_as_sync_bot; then KC_LOGIN_MODE=sync-bot break fi if login_as_admin; then KC_LOGIN_MODE=bootstrap break fi info "Keycloak not ready / no valid credentials yet, retrying in 5s..." sleep 5 done info "Authenticated (mode=$KC_LOGIN_MODE)" # ------------------------------------------------------------- # Helpers # ------------------------------------------------------------- realm_exists() { $KC get "realms/$1" >/dev/null 2>&1 } user_id() { # $1 = realm, $2 = username $KC get users -r "$1" -q username="$2" --fields id 2>/dev/null \ | sed -n 's/.*"id"[ ]*:[ ]*"\([^"]*\)".*/\1/p' \ | head -n1 } group_id() { # $1 = realm, $2 = group name (top level only) $KC get "group-by-path/$2" -r "$1" --fields id 2>/dev/null \ | sed -n 's/.*"id"[ ]*:[ ]*"\([^"]*\)".*/\1/p' \ | head -n1 } ensure_group() { # $1 = realm, $2 = group name if [ -n "$(group_id "$1" "$2")" ]; then info "= group $2 ($1)" else $KC create groups -r "$1" -s name="$2" >/dev/null info "+ group $2 ($1)" fi } ensure_user() { # $1=realm $2=username $3=password $4=firstname $5=lastname if [ -n "$(user_id "$1" "$2")" ]; then info "= user $2 ($1)" return fi $KC create users -r "$1" \ -s username="$2" \ -s email="$2" \ -s firstName="$4" \ -s lastName="$5" \ -s enabled=true \ -s emailVerified=true \ -s 'requiredActions=["CONFIGURE_TOTP"]' >/dev/null $KC set-password -r "$1" --username "$2" --new-password "$3" info "+ user $2 ($1)" } ensure_user_in_group() { # $1=realm $2=username $3=group local uid gid uid=$(user_id "$1" "$2") gid=$(group_id "$1" "$3") if [ -z "$uid" ]; then warn "skip group bind: user $2 missing in $1"; return; fi if [ -z "$gid" ]; then warn "skip group bind: group $3 missing in $1"; return; fi $KC update "users/$uid/groups/$gid" -r "$1" -n >/dev/null 2>&1 || true info " $2 -> group $3 ($1)" } ensure_user_realm_role() { # $1=realm $2=username $3=role $KC add-roles -r "$1" --uusername "$2" --rolename "$3" >/dev/null 2>&1 || true info " $2 -> realm role $3 ($1)" } ensure_user_client_role() { # $1=realm $2=username $3=client $4=role $KC add-roles -r "$1" --uusername "$2" --cclientid "$3" --rolename "$4" >/dev/null 2>&1 || true info " $2 -> client role $3/$4 ($1)" } client_internal_id() { # $1=realm $2=clientId $KC get clients -r "$1" -q clientId="$2" --fields id 2>/dev/null \ | sed -n 's/.*"id"[ ]*:[ ]*"\([^"]*\)".*/\1/p' \ | head -n1 } rename_client() { # $1=realm $2=oldClientId $3=newClientId local cid cid=$(client_internal_id "$1" "$2") if [ -n "$cid" ]; then $KC update "clients/$cid" -r "$1" -s "clientId=$3" >/dev/null info " renamed client $2 -> $3 ($1)" fi } set_client_uris() { # $1=realm $2=clientId $3=redirectUris(json) $4=webOrigins(json) $5=postLogoutUris(##-separated) local cid cid=$(client_internal_id "$1" "$2") if [ -z "$cid" ]; then warn "client $2 not found in $1, skipping URI sync" return fi $KC update "clients/$cid" -r "$1" \ -s "redirectUris=$3" \ -s "webOrigins=$4" \ -s "attributes.\"post.logout.redirect.uris\"=$5" >/dev/null info " client $2 URIs synced ($1)" } set_client_pkce() { # $1=realm $2=clientId $3=method ("S256", "plain", or "" to disable) local cid cid=$(client_internal_id "$1" "$2") if [ -z "$cid" ]; then warn "client $2 not found in $1, skipping PKCE update" return fi $KC update "clients/$cid" -r "$1" \ -s "attributes.\"pkce.code.challenge.method\"=$3" >/dev/null info " client $2 PKCE method set to '${3:-none}' ($1)" } configure_webauthn() { # $1=realm $2=Relying Party display name (shown in browser passkey prompt) local realm=$1 rp=$2 $KC update "realms/$realm" \ -s "webAuthnPolicyRpEntityName=$rp" \ -s 'webAuthnPolicySignatureAlgorithms=["ES256","RS256","EdDSA"]' \ -s 'webAuthnPolicyUserVerificationRequirement=preferred' \ -s 'webAuthnPolicyAttestationConveyancePreference=none' \ -s 'webAuthnPolicyRequireResidentKey=not specified' \ -s "webAuthnPolicyPasswordlessRpEntityName=$rp" \ -s 'webAuthnPolicyPasswordlessSignatureAlgorithms=["ES256","RS256","EdDSA"]' \ -s 'webAuthnPolicyPasswordlessUserVerificationRequirement=preferred' \ -s 'webAuthnPolicyPasswordlessAttestationConveyancePreference=none' \ -s 'webAuthnPolicyPasswordlessRequireResidentKey=not specified' >/dev/null info " webauthn policies set ($realm, RP=$rp)" # Enable required actions so users can self-enroll passkeys via the # account console (Sign-in -> Passkey / Two-factor authentication). for ra in webauthn-register webauthn-register-passwordless; do $KC update "authentication/required-actions/$ra" -r "$realm" \ -s enabled=true \ -s defaultAction=false >/dev/null 2>&1 || true info " required action $ra enabled ($realm)" done } ensure_client() { # $1=realm $2=clientId $3=name $4=description $5=secret # $6=redirectUris(json) $7=webOrigins(json) $8=postLogoutUris(##-separated) if [ -n "$(client_internal_id "$1" "$2")" ]; then info "= client $2 ($1)" return fi $KC create clients -r "$1" \ -s "clientId=$2" \ -s "name=$3" \ -s "description=$4" \ -s 'protocol=openid-connect' \ -s 'enabled=true' \ -s 'publicClient=false' \ -s "secret=$5" \ -s 'standardFlowEnabled=true' \ -s 'implicitFlowEnabled=false' \ -s 'directAccessGrantsEnabled=false' \ -s 'serviceAccountsEnabled=false' \ -s 'frontchannelLogout=true' \ -s "redirectUris=$6" \ -s "webOrigins=$7" \ -s "attributes.\"post.logout.redirect.uris\"=$8" \ -s 'attributes."pkce.code.challenge.method"=S256' >/dev/null info "+ client $2 ($1)" } ensure_sync_bot() { # Create (or keep in sync) the automation service client in master. # Grants its service account the master 'admin' realm role so that # sync.sh can authenticate with client_credentials after the default # admin user is disabled. local cid cid=$(client_internal_id master "$SYNC_BOT_CLIENT") if [ -z "$cid" ]; then $KC create clients -r master \ -s "clientId=$SYNC_BOT_CLIENT" \ -s "name=Sync Bot" \ -s "description=Service account used by init/sync.sh for unattended config reconciliation" \ -s 'protocol=openid-connect' \ -s 'enabled=true' \ -s 'publicClient=false' \ -s "secret=$SYNC_BOT_SECRET" \ -s 'standardFlowEnabled=false' \ -s 'implicitFlowEnabled=false' \ -s 'directAccessGrantsEnabled=false' \ -s 'serviceAccountsEnabled=true' >/dev/null cid=$(client_internal_id master "$SYNC_BOT_CLIENT") info "+ service client $SYNC_BOT_CLIENT (master)" else $KC update "clients/$cid" -r master \ -s 'enabled=true' \ -s 'serviceAccountsEnabled=true' \ -s 'standardFlowEnabled=false' \ -s 'directAccessGrantsEnabled=false' \ -s "secret=$SYNC_BOT_SECRET" >/dev/null info "= service client $SYNC_BOT_CLIENT (master)" fi # Grant 'admin' realm role to the auto-generated service account user $KC add-roles -r master \ --uusername "service-account-${SYNC_BOT_CLIENT}" \ --rolename admin >/dev/null 2>&1 || true info " service-account-${SYNC_BOT_CLIENT} -> realm role admin" } disable_default_admin() { # Only disable once we're sure sync-bot can authenticate (otherwise # next run would be locked out). if ! login_as_sync_bot; then warn "sync-bot auth not working, refusing to disable default admin" login_as_admin >/dev/null 2>&1 || true return fi # Re-login as admin to perform the disable action (sync-bot's token # was used only for verification) login_as_admin >/dev/null 2>&1 || true local admin_id current admin_id=$(user_id master "$KC_ADMIN") if [ -z "$admin_id" ]; then info "no '$KC_ADMIN' user in master, nothing to disable" return fi current=$($KC get "users/$admin_id" -r master --fields enabled 2>/dev/null \ | sed -n 's/.*"enabled"[ ]*:[ ]*\(true\|false\).*/\1/p' | head -n1) if [ "$current" = "false" ]; then info "= default admin user '$KC_ADMIN' already disabled" return fi $KC update "users/$admin_id" -r master -s enabled=false >/dev/null info "+ default admin user '$KC_ADMIN' disabled" } # ============================================================= # Master realm: SMTP, theme, locale # ============================================================= log "Configuring master realm (SMTP, theme, locale)" $KC update realms/master \ -s "smtpServer.host=${SMTP_HOST}" \ -s "smtpServer.port=${SMTP_PORT}" \ -s "smtpServer.from=${SMTP_FROM}" \ -s "smtpServer.fromDisplayName=${SMTP_FROM_DISPLAY_NAME}" \ -s "smtpServer.auth=true" \ -s "smtpServer.starttls=true" \ -s "smtpServer.ssl=false" \ -s "smtpServer.user=${SMTP_USER}" \ -s "smtpServer.password=${SMTP_PASSWORD}" \ -s "loginTheme=${LOGIN_THEME}" \ -s "internationalizationEnabled=true" \ -s 'supportedLocales=["fr"]' \ -s "defaultLocale=fr" info "master realm updated" log "Configuring WebAuthn / passkey for master realm" configure_webauthn master "E-Cosplay Auth" # ============================================================= # Master realm: automation service account (sync-bot) # ============================================================= log "Ensuring sync-bot service client in master realm" ensure_sync_bot # ============================================================= # Master realm: global Keycloak admin user # ============================================================= log "Ensuring global Keycloak admin user in master realm" ensure_user master "$ADMIN_USER_USERNAME" "$ADMIN_USER_PASSWORD" "$ADMIN_USER_FIRSTNAME" "$ADMIN_USER_LASTNAME" ensure_user_realm_role master "$ADMIN_USER_USERNAME" admin # ============================================================= # Ecosplay realm: groups + application admin user # ============================================================= if realm_exists ecosplay; then log "Configuring WebAuthn / passkey for ecosplay realm" configure_webauthn ecosplay "E-Cosplay" log "Ensuring groups on ecosplay realm" for grp in $ECOSPLAY_GROUPS; do ensure_group ecosplay "$grp" done log "Ensuring application admin user in ecosplay realm" ensure_user ecosplay "$ADMIN_USER_USERNAME" "$ADMIN_USER_PASSWORD" "$ADMIN_USER_FIRSTNAME" "$ADMIN_USER_LASTNAME" ensure_user_client_role ecosplay "$ADMIN_USER_USERNAME" realm-management realm-admin ensure_user_in_group ecosplay "$ADMIN_USER_USERNAME" super_admin_asso ensure_user_in_group ecosplay "$ADMIN_USER_USERNAME" superadmin log "Reconciling ecosplay_web client" # Legacy rename: dash -> underscore (if migrated from older import) rename_client ecosplay ecosplay-web ecosplay_web set_client_uris ecosplay ecosplay_web \ '["https://www.e-cosplay.fr/oauth/keycloak","https://cos.local/oauth/keycloak"]' \ '["https://www.e-cosplay.fr","https://cos.local"]' \ 'https://www.e-cosplay.fr/*##https://cos.local/*' log "Reconciling eticket client" ensure_client ecosplay eticket "E-Ticket" \ "Application billetterie ticket.e-cosplay.fr" \ "change-me-in-admin-console" \ '["https://ticket.e-cosplay.fr/api/auth/login/sso/validate","https://cos.local/api/auth/login/sso/validate","https://ticket.e-cosplay.fr/connection/sso/check","https://cos.local/connection/sso/check"]' \ '["https://ticket.e-cosplay.fr","https://cos.local"]' \ 'https://ticket.e-cosplay.fr/*##https://cos.local/*' set_client_uris ecosplay eticket \ '["https://ticket.e-cosplay.fr/api/auth/login/sso/validate","https://cos.local/api/auth/login/sso/validate","https://ticket.e-cosplay.fr/connection/sso/check","https://cos.local/connection/sso/check"]' \ '["https://ticket.e-cosplay.fr","https://cos.local"]' \ 'https://ticket.e-cosplay.fr/*##https://cos.local/*' log "Reconciling ecosplay_code client" ensure_client ecosplay ecosplay_code "E-Cosplay Code" \ "Forge de code (Gitea) - login SSO via ecosplay_code provider (Gitea ne supporte pas PKCE)" \ "change-me-in-admin-console" \ '["https://code.e-cosplay.fr/user/oauth2/ecosplay_code/callback","https://cos.local/user/oauth2/ecosplay_code/callback"]' \ '["https://code.e-cosplay.fr","https://cos.local"]' \ 'https://code.e-cosplay.fr/*##https://cos.local/*' set_client_uris ecosplay ecosplay_code \ '["https://code.e-cosplay.fr/user/oauth2/ecosplay_code/callback","https://cos.local/user/oauth2/ecosplay_code/callback"]' \ '["https://code.e-cosplay.fr","https://cos.local"]' \ 'https://code.e-cosplay.fr/*##https://cos.local/*' # Gitea does not support PKCE on OIDC sources, disable the requirement set_client_pkce ecosplay ecosplay_code "" else warn "ecosplay realm not found — will be imported on next boot" fi # ============================================================= # Disable default bootstrap admin (only after sync-bot is proven) # ============================================================= log "Disabling default bootstrap admin user" disable_default_admin log "Sync complete"