Skip to main content

cli_shared/
credentials.rs

1//! Global credential store for Heddle authentication.
2//!
3//! Manages `~/.heddle/credentials.toml` for persistent server credentials.
4
5use std::{collections::BTreeMap, fs, path::PathBuf};
6
7use anyhow::{Context, Result};
8use objects::fs_atomic::write_file_atomic_secret;
9use serde::{Deserialize, Serialize};
10
11static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
12
13pub fn lock_test_env() -> std::sync::MutexGuard<'static, ()> {
14    TEST_ENV_LOCK
15        .lock()
16        .unwrap_or_else(|poisoned| poisoned.into_inner())
17}
18
19/// How many seconds before expiry we proactively rotate.
20/// 7 days gives plenty of buffer for intermittent CLI usage — if someone
21/// pushes once a week, the token stays fresh indefinitely.
22const ROTATION_WINDOW_SECS: u64 = 7 * 24 * 3600; // 7 days
23
24/// Top-level credential store.
25#[derive(Debug, Serialize, Deserialize, Default)]
26pub struct CredentialStore {
27    #[serde(default)]
28    pub defaults: CredentialDefaults,
29    #[serde(default)]
30    pub servers: BTreeMap<String, ServerCredential>,
31}
32
33/// Default settings for credential resolution.
34#[derive(Debug, Serialize, Deserialize, Default)]
35pub struct CredentialDefaults {
36    pub server: Option<String>,
37}
38
39/// Credential for a single Heddle server.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ServerCredential {
42    pub token: String,
43    pub subject: String,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub device_id: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub credential_id: Option<String>,
48    #[serde(
49        default,
50        alias = "private_key",
51        skip_serializing_if = "Option::is_none"
52    )]
53    pub private_key_pem: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub expires_at: Option<String>,
56}
57
58/// Path to the global credentials file: `<heddle_home>/credentials.toml`.
59///
60/// Uses the same home resolution as device identity (`$HEDDLE_HOME` if set,
61/// else `$HOME/.heddle`), so credentials and device keys stay co-located.
62pub fn credentials_path() -> PathBuf {
63    repo::identity::heddle_home_dir().join("credentials.toml")
64}
65
66/// Load the credential store from disk. Returns an empty store if the file
67/// does not exist.
68pub fn load_credentials() -> Result<CredentialStore> {
69    let path = credentials_path();
70    match fs::read_to_string(&path) {
71        Ok(contents) => {
72            toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))
73        }
74        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CredentialStore::default()),
75        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
76    }
77}
78
79/// Write the credential store to disk, creating the parent directory if needed.
80pub fn save_credentials(store: &CredentialStore) -> Result<()> {
81    let path = credentials_path();
82    if let Some(parent) = path.parent() {
83        objects::fs_atomic::create_private_dir_all(parent)
84            .with_context(|| format!("creating directory {}", parent.display()))?;
85    }
86    let contents = toml::to_string_pretty(store).context("serializing credentials")?;
87    write_file_atomic_secret(&path, contents.as_bytes())
88        .with_context(|| format!("writing {}", path.display()))?;
89
90    Ok(())
91}
92
93/// Look up a credential by server hostname.
94pub fn get_server_credential(server: &str) -> Result<Option<ServerCredential>> {
95    let store = load_credentials()?;
96    Ok(store.servers.get(server).cloned())
97}
98
99/// Insert or update a credential for the given server. Also sets the default
100/// server if none is configured.
101pub fn store_server_credential(server: &str, cred: ServerCredential) -> Result<()> {
102    let mut store = load_credentials()?;
103    store.servers.insert(server.to_string(), cred);
104    if store.defaults.server.is_none() {
105        store.defaults.server = Some(server.to_string());
106    }
107    save_credentials(&store)
108}
109
110/// Resolve a credential for a server key, trying common key variations.
111///
112/// The credential store key may include a scheme prefix (e.g. `http://host:port`)
113/// while the remote URL parser strips scheme prefixes (producing just `host:port`).
114/// This function tries the bare key first, then common scheme-prefixed variants.
115pub fn resolve_credential_for_server(server_key: &str) -> Result<Option<ServerCredential>> {
116    let store = load_credentials()?;
117
118    // Try exact match first.
119    if let Some(cred) = store.servers.get(server_key) {
120        return Ok(Some(cred.clone()));
121    }
122
123    // Try with scheme prefixes (auth login stores the full --server URL as the key).
124    for prefix in &["http://", "https://", "heddle://"] {
125        let prefixed = format!("{prefix}{server_key}");
126        if let Some(cred) = store.servers.get(&prefixed) {
127            return Ok(Some(cred.clone()));
128        }
129    }
130
131    // Try stripping scheme prefixes (in case the key has a scheme but the store doesn't).
132    let stripped = server_key
133        .strip_prefix("http://")
134        .or_else(|| server_key.strip_prefix("https://"))
135        .or_else(|| server_key.strip_prefix("heddle://"));
136    if let Some(bare) = stripped
137        && let Some(cred) = store.servers.get(bare)
138    {
139        return Ok(Some(cred.clone()));
140    }
141
142    Ok(None)
143}
144
145/// Remove the credential for a server.
146pub fn remove_server_credential(server: &str) -> Result<()> {
147    let mut store = load_credentials()?;
148    store.servers.remove(server);
149    if store.defaults.server.as_deref() == Some(server) {
150        store.defaults.server = None;
151    }
152    save_credentials(&store)
153}
154
155/// Resolve the default server from the credential store.
156pub fn default_server() -> Result<Option<String>> {
157    let store = load_credentials()?;
158    Ok(store.defaults.server)
159}
160
161/// Returns `true` if the credential's stored expiry is within the
162/// next [`ROTATION_WINDOW_SECS`] seconds.
163///
164/// Reads `cred.expires_at` (RFC 3339) rather than the token bytes
165/// directly: Biscuit tokens are intentionally opaque, but we
166/// already cache the expiry alongside the token at issue time, which
167/// is the source of truth the CLI needs for rotation decisions.
168/// Returns `false` on any parse failure so a stale credential row
169/// doesn't block normal CLI operation.
170pub fn token_needs_rotation(cred: &ServerCredential) -> bool {
171    let Some(expires_str) = cred.expires_at.as_deref() else {
172        // No stored expiry — older credential row, or a token type
173        // (e.g. service-account credential issued without one) that
174        // the server doesn't expire. Skip rotation.
175        return false;
176    };
177    let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_str) else {
178        return false;
179    };
180    let now = chrono::Utc::now().timestamp();
181    let exp = expires_at.timestamp();
182    exp.saturating_sub(now) <= ROTATION_WINDOW_SECS as i64
183}
184
185#[cfg(test)]
186mod tests {
187    use std::{
188        fs,
189        panic::{AssertUnwindSafe, catch_unwind},
190        path::PathBuf,
191        sync::atomic::{AtomicU64, Ordering},
192        time::{SystemTime, UNIX_EPOCH},
193    };
194
195    use super::*;
196
197    static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
198
199    fn unique_temp_dir(prefix: &str) -> PathBuf {
200        let unique = SystemTime::now()
201            .duration_since(UNIX_EPOCH)
202            .expect("system time before unix epoch")
203            .as_nanos();
204        let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
205        std::env::temp_dir().join(format!(
206            "{prefix}-{unique}-{}-{counter}",
207            std::process::id()
208        ))
209    }
210
211    fn with_home_dir<T>(home: PathBuf, f: impl FnOnce() -> T) -> T {
212        let _guard = lock_test_env();
213        let original_home = std::env::var_os("HOME");
214        let original_heddle_home = std::env::var_os("HEDDLE_HOME");
215        unsafe {
216            std::env::set_var("HOME", &home);
217            // Prefer HOME-derived path in these tests unless a case sets HEDDLE_HOME.
218            std::env::remove_var("HEDDLE_HOME");
219        }
220        let result = catch_unwind(AssertUnwindSafe(f));
221        match original_home {
222            Some(value) => unsafe {
223                std::env::set_var("HOME", value);
224            },
225            None => unsafe {
226                std::env::remove_var("HOME");
227            },
228        }
229        match original_heddle_home {
230            Some(value) => unsafe {
231                std::env::set_var("HEDDLE_HOME", value);
232            },
233            None => unsafe {
234                std::env::remove_var("HEDDLE_HOME");
235            },
236        }
237        match result {
238            Ok(value) => value,
239            Err(payload) => std::panic::resume_unwind(payload),
240        }
241    }
242
243    #[test]
244    fn save_credentials_round_trips_through_atomic_write() {
245        let home = unique_temp_dir("heddle-credentials-test");
246        fs::create_dir_all(&home).expect("create temp home");
247
248        with_home_dir(home.clone(), || {
249            let mut store = CredentialStore::default();
250            store.servers.insert(
251                "heddle.example:8421".to_string(),
252                ServerCredential {
253                    token: "token-123".to_string(),
254                    subject: "dev".to_string(),
255                    device_id: Some("device-1".to_string()),
256                    credential_id: Some("cred-1".to_string()),
257                    private_key_pem: Some("pem".to_string()),
258                    expires_at: Some("2026-01-01T00:00:00Z".to_string()),
259                },
260            );
261            save_credentials(&store).expect("save credentials");
262
263            let path = credentials_path();
264            assert!(path.exists(), "expected credentials file to exist");
265
266            let loaded = load_credentials().expect("load credentials");
267            let cred = loaded
268                .servers
269                .get("heddle.example:8421")
270                .expect("stored credential");
271            assert_eq!(cred.subject, "dev");
272            assert_eq!(cred.token, "token-123");
273        });
274
275        let _ = fs::remove_dir_all(home);
276    }
277
278    #[test]
279    fn legacy_private_key_loads_and_saves_as_private_key_pem() {
280        let legacy = r#"
281[servers."heddle.example:8421"]
282token = "token-123"
283subject = "dev"
284private_key = "legacy-pem"
285"#;
286
287        let store: CredentialStore = toml::from_str(legacy).expect("load legacy credential");
288        let credential = store
289            .servers
290            .get("heddle.example:8421")
291            .expect("legacy credential");
292        assert_eq!(credential.private_key_pem.as_deref(), Some("legacy-pem"));
293
294        let canonical = toml::to_string_pretty(&store).expect("serialize canonical credential");
295        assert!(canonical.contains("private_key_pem = \"legacy-pem\""));
296        assert!(!canonical.contains("\nprivate_key ="));
297    }
298
299    #[cfg(unix)]
300    #[test]
301    fn save_credentials_writes_credential_file_0600() {
302        use std::os::unix::fs::PermissionsExt;
303
304        let home = unique_temp_dir("heddle-credentials-mode-test");
305        fs::create_dir_all(&home).expect("create temp home");
306
307        with_home_dir(home.clone(), || {
308            let mut store = CredentialStore::default();
309            store.servers.insert(
310                "heddle.example:8421".to_string(),
311                ServerCredential {
312                    token: "token-123".to_string(),
313                    subject: "dev".to_string(),
314                    device_id: None,
315                    credential_id: None,
316                    private_key_pem: Some("pem".to_string()),
317                    expires_at: None,
318                },
319            );
320            save_credentials(&store).expect("save credentials");
321
322            let mode = fs::metadata(credentials_path())
323                .expect("credentials metadata")
324                .permissions()
325                .mode()
326                & 0o777;
327            assert_eq!(mode, 0o600);
328        });
329
330        let _ = fs::remove_dir_all(home);
331    }
332
333    #[cfg(unix)]
334    #[test]
335    fn save_credentials_permission_failure_returns_error() {
336        use std::os::unix::fs::PermissionsExt;
337
338        let home = unique_temp_dir("heddle-credentials-permission-test");
339        let heddle_dir = home.join(".heddle");
340        fs::create_dir_all(&heddle_dir).expect("create credentials dir");
341        fs::set_permissions(&heddle_dir, fs::Permissions::from_mode(0o500))
342            .expect("make credentials dir unwritable");
343
344        with_home_dir(home.clone(), || {
345            let mut store = CredentialStore::default();
346            store.servers.insert(
347                "heddle.example:8421".to_string(),
348                ServerCredential {
349                    token: "token-123".to_string(),
350                    subject: "dev".to_string(),
351                    device_id: None,
352                    credential_id: None,
353                    private_key_pem: Some("pem".to_string()),
354                    expires_at: None,
355                },
356            );
357
358            let err = save_credentials(&store).expect_err("permission failure must propagate");
359            assert!(
360                err.to_string().contains("writing") || err.to_string().contains("Permission"),
361                "unexpected error: {err:?}"
362            );
363            assert!(
364                !credentials_path().exists(),
365                "failed write must not publish credentials"
366            );
367        });
368
369        fs::set_permissions(&heddle_dir, fs::Permissions::from_mode(0o700))
370            .expect("restore credentials dir");
371        let _ = fs::remove_dir_all(home);
372    }
373
374    #[test]
375    fn resolve_credential_for_server_accepts_scheme_prefixed_keys() {
376        let home = unique_temp_dir("heddle-credentials-test");
377        fs::create_dir_all(&home).expect("create temp home");
378
379        with_home_dir(home.clone(), || {
380            let mut store = CredentialStore::default();
381            store.servers.insert(
382                "http://heddle.example:8421".to_string(),
383                ServerCredential {
384                    token: "token-abc".to_string(),
385                    subject: "dev".to_string(),
386                    device_id: None,
387                    credential_id: None,
388                    private_key_pem: None,
389                    expires_at: None,
390                },
391            );
392            save_credentials(&store).expect("save credentials");
393
394            let resolved = resolve_credential_for_server("heddle.example:8421")
395                .expect("resolve credential")
396                .expect("scheme-prefixed credential");
397            assert_eq!(resolved.token, "token-abc");
398            assert_eq!(resolved.subject, "dev");
399        });
400
401        let _ = fs::remove_dir_all(home);
402    }
403
404    #[test]
405    fn credentials_path_honors_heddle_home() {
406        let home = unique_temp_dir("heddle-credentials-heddle-home");
407        fs::create_dir_all(&home).expect("create temp home");
408        let heddle_home = home.join("custom-heddle");
409
410        let _guard = lock_test_env();
411        let original_home = std::env::var_os("HOME");
412        let original_heddle_home = std::env::var_os("HEDDLE_HOME");
413        unsafe {
414            std::env::set_var("HOME", &home);
415            std::env::set_var("HEDDLE_HOME", &heddle_home);
416        }
417        let path = credentials_path();
418        match original_home {
419            Some(value) => unsafe {
420                std::env::set_var("HOME", value);
421            },
422            None => unsafe {
423                std::env::remove_var("HOME");
424            },
425        }
426        match original_heddle_home {
427            Some(value) => unsafe {
428                std::env::set_var("HEDDLE_HOME", value);
429            },
430            None => unsafe {
431                std::env::remove_var("HEDDLE_HOME");
432            },
433        }
434
435        assert_eq!(path, heddle_home.join("credentials.toml"));
436        let _ = fs::remove_dir_all(home);
437    }
438}