pvm/docker/setup-zitadel.sh
Mikkel Georgsen a22ba48709 Add Zitadel OIDC setup, SMTP config, and security fixes
- Add setup-zitadel.sh: idempotent script that creates PVM project
  and OIDC app via Zitadel Management API using machine user PAT
- Add machine user + PAT auto-generation to docker-compose via
  FIRSTINSTANCE env vars with bind-mounted machinekey directory
- Add SMTP configuration for email sending (verification, password reset)
- Fix JWT algorithm confusion attack: restrict to RS256/384/512 only
- Add docs/TODO_SECURITY.md tracking review findings
- Update .env.example files with correct local dev URLs
- Add docker/machinekey/ to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:34:44 +01:00

200 lines
5.9 KiB
Bash
Executable file

#!/bin/sh
#
# Setup script for Zitadel OIDC application (dev environment only).
#
# Creates a "PVM" project and "PVM Dashboard" OIDC application in Zitadel.
# Idempotent: skips creation if project/app already exist.
#
# Usage:
# ./docker/setup-zitadel.sh
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ZITADEL_URL="http://localhost:8080"
PAT_FILE="${SCRIPT_DIR}/machinekey/admin.pat"
DASHBOARD_ENV_FILE="${SCRIPT_DIR}/../apps/dashboard/.env"
# ---------- helpers ----------
log() { printf '[setup-zitadel] %s\n' "$*" >&2; }
fail() { log "ERROR: $*" >&2; exit 1; }
wait_for_zitadel() {
log "Waiting for Zitadel to be healthy..."
for _ in $(seq 1 30); do
if curl -sf "${ZITADEL_URL}/debug/healthz" > /dev/null 2>&1; then
log "Zitadel is healthy."
return 0
fi
sleep 2
done
fail "Zitadel did not become healthy within 60 seconds."
}
wait_for_pat() {
log "Waiting for PAT file at ${PAT_FILE}..."
for _ in $(seq 1 15); do
if [ -s "$PAT_FILE" ]; then
log "PAT file found."
return 0
fi
sleep 2
done
fail "PAT file not found or empty after 30 seconds. Did Zitadel finish init?"
}
# ---------- API calls ----------
find_project() {
RESPONSE=$(curl -sf -X POST "${ZITADEL_URL}/management/v1/projects/_search" \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{"queries":[{"nameQuery":{"name":"PVM","method":"TEXT_QUERY_METHOD_EQUALS"}}]}') || return 1
PROJECT_ID=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$PROJECT_ID" ]; then
echo "$PROJECT_ID"
return 0
fi
return 1
}
find_oidc_app() {
PROJECT_ID="$1"
RESPONSE=$(curl -sf "${ZITADEL_URL}/management/v1/projects/${PROJECT_ID}/apps/_search" \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{}') || return 1
# Look for "PVM Dashboard" in the app list
if echo "$RESPONSE" | grep -q '"name":"PVM Dashboard"'; then
CLIENT_ID=$(echo "$RESPONSE" | grep -o '"clientId":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$CLIENT_ID" ]; then
echo "$CLIENT_ID"
return 0
fi
fi
return 1
}
create_project() {
log "Creating project 'PVM'..."
RESPONSE=$(curl -sf -X POST "${ZITADEL_URL}/management/v1/projects" \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{"name": "PVM"}') \
|| fail "Failed to create project."
PROJECT_ID=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
[ -n "$PROJECT_ID" ] || fail "Could not extract project ID from response: $RESPONSE"
log "Project created with ID: $PROJECT_ID"
echo "$PROJECT_ID"
}
create_oidc_app() {
PROJECT_ID="$1"
log "Creating OIDC application 'PVM Dashboard' in project $PROJECT_ID..."
RESPONSE=$(curl -sf -X POST "${ZITADEL_URL}/management/v1/projects/${PROJECT_ID}/apps/oidc" \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{
"name": "PVM Dashboard",
"redirectUris": ["http://localhost:5173/auth/callback/zitadel"],
"postLogoutRedirectUris": ["http://localhost:5173"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
"appType": "OIDC_APP_TYPE_WEB",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC",
"devMode": true,
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
"accessTokenRoleAssertion": true,
"idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true
}') \
|| fail "Failed to create OIDC app."
CLIENT_ID=$(echo "$RESPONSE" | grep -o '"clientId":"[^"]*"' | head -1 | cut -d'"' -f4)
CLIENT_SECRET=$(echo "$RESPONSE" | grep -o '"clientSecret":"[^"]*"' | head -1 | cut -d'"' -f4)
[ -n "$CLIENT_ID" ] || fail "Could not extract client ID from response: $RESPONSE"
log "OIDC app created."
log " Client ID: $CLIENT_ID"
[ -n "$CLIENT_SECRET" ] && log " Client Secret: $CLIENT_SECRET"
echo "${CLIENT_ID}|${CLIENT_SECRET}"
}
write_dashboard_env() {
CLIENT_ID="$1"
CLIENT_SECRET="$2"
AUTH_SECRET=$(openssl rand -base64 32)
log "Writing dashboard .env to ${DASHBOARD_ENV_FILE}..."
cat > "$DASHBOARD_ENV_FILE" <<EOF
# Zitadel OIDC Configuration (generated by setup-zitadel.sh)
AUTH_ZITADEL_ISSUER=http://localhost:8080
AUTH_ZITADEL_CLIENT_ID=${CLIENT_ID}
AUTH_ZITADEL_CLIENT_SECRET=${CLIENT_SECRET}
# Auth.js secret
AUTH_SECRET=${AUTH_SECRET}
# Backend API URL
PUBLIC_API_URL=http://localhost:3001
# Zitadel account management URL
PUBLIC_ZITADEL_ACCOUNT_URL=http://localhost:8080/ui/console
# App URL (for OIDC redirects)
ORIGIN=http://localhost:5173
EOF
log "Dashboard .env written successfully."
}
# ---------- main ----------
main() {
wait_for_zitadel
wait_for_pat
PAT=$(cat "$PAT_FILE")
[ -n "$PAT" ] || fail "PAT is empty."
log "PAT obtained successfully."
# Idempotent: check if project already exists
if PROJECT_ID=$(find_project); then
log "Project 'PVM' already exists with ID: $PROJECT_ID"
else
PROJECT_ID=$(create_project)
fi
# Idempotent: check if OIDC app already exists
if EXISTING_CLIENT_ID=$(find_oidc_app "$PROJECT_ID"); then
log "OIDC app 'PVM Dashboard' already exists with client ID: $EXISTING_CLIENT_ID"
if [ -f "$DASHBOARD_ENV_FILE" ]; then
log "Dashboard .env already exists — skipping overwrite."
log "Delete apps/dashboard/.env and re-run to regenerate."
else
log "WARNING: OIDC app exists but no .env file found."
log "You may need to recreate the app (delete it in Zitadel console, then re-run)."
fi
else
RESULT=$(create_oidc_app "$PROJECT_ID")
CLIENT_ID=$(echo "$RESULT" | cut -d'|' -f1)
CLIENT_SECRET=$(echo "$RESULT" | cut -d'|' -f2)
write_dashboard_env "$CLIENT_ID" "$CLIENT_SECRET"
fi
log ""
log "=== Setup complete ==="
log ""
log "Zitadel Console: ${ZITADEL_URL}/ui/console"
log "Admin login: admin@zitadel.localhost / Admin1234!"
log "Dashboard: http://localhost:5173"
log ""
}
main