diff --git a/.gitignore b/.gitignore index c9b357c..468ddf7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ Thumbs.db # Docker docker/data/ +docker/machinekey/ diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 1287633..7e94702 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -1,5 +1,6 @@ # 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_SECRET=your-client-secret @@ -10,7 +11,7 @@ AUTH_SECRET=your-auth-secret PUBLIC_API_URL=http://localhost:3001 # 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) ORIGIN=http://localhost:5173 diff --git a/crates/pvm-auth/src/middleware.rs b/crates/pvm-auth/src/middleware.rs index 8f524d7..0835d17 100644 --- a/crates/pvm-auth/src/middleware.rs +++ b/crates/pvm-auth/src/middleware.rs @@ -2,7 +2,7 @@ use axum::extract::{FromRef, FromRequestParts}; use axum::http::request::Parts; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; use serde::Serialize; use tracing::warn; @@ -106,10 +106,16 @@ where let decoding_key = DecodingKey::from_jwk(jwk) .map_err(|e| AuthError::InvalidToken(e.to_string()))?; - // Validate the token - let mut validation = Validation::new( - header.alg, - ); + // Validate the token — restrict to RSA algorithms only (Zitadel uses RS256). + // Never trust the alg from the JWT header directly to prevent algorithm confusion attacks. + 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_issuer(&[&auth_state.issuer]); diff --git a/docker/.env.example b/docker/.env.example index a864cc8..8db8778 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -3,5 +3,10 @@ ZITADEL_MASTERKEY=changeMe_must_be_exactly_32_char ZITADEL_DB_PASSWORD=zitadel-dev-password 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_DB_PASSWORD=pvm-dev-password diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 068d224..2b672dd 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -2,6 +2,7 @@ services: zitadel: image: ghcr.io/zitadel/zitadel:latest command: start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled + user: "0" environment: ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db ZITADEL_DATABASE_POSTGRES_PORT: 5432 @@ -19,11 +20,25 @@ services: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "${ZITADEL_ADMIN_PASSWORD}" 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: - "8080:8080" depends_on: zitadel-db: condition: service_healthy + volumes: + - ./machinekey:/machinekey healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/debug/healthz"] interval: 10s diff --git a/docker/setup-zitadel.sh b/docker/setup-zitadel.sh new file mode 100755 index 0000000..94f4941 --- /dev/null +++ b/docker/setup-zitadel.sh @@ -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" <