#!/bin/sh set -e # Cameleer SaaS — Logto Bootstrap Script # Creates SPA app, M2M app, default user via Logto Management API. # Then removes seeded M2M apps with known secrets (security hardening). # Idempotent: checks existence before creating. LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}" LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://logto:3002}" MGMT_API_RESOURCE="https://default.logto.app/api" BOOTSTRAP_FILE="/data/logto-bootstrap.json" PG_HOST="${PG_HOST:-postgres}" PG_USER="${PG_USER:-cameleer}" PG_DB="logto" SPA_APP_NAME="Cameleer SaaS" M2M_APP_NAME="Cameleer SaaS Backend" DEFAULT_USERNAME="camel" DEFAULT_PASSWORD="camel" REDIRECT_URIS='["http://localhost/callback","http://localhost:8080/callback","http://localhost:5173/callback"]' POST_LOGOUT_URIS='["http://localhost","http://localhost:8080","http://localhost:5173"]' log() { echo "[logto-bootstrap] $1"; } # Install jq + curl (not in postgres:16-alpine by default) apk add --no-cache jq curl >/dev/null 2>&1 # --- Wait for Logto --- log "Waiting for Logto to be ready..." for i in $(seq 1 60); do if curl -sf "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" >/dev/null 2>&1; then log "Logto is ready." break fi [ "$i" -eq 60 ] && { log "ERROR: Logto not ready after 60s"; exit 1; } sleep 1 done # --- Read m-default secret from Postgres (admin tenant) --- log "Reading m-default secret from database..." M_DEFAULT_SECRET=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \ "SELECT secret FROM applications WHERE id = 'm-default' AND tenant_id = 'admin';") [ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found in DB"; exit 1; } log "Got m-default secret." # --- Get Management API token --- get_admin_token() { curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Host: localhost:3002" \ -d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all" } get_default_token() { curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Host: localhost:3001" \ -d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all" } log "Getting Management API token..." TOKEN_RESPONSE=$(get_admin_token "m-default" "$M_DEFAULT_SECRET") log "Token response: $(echo "$TOKEN_RESPONSE" | head -c 200)" TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null) [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; } log "Got Management API token." # --- Helper: API calls --- api_get() { curl -s -H "Authorization: Bearer $TOKEN" -H "Host: localhost:3001" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]" } api_post() { curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: localhost:3001" \ -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true } api_delete() { curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -H "Host: localhost:3001" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true } # --- Find or create SPA app --- log "Checking for existing SPA app..." EXISTING_APPS=$(api_get "/api/applications") SPA_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$SPA_APP_NAME\" and .type == \"SPA\") | .id") if [ -n "$SPA_ID" ]; then log "SPA app already exists: $SPA_ID" else log "Creating SPA app..." SPA_RESPONSE=$(api_post "/api/applications" "{ \"name\": \"$SPA_APP_NAME\", \"type\": \"SPA\", \"oidcClientMetadata\": { \"redirectUris\": $REDIRECT_URIS, \"postLogoutRedirectUris\": $POST_LOGOUT_URIS } }") SPA_ID=$(echo "$SPA_RESPONSE" | jq -r '.id') log "Created SPA app: $SPA_ID" fi # --- Find or create M2M app --- log "Checking for existing M2M app..." M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id") M2M_SECRET="" if [ -n "$M2M_ID" ]; then log "M2M app already exists: $M2M_ID" M2M_SECRET=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \ "SELECT secret FROM applications WHERE id = '$M2M_ID' AND tenant_id = 'default';") else log "Creating M2M app..." M2M_RESPONSE=$(api_post "/api/applications" "{ \"name\": \"$M2M_APP_NAME\", \"type\": \"MachineToMachine\" }") M2M_ID=$(echo "$M2M_RESPONSE" | jq -r '.id') M2M_SECRET=$(echo "$M2M_RESPONSE" | jq -r '.secret') log "Created M2M app: $M2M_ID" # Assign Management API role log "Assigning Management API access..." MGMT_RESOURCE_ID=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \ "SELECT id FROM resources WHERE indicator = '$MGMT_API_RESOURCE' AND tenant_id = 'default';") if [ -n "$MGMT_RESOURCE_ID" ]; then SCOPE_IDS=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \ "SELECT json_agg(id) FROM scopes WHERE resource_id = '$MGMT_RESOURCE_ID' AND tenant_id = 'default';" | tr -d '[:space:]') ROLE_RESPONSE=$(api_post "/api/roles" "{ \"name\": \"cameleer-m2m-management\", \"description\": \"Full Management API access for Cameleer SaaS\", \"type\": \"MachineToMachine\", \"scopeIds\": $SCOPE_IDS }") ROLE_ID=$(echo "$ROLE_RESPONSE" | jq -r '.id') if [ -n "$ROLE_ID" ] && [ "$ROLE_ID" != "null" ]; then api_post "/api/roles/$ROLE_ID/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null log "Assigned Management API role to M2M app." # Verify our M2M app works VERIFY=$(get_default_token "$M2M_ID" "$M2M_SECRET") VERIFY_TOKEN=$(echo "$VERIFY" | jq -r '.access_token') if [ -n "$VERIFY_TOKEN" ] && [ "$VERIFY_TOKEN" != "null" ]; then log "Verified M2M app works." else log "WARNING: M2M app verification failed — skipping seeded app cleanup" M2M_SECRET="" fi fi fi fi # --- Find or create default user --- log "Checking for existing user '$DEFAULT_USERNAME'..." USER_ID=$(api_get "/api/users?search=$DEFAULT_USERNAME" | jq -r ".[] | select(.username == \"$DEFAULT_USERNAME\") | .id") if [ -n "$USER_ID" ]; then log "User '$DEFAULT_USERNAME' already exists: $USER_ID" else log "Creating user '$DEFAULT_USERNAME'..." USER_RESPONSE=$(api_post "/api/users" "{ \"username\": \"$DEFAULT_USERNAME\", \"password\": \"$DEFAULT_PASSWORD\", \"name\": \"Cameleer Admin\" }") USER_ID=$(echo "$USER_RESPONSE" | jq -r '.id') log "Created user: $USER_ID" fi # --- Cleanup seeded M2M apps with known secrets --- if [ -n "$M2M_SECRET" ]; then log "Cleaning up seeded apps with known secrets..." for SEEDED_ID in "m-default" "m-admin" "s6cz3wajdv8gtdyz8e941"; do if echo "$EXISTING_APPS" | jq -e ".[] | select(.id == \"$SEEDED_ID\")" >/dev/null 2>&1; then api_delete "/api/applications/$SEEDED_ID" log "Deleted seeded app: $SEEDED_ID" fi done else log "Skipping seeded app cleanup (M2M secret not verified)" fi # --- Write bootstrap results --- log "Writing bootstrap config to $BOOTSTRAP_FILE..." mkdir -p "$(dirname "$BOOTSTRAP_FILE")" cat > "$BOOTSTRAP_FILE" <