Skip to main content

heddle_client/
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(default, skip_serializing_if = "Option::is_none")]
49    pub private_key_pem: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub expires_at: Option<String>,
52}
53
54/// Path to the global credentials file: `~/.heddle/credentials.toml`.
55pub fn credentials_path() -> PathBuf {
56    dirs_or_home().join("credentials.toml")
57}
58
59/// Load the credential store from disk. Returns an empty store if the file
60/// does not exist.
61pub fn load_credentials() -> Result<CredentialStore> {
62    let path = credentials_path();
63    match fs::read_to_string(&path) {
64        Ok(contents) => {
65            toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))
66        }
67        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CredentialStore::default()),
68        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
69    }
70}
71
72/// Write the credential store to disk, creating the parent directory if needed.
73pub fn save_credentials(store: &CredentialStore) -> Result<()> {
74    let path = credentials_path();
75    if let Some(parent) = path.parent() {
76        fs::create_dir_all(parent)
77            .with_context(|| format!("creating directory {}", parent.display()))?;
78    }
79    let contents = toml::to_string_pretty(store).context("serializing credentials")?;
80    write_file_atomic_secret(&path, contents.as_bytes())
81        .with_context(|| format!("writing {}", path.display()))?;
82
83    Ok(())
84}
85
86/// Look up a credential by server hostname.
87pub fn get_server_credential(server: &str) -> Result<Option<ServerCredential>> {
88    let store = load_credentials()?;
89    Ok(store.servers.get(server).cloned())
90}
91
92/// Insert or update a credential for the given server. Also sets the default
93/// server if none is configured.
94pub fn store_server_credential(server: &str, cred: ServerCredential) -> Result<()> {
95    let mut store = load_credentials()?;
96    store.servers.insert(server.to_string(), cred);
97    if store.defaults.server.is_none() {
98        store.defaults.server = Some(server.to_string());
99    }
100    save_credentials(&store)
101}
102
103/// Resolve a credential for a server key, trying common key variations.
104///
105/// The credential store key may include a scheme prefix (e.g. `http://host:port`)
106/// while the remote URL parser strips scheme prefixes (producing just `host:port`).
107/// This function tries the bare key first, then common scheme-prefixed variants.
108pub fn resolve_credential_for_server(server_key: &str) -> Result<Option<ServerCredential>> {
109    let store = load_credentials()?;
110
111    // Try exact match first.
112    if let Some(cred) = store.servers.get(server_key) {
113        return Ok(Some(cred.clone()));
114    }
115
116    // Try with scheme prefixes (auth login stores the full --server URL as the key).
117    for prefix in &["http://", "https://", "heddle://"] {
118        let prefixed = format!("{prefix}{server_key}");
119        if let Some(cred) = store.servers.get(&prefixed) {
120            return Ok(Some(cred.clone()));
121        }
122    }
123
124    // Try stripping scheme prefixes (in case the key has a scheme but the store doesn't).
125    let stripped = server_key
126        .strip_prefix("http://")
127        .or_else(|| server_key.strip_prefix("https://"))
128        .or_else(|| server_key.strip_prefix("heddle://"));
129    if let Some(bare) = stripped
130        && let Some(cred) = store.servers.get(bare)
131    {
132        return Ok(Some(cred.clone()));
133    }
134
135    Ok(None)
136}
137
138/// Remove the credential for a server.
139pub fn remove_server_credential(server: &str) -> Result<()> {
140    let mut store = load_credentials()?;
141    store.servers.remove(server);
142    if store.defaults.server.as_deref() == Some(server) {
143        store.defaults.server = None;
144    }
145    save_credentials(&store)
146}
147
148/// Resolve the default server from the credential store.
149pub fn default_server() -> Result<Option<String>> {
150    let store = load_credentials()?;
151    Ok(store.defaults.server)
152}
153
154/// Returns `true` if the credential's stored expiry is within the
155/// next [`ROTATION_WINDOW_SECS`] seconds.
156///
157/// Reads `cred.expires_at` (RFC 3339) rather than the token bytes
158/// directly: Biscuit tokens are intentionally opaque, but we
159/// already cache the expiry alongside the token at issue time, which
160/// is the source of truth the CLI needs for rotation decisions.
161/// Returns `false` on any parse failure so a stale credential row
162/// doesn't block normal CLI operation.
163pub fn token_needs_rotation(cred: &ServerCredential) -> bool {
164    let Some(expires_str) = cred.expires_at.as_deref() else {
165        // No stored expiry — older credential row, or a token type
166        // (e.g. service-account credential issued without one) that
167        // the server doesn't expire. Skip rotation.
168        return false;
169    };
170    let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_str) else {
171        return false;
172    };
173    let now = chrono::Utc::now().timestamp();
174    let exp = expires_at.timestamp();
175    exp.saturating_sub(now) <= ROTATION_WINDOW_SECS as i64
176}
177
178/// Returns `~/.heddle/`, using `$HOME` as the base.
179fn dirs_or_home() -> PathBuf {
180    std::env::var("HOME")
181        .map(PathBuf::from)
182        .unwrap_or_else(|_| PathBuf::from("."))
183        .join(".heddle")
184}
185
186#[cfg(test)]
187mod tests {
188    use std::{
189        fs,
190        panic::{AssertUnwindSafe, catch_unwind},
191        path::PathBuf,
192        sync::atomic::{AtomicU64, Ordering},
193        time::{SystemTime, UNIX_EPOCH},
194    };
195
196    use super::*;
197
198    static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
199
200    fn unique_temp_dir(prefix: &str) -> PathBuf {
201        let unique = SystemTime::now()
202            .duration_since(UNIX_EPOCH)
203            .expect("system time before unix epoch")
204            .as_nanos();
205        let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
206        std::env::temp_dir().join(format!(
207            "{prefix}-{unique}-{}-{counter}",
208            std::process::id()
209        ))
210    }
211
212    fn with_home_dir<T>(home: PathBuf, f: impl FnOnce() -> T) -> T {
213        let _guard = lock_test_env();
214        let original_home = std::env::var_os("HOME");
215        unsafe {
216            std::env::set_var("HOME", &home);
217        }
218        let result = catch_unwind(AssertUnwindSafe(f));
219        match original_home {
220            Some(value) => unsafe {
221                std::env::set_var("HOME", value);
222            },
223            None => unsafe {
224                std::env::remove_var("HOME");
225            },
226        }
227        match result {
228            Ok(value) => value,
229            Err(payload) => std::panic::resume_unwind(payload),
230        }
231    }
232
233    #[test]
234    fn save_credentials_round_trips_through_atomic_write() {
235        let home = unique_temp_dir("heddle-credentials-test");
236        fs::create_dir_all(&home).expect("create temp home");
237
238        with_home_dir(home.clone(), || {
239            let mut store = CredentialStore::default();
240            store.servers.insert(
241                "heddle.example:8421".to_string(),
242                ServerCredential {
243                    token: "token-123".to_string(),
244                    subject: "dev".to_string(),
245                    device_id: Some("device-1".to_string()),
246                    credential_id: Some("cred-1".to_string()),
247                    private_key_pem: Some("pem".to_string()),
248                    expires_at: Some("2026-01-01T00:00:00Z".to_string()),
249                },
250            );
251            save_credentials(&store).expect("save credentials");
252
253            let path = credentials_path();
254            assert!(path.exists(), "expected credentials file to exist");
255
256            let loaded = load_credentials().expect("load credentials");
257            let cred = loaded
258                .servers
259                .get("heddle.example:8421")
260                .expect("stored credential");
261            assert_eq!(cred.subject, "dev");
262            assert_eq!(cred.token, "token-123");
263        });
264
265        let _ = fs::remove_dir_all(home);
266    }
267
268    #[cfg(unix)]
269    #[test]
270    fn save_credentials_writes_credential_file_0600() {
271        use std::os::unix::fs::PermissionsExt;
272
273        let home = unique_temp_dir("heddle-credentials-mode-test");
274        fs::create_dir_all(&home).expect("create temp home");
275
276        with_home_dir(home.clone(), || {
277            let mut store = CredentialStore::default();
278            store.servers.insert(
279                "heddle.example:8421".to_string(),
280                ServerCredential {
281                    token: "token-123".to_string(),
282                    subject: "dev".to_string(),
283                    device_id: None,
284                    credential_id: None,
285                    private_key_pem: Some("pem".to_string()),
286                    expires_at: None,
287                },
288            );
289            save_credentials(&store).expect("save credentials");
290
291            let mode = fs::metadata(credentials_path())
292                .expect("credentials metadata")
293                .permissions()
294                .mode()
295                & 0o777;
296            assert_eq!(mode, 0o600);
297        });
298
299        let _ = fs::remove_dir_all(home);
300    }
301
302    #[cfg(unix)]
303    #[test]
304    fn save_credentials_permission_failure_returns_error() {
305        use std::os::unix::fs::PermissionsExt;
306
307        let home = unique_temp_dir("heddle-credentials-permission-test");
308        let heddle_dir = home.join(".heddle");
309        fs::create_dir_all(&heddle_dir).expect("create credentials dir");
310        fs::set_permissions(&heddle_dir, fs::Permissions::from_mode(0o500))
311            .expect("make credentials dir unwritable");
312
313        with_home_dir(home.clone(), || {
314            let mut store = CredentialStore::default();
315            store.servers.insert(
316                "heddle.example:8421".to_string(),
317                ServerCredential {
318                    token: "token-123".to_string(),
319                    subject: "dev".to_string(),
320                    device_id: None,
321                    credential_id: None,
322                    private_key_pem: Some("pem".to_string()),
323                    expires_at: None,
324                },
325            );
326
327            let err = save_credentials(&store).expect_err("permission failure must propagate");
328            assert!(
329                err.to_string().contains("writing") || err.to_string().contains("Permission"),
330                "unexpected error: {err:?}"
331            );
332            assert!(
333                !credentials_path().exists(),
334                "failed write must not publish credentials"
335            );
336        });
337
338        fs::set_permissions(&heddle_dir, fs::Permissions::from_mode(0o700))
339            .expect("restore credentials dir");
340        let _ = fs::remove_dir_all(home);
341    }
342
343    #[test]
344    fn resolve_credential_for_server_accepts_scheme_prefixed_keys() {
345        let home = unique_temp_dir("heddle-credentials-test");
346        fs::create_dir_all(&home).expect("create temp home");
347
348        with_home_dir(home.clone(), || {
349            let mut store = CredentialStore::default();
350            store.servers.insert(
351                "http://heddle.example:8421".to_string(),
352                ServerCredential {
353                    token: "token-abc".to_string(),
354                    subject: "dev".to_string(),
355                    device_id: None,
356                    credential_id: None,
357                    private_key_pem: None,
358                    expires_at: None,
359                },
360            );
361            save_credentials(&store).expect("save credentials");
362
363            let resolved = resolve_credential_for_server("heddle.example:8421")
364                .expect("resolve credential")
365                .expect("scheme-prefixed credential");
366            assert_eq!(resolved.token, "token-abc");
367            assert_eq!(resolved.subject, "dev");
368        });
369
370        let _ = fs::remove_dir_all(home);
371    }
372}