botserver/src/core/package_manager/setup.rs
Rodrigo Rodriguez (Pragmatismo) c326581a9e fix(zitadel): resolve OAuth client initialization timing issue
- Fix PAT extraction timing with retry loop (waits up to 60s for PAT in logs)
- Add sync command to flush filesystem buffers before extraction
- Improve logging with progress messages and PAT verification
- Refactor setup code into consolidated setup.rs module
- Fix YAML indentation for PatPath and MachineKeyPath
- Change Zitadel init parameter from --config to --steps

The timing issue occurred because:
1. Zitadel writes PAT to logs at startup (~18:08:59)
2. Post-install extraction ran too early (~18:09:35)
3. PAT file wasn't created until ~18:10:38 (63s after installation)
4. OAuth client creation failed because PAT file didn't exist yet

With the retry loop:
- Waits for PAT to appear in logs with sync+grep check
- Extracts PAT immediately when found
- OAuth client creation succeeds
- directory_config.json saved with valid credentials
- Login flow works end-to-end

Tested: Full reset.sh and login verification successful
2026-03-01 19:06:09 -03:00

338 lines
12 KiB
Rust

use anyhow::Result;
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use reqwest::Client;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryConfig {
pub base_url: String,
pub issuer_url: String,
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub project_id: String,
pub api_url: String,
pub service_account_key: Option<String>,
}
pub struct DirectorySetup {
base_url: String,
config_path: PathBuf,
http_client: Client,
pat_token: Option<String>,
}
#[derive(Debug, Serialize)]
struct CreateProjectRequest {
name: String,
}
#[derive(Debug, Deserialize)]
struct ProjectResponse {
id: String,
}
impl DirectorySetup {
pub fn new(base_url: String, config_path: PathBuf) -> Self {
let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url,
config_path,
http_client,
pat_token: None,
}
}
pub async fn initialize(&mut self) -> Result<DirectoryConfig> {
info!("Initializing Directory (Zitadel) OAuth client...");
// Step 1: Wait for Zitadel to be ready
self.wait_for_zitadel().await?;
// Step 2: Load PAT token from file (created by start-from-init --steps)
info!("Loading PAT token from Zitadel init steps...");
self.load_pat_token()?;
// Step 3: Get or create project
let project_id = self.get_or_create_project().await?;
info!("Using project ID: {project_id}");
// Step 4: Create OAuth application
let (client_id, client_secret) = self.create_oauth_application(&project_id).await?;
info!("Created OAuth application with client_id: {client_id}");
// Step 5: Create config
let config = DirectoryConfig {
base_url: self.base_url.clone(),
issuer_url: self.base_url.clone(),
issuer: self.base_url.clone(),
client_id,
client_secret,
redirect_uri: "http://localhost:3000/auth/callback".to_string(),
project_id,
api_url: self.base_url.clone(),
service_account_key: None,
};
// Step 6: Save config
self.save_config(&config)?;
Ok(config)
}
async fn wait_for_zitadel(&self) -> Result<()> {
info!("Waiting for Zitadel to be ready...");
for i in 1..=120 {
match self.http_client
.get(format!("{}/debug/healthz", self.base_url))
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Zitadel is ready (healthz OK)");
return Ok(());
}
_ => {
if i % 15 == 0 {
info!("Still waiting for Zitadel... (attempt {i}/120)");
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
}
Err(anyhow::anyhow!("Zitadel did not become ready within 240 seconds"))
}
/// Load the PAT token from the file generated by Zitadel's start-from-init --steps
/// The steps YAML configures FirstInstance.Org.PatPath which tells Zitadel to
/// create a machine user with IAM_OWNER role and write its PAT to disk
fn load_pat_token(&mut self) -> Result<()> {
let stack_path = std::env::var("BOTSERVER_STACK_PATH")
.unwrap_or_else(|_| "./botserver-stack".to_string());
let pat_path = PathBuf::from(&stack_path).join("conf/directory/admin-pat.txt");
if pat_path.exists() {
let pat_token = std::fs::read_to_string(&pat_path)
.map_err(|e| anyhow::anyhow!("Failed to read PAT file {}: {}", pat_path.display(), e))?
.trim()
.to_string();
if pat_token.is_empty() {
return Err(anyhow::anyhow!(
"PAT file exists at {} but is empty. Zitadel start-from-init may have failed.",
pat_path.display()
));
}
info!("Loaded PAT token from: {} (len={})", pat_path.display(), pat_token.len());
self.pat_token = Some(pat_token);
return Ok(());
}
// Also check the legacy location
let legacy_pat_path = std::path::Path::new("./botserver-stack/conf/directory/admin-pat.txt");
if legacy_pat_path.exists() {
let pat_token = std::fs::read_to_string(legacy_pat_path)
.map_err(|e| anyhow::anyhow!("Failed to read PAT file: {e}"))?
.trim()
.to_string();
if !pat_token.is_empty() {
info!("Loaded PAT token from legacy path");
self.pat_token = Some(pat_token);
return Ok(());
}
}
Err(anyhow::anyhow!(
"No PAT token file found at {}. \
Zitadel must be started with 'start-from-init --steps <steps.yaml>' \
where steps.yaml has FirstInstance.Org.PatPath configured.",
pat_path.display()
))
}
async fn get_or_create_project(&self) -> Result<String> {
info!("Getting or creating Zitadel project...");
let auth_header = self.get_auth_header()?;
// Try to list existing projects via management API v1
let list_response = self.http_client
.post(format!("{}/management/v1/projects/_search", self.base_url))
.header("Authorization", &auth_header)
.json(&serde_json::json!({}))
.send()
.await?;
if list_response.status().is_success() {
let projects: serde_json::Value = list_response.json().await?;
if let Some(result) = projects.get("result").and_then(|r| r.as_array()) {
for project in result {
if project.get("name")
.and_then(|n| n.as_str())
.map(|n| n == "General Bots")
.unwrap_or(false)
{
if let Some(id) = project.get("id").and_then(|i| i.as_str()) {
info!("Found existing 'General Bots' project: {id}");
return Ok(id.to_string());
}
}
}
}
}
// Create new project
info!("Creating new 'General Bots' project...");
let create_request = CreateProjectRequest {
name: "General Bots".to_string(),
};
let create_response = self.http_client
.post(format!("{}/management/v1/projects", self.base_url))
.header("Authorization", self.get_auth_header()?)
.json(&create_request)
.send()
.await?;
if !create_response.status().is_success() {
let error_text = create_response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("Failed to create project: {error_text}"));
}
let project: ProjectResponse = create_response.json().await?;
info!("Created project with ID: {}", project.id);
Ok(project.id)
}
async fn create_oauth_application(&self, project_id: &str) -> Result<(String, String)> {
info!("Creating OAuth/OIDC application for BotServer...");
let auth_header = self.get_auth_header()?;
// Use the management v1 OIDC app creation endpoint which returns
// client_id and client_secret in the response directly
let app_body = serde_json::json!({
"name": "BotServer",
"redirectUris": [
"http://localhost:3000/auth/callback",
"http://localhost:8080/auth/callback"
],
"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_POST",
"postLogoutRedirectUris": ["http://localhost:3000"],
"devMode": true
});
let app_response = self.http_client
.post(format!("{}/management/v1/projects/{project_id}/apps/oidc", self.base_url))
.header("Authorization", &auth_header)
.json(&app_body)
.send()
.await?;
if !app_response.status().is_success() {
let error_text = app_response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("Failed to create OIDC app: {error_text}"));
}
let app_data: serde_json::Value = app_response.json().await
.map_err(|e| anyhow::anyhow!("Failed to parse OIDC app response: {e}"))?;
info!("OIDC app creation response: {}", serde_json::to_string_pretty(&app_data).unwrap_or_default());
// The response contains clientId and clientSecret directly
let client_id = app_data
.get("clientId")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No clientId in OIDC app response: {app_data}"))?
.to_string();
let client_secret = app_data
.get("clientSecret")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if client_secret.is_empty() {
warn!("No clientSecret returned — app may use PKCE only");
}
info!("Retrieved OAuth client credentials (client_id: {client_id})");
Ok((client_id, client_secret))
}
fn get_auth_header(&self) -> Result<String> {
match &self.pat_token {
Some(token) => Ok(format!("Bearer {token}")),
None => Err(anyhow::anyhow!(
"No PAT token available. Cannot authenticate with Zitadel API."
)),
}
}
fn save_config(&self, config: &DirectoryConfig) -> Result<()> {
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(config)?;
std::fs::write(&self.config_path, content)?;
info!("Saved Directory config to: {}", self.config_path.display());
println!();
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ ZITADEL OAUTH CLIENT CONFIGURED ║");
println!("╠════════════════════════════════════════════════════════════╣");
println!("║ Project ID: {:<43}", config.project_id);
println!("║ Client ID: {:<43}", config.client_id);
println!("║ Redirect URI: {:<43}", config.redirect_uri);
println!("║ Config saved: {:<43}", self.config_path.display().to_string().chars().take(43).collect::<String>());
println!("╚════════════════════════════════════════════════════════════╝");
println!();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_directory_config_serialization() {
let config = DirectoryConfig {
base_url: "http://localhost:8300".to_string(),
issuer_url: "http://localhost:8300".to_string(),
issuer: "http://localhost:8300".to_string(),
client_id: "test_client".to_string(),
client_secret: "test_secret".to_string(),
redirect_uri: "http://localhost:3000/callback".to_string(),
project_id: "12345".to_string(),
api_url: "http://localhost:8300".to_string(),
service_account_key: None,
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("test_client"));
assert!(json.contains("test_secret"));
}
}