Skip to main content

omni_dev/atlassian/
auth.rs

1//! Atlassian credential management.
2//!
3//! Loads and saves Atlassian Cloud API credentials from/to the
4//! `~/.omni-dev/settings.json` file using the existing `env` map.
5
6use std::collections::HashMap;
7use std::fs;
8
9use anyhow::{Context, Result};
10use serde::Serialize;
11
12use crate::atlassian::error::AtlassianError;
13use crate::utils::settings::Settings;
14
15/// Environment variable / settings key for the Atlassian instance URL.
16pub const ATLASSIAN_INSTANCE_URL: &str = "ATLASSIAN_INSTANCE_URL";
17
18/// Environment variable / settings key for the Atlassian user email.
19pub const ATLASSIAN_EMAIL: &str = "ATLASSIAN_EMAIL";
20
21/// Environment variable / settings key for the Atlassian API token.
22pub const ATLASSIAN_API_TOKEN: &str = "ATLASSIAN_API_TOKEN";
23
24/// Atlassian Cloud credentials.
25#[derive(Debug, Clone)]
26pub struct AtlassianCredentials {
27    /// Instance base URL (e.g., "https://myorg.atlassian.net").
28    pub instance_url: String,
29
30    /// User email address.
31    pub email: String,
32
33    /// API token.
34    pub api_token: String,
35}
36
37/// Loads Atlassian credentials from environment variables or settings.json.
38///
39/// Checks environment variables first, then falls back to the settings file.
40pub fn load_credentials() -> Result<AtlassianCredentials> {
41    let settings = Settings::load().unwrap_or(Settings {
42        env: HashMap::new(),
43    });
44
45    let instance_url = settings
46        .get_env_var(ATLASSIAN_INSTANCE_URL)
47        .ok_or(AtlassianError::CredentialsNotFound)?;
48    let email = settings
49        .get_env_var(ATLASSIAN_EMAIL)
50        .ok_or(AtlassianError::CredentialsNotFound)?;
51    let api_token = settings
52        .get_env_var(ATLASSIAN_API_TOKEN)
53        .ok_or(AtlassianError::CredentialsNotFound)?;
54
55    // Normalize: strip trailing slash from instance URL
56    let instance_url = instance_url.trim_end_matches('/').to_string();
57
58    Ok(AtlassianCredentials {
59        instance_url,
60        email,
61        api_token,
62    })
63}
64
65/// Summary of a single Atlassian credential scope.
66///
67/// Reports which credential keys are present without exposing their values.
68/// Safe to serialize and return over the MCP surface.
69#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
70pub struct AtlassianScopeStatus {
71    /// Scope name (currently always `"default"`; forward-compatible for
72    /// per-instance scopes).
73    pub name: String,
74    /// Whether [`ATLASSIAN_EMAIL`] is present.
75    pub has_email: bool,
76    /// Whether [`ATLASSIAN_API_TOKEN`] is present. Token value is never exposed.
77    pub has_token: bool,
78    /// Value of [`ATLASSIAN_INSTANCE_URL`] when set. The URL is considered
79    /// non-secret; returning it helps the assistant surface which instance
80    /// a scope targets without exposing credentials.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub instance_url: Option<String>,
83}
84
85/// Aggregate credential status across every known Atlassian scope.
86#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
87pub struct AuthStatus {
88    /// One entry per scope. Currently a single default scope; kept as a list
89    /// so future multi-instance support does not require a schema change.
90    pub scopes: Vec<AtlassianScopeStatus>,
91}
92
93/// Builds an [`AuthStatus`] from the current settings / environment.
94///
95/// Reports credential presence without leaking any secret values.
96/// [`AtlassianScopeStatus::instance_url`] is returned verbatim when set —
97/// URLs are explicitly non-secret; tokens and emails are flagged as booleans
98/// only. Safe to call with no credentials configured (returns a scope with
99/// every flag `false`).
100pub fn status() -> AuthStatus {
101    let settings = Settings::load().unwrap_or(Settings {
102        env: HashMap::new(),
103    });
104
105    let instance_url = settings
106        .get_env_var(ATLASSIAN_INSTANCE_URL)
107        .map(|v| v.trim_end_matches('/').to_string());
108    let has_email = settings.get_env_var(ATLASSIAN_EMAIL).is_some();
109    let has_token = settings.get_env_var(ATLASSIAN_API_TOKEN).is_some();
110
111    AuthStatus {
112        scopes: vec![AtlassianScopeStatus {
113            name: "default".to_string(),
114            has_email,
115            has_token,
116            instance_url,
117        }],
118    }
119}
120
121/// Saves Atlassian credentials to `~/.omni-dev/settings.json`.
122///
123/// Reads the existing settings file, merges the new credential keys into
124/// the `env` map, and writes back. Preserves all other settings.
125pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
126    let settings_path = Settings::get_settings_path()?;
127
128    // Read existing settings as a generic JSON value to preserve unknown fields
129    let mut settings_value: serde_json::Value = if settings_path.exists() {
130        let content = fs::read_to_string(&settings_path)
131            .with_context(|| format!("Failed to read {}", settings_path.display()))?;
132        serde_json::from_str(&content)
133            .with_context(|| format!("Failed to parse {}", settings_path.display()))?
134    } else {
135        serde_json::json!({})
136    };
137
138    // Ensure the "env" key exists as an object
139    if !settings_value
140        .get("env")
141        .is_some_and(serde_json::Value::is_object)
142    {
143        settings_value["env"] = serde_json::json!({});
144    }
145
146    // Merge credential keys — safe because we just ensured "env" is an object above
147    let Some(env) = settings_value["env"].as_object_mut() else {
148        anyhow::bail!("Internal error: env key is not an object after initialization");
149    };
150    env.insert(
151        ATLASSIAN_INSTANCE_URL.to_string(),
152        serde_json::Value::String(credentials.instance_url.clone()),
153    );
154    env.insert(
155        ATLASSIAN_EMAIL.to_string(),
156        serde_json::Value::String(credentials.email.clone()),
157    );
158    env.insert(
159        ATLASSIAN_API_TOKEN.to_string(),
160        serde_json::Value::String(credentials.api_token.clone()),
161    );
162
163    // Ensure parent directory exists
164    if let Some(parent) = settings_path.parent() {
165        fs::create_dir_all(parent)
166            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
167    }
168
169    // Write back
170    let formatted = serde_json::to_string_pretty(&settings_value)
171        .context("Failed to serialize settings JSON")?;
172    fs::write(&settings_path, formatted)
173        .with_context(|| format!("Failed to write {}", settings_path.display()))?;
174
175    Ok(())
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used, clippy::expect_used)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn save_and_read_credentials() {
185        let temp_dir = {
186            std::fs::create_dir_all("tmp").ok();
187            tempfile::TempDir::new_in("tmp").unwrap()
188        };
189        let settings_path = temp_dir.path().join("settings.json");
190
191        // Start with existing settings
192        let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
193        fs::write(&settings_path, existing).unwrap();
194
195        // Read it back as a value, add credentials, write
196        let content = fs::read_to_string(&settings_path).unwrap();
197        let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
198        val["env"]["ATLASSIAN_INSTANCE_URL"] =
199            serde_json::Value::String("https://test.atlassian.net".to_string());
200        val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
201        val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
202        let formatted = serde_json::to_string_pretty(&val).unwrap();
203        fs::write(&settings_path, formatted).unwrap();
204
205        // Verify existing keys are preserved
206        let content = fs::read_to_string(&settings_path).unwrap();
207        let val: serde_json::Value = serde_json::from_str(&content).unwrap();
208        assert_eq!(val["env"]["SOME_KEY"], "value");
209        assert_eq!(
210            val["env"]["ATLASSIAN_INSTANCE_URL"],
211            "https://test.atlassian.net"
212        );
213        assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
214        assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
215    }
216
217    #[test]
218    fn load_credentials_normalizes_trailing_slash() {
219        // Test the trailing-slash normalization logic directly
220        let url = "https://env.atlassian.net/";
221        let normalized = url.trim_end_matches('/').to_string();
222        assert_eq!(normalized, "https://env.atlassian.net");
223    }
224
225    #[test]
226    fn constant_key_names() {
227        assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
228        assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
229        assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
230    }
231
232    #[test]
233    fn credentials_struct_clone_and_debug() {
234        let creds = AtlassianCredentials {
235            instance_url: "https://org.atlassian.net".to_string(),
236            email: "user@test.com".to_string(),
237            api_token: "token".to_string(),
238        };
239        let cloned = creds.clone();
240        assert_eq!(cloned.instance_url, creds.instance_url);
241        assert_eq!(cloned.email, creds.email);
242        assert_eq!(cloned.api_token, creds.api_token);
243        // Verify Debug is implemented
244        let debug = format!("{creds:?}");
245        assert!(debug.contains("AtlassianCredentials"));
246    }
247
248    /// Mutex shared by every test that mutates `HOME` and the Atlassian
249    /// credential env vars. Serialises those tests against each other so
250    /// parallel execution doesn't race on process-wide env state.
251    static AUTH_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
252
253    /// RAII guard: snapshots `HOME` + every Atlassian credential env var on
254    /// construction and restores them on drop. Concentrating the save/restore
255    /// branches into one place (here) instead of inlining them in each test
256    /// keeps coverage high — every test exercises the same guard drop path.
257    struct EnvGuard {
258        _lock: std::sync::MutexGuard<'static, ()>,
259        snapshot: Vec<(&'static str, Option<String>)>,
260    }
261
262    impl EnvGuard {
263        fn take() -> Self {
264            let lock = AUTH_ENV_MUTEX
265                .lock()
266                .unwrap_or_else(std::sync::PoisonError::into_inner);
267            let keys = [
268                "HOME",
269                ATLASSIAN_INSTANCE_URL,
270                ATLASSIAN_EMAIL,
271                ATLASSIAN_API_TOKEN,
272            ];
273            let snapshot = keys
274                .into_iter()
275                .map(|k| (k, std::env::var(k).ok()))
276                .collect();
277            Self {
278                _lock: lock,
279                snapshot,
280            }
281        }
282    }
283
284    impl Drop for EnvGuard {
285        fn drop(&mut self) {
286            for (k, v) in &self.snapshot {
287                match v {
288                    Some(val) => std::env::set_var(k, val),
289                    None => std::env::remove_var(k),
290                }
291            }
292        }
293    }
294
295    fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
296        let dir = {
297            std::fs::create_dir_all("tmp").ok();
298            tempfile::TempDir::new_in("tmp").unwrap()
299        };
300        std::env::set_var("HOME", dir.path());
301        std::env::remove_var(ATLASSIAN_INSTANCE_URL);
302        std::env::remove_var(ATLASSIAN_EMAIL);
303        std::env::remove_var(ATLASSIAN_API_TOKEN);
304        dir
305    }
306
307    #[test]
308    fn status_reports_all_false_when_nothing_configured() {
309        let guard = EnvGuard::take();
310        let _dir = with_empty_home(&guard);
311
312        let status = status();
313        assert_eq!(status.scopes.len(), 1);
314        let scope = &status.scopes[0];
315        assert_eq!(scope.name, "default");
316        assert!(!scope.has_email);
317        assert!(!scope.has_token);
318        assert_eq!(scope.instance_url, None);
319    }
320
321    #[test]
322    fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
323        let guard = EnvGuard::take();
324        let dir = with_empty_home(&guard);
325        let omni_dir = dir.path().join(".omni-dev");
326        fs::create_dir_all(&omni_dir).unwrap();
327        fs::write(
328            omni_dir.join("settings.json"),
329            r#"{"env":{
330                "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
331                "ATLASSIAN_EMAIL":"person@example.com",
332                "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
333            }}"#,
334        )
335        .unwrap();
336
337        let status = status();
338        assert_eq!(status.scopes.len(), 1);
339        let scope = &status.scopes[0];
340        assert!(scope.has_email);
341        assert!(scope.has_token);
342        assert_eq!(
343            scope.instance_url.as_deref(),
344            Some("https://status.atlassian.net")
345        );
346
347        let yaml = serde_yaml::to_string(&status).unwrap();
348        assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
349        assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
350    }
351
352    #[test]
353    fn status_returns_instance_url_from_env_without_trailing_slash() {
354        let guard = EnvGuard::take();
355        let _dir = with_empty_home(&guard);
356        std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
357
358        let status = status();
359        let scope = &status.scopes[0];
360        assert_eq!(
361            scope.instance_url.as_deref(),
362            Some("https://env.atlassian.net")
363        );
364        assert!(!scope.has_email);
365        assert!(!scope.has_token);
366    }
367
368    /// Single test for save_credentials to avoid HOME env var race conditions.
369    /// Tests both fresh-file creation and merge-with-existing in sequence.
370    #[test]
371    fn save_credentials_creates_and_preserves() {
372        // Share the mutex with the other env-mutating tests in this module
373        // so that setting HOME here doesn't race with `status()` tests.
374        let _guard = EnvGuard::take();
375        let original_home = std::env::var("HOME").ok();
376
377        // ── Part 1: creates file from scratch ──────────────────────
378        {
379            let temp_dir = {
380                std::fs::create_dir_all("tmp").ok();
381                tempfile::TempDir::new_in("tmp").unwrap()
382            };
383            std::env::set_var("HOME", temp_dir.path());
384
385            let creds = AtlassianCredentials {
386                instance_url: "https://save.atlassian.net".to_string(),
387                email: "save@example.com".to_string(),
388                api_token: "save-token".to_string(),
389            };
390            save_credentials(&creds).unwrap();
391
392            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
393            assert!(settings_path.exists());
394            let content = fs::read_to_string(&settings_path).unwrap();
395            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
396            assert_eq!(
397                val["env"]["ATLASSIAN_INSTANCE_URL"],
398                "https://save.atlassian.net"
399            );
400            assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
401            assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
402        }
403
404        // ── Part 2: preserves existing keys ────────────────────────
405        {
406            let temp_dir = {
407                std::fs::create_dir_all("tmp").ok();
408                tempfile::TempDir::new_in("tmp").unwrap()
409            };
410            let omni_dir = temp_dir.path().join(".omni-dev");
411            fs::create_dir_all(&omni_dir).unwrap();
412            let settings_path = omni_dir.join("settings.json");
413            fs::write(
414                &settings_path,
415                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
416            )
417            .unwrap();
418
419            std::env::set_var("HOME", temp_dir.path());
420
421            let creds = AtlassianCredentials {
422                instance_url: "https://org.atlassian.net".to_string(),
423                email: "user@test.com".to_string(),
424                api_token: "token".to_string(),
425            };
426            save_credentials(&creds).unwrap();
427
428            let content = fs::read_to_string(&settings_path).unwrap();
429            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
430            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
431            assert_eq!(val["extra"], true);
432            assert_eq!(
433                val["env"]["ATLASSIAN_INSTANCE_URL"],
434                "https://org.atlassian.net"
435            );
436        }
437
438        // Restore HOME
439        if let Some(home) = original_home {
440            std::env::set_var("HOME", home);
441        }
442    }
443}