Replace Zitadel's built-in login v1 with a fully custom SvelteKit-based login experience using Zitadel Session API v2. Keeps the existing OIDC authorization code flow (Auth.js handles token exchange) while providing branded login, signup, password reset, and TOTP pages. - Enable Login V2 in docker-compose, assign IAM_LOGIN_CLIENT role in setup script - Add server-only Zitadel API client ($lib/server/zitadel.ts) with session, user, and auth-request management functions - Create reusable auth UI components (AuthCard, FormField, FormError, LoadingButton) - Rewrite login page with email/password form and TOTP second factor support - Add signup page with auto-login after registration - Add password reset flow (request + verify pages) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
240 lines
7.7 KiB
Bash
Executable file
240 lines
7.7 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; }
|
|
|
|
# ---------- preflight ----------
|
|
|
|
command -v jq >/dev/null 2>&1 || fail "jq is required but not installed. Install it with: apt install jq"
|
|
|
|
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=$(printf '%s' "$RESPONSE" | jq -r '.result[0].id // empty')
|
|
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 and extract its clientId
|
|
CLIENT_ID=$(printf '%s' "$RESPONSE" | jq -r '.result[] | select(.name == "PVM Dashboard") | .oidcConfig.clientId // empty' 2>/dev/null)
|
|
if [ -n "$CLIENT_ID" ]; then
|
|
echo "$CLIENT_ID"
|
|
return 0
|
|
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=$(printf '%s' "$RESPONSE" | jq -r '.id // empty')
|
|
[ -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"
|
|
|
|
# devMode=true skips redirect URI validation (allows http://). Disable in production.
|
|
if [ "${ZITADEL_PRODUCTION:-}" = "true" ]; then
|
|
DEV_MODE="false"
|
|
else
|
|
DEV_MODE="true"
|
|
fi
|
|
|
|
log "Creating OIDC application 'PVM Dashboard' in project $PROJECT_ID (devMode=$DEV_MODE)..."
|
|
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\", \"http://localhost:5173/login\"],
|
|
\"responseTypes\": [\"OIDC_RESPONSE_TYPE_CODE\"],
|
|
\"grantTypes\": [\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\", \"OIDC_GRANT_TYPE_REFRESH_TOKEN\"],
|
|
\"appType\": \"OIDC_APP_TYPE_WEB\",
|
|
\"authMethodType\": \"OIDC_AUTH_METHOD_TYPE_BASIC\",
|
|
\"devMode\": ${DEV_MODE},
|
|
\"accessTokenType\": \"OIDC_TOKEN_TYPE_BEARER\",
|
|
\"accessTokenRoleAssertion\": true,
|
|
\"idTokenRoleAssertion\": true,
|
|
\"idTokenUserinfoAssertion\": true
|
|
}") \
|
|
|| fail "Failed to create OIDC app."
|
|
|
|
CLIENT_ID=$(printf '%s' "$RESPONSE" | jq -r '.clientId // empty')
|
|
CLIENT_SECRET=$(printf '%s' "$RESPONSE" | jq -r '.clientSecret // empty')
|
|
|
|
[ -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}"
|
|
}
|
|
|
|
assign_iam_login_client_role() {
|
|
log "Finding machine user ID for role assignment..."
|
|
RESPONSE=$(curl -sf -X POST "${ZITADEL_URL}/v2/users" \
|
|
-H "Authorization: Bearer ${PAT}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"queries":[{"loginNameQuery":{"loginName":"pvm-setup@zitadel.localhost","method":"TEXT_QUERY_METHOD_EQUALS"}}]}') \
|
|
|| fail "Failed to search for machine user."
|
|
|
|
MACHINE_USER_ID=$(printf '%s' "$RESPONSE" | jq -r '.result[0].userId // empty')
|
|
[ -n "$MACHINE_USER_ID" ] || fail "Could not find machine user 'pvm-setup@zitadel.localhost'."
|
|
log "Machine user ID: $MACHINE_USER_ID"
|
|
|
|
log "Assigning IAM_LOGIN_CLIENT role to machine user..."
|
|
curl -sf -X POST "${ZITADEL_URL}/admin/v1/members" \
|
|
-H "Authorization: Bearer ${PAT}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"userId\": \"${MACHINE_USER_ID}\", \"roles\": [\"IAM_LOGIN_CLIENT\"]}" \
|
|
> /dev/null || fail "Failed to assign IAM_LOGIN_CLIENT role."
|
|
log "IAM_LOGIN_CLIENT role assigned successfully."
|
|
}
|
|
|
|
write_dashboard_env() {
|
|
CLIENT_ID="$1"
|
|
CLIENT_SECRET="$2"
|
|
SERVICE_USER_TOKEN="$3"
|
|
|
|
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
|
|
|
|
# Zitadel service user PAT (for Session API v2 calls from server-side)
|
|
ZITADEL_SERVICE_USER_TOKEN=${SERVICE_USER_TOKEN}
|
|
|
|
# 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."
|
|
|
|
# Assign IAM_LOGIN_CLIENT role so PAT can call session/auth-request APIs
|
|
assign_iam_login_client_role
|
|
|
|
# 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 "Cannot recover client secret — recreate the app or write .env manually."
|
|
log "Writing partial .env with service user token (client secret will be empty)..."
|
|
write_dashboard_env "$EXISTING_CLIENT_ID" "" "$PAT"
|
|
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" "$PAT"
|
|
fi
|
|
|
|
log ""
|
|
log "=== Setup complete ==="
|
|
log ""
|
|
log "Zitadel Console: ${ZITADEL_URL}/ui/console"
|
|
log "Admin login: admin@zitadel.localhost / (see ZITADEL_ADMIN_PASSWORD in .env)"
|
|
log "Dashboard: http://localhost:5173"
|
|
log ""
|
|
}
|
|
|
|
main
|