Skip to main content

ito_core/
backend_auth.rs

1//! Backend server authentication setup and token resolution.
2//!
3//! This module owns the precedence logic for assembling auth credentials
4//! (CLI flags > environment variables > global config file) and the `--init`
5//! orchestration that generates and persists tokens on first run.
6
7use std::path::Path;
8
9use ito_config::{ConfigContext, global_config_path, load_global_ito_config};
10
11use crate::errors::{CoreError, CoreResult};
12
13// ---------------------------------------------------------------------------
14// Init result
15// ---------------------------------------------------------------------------
16
17/// Outcome of [`init_backend_auth`].
18#[derive(Debug)]
19pub enum InitAuthResult {
20    /// Auth was already configured — no changes were made.
21    AlreadyConfigured {
22        /// Display-friendly path to the global config file.
23        config_path: String,
24    },
25    /// New tokens were generated and persisted.
26    Generated {
27        /// Display-friendly path to the global config file.
28        config_path: String,
29    },
30}
31
32// ---------------------------------------------------------------------------
33// Init orchestration
34// ---------------------------------------------------------------------------
35
36/// Check for existing backend auth in the global config; if absent, generate
37/// new tokens and persist them.
38///
39/// Returns an [`InitAuthResult`] describing what happened.
40pub fn init_backend_auth(ctx: &ConfigContext) -> CoreResult<InitAuthResult> {
41    let global_config = load_global_ito_config(ctx);
42    let existing_auth = &global_config.backend_server.auth;
43
44    let has_tokens = existing_auth
45        .admin_tokens
46        .iter()
47        .any(|t| !t.trim().is_empty());
48
49    let config_display = global_config_display_path(ctx);
50
51    if has_tokens {
52        return Ok(InitAuthResult::AlreadyConfigured {
53            config_path: config_display,
54        });
55    }
56
57    let admin_token = crate::token::generate_token();
58    let token_seed = crate::token::generate_token();
59
60    write_auth_to_global_config(ctx, &admin_token, &token_seed)?;
61
62    Ok(InitAuthResult::Generated {
63        config_path: config_display,
64    })
65}
66
67// ---------------------------------------------------------------------------
68// Token resolution (CLI > env > config)
69// ---------------------------------------------------------------------------
70
71/// Resolve admin tokens by merging CLI flags, the `ITO_BACKEND_ADMIN_TOKEN`
72/// env var, and global config entries — in that precedence order.
73///
74/// All non-empty sources contribute; duplicates are removed.
75pub fn resolve_admin_tokens(cli_tokens: &[String], config_tokens: &[String]) -> Vec<String> {
76    let mut tokens: Vec<String> = cli_tokens.to_vec();
77
78    if let Ok(env_token) = std::env::var("ITO_BACKEND_ADMIN_TOKEN") {
79        let trimmed = env_token.trim().to_string();
80        if !trimmed.is_empty() && !tokens.contains(&trimmed) {
81            tokens.push(trimmed);
82        }
83    }
84
85    for token in config_tokens {
86        let trimmed = token.trim().to_string();
87        if !trimmed.is_empty() && !tokens.contains(&trimmed) {
88            tokens.push(trimmed);
89        }
90    }
91
92    tokens
93}
94
95/// Resolve the HMAC token seed from CLI flag, the `ITO_BACKEND_TOKEN_SEED`
96/// env var, or global config — returning the first non-empty value found.
97pub fn resolve_token_seed(
98    cli_seed: &Option<String>,
99    config_seed: &Option<String>,
100) -> Option<String> {
101    if let Some(seed) = cli_seed.as_ref().filter(|s| !s.trim().is_empty()) {
102        return Some(seed.clone());
103    }
104
105    if let Ok(env_seed) = std::env::var("ITO_BACKEND_TOKEN_SEED") {
106        let trimmed = env_seed.trim().to_string();
107        if !trimmed.is_empty() {
108            return Some(trimmed);
109        }
110    }
111
112    if let Some(seed) = config_seed.as_ref().filter(|s| !s.trim().is_empty()) {
113        return Some(seed.clone());
114    }
115
116    None
117}
118
119// ---------------------------------------------------------------------------
120// Config persistence
121// ---------------------------------------------------------------------------
122
123/// Return a display-friendly path to the global config file.
124pub fn global_config_display_path(ctx: &ConfigContext) -> String {
125    global_config_path(ctx)
126        .map(|p| p.display().to_string())
127        .unwrap_or_else(|| "~/.config/ito/config.json".to_string())
128}
129
130/// Write admin token and token seed into the global config file.
131///
132/// Reads the existing config (if any), merges in the new auth values, and
133/// writes back. Creates the config directory and file if needed, with
134/// restrictive permissions (directory 0700, file 0600 on Unix).
135pub fn write_auth_to_global_config(
136    ctx: &ConfigContext,
137    admin_token: &str,
138    token_seed: &str,
139) -> CoreResult<()> {
140    let Some(config_path) = global_config_path(ctx) else {
141        return Err(CoreError::validation(
142            "Cannot determine global config path (HOME not set)",
143        ));
144    };
145
146    // Ensure parent directory exists with restrictive permissions (0700)
147    if let Some(parent) = config_path.parent() {
148        std::fs::create_dir_all(parent)
149            .map_err(|e| CoreError::io(format!("create config dir {}", parent.display()), e))?;
150
151        #[cfg(unix)]
152        {
153            use std::os::unix::fs::PermissionsExt;
154            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(
155                |e| CoreError::io(format!("set permissions on {}", parent.display()), e),
156            )?;
157        }
158    }
159
160    // Read existing config as raw JSON to preserve unrelated fields
161    let mut doc: serde_json::Value = if config_path.exists() {
162        let contents = std::fs::read_to_string(&config_path)
163            .map_err(|e| CoreError::io(format!("read {}", config_path.display()), e))?;
164        serde_json::from_str(&contents).unwrap_or_else(|_| serde_json::json!({}))
165    } else {
166        serde_json::json!({})
167    };
168
169    // Merge in the new auth values
170    let root = doc.as_object_mut().ok_or_else(|| {
171        CoreError::validation(format!(
172            "{} is not a JSON object; delete it and re-run --init",
173            config_path.display()
174        ))
175    })?;
176
177    let backend_server = root
178        .entry("backendServer")
179        .or_insert_with(|| serde_json::json!({}))
180        .as_object_mut()
181        .ok_or_else(|| CoreError::validation("config key 'backendServer' must be a JSON object"))?;
182
183    let auth_obj = backend_server
184        .entry("auth")
185        .or_insert_with(|| serde_json::json!({}))
186        .as_object_mut()
187        .ok_or_else(|| {
188            CoreError::validation("config key 'backendServer.auth' must be a JSON object")
189        })?;
190
191    auth_obj.insert("adminTokens".to_string(), serde_json::json!([admin_token]));
192    auth_obj.insert("tokenSeed".to_string(), serde_json::json!(token_seed));
193
194    // Write back with pretty formatting and restrictive permissions (0600)
195    let formatted = serde_json::to_string_pretty(&doc)
196        .map_err(|e| CoreError::serde("serialize global config", e.to_string()))?;
197
198    write_config_file(&config_path, &(formatted + "\n"))?;
199
200    Ok(())
201}
202
203/// Write content to a file with restrictive permissions (0600 on Unix).
204///
205/// On non-Unix platforms, falls back to `std::fs::write` which uses the
206/// process umask.
207fn write_config_file(path: &Path, content: &str) -> CoreResult<()> {
208    #[cfg(unix)]
209    {
210        use std::io::Write;
211        use std::os::unix::fs::OpenOptionsExt;
212
213        let mut file = std::fs::OpenOptions::new()
214            .write(true)
215            .create(true)
216            .truncate(true)
217            .mode(0o600)
218            .open(path)
219            .map_err(|e| CoreError::io(format!("write {}", path.display()), e))?;
220
221        file.write_all(content.as_bytes())
222            .map_err(|e| CoreError::io(format!("write {}", path.display()), e))?;
223    }
224
225    #[cfg(not(unix))]
226    {
227        std::fs::write(path, content)
228            .map_err(|e| CoreError::io(format!("write {}", path.display()), e))?;
229    }
230
231    Ok(())
232}