2026-04-05 00:22:22 +02:00
|
|
|
#!/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"
|
2026-04-05 01:01:32 +02:00
|
|
|
API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
|
|
|
|
API_RESOURCE_NAME="Cameleer SaaS API"
|
2026-04-05 00:22:22 +02:00
|
|
|
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"; }
|
|
|
|
|
|
2026-04-05 00:28:23 +02:00
|
|
|
# Install jq + curl (not in postgres:16-alpine by default)
|
|
|
|
|
apk add --no-cache jq curl >/dev/null 2>&1
|
2026-04-05 00:22:22 +02:00
|
|
|
|
|
|
|
|
# --- Wait for Logto ---
|
|
|
|
|
log "Waiting for Logto to be ready..."
|
|
|
|
|
for i in $(seq 1 60); do
|
2026-04-05 00:28:23 +02:00
|
|
|
if curl -sf "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" >/dev/null 2>&1; then
|
2026-04-05 00:22:22 +02:00
|
|
|
log "Logto is ready."
|
|
|
|
|
break
|
|
|
|
|
fi
|
|
|
|
|
[ "$i" -eq 60 ] && { log "ERROR: Logto not ready after 60s"; exit 1; }
|
|
|
|
|
sleep 1
|
|
|
|
|
done
|
|
|
|
|
|
2026-04-05 00:28:23 +02:00
|
|
|
# --- Read m-default secret from Postgres (admin tenant) ---
|
2026-04-05 00:22:22 +02:00
|
|
|
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 \
|
2026-04-05 00:28:23 +02:00
|
|
|
"SELECT secret FROM applications WHERE id = 'm-default' AND tenant_id = 'admin';")
|
2026-04-05 00:22:22 +02:00
|
|
|
|
|
|
|
|
[ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found in DB"; exit 1; }
|
|
|
|
|
log "Got m-default secret."
|
|
|
|
|
|
|
|
|
|
# --- Get Management API token ---
|
2026-04-05 00:33:43 +02:00
|
|
|
get_admin_token() {
|
2026-04-05 00:28:23 +02:00
|
|
|
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"
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 00:33:43 +02:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 00:22:22 +02:00
|
|
|
log "Getting Management API token..."
|
2026-04-05 00:33:43 +02:00
|
|
|
TOKEN_RESPONSE=$(get_admin_token "m-default" "$M_DEFAULT_SECRET")
|
2026-04-05 00:28:23 +02:00
|
|
|
log "Token response: $(echo "$TOKEN_RESPONSE" | head -c 200)"
|
|
|
|
|
TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
2026-04-05 00:22:22 +02:00
|
|
|
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
|
|
|
|
|
log "Got Management API token."
|
|
|
|
|
|
|
|
|
|
# --- Helper: API calls ---
|
|
|
|
|
api_get() {
|
2026-04-05 00:28:23 +02:00
|
|
|
curl -s -H "Authorization: Bearer $TOKEN" -H "Host: localhost:3001" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api_post() {
|
2026-04-05 00:28:23 +02:00
|
|
|
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
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api_delete() {
|
2026-04-05 00:28:23 +02:00
|
|
|
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -H "Host: localhost:3001" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
2026-04-05 01:01:32 +02:00
|
|
|
# --- Find or create API resource ---
|
|
|
|
|
log "Checking for existing API resource..."
|
|
|
|
|
EXISTING_RESOURCES=$(api_get "/api/resources")
|
|
|
|
|
API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id")
|
|
|
|
|
|
|
|
|
|
if [ -n "$API_RESOURCE_ID" ]; then
|
|
|
|
|
log "API resource already exists: $API_RESOURCE_ID"
|
|
|
|
|
else
|
|
|
|
|
log "Creating API resource..."
|
|
|
|
|
RESOURCE_RESPONSE=$(api_post "/api/resources" "{
|
|
|
|
|
\"name\": \"$API_RESOURCE_NAME\",
|
|
|
|
|
\"indicator\": \"$API_RESOURCE_INDICATOR\"
|
|
|
|
|
}")
|
|
|
|
|
API_RESOURCE_ID=$(echo "$RESOURCE_RESPONSE" | jq -r '.id')
|
|
|
|
|
log "Created API resource: $API_RESOURCE_ID"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-04-05 00:22:22 +02:00
|
|
|
# --- 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
|
2026-04-05 00:33:43 +02:00
|
|
|
VERIFY=$(get_default_token "$M2M_ID" "$M2M_SECRET")
|
2026-04-05 00:22:22 +02:00
|
|
|
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" <<EOF
|
|
|
|
|
{
|
|
|
|
|
"spaClientId": "$SPA_ID",
|
|
|
|
|
"m2mClientId": "$M2M_ID",
|
|
|
|
|
"m2mClientSecret": "$M2M_SECRET",
|
2026-04-05 01:01:32 +02:00
|
|
|
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
|
2026-04-05 00:22:22 +02:00
|
|
|
"defaultUsername": "$DEFAULT_USERNAME"
|
|
|
|
|
}
|
|
|
|
|
EOF
|
|
|
|
|
|
|
|
|
|
log "Bootstrap complete!"
|
|
|
|
|
log " SPA Client ID: $SPA_ID"
|
|
|
|
|
log " M2M Client ID: $M2M_ID"
|
|
|
|
|
log " Default user: $DEFAULT_USERNAME / $DEFAULT_PASSWORD"
|