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>
This commit is contained in:
parent
633f2ad684
commit
a22ba48709
7 changed files with 272 additions and 7 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,3 +26,4 @@ Thumbs.db
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker/data/
|
docker/data/
|
||||||
|
docker/machinekey/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Zitadel OIDC Configuration
|
# Zitadel OIDC Configuration
|
||||||
AUTH_ZITADEL_ISSUER=https://auth.pvm.example.com
|
# For local dev, run: ./docker/setup-zitadel.sh (auto-generates this file)
|
||||||
|
AUTH_ZITADEL_ISSUER=http://localhost:8080
|
||||||
AUTH_ZITADEL_CLIENT_ID=your-client-id
|
AUTH_ZITADEL_CLIENT_ID=your-client-id
|
||||||
AUTH_ZITADEL_CLIENT_SECRET=your-client-secret
|
AUTH_ZITADEL_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
|
|
@ -10,7 +11,7 @@ AUTH_SECRET=your-auth-secret
|
||||||
PUBLIC_API_URL=http://localhost:3001
|
PUBLIC_API_URL=http://localhost:3001
|
||||||
|
|
||||||
# Zitadel account management URL (for password/MFA changes)
|
# Zitadel account management URL (for password/MFA changes)
|
||||||
PUBLIC_ZITADEL_ACCOUNT_URL=https://auth.pvm.example.com/ui/console
|
PUBLIC_ZITADEL_ACCOUNT_URL=http://localhost:8080/ui/console
|
||||||
|
|
||||||
# App URL (for OIDC redirects)
|
# App URL (for OIDC redirects)
|
||||||
ORIGIN=http://localhost:5173
|
ORIGIN=http://localhost:5173
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use axum::extract::{FromRef, FromRequestParts};
|
||||||
use axum::http::request::Parts;
|
use axum::http::request::Parts;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
|
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
|
@ -106,10 +106,16 @@ where
|
||||||
let decoding_key = DecodingKey::from_jwk(jwk)
|
let decoding_key = DecodingKey::from_jwk(jwk)
|
||||||
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
|
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
|
||||||
|
|
||||||
// Validate the token
|
// Validate the token — restrict to RSA algorithms only (Zitadel uses RS256).
|
||||||
let mut validation = Validation::new(
|
// Never trust the alg from the JWT header directly to prevent algorithm confusion attacks.
|
||||||
header.alg,
|
const ALLOWED_ALGS: [Algorithm; 3] = [Algorithm::RS256, Algorithm::RS384, Algorithm::RS512];
|
||||||
);
|
if !ALLOWED_ALGS.contains(&header.alg) {
|
||||||
|
return Err(AuthError::InvalidToken(format!(
|
||||||
|
"Unsupported algorithm: {:?}",
|
||||||
|
header.alg
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut validation = Validation::new(header.alg);
|
||||||
validation.set_audience(&[&auth_state.audience]);
|
validation.set_audience(&[&auth_state.audience]);
|
||||||
validation.set_issuer(&[&auth_state.issuer]);
|
validation.set_issuer(&[&auth_state.issuer]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,10 @@ ZITADEL_MASTERKEY=changeMe_must_be_exactly_32_char
|
||||||
ZITADEL_DB_PASSWORD=zitadel-dev-password
|
ZITADEL_DB_PASSWORD=zitadel-dev-password
|
||||||
ZITADEL_ADMIN_PASSWORD=Admin1234!
|
ZITADEL_ADMIN_PASSWORD=Admin1234!
|
||||||
|
|
||||||
|
# SMTP (Zitadel email sending)
|
||||||
|
ZITADEL_SMTP_HOST=mail.example.com
|
||||||
|
ZITADEL_SMTP_USER=noreply@example.com
|
||||||
|
ZITADEL_SMTP_PASSWORD=your-smtp-password
|
||||||
|
|
||||||
# PVM Application Database
|
# PVM Application Database
|
||||||
PVM_DB_PASSWORD=pvm-dev-password
|
PVM_DB_PASSWORD=pvm-dev-password
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ services:
|
||||||
zitadel:
|
zitadel:
|
||||||
image: ghcr.io/zitadel/zitadel:latest
|
image: ghcr.io/zitadel/zitadel:latest
|
||||||
command: start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled
|
command: start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled
|
||||||
|
user: "0"
|
||||||
environment:
|
environment:
|
||||||
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
|
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
|
||||||
ZITADEL_DATABASE_POSTGRES_PORT: 5432
|
ZITADEL_DATABASE_POSTGRES_PORT: 5432
|
||||||
|
|
@ -19,11 +20,25 @@ services:
|
||||||
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin
|
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin
|
||||||
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "${ZITADEL_ADMIN_PASSWORD}"
|
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "${ZITADEL_ADMIN_PASSWORD}"
|
||||||
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
|
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
|
||||||
|
# Machine user for automated setup (PAT written to bind mount)
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: pvm-setup
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: PVM Setup Service User
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2030-01-01T00:00:00Z"
|
||||||
|
ZITADEL_FIRSTINSTANCE_PATPATH: /machinekey/admin.pat
|
||||||
|
# SMTP for email sending (verification, password reset)
|
||||||
|
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST: "${ZITADEL_SMTP_HOST}:465"
|
||||||
|
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_USER: "${ZITADEL_SMTP_USER}"
|
||||||
|
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_PASSWORD: "${ZITADEL_SMTP_PASSWORD}"
|
||||||
|
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_TLS: "true"
|
||||||
|
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROM: "${ZITADEL_SMTP_USER}"
|
||||||
|
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROMNAME: "PVM"
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
zitadel-db:
|
zitadel-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./machinekey:/machinekey
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-sf", "http://localhost:8080/debug/healthz"]
|
test: ["CMD", "curl", "-sf", "http://localhost:8080/debug/healthz"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
|
||||||
200
docker/setup-zitadel.sh
Executable file
200
docker/setup-zitadel.sh
Executable file
|
|
@ -0,0 +1,200 @@
|
||||||
|
#!/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
|
||||||
37
docs/TODO_SECURITY.md
Normal file
37
docs/TODO_SECURITY.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Security & Technical Debt
|
||||||
|
|
||||||
|
Items identified during code review that should be addressed before production.
|
||||||
|
|
||||||
|
## High Priority
|
||||||
|
|
||||||
|
### Token refresh logic in SvelteKit (`apps/dashboard/src/auth.ts`)
|
||||||
|
The JWT callback stores `expiresAt` and `refreshToken` but never checks expiry or initiates a refresh. Auth.js does not auto-refresh tokens. Without this, users get silently logged out when their access token expires (typically 5-15 minutes for Zitadel).
|
||||||
|
|
||||||
|
**Fix:** Add expiry check in the `jwt` callback and use `refreshToken` to obtain a new access token when expired.
|
||||||
|
|
||||||
|
### JWKS cache thundering herd (`crates/pvm-auth/src/jwks.rs`)
|
||||||
|
When the cache expires, every concurrent request sees stale cache and calls `refresh()` simultaneously. The `RwLock` serializes writes but each request still makes an HTTP call before acquiring the lock.
|
||||||
|
|
||||||
|
**Fix:** Add a "refresh-in-progress" flag or use double-checked locking so only one request triggers the refresh while others wait.
|
||||||
|
|
||||||
|
## Medium Priority
|
||||||
|
|
||||||
|
### `trustHost: true` in auth.ts
|
||||||
|
Disables CSRF origin check in Auth.js. Required for local dev behind localhost, but must be removed or made conditional for production.
|
||||||
|
|
||||||
|
### `devMode: true` in OIDC app config (`docker/setup-zitadel.sh`)
|
||||||
|
Disables redirect URI validation in Zitadel. The setup script is dev-only, but if a similar script is used for production, this must be `false`.
|
||||||
|
|
||||||
|
### Custom login UI
|
||||||
|
Replace the default Zitadel login v1 UI with a fully custom login/signup flow built into the SvelteKit dashboard using Zitadel's Session API. Includes: login, signup, password reset, 2FA flows. Must match PVM visual design.
|
||||||
|
|
||||||
|
## Low Priority
|
||||||
|
|
||||||
|
### Shell script JSON parsing (`docker/setup-zitadel.sh`)
|
||||||
|
Uses `grep -o` and `cut` to extract JSON fields. Fragile if JSON format changes. Consider using `jq` with a fallback.
|
||||||
|
|
||||||
|
### PAT expiration
|
||||||
|
Machine user PAT expires 2030-01-01. Fine for dev, but production should use shorter-lived credentials.
|
||||||
|
|
||||||
|
### Running Zitadel as root (`docker-compose.dev.yml`)
|
||||||
|
`user: "0"` is required for `start-from-init` to write the PAT file. Dev-only concern — production deployment should use proper volume permissions.
|
||||||
Loading…
Add table
Reference in a new issue