Skip to main content

cli_engine/auth/
storage.rs

1//! Injectable credential storage backends.
2//!
3//! Auth providers persist credentials through the [`CredentialStorage`] trait
4//! rather than talking to a keychain or the filesystem directly. This decouples
5//! *what* is stored (a provider's serialized token) from *where* it is stored,
6//! so a single storage backend can be shared across providers and swapped out —
7//! for tests, or to disable the system keychain on machines where it is
8//! unavailable (headless Linux, WSL).
9//!
10//! Backends map one-to-one onto [`CredentialStore`](crate::config::CredentialStore) modes:
11//!
12//! - [`FileStorage`] — unencrypted JSON under the config base directory. Always
13//!   available; needs no system dependencies.
14//! - `KeyringStorage` — the system keychain only (requires the `pkce-auth`
15//!   feature and the `keyring` crate).
16//! - `AutoStorage` — keychain with a transparent file fallback when the
17//!   keychain backend is unavailable (requires `pkce-auth`).
18//!
19//! [`default_storage`] picks a backend from the resolved
20//! [`CredentialStore`](crate::config::CredentialStore) mode (CLI flag, env var,
21//! config file, or the `Keyring` default); see [`crate::config`].
22
23use std::sync::Arc;
24
25use async_trait::async_trait;
26
27use crate::Result;
28use crate::config::CredentialStore;
29use crate::fs::{config_base_dir, is_safe_path_component};
30
31/// Identifies a single stored credential.
32///
33/// Backends derive their storage location from this key: the keychain service
34/// name and the file path are both functions of `(app_id, provider, env)`.
35#[derive(Clone, Copy, Debug)]
36pub struct CredentialKey<'key> {
37    /// Application id; namespaces credentials across CLIs sharing a keychain.
38    pub app_id: &'key str,
39    /// Auth provider name.
40    pub provider: &'key str,
41    /// Target environment name.
42    pub env: &'key str,
43}
44
45impl<'key> CredentialKey<'key> {
46    /// Creates a key from its parts.
47    #[must_use]
48    pub fn new(app_id: &'key str, provider: &'key str, env: &'key str) -> Self {
49        Self {
50            app_id,
51            provider,
52            env,
53        }
54    }
55}
56
57/// A pluggable place to persist a provider's serialized credential.
58///
59/// Values are opaque strings (typically JSON); the backend never interprets
60/// them, so it stays independent of any provider's token shape. Callers own
61/// (de)serialization and any validity/expiry checks.
62#[async_trait]
63pub trait CredentialStorage: Send + Sync + std::fmt::Debug {
64    /// Loads the stored blob for `key`, or `None` when absent or unreadable.
65    ///
66    /// Backends own the policy for distinguishing "no entry" from "store
67    /// unavailable" (see `AutoStorage`); both collapse to `None` here.
68    async fn load(&self, key: &CredentialKey<'_>) -> Option<String>;
69
70    /// Persists `value` for `key`, replacing any existing value.
71    ///
72    /// # Errors
73    /// Returns an error when the backend cannot durably store the value.
74    async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()>;
75
76    /// Removes the blob for `key`. Best-effort: absence is not an error.
77    async fn delete(&self, key: &CredentialKey<'_>);
78
79    /// Lists environment names with stored credentials, if the backend supports
80    /// enumeration. The default returns an empty list (the keychain cannot be
81    /// enumerated by service prefix).
82    ///
83    /// # Errors
84    /// Returns an error when enumeration is attempted but fails.
85    async fn list(&self) -> Result<Vec<String>> {
86        Ok(Vec::new())
87    }
88}
89
90/// Unencrypted file-based credential storage.
91///
92/// Stores each credential as JSON at
93/// `<config-base>/<app>/credentials/<provider>-<env>.json`, where `<app>` is the
94/// key's `app_id` (or `provider` when `app_id` is empty) and `<config-base>` is
95/// resolved by [`config_base_dir`]. On Unix the file is created `0600` and the
96/// parent directory is best-effort restricted to `0700`.
97///
98/// Credentials are written in clear text, so prefer the system keychain where
99/// one is available.
100#[derive(Clone, Copy, Debug, Default)]
101pub struct FileStorage;
102
103impl FileStorage {
104    /// Creates a file-storage backend.
105    #[must_use]
106    pub fn new() -> Self {
107        Self
108    }
109
110    /// Resolves the on-disk path for `key`, or `None` when the config base
111    /// directory is unavailable or any key component is unsafe as a path
112    /// segment.
113    fn path_for(key: &CredentialKey<'_>) -> Option<std::path::PathBuf> {
114        let app = if key.app_id.is_empty() {
115            key.provider
116        } else {
117            key.app_id
118        };
119        if !is_safe_path_component(app)
120            || !is_safe_path_component(key.provider)
121            || !is_safe_path_component(key.env)
122        {
123            tracing::warn!(
124                app,
125                provider = key.provider,
126                env = key.env,
127                "refusing credential path with unsafe component"
128            );
129            return None;
130        }
131        let base = config_base_dir()?;
132        Some(
133            base.join(app)
134                .join("credentials")
135                .join(format!("{}-{}.json", key.provider, key.env)),
136        )
137    }
138}
139
140#[async_trait]
141impl CredentialStorage for FileStorage {
142    async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
143        let path = Self::path_for(key)?;
144        match tokio::fs::read_to_string(&path).await {
145            Ok(s) => Some(s),
146            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
147            Err(e) => {
148                tracing::warn!(path = %path.display(), error = %e, "credential file read failed");
149                None
150            }
151        }
152    }
153
154    async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
155        let path = Self::path_for(key).ok_or_else(|| {
156            crate::error::CliCoreError::message("could not determine credential file path")
157        })?;
158        let value = value.to_owned();
159        tokio::task::spawn_blocking(move || crate::fs::write_string_atomic(&path, &value))
160            .await
161            .map_err(|e| {
162                crate::error::CliCoreError::message(format!(
163                    "credential file write task {}: {e}",
164                    if e.is_cancelled() {
165                        "cancelled"
166                    } else {
167                        "panicked"
168                    }
169                ))
170            })?
171    }
172
173    async fn delete(&self, key: &CredentialKey<'_>) {
174        let Some(path) = Self::path_for(key) else {
175            return;
176        };
177        match tokio::fs::remove_file(&path).await {
178            Ok(()) => {}
179            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
180            Err(e) => {
181                tracing::warn!(path = %path.display(), error = %e, "failed to delete credential file");
182            }
183        }
184    }
185}
186
187#[cfg(feature = "pkce-auth")]
188pub use keychain::{AutoStorage, KeyringStorage};
189
190#[cfg(feature = "pkce-auth")]
191mod keychain {
192    use super::{CredentialKey, CredentialStorage, FileStorage, Result, async_trait};
193
194    const KEYCHAIN_USER: &str = "token";
195
196    /// Derives the keychain service name for `key`:
197    /// `<app_id>/<provider>/<env>`, or `<provider>/<env>` when `app_id` is empty.
198    fn keychain_service(key: &CredentialKey<'_>) -> String {
199        if key.app_id.is_empty() {
200            format!("{}/{}", key.provider, key.env)
201        } else {
202            format!("{}/{}/{}", key.app_id, key.provider, key.env)
203        }
204    }
205
206    /// System-keychain credential storage.
207    ///
208    /// Reads and writes the OS keychain only. `load` returns `None` for both
209    /// "no entry" and "keychain backend unavailable" — callers cannot
210    /// distinguish the two states. `save` is a hard error when the write
211    /// fails. No file is ever written — use [`AutoStorage`] for a file
212    /// fallback or [`super::FileStorage`] to skip the keychain entirely.
213    ///
214    /// If you need the three-state distinction (entry present / reachable but
215    /// empty / backend unavailable), use the crate-internal `read_three_state`
216    /// helper (as [`AutoStorage`] does internally).
217    #[derive(Clone, Copy, Debug, Default)]
218    pub struct KeyringStorage;
219
220    impl KeyringStorage {
221        /// Creates a keychain-storage backend.
222        #[must_use]
223        pub fn new() -> Self {
224            Self
225        }
226
227        /// Three-state keychain read used by [`AutoStorage`] to decide whether
228        /// to consult a file fallback.
229        ///
230        /// `Some(Some(json))` = entry found; `Some(None)` = keychain reachable
231        /// but empty; `None` = keychain backend unavailable.
232        pub(super) async fn read_three_state(
233            &self,
234            key: &CredentialKey<'_>,
235        ) -> Option<Option<String>> {
236            let service = keychain_service(key);
237            match tokio::task::spawn_blocking({
238                let service = service.clone();
239                move || keychain_read_blocking(&service, KEYCHAIN_USER)
240            })
241            .await
242            {
243                Ok(result) => result,
244                Err(e) => {
245                    let reason = if e.is_cancelled() {
246                        "cancelled"
247                    } else {
248                        "panicked"
249                    };
250                    tracing::warn!(service, error = %e, reason, "keychain read task failed");
251                    None
252                }
253            }
254        }
255
256        /// Writes to the keychain, returning whether the write succeeded.
257        pub(super) async fn write_raw(&self, key: &CredentialKey<'_>, value: &str) -> bool {
258            let service = keychain_service(key);
259            let value = value.to_owned();
260            match tokio::task::spawn_blocking({
261                let service = service.clone();
262                move || keychain_write_blocking(&service, KEYCHAIN_USER, &value)
263            })
264            .await
265            {
266                Ok(saved) => saved,
267                Err(e) => {
268                    let reason = if e.is_cancelled() {
269                        "cancelled"
270                    } else {
271                        "panicked"
272                    };
273                    tracing::warn!(service, error = %e, reason, "keychain write task failed");
274                    false
275                }
276            }
277        }
278
279        /// Best-effort keychain entry deletion.
280        pub(super) async fn delete_entry(&self, key: &CredentialKey<'_>) {
281            let service = keychain_service(key);
282            let service_for_warn = service.clone();
283            if let Err(e) =
284                tokio::task::spawn_blocking(move || match keyring::Entry::new(&service, KEYCHAIN_USER) {
285                    Err(e) => {
286                        tracing::warn!(service, error = %e, "keychain entry creation failed on delete");
287                    }
288                    Ok(entry) => match entry.delete_credential() {
289                        Ok(()) | Err(keyring::Error::NoEntry) => {}
290                        Err(e) => {
291                            tracing::warn!(service, error = %e, "keychain delete failed");
292                        }
293                    },
294                })
295                .await
296            {
297                let reason = if e.is_cancelled() {
298                    "cancelled"
299                } else {
300                    "panicked"
301                };
302                tracing::warn!(service = service_for_warn, error = %e, reason, "keychain delete task failed");
303            }
304        }
305    }
306
307    #[async_trait]
308    impl CredentialStorage for KeyringStorage {
309        async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
310            // Collapse "no entry" and "unavailable" to None: keychain-only mode
311            // never falls back to a file.
312            self.read_three_state(key).await.flatten()
313        }
314
315        async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
316            if self.write_raw(key, value).await {
317                Ok(())
318            } else {
319                Err(crate::error::CliCoreError::message(
320                    "failed to save token to keychain — check logs for the underlying error, \
321                     ensure your system keychain (e.g. gnome-keyring, macOS Keychain) is running \
322                     and unlocked, or select file storage (credential store \"file\" or \"auto\")",
323                ))
324            }
325        }
326
327        async fn delete(&self, key: &CredentialKey<'_>) {
328            self.delete_entry(key).await;
329        }
330    }
331
332    /// Keychain storage with a transparent unencrypted-file fallback.
333    ///
334    /// Preferred when a keychain is usually present but may be missing (WSL,
335    /// headless sessions). Behavior:
336    /// - `load`: keychain entry present → use it; keychain reachable but empty →
337    ///   `None` (the file is stale or absent, force re-auth); keychain
338    ///   unavailable → read the file.
339    /// - `save`: try the keychain; on success remove any stale file; on failure
340    ///   write the file.
341    /// - `delete`: remove from both the keychain and the file.
342    #[derive(Clone, Copy, Debug, Default)]
343    pub struct AutoStorage {
344        file: FileStorage,
345        keyring: KeyringStorage,
346    }
347
348    impl AutoStorage {
349        /// Creates an auto (keychain-with-file-fallback) backend.
350        #[must_use]
351        pub fn new() -> Self {
352            Self {
353                file: FileStorage::new(),
354                keyring: KeyringStorage::new(),
355            }
356        }
357    }
358
359    #[async_trait]
360    impl CredentialStorage for AutoStorage {
361        async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
362            match self.keyring.read_three_state(key).await {
363                // Keychain has the entry.
364                Some(Some(json)) => Some(json),
365                // Keychain is reachable but empty: skip the file and force login.
366                Some(None) => None,
367                // Keychain backend unavailable: fall back to the file.
368                None => self.file.load(key).await,
369            }
370        }
371
372        async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
373            if self.keyring.write_raw(key, value).await {
374                // Keychain is the source of truth now; drop any stale file copy.
375                self.file.delete(key).await;
376                return Ok(());
377            }
378            self.file.save(key, value).await
379        }
380
381        async fn delete(&self, key: &CredentialKey<'_>) {
382            self.keyring.delete(key).await;
383            self.file.delete(key).await;
384        }
385    }
386
387    /// Reads a token JSON string from the system keychain. Sync; call inside
388    /// `spawn_blocking`.
389    fn keychain_read_blocking(service: &str, user: &str) -> Option<Option<String>> {
390        match keyring::Entry::new(service, user) {
391            Err(e) => {
392                tracing::warn!(service, error = %e, "keychain entry creation failed");
393                None
394            }
395            Ok(entry) => match entry.get_password() {
396                Err(keyring::Error::NoEntry) => {
397                    tracing::debug!(service, "no stored token in keychain");
398                    Some(None)
399                }
400                Err(e) => {
401                    tracing::warn!(service, error = %e, "keychain read failed");
402                    None
403                }
404                Ok(json) => Some(Some(json)),
405            },
406        }
407    }
408
409    /// Writes a token JSON string to the system keychain. Sync; call inside
410    /// `spawn_blocking`.
411    fn keychain_write_blocking(service: &str, user: &str, json: &str) -> bool {
412        match keyring::Entry::new(service, user) {
413            Err(e) => {
414                tracing::warn!(service, error = %e, "keychain entry creation failed");
415                false
416            }
417            Ok(entry) => match entry.set_password(json) {
418                Err(e) => {
419                    tracing::warn!(service, error = %e, "keychain write failed");
420                    false
421                }
422                Ok(()) => {
423                    tracing::debug!(service, "token saved to keychain");
424                    true
425                }
426            },
427        }
428    }
429}
430
431/// Builds the credential storage backend for `mode`.
432///
433/// `File` always yields a [`FileStorage`]. `Keyring`/`Auto` yield the keychain
434/// backends when the `pkce-auth` feature is enabled; without it they log a
435/// warning and degrade to [`FileStorage`], since no keychain backend is
436/// compiled in.
437#[must_use]
438pub fn storage_for(mode: CredentialStore) -> Arc<dyn CredentialStorage> {
439    match mode {
440        CredentialStore::File => Arc::new(FileStorage::new()),
441        #[cfg(feature = "pkce-auth")]
442        CredentialStore::Keyring => Arc::new(KeyringStorage::new()),
443        #[cfg(feature = "pkce-auth")]
444        CredentialStore::Auto => Arc::new(AutoStorage::new()),
445        #[cfg(not(feature = "pkce-auth"))]
446        mode => {
447            tracing::warn!(
448                %mode,
449                "keyring backends unavailable (pkce-auth feature disabled); using file storage"
450            );
451            Arc::new(FileStorage::new())
452        }
453    }
454}
455
456/// Resolves the configured [`CredentialStore`] for `app_id` and builds the
457/// matching backend.
458///
459/// Resolution consults (in priority order): the `--credential-store` CLI flag
460/// stored in a per-thread latch by [`crate::cli::Cli::run`]; the
461/// `${PREFIX}_CREDENTIAL_STORE` env var; the config file; and finally the
462/// default [`CredentialStore::Keyring`].
463///
464/// **Note**: this function reads thread-local state set by `Cli::run`. Calling
465/// it outside of a `Cli::run` execution (e.g. in a standalone binary without a
466/// `Cli`) always sees `None` for the flag and relies solely on the env var and
467/// config file. Prefer [`storage_for`] with an explicit [`CredentialStore`]
468/// when you don't need the full resolution chain.
469#[must_use]
470pub fn default_storage(app_id: &str) -> Arc<dyn CredentialStorage> {
471    let mode = crate::config::resolve_credential_store(app_id, |k| std::env::var(k).ok());
472    storage_for(mode)
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::config::test_env::{EnvVarGuard, lock, with_xdg_config_home};
479
480    #[test]
481    fn file_path_uses_app_id_and_provider() {
482        let dir = std::env::temp_dir().join("cli-engine-storage-test-xdg");
483        with_xdg_config_home(&dir, || {
484            let key = CredentialKey::new("myapp", "prov", "prod");
485            assert_eq!(
486                FileStorage::path_for(&key),
487                Some(dir.join("myapp").join("credentials").join("prov-prod.json"))
488            );
489            // empty app_id falls back to provider as the dir
490            let key2 = CredentialKey::new("", "prov", "prod");
491            assert_eq!(
492                FileStorage::path_for(&key2),
493                Some(dir.join("prov").join("credentials").join("prov-prod.json"))
494            );
495        });
496    }
497
498    #[test]
499    fn file_path_rejects_unsafe_components() {
500        for env in ["../../etc/passwd", "dev/subdir", "dev\\subdir", ".."] {
501            let key = CredentialKey::new("app", "prov", env);
502            assert_eq!(
503                FileStorage::path_for(&key),
504                None,
505                "{env:?} should be rejected"
506            );
507        }
508    }
509
510    #[test]
511    fn file_path_rejects_relative_base_dir() {
512        with_xdg_config_home(std::path::Path::new("."), || {
513            let key = CredentialKey::new("app", "prov", "dev");
514            assert_eq!(FileStorage::path_for(&key), None);
515        });
516    }
517
518    #[tokio::test]
519    // The guard is intentionally held across awaits to serialize env mutation.
520    #[allow(clippy::await_holding_lock)]
521    async fn file_storage_round_trip() {
522        let dir = tempfile::tempdir().expect("tempdir");
523        // Hold the shared lock + env guard across the awaits (tokio::test uses a
524        // current-thread runtime, so the non-Send guard is fine).
525        let _lock = lock();
526        let _env = EnvVarGuard::set("XDG_CONFIG_HOME", Some(dir.path()));
527
528        let store = FileStorage::new();
529        let key = CredentialKey::new("app", "prov", "dev");
530        assert_eq!(store.load(&key).await, None);
531        store.save(&key, "{\"token\":\"abc\"}").await.expect("save");
532        assert_eq!(
533            store.load(&key).await.as_deref(),
534            Some("{\"token\":\"abc\"}")
535        );
536        store.delete(&key).await;
537        assert_eq!(store.load(&key).await, None);
538    }
539
540    #[test]
541    fn storage_for_file_is_always_available() {
542        // Just assert it constructs without panicking; behavior covered above.
543        let store = storage_for(CredentialStore::File);
544        assert!(format!("{store:?}").contains("FileStorage"));
545    }
546}