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