feat: zero-config first-run experience with Logto bootstrap
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 37s

- 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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 00:22:22 +02:00
parent cda7dfbaa7
commit 021b056bce
9 changed files with 371 additions and 28 deletions

View File

@@ -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

View File

@@ -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:

198
docker/logto-bootstrap.sh Normal file
View File

@@ -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" <<EOF
{
"spaClientId": "$SPA_ID",
"m2mClientId": "$M2M_ID",
"m2mClientSecret": "$M2M_SECRET",
"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"

View File

@@ -0,0 +1,61 @@
package net.siegeln.cameleer.saas.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.util.Map;
@RestController
public class PublicConfigController {
private static final Logger log = LoggerFactory.getLogger(PublicConfigController.class);
private static final String BOOTSTRAP_FILE = "/data/bootstrap/logto-bootstrap.json";
@Value("${cameleer.identity.logto-endpoint:}")
private String logtoEndpoint;
@Value("${cameleer.identity.spa-client-id:}")
private String spaClientId;
private final ObjectMapper objectMapper = new ObjectMapper();
@GetMapping("/api/config")
public Map<String, String> 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 "";
}
}

View File

@@ -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()

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Spinner />
</div>
);
}
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() {
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
Managed Apache Camel Runtime
</p>
<Button onClick={handleLogin}>Sign in with Logto</Button>
{config?.logtoClientId ? (
<Button onClick={handleLogin}>Sign in with Logto</Button>
) : (
<p style={{ color: 'var(--color-text-secondary)' }}>
Identity provider not configured. Run the bootstrap script or check HOWTO.md.
</p>
)}
</div>
</div>
);

27
ui/src/config.ts Normal file
View File

@@ -0,0 +1,27 @@
interface AppConfig {
logtoEndpoint: string;
logtoClientId: string;
}
let cached: AppConfig | null = null;
export async function fetchConfig(): Promise<AppConfig> {
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;
}