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    load_credentials_with_instance(None)
42}
43
44/// Loads Atlassian credentials, optionally overriding the instance URL.
45///
46/// When `instance_override` is `Some`, that URL is used verbatim (after
47/// trailing-slash normalization) and the `ATLASSIAN_INSTANCE_URL` env /
48/// settings lookup is skipped — so a caller-supplied instance (e.g.
49/// `jira create --instance`) works even when no instance is configured in the
50/// environment. `ATLASSIAN_EMAIL` and `ATLASSIAN_API_TOKEN` are still required.
51/// When `None`, behaves exactly like [`load_credentials`].
52pub fn load_credentials_with_instance(
53    instance_override: Option<&str>,
54) -> Result<AtlassianCredentials> {
55    let settings = Settings::load().unwrap_or(Settings {
56        env: HashMap::new(),
57    });
58
59    let instance_url = match instance_override {
60        Some(url) => url.to_string(),
61        None => settings
62            .get_env_var(ATLASSIAN_INSTANCE_URL)
63            .ok_or(AtlassianError::CredentialsNotFound)?,
64    };
65    let email = settings
66        .get_env_var(ATLASSIAN_EMAIL)
67        .ok_or(AtlassianError::CredentialsNotFound)?;
68    let api_token = settings
69        .get_env_var(ATLASSIAN_API_TOKEN)
70        .ok_or(AtlassianError::CredentialsNotFound)?;
71
72    // Normalize: strip trailing slash from instance URL
73    let instance_url = instance_url.trim_end_matches('/').to_string();
74
75    Ok(AtlassianCredentials {
76        instance_url,
77        email,
78        api_token,
79    })
80}
81
82/// Summary of a single Atlassian credential scope.
83///
84/// Reports which credential keys are present without exposing their values.
85/// Safe to serialize and return over the MCP surface.
86#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
87pub struct AtlassianScopeStatus {
88    /// Scope name (currently always `"default"`; forward-compatible for
89    /// per-instance scopes).
90    pub name: String,
91    /// Whether [`ATLASSIAN_EMAIL`] is present.
92    pub has_email: bool,
93    /// Whether [`ATLASSIAN_API_TOKEN`] is present. Token value is never exposed.
94    pub has_token: bool,
95    /// Value of [`ATLASSIAN_INSTANCE_URL`] when set. The URL is considered
96    /// non-secret; returning it helps the assistant surface which instance
97    /// a scope targets without exposing credentials.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub instance_url: Option<String>,
100}
101
102/// Aggregate credential status across every known Atlassian scope.
103#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
104pub struct AuthStatus {
105    /// One entry per scope. Currently a single default scope; kept as a list
106    /// so future multi-instance support does not require a schema change.
107    pub scopes: Vec<AtlassianScopeStatus>,
108}
109
110/// Builds an [`AuthStatus`] from the current settings / environment.
111///
112/// Reports credential presence without leaking any secret values.
113/// [`AtlassianScopeStatus::instance_url`] is returned verbatim when set —
114/// URLs are explicitly non-secret; tokens and emails are flagged as booleans
115/// only. Safe to call with no credentials configured (returns a scope with
116/// every flag `false`).
117pub fn status() -> AuthStatus {
118    let settings = Settings::load().unwrap_or(Settings {
119        env: HashMap::new(),
120    });
121
122    let instance_url = settings
123        .get_env_var(ATLASSIAN_INSTANCE_URL)
124        .map(|v| v.trim_end_matches('/').to_string());
125    let has_email = settings.get_env_var(ATLASSIAN_EMAIL).is_some();
126    let has_token = settings.get_env_var(ATLASSIAN_API_TOKEN).is_some();
127
128    AuthStatus {
129        scopes: vec![AtlassianScopeStatus {
130            name: "default".to_string(),
131            has_email,
132            has_token,
133            instance_url,
134        }],
135    }
136}
137
138/// Saves Atlassian credentials to `~/.omni-dev/settings.json`.
139///
140/// Reads the existing settings file, merges the new credential keys into
141/// the `env` map, and writes back. Preserves all other settings.
142pub fn save_credentials(credentials: &AtlassianCredentials) -> Result<()> {
143    let settings_path = Settings::get_settings_path()?;
144
145    // Read existing settings as a generic JSON value to preserve unknown fields
146    let mut settings_value: serde_json::Value = if settings_path.exists() {
147        let content = fs::read_to_string(&settings_path)
148            .with_context(|| format!("Failed to read {}", settings_path.display()))?;
149        serde_json::from_str(&content)
150            .with_context(|| format!("Failed to parse {}", settings_path.display()))?
151    } else {
152        serde_json::json!({})
153    };
154
155    // Ensure the "env" key exists as an object
156    if !settings_value
157        .get("env")
158        .is_some_and(serde_json::Value::is_object)
159    {
160        settings_value["env"] = serde_json::json!({});
161    }
162
163    // Merge credential keys — safe because we just ensured "env" is an object above
164    let Some(env) = settings_value["env"].as_object_mut() else {
165        anyhow::bail!("Internal error: env key is not an object after initialization");
166    };
167    env.insert(
168        ATLASSIAN_INSTANCE_URL.to_string(),
169        serde_json::Value::String(credentials.instance_url.clone()),
170    );
171    env.insert(
172        ATLASSIAN_EMAIL.to_string(),
173        serde_json::Value::String(credentials.email.clone()),
174    );
175    env.insert(
176        ATLASSIAN_API_TOKEN.to_string(),
177        serde_json::Value::String(credentials.api_token.clone()),
178    );
179
180    // Ensure parent directory exists
181    if let Some(parent) = settings_path.parent() {
182        fs::create_dir_all(parent)
183            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
184    }
185
186    // Write back
187    let formatted = serde_json::to_string_pretty(&settings_value)
188        .context("Failed to serialize settings JSON")?;
189    fs::write(&settings_path, formatted)
190        .with_context(|| format!("Failed to write {}", settings_path.display()))?;
191
192    Ok(())
193}
194
195/// Crate-internal test utilities for code that calls [`load_credentials`] /
196/// [`crate::cli::atlassian::helpers::create_client`]. Lives here because it
197/// needs the credential constants and shares process-wide env state with
198/// auth.rs's own tests — every consumer must serialise on
199/// [`AUTH_ENV_MUTEX`] to avoid racing.
200#[cfg(test)]
201#[allow(clippy::unwrap_used, clippy::expect_used)]
202pub(crate) mod test_util {
203    use super::{ATLASSIAN_API_TOKEN, ATLASSIAN_EMAIL, ATLASSIAN_INSTANCE_URL};
204
205    /// Mutex shared by every test that mutates `HOME` and the Atlassian
206    /// credential env vars. Serialises those tests against each other so
207    /// parallel execution doesn't race on process-wide env state.
208    pub(crate) static AUTH_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
209
210    /// RAII guard: snapshots `HOME` + every Atlassian credential env var on
211    /// construction and restores them on drop. Concentrating the save/restore
212    /// branches into one place (here) instead of inlining them in each test
213    /// keeps coverage high — every test exercises the same guard drop path.
214    pub(crate) struct EnvGuard {
215        _lock: std::sync::MutexGuard<'static, ()>,
216        snapshot: Vec<(&'static str, Option<String>)>,
217    }
218
219    impl EnvGuard {
220        pub(crate) fn take() -> Self {
221            let lock = AUTH_ENV_MUTEX
222                .lock()
223                .unwrap_or_else(std::sync::PoisonError::into_inner);
224            let keys = [
225                "HOME",
226                ATLASSIAN_INSTANCE_URL,
227                ATLASSIAN_EMAIL,
228                ATLASSIAN_API_TOKEN,
229            ];
230            let snapshot = keys
231                .into_iter()
232                .map(|k| (k, std::env::var(k).ok()))
233                .collect();
234            Self {
235                _lock: lock,
236                snapshot,
237            }
238        }
239
240        /// Clears the three Atlassian credential env vars and points `HOME`
241        /// at a fresh empty tempdir so `load_credentials()` returns
242        /// `CredentialsNotFound`. Useful for testing the Err propagation
243        /// path of code that calls `create_client()`.
244        pub(crate) fn clear_credentials(&self) -> tempfile::TempDir {
245            let dir = {
246                std::fs::create_dir_all("tmp").ok();
247                tempfile::TempDir::new_in("tmp").unwrap()
248            };
249            std::env::set_var("HOME", dir.path());
250            std::env::remove_var(ATLASSIAN_INSTANCE_URL);
251            std::env::remove_var(ATLASSIAN_EMAIL);
252            std::env::remove_var(ATLASSIAN_API_TOKEN);
253            dir
254        }
255
256        /// Sets the three Atlassian credential env vars to point at a wiremock
257        /// (or any HTTP) endpoint. The matching `HOME` is replaced with a
258        /// fresh tempdir so any `~/.omni-dev/settings.json` on the developer's
259        /// machine does not bleed in.
260        pub(crate) fn set_credentials(&self, instance_url: &str) -> tempfile::TempDir {
261            let dir = {
262                std::fs::create_dir_all("tmp").ok();
263                tempfile::TempDir::new_in("tmp").unwrap()
264            };
265            std::env::set_var("HOME", dir.path());
266            std::env::set_var(ATLASSIAN_INSTANCE_URL, instance_url);
267            std::env::set_var(ATLASSIAN_EMAIL, "test@example.com");
268            std::env::set_var(ATLASSIAN_API_TOKEN, "test-token");
269            dir
270        }
271    }
272
273    impl Drop for EnvGuard {
274        fn drop(&mut self) {
275            for (k, v) in &self.snapshot {
276                match v {
277                    Some(val) => std::env::set_var(k, val),
278                    None => std::env::remove_var(k),
279                }
280            }
281        }
282    }
283}
284
285#[cfg(test)]
286#[allow(clippy::unwrap_used, clippy::expect_used)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn save_and_read_credentials() {
292        let temp_dir = {
293            std::fs::create_dir_all("tmp").ok();
294            tempfile::TempDir::new_in("tmp").unwrap()
295        };
296        let settings_path = temp_dir.path().join("settings.json");
297
298        // Start with existing settings
299        let existing = r#"{"env": {"SOME_KEY": "value"}}"#;
300        fs::write(&settings_path, existing).unwrap();
301
302        // Read it back as a value, add credentials, write
303        let content = fs::read_to_string(&settings_path).unwrap();
304        let mut val: serde_json::Value = serde_json::from_str(&content).unwrap();
305        val["env"]["ATLASSIAN_INSTANCE_URL"] =
306            serde_json::Value::String("https://test.atlassian.net".to_string());
307        val["env"]["ATLASSIAN_EMAIL"] = serde_json::Value::String("user@example.com".to_string());
308        val["env"]["ATLASSIAN_API_TOKEN"] = serde_json::Value::String("secret-token".to_string());
309        let formatted = serde_json::to_string_pretty(&val).unwrap();
310        fs::write(&settings_path, formatted).unwrap();
311
312        // Verify existing keys are preserved
313        let content = fs::read_to_string(&settings_path).unwrap();
314        let val: serde_json::Value = serde_json::from_str(&content).unwrap();
315        assert_eq!(val["env"]["SOME_KEY"], "value");
316        assert_eq!(
317            val["env"]["ATLASSIAN_INSTANCE_URL"],
318            "https://test.atlassian.net"
319        );
320        assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "user@example.com");
321        assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "secret-token");
322    }
323
324    #[test]
325    fn load_credentials_normalizes_trailing_slash() {
326        // Test the trailing-slash normalization logic directly
327        let url = "https://env.atlassian.net/";
328        let normalized = url.trim_end_matches('/').to_string();
329        assert_eq!(normalized, "https://env.atlassian.net");
330    }
331
332    #[test]
333    fn constant_key_names() {
334        assert_eq!(ATLASSIAN_INSTANCE_URL, "ATLASSIAN_INSTANCE_URL");
335        assert_eq!(ATLASSIAN_EMAIL, "ATLASSIAN_EMAIL");
336        assert_eq!(ATLASSIAN_API_TOKEN, "ATLASSIAN_API_TOKEN");
337    }
338
339    #[test]
340    fn credentials_struct_clone_and_debug() {
341        let creds = AtlassianCredentials {
342            instance_url: "https://org.atlassian.net".to_string(),
343            email: "user@test.com".to_string(),
344            api_token: "token".to_string(),
345        };
346        let cloned = creds.clone();
347        assert_eq!(cloned.instance_url, creds.instance_url);
348        assert_eq!(cloned.email, creds.email);
349        assert_eq!(cloned.api_token, creds.api_token);
350        // Verify Debug is implemented
351        let debug = format!("{creds:?}");
352        assert!(debug.contains("AtlassianCredentials"));
353    }
354
355    use super::test_util::EnvGuard;
356
357    fn with_empty_home(_guard: &EnvGuard) -> tempfile::TempDir {
358        let dir = {
359            std::fs::create_dir_all("tmp").ok();
360            tempfile::TempDir::new_in("tmp").unwrap()
361        };
362        std::env::set_var("HOME", dir.path());
363        std::env::remove_var(ATLASSIAN_INSTANCE_URL);
364        std::env::remove_var(ATLASSIAN_EMAIL);
365        std::env::remove_var(ATLASSIAN_API_TOKEN);
366        dir
367    }
368
369    #[test]
370    fn status_reports_all_false_when_nothing_configured() {
371        let guard = EnvGuard::take();
372        let _dir = with_empty_home(&guard);
373
374        let status = status();
375        assert_eq!(status.scopes.len(), 1);
376        let scope = &status.scopes[0];
377        assert_eq!(scope.name, "default");
378        assert!(!scope.has_email);
379        assert!(!scope.has_token);
380        assert_eq!(scope.instance_url, None);
381    }
382
383    #[test]
384    fn status_reports_presence_flags_from_settings_without_leaking_secrets() {
385        let guard = EnvGuard::take();
386        let dir = with_empty_home(&guard);
387        let omni_dir = dir.path().join(".omni-dev");
388        fs::create_dir_all(&omni_dir).unwrap();
389        fs::write(
390            omni_dir.join("settings.json"),
391            r#"{"env":{
392                "ATLASSIAN_INSTANCE_URL":"https://status.atlassian.net/",
393                "ATLASSIAN_EMAIL":"person@example.com",
394                "ATLASSIAN_API_TOKEN":"sekret-do-not-leak"
395            }}"#,
396        )
397        .unwrap();
398
399        let status = status();
400        assert_eq!(status.scopes.len(), 1);
401        let scope = &status.scopes[0];
402        assert!(scope.has_email);
403        assert!(scope.has_token);
404        assert_eq!(
405            scope.instance_url.as_deref(),
406            Some("https://status.atlassian.net")
407        );
408
409        let yaml = serde_yaml::to_string(&status).unwrap();
410        assert!(!yaml.contains("sekret-do-not-leak"), "leaked token: {yaml}");
411        assert!(!yaml.contains("person@example.com"), "leaked email: {yaml}");
412    }
413
414    #[test]
415    fn status_returns_instance_url_from_env_without_trailing_slash() {
416        let guard = EnvGuard::take();
417        let _dir = with_empty_home(&guard);
418        std::env::set_var(ATLASSIAN_INSTANCE_URL, "https://env.atlassian.net/");
419
420        let status = status();
421        let scope = &status.scopes[0];
422        assert_eq!(
423            scope.instance_url.as_deref(),
424            Some("https://env.atlassian.net")
425        );
426        assert!(!scope.has_email);
427        assert!(!scope.has_token);
428    }
429
430    /// Single test for save_credentials to avoid HOME env var race conditions.
431    /// Tests both fresh-file creation and merge-with-existing in sequence.
432    #[test]
433    fn save_credentials_creates_and_preserves() {
434        // Share the mutex with the other env-mutating tests in this module
435        // so that setting HOME here doesn't race with `status()` tests.
436        let _guard = EnvGuard::take();
437        let original_home = std::env::var("HOME").ok();
438
439        // ── Part 1: creates file from scratch ──────────────────────
440        {
441            let temp_dir = {
442                std::fs::create_dir_all("tmp").ok();
443                tempfile::TempDir::new_in("tmp").unwrap()
444            };
445            std::env::set_var("HOME", temp_dir.path());
446
447            let creds = AtlassianCredentials {
448                instance_url: "https://save.atlassian.net".to_string(),
449                email: "save@example.com".to_string(),
450                api_token: "save-token".to_string(),
451            };
452            save_credentials(&creds).unwrap();
453
454            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
455            assert!(settings_path.exists());
456            let content = fs::read_to_string(&settings_path).unwrap();
457            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
458            assert_eq!(
459                val["env"]["ATLASSIAN_INSTANCE_URL"],
460                "https://save.atlassian.net"
461            );
462            assert_eq!(val["env"]["ATLASSIAN_EMAIL"], "save@example.com");
463            assert_eq!(val["env"]["ATLASSIAN_API_TOKEN"], "save-token");
464        }
465
466        // ── Part 2: preserves existing keys ────────────────────────
467        {
468            let temp_dir = {
469                std::fs::create_dir_all("tmp").ok();
470                tempfile::TempDir::new_in("tmp").unwrap()
471            };
472            let omni_dir = temp_dir.path().join(".omni-dev");
473            fs::create_dir_all(&omni_dir).unwrap();
474            let settings_path = omni_dir.join("settings.json");
475            fs::write(
476                &settings_path,
477                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
478            )
479            .unwrap();
480
481            std::env::set_var("HOME", temp_dir.path());
482
483            let creds = AtlassianCredentials {
484                instance_url: "https://org.atlassian.net".to_string(),
485                email: "user@test.com".to_string(),
486                api_token: "token".to_string(),
487            };
488            save_credentials(&creds).unwrap();
489
490            let content = fs::read_to_string(&settings_path).unwrap();
491            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
492            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
493            assert_eq!(val["extra"], true);
494            assert_eq!(
495                val["env"]["ATLASSIAN_INSTANCE_URL"],
496                "https://org.atlassian.net"
497            );
498        }
499
500        // Restore HOME
501        if let Some(home) = original_home {
502            std::env::set_var("HOME", home);
503        }
504    }
505
506    #[test]
507    fn load_credentials_with_instance_override_supplies_instance_url() {
508        // The override lets a caller (e.g. `jira create --instance`) target an
509        // instance even when ATLASSIAN_INSTANCE_URL is unset, as long as email
510        // and token are present. The trailing slash is normalized.
511        let guard = EnvGuard::take();
512        let _dir = with_empty_home(&guard);
513        std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
514        std::env::set_var(ATLASSIAN_API_TOKEN, "token");
515
516        let creds =
517            load_credentials_with_instance(Some("https://override.atlassian.net/")).unwrap();
518        assert_eq!(creds.instance_url, "https://override.atlassian.net");
519        assert_eq!(creds.email, "person@example.com");
520        assert_eq!(creds.api_token, "token");
521    }
522
523    #[test]
524    fn load_credentials_with_instance_none_requires_env_instance() {
525        // Without an override and without ATLASSIAN_INSTANCE_URL configured,
526        // resolution fails just like load_credentials() does today.
527        let guard = EnvGuard::take();
528        let _dir = with_empty_home(&guard);
529        std::env::set_var(ATLASSIAN_EMAIL, "person@example.com");
530        std::env::set_var(ATLASSIAN_API_TOKEN, "token");
531
532        assert!(load_credentials_with_instance(None).is_err());
533    }
534}