From 021b056bce21f0dd2e0fe3e690be4d381470e222 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:22:22 +0200 Subject: [PATCH] feat: zero-config first-run experience with Logto bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logto-bootstrap.sh: API-driven init script that creates SPA app, M2M app, and default user (camel/camel) via Logto Management API. Reads m-default secret from DB, then removes seeded apps with known secrets (security hardening). Idempotent. - PublicConfigController: /api/config public endpoint serves Logto client ID from bootstrap output file (runtime, not build-time) - Frontend: LoginPage + CallbackPage fetch config from /api/config instead of import.meta.env (fixes Vite build-time baking issue) - Docker Compose: logto-bootstrap init service with health-gated dependency chain, shared volume for bootstrap config - SecurityConfig: permit /api/config without auth Flow: docker compose up → bootstrap creates apps/user → SPA fetches config → login page shows → sign in with Logto → camel/camel Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 1 + docker-compose.yml | 29 +++ docker/logto-bootstrap.sh | 198 ++++++++++++++++++ .../saas/config/PublicConfigController.java | 61 ++++++ .../cameleer/saas/config/SecurityConfig.java | 1 + src/main/resources/application.yml | 1 + ui/src/auth/CallbackPage.tsx | 43 ++-- ui/src/auth/LoginPage.tsx | 38 +++- ui/src/config.ts | 27 +++ 9 files changed, 371 insertions(+), 28 deletions(-) create mode 100644 docker/logto-bootstrap.sh create mode 100644 src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java create mode 100644 ui/src/config.ts diff --git a/.env.example b/.env.example index ef88828..22f2871 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks LOGTO_DB_PASSWORD=change_me_in_production LOGTO_M2M_CLIENT_ID= LOGTO_M2M_CLIENT_SECRET= +LOGTO_SPA_CLIENT_ID= # Ed25519 Keys (mount PEM files) CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key diff --git a/docker-compose.yml b/docker-compose.yml index 4fc71cb..2802ad9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,12 @@ services: ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001} ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002} TRUST_PROXY_HEADER: 1 + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""] + interval: 5s + timeout: 5s + retries: 30 + start_period: 15s labels: - traefik.enable=true - traefik.http.routers.logto.rule=PathPrefix(`/oidc`) || PathPrefix(`/interaction`) @@ -49,16 +55,38 @@ services: networks: - cameleer + logto-bootstrap: + image: postgres:16-alpine + depends_on: + logto: + condition: service_healthy + restart: "no" + entrypoint: ["sh", "/scripts/logto-bootstrap.sh"] + environment: + LOGTO_ENDPOINT: http://logto:3001 + LOGTO_ADMIN_ENDPOINT: http://logto:3002 + PG_HOST: postgres + PG_USER: ${POSTGRES_USER:-cameleer} + PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev} + volumes: + - ./docker/logto-bootstrap.sh:/scripts/logto-bootstrap.sh:ro + - bootstrapdata:/data + networks: + - cameleer + cameleer-saas: image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest} restart: unless-stopped depends_on: postgres: condition: service_healthy + logto-bootstrap: + condition: service_completed_successfully volumes: - /var/run/docker.sock:/var/run/docker.sock - ./keys:/etc/cameleer/keys:ro - jardata:/data/jars + - bootstrapdata:/data/bootstrap:ro environment: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer} @@ -139,3 +167,4 @@ volumes: chdata: acme: jardata: + bootstrapdata: diff --git a/docker/logto-bootstrap.sh b/docker/logto-bootstrap.sh new file mode 100644 index 0000000..4c94eb0 --- /dev/null +++ b/docker/logto-bootstrap.sh @@ -0,0 +1,198 @@ +#!/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 (not in postgres:16-alpine by default) +apk add --no-cache jq >/dev/null 2>&1 + +# --- Wait for Logto --- +log "Waiting for Logto to be ready..." +for i in $(seq 1 60); do + if wget -qO /dev/null "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" 2>/dev/null; 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 --- +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 = 'default';") + +[ -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_token() { + wget -qO- --post-data="grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all" \ + --header="Content-Type: application/x-www-form-urlencoded" \ + "${LOGTO_ADMIN_ENDPOINT}/oidc/token" 2>/dev/null +} + +log "Getting Management API token..." +TOKEN=$(get_token "m-default" "$M_DEFAULT_SECRET" | jq -r '.access_token') +[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; } +log "Got Management API token." + +# --- Helper: API calls --- +api_get() { + wget -qO- --header="Authorization: Bearer $TOKEN" "${LOGTO_ENDPOINT}${1}" 2>/dev/null +} + +api_post() { + echo "$2" | wget -qO- --post-file=/dev/stdin \ + --header="Authorization: Bearer $TOKEN" \ + --header="Content-Type: application/json" \ + "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true +} + +api_delete() { + wget -qO- --method=DELETE \ + --header="Authorization: Bearer $TOKEN" \ + "${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_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" < config() { + String clientId = spaClientId; + + // Fall back to bootstrap file if env var not set + if (clientId == null || clientId.isEmpty()) { + clientId = readBootstrapClientId(); + } + + // Use external Logto endpoint for browser redirects + String endpoint = logtoEndpoint; + if (endpoint == null || endpoint.isEmpty()) { + endpoint = "http://localhost:3001"; + } + + return Map.of( + "logtoEndpoint", endpoint, + "logtoClientId", clientId != null ? clientId : "" + ); + } + + private String readBootstrapClientId() { + try { + File file = new File(BOOTSTRAP_FILE); + if (file.exists()) { + JsonNode node = objectMapper.readTree(file); + return node.has("spaClientId") ? node.get("spaClientId").asText() : ""; + } + } catch (Exception e) { + log.warn("Failed to read bootstrap config: {}", e.getMessage()); + } + return ""; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java index 1257b97..b629af2 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java @@ -49,6 +49,7 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health").permitAll() .requestMatchers("/auth/verify").permitAll() + .requestMatchers("/api/config").permitAll() .requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license").permitAll() .requestMatchers("/assets/**", "/favicon.ico").permitAll() .anyRequest().authenticated() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a4bd66..6198cf9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,6 +37,7 @@ cameleer: logto-endpoint: ${LOGTO_ENDPOINT:} m2m-client-id: ${LOGTO_M2M_CLIENT_ID:} m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:} + spa-client-id: ${LOGTO_SPA_CLIENT_ID:} runtime: max-jar-size: 209715200 jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars} diff --git a/ui/src/auth/CallbackPage.tsx b/ui/src/auth/CallbackPage.tsx index 77aa7c1..af935cd 100644 --- a/ui/src/auth/CallbackPage.tsx +++ b/ui/src/auth/CallbackPage.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useAuthStore } from './auth-store'; import { Spinner } from '@cameleer/design-system'; +import { fetchConfig } from '../config'; export function CallbackPage() { const navigate = useNavigate(); @@ -15,30 +16,30 @@ export function CallbackPage() { return; } - const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001'; - const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || ''; const redirectUri = `${window.location.origin}/callback`; - fetch(`${logtoEndpoint}/oidc/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code, - client_id: clientId, - redirect_uri: redirectUri, - }), - }) - .then((r) => r.json()) - .then((data) => { - if (data.access_token) { - login(data.access_token, data.refresh_token || ''); - navigate('/'); - } else { - navigate('/login'); - } + fetchConfig().then((config) => { + fetch(`${config.logtoEndpoint}/oidc/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: config.logtoClientId, + redirect_uri: redirectUri, + }), }) - .catch(() => navigate('/login')); + .then((r) => r.json()) + .then((data) => { + if (data.access_token) { + login(data.access_token, data.refresh_token || ''); + navigate('/'); + } else { + navigate('/login'); + } + }) + .catch(() => navigate('/login')); + }); }, [login, navigate]); return ( diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index a418a29..496542e 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -1,18 +1,36 @@ -import { Button } from '@cameleer/design-system'; +import { useEffect, useState } from 'react'; +import { Button, Spinner } from '@cameleer/design-system'; +import { fetchConfig } from '../config'; export function LoginPage() { - const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001'; - const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || ''; - const redirectUri = `${window.location.origin}/callback`; + const [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string } | null>(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchConfig().then((c) => { + setConfig(c); + setLoading(false); + }); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } const handleLogin = () => { + if (!config?.logtoClientId) return; + const redirectUri = `${window.location.origin}/callback`; const params = new URLSearchParams({ - client_id: clientId, + client_id: config.logtoClientId, redirect_uri: redirectUri, response_type: 'code', scope: 'openid profile email offline_access', }); - window.location.href = `${logtoEndpoint}/oidc/auth?${params}`; + window.location.href = `${config.logtoEndpoint}/oidc/auth?${params}`; }; return ( @@ -22,7 +40,13 @@ export function LoginPage() {

Managed Apache Camel Runtime

- + {config?.logtoClientId ? ( + + ) : ( +

+ Identity provider not configured. Run the bootstrap script or check HOWTO.md. +

+ )} ); diff --git a/ui/src/config.ts b/ui/src/config.ts new file mode 100644 index 0000000..25e9d38 --- /dev/null +++ b/ui/src/config.ts @@ -0,0 +1,27 @@ +interface AppConfig { + logtoEndpoint: string; + logtoClientId: string; +} + +let cached: AppConfig | null = null; + +export async function fetchConfig(): Promise { + if (cached) return cached; + + try { + const response = await fetch('/api/config'); + if (response.ok) { + cached = await response.json(); + return cached!; + } + } catch { + // Config endpoint not available (e.g., Vite dev without backend) + } + + // Fallback to env vars (Vite dev mode) + cached = { + logtoEndpoint: import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001', + logtoClientId: import.meta.env.VITE_LOGTO_CLIENT_ID || '', + }; + return cached; +}