Skip to main content

agentis_pay_shared/
auth.rs

1use std::collections::HashMap;
2use std::fs::{self, OpenOptions};
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7use anyhow::{Context, Result, anyhow};
8use dirs::home_dir;
9use keyring_core::{Entry, Error as KeyringError};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13const CREDENTIALS_FILE: &str = "credentials.json";
14const KEYRING_USERNAME: &str = "credentials";
15#[cfg(not(test))]
16const SAMPLE_KEYRING_ENV: &str = "AGENTIS_PAY_USE_SAMPLE_KEYRING";
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Credentials {
20    pub jwt: Option<String>,
21    pub refresh_token: Option<String>,
22    pub created_at: Option<String>,
23    pub jwt_expires_at: Option<String>,
24    pub installation_id: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub pending_auth_token: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub oauth_client_id: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub agent_name: Option<String>,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34struct KeyringSecrets {
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    jwt: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    refresh_token: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pending_auth_token: Option<String>,
41}
42
43fn new_installation_id() -> String {
44    Uuid::now_v7().to_string()
45}
46
47impl Default for Credentials {
48    fn default() -> Self {
49        Self {
50            jwt: None,
51            refresh_token: None,
52            created_at: None,
53            jwt_expires_at: None,
54            installation_id: Some(new_installation_id()),
55            pending_auth_token: None,
56            oauth_client_id: None,
57            agent_name: None,
58        }
59    }
60}
61
62impl KeyringSecrets {
63    fn from_credentials(data: &Credentials) -> Self {
64        Self {
65            jwt: data.jwt.clone().filter(|value| !value.trim().is_empty()),
66            refresh_token: data
67                .refresh_token
68                .clone()
69                .filter(|value| !value.trim().is_empty()),
70            pending_auth_token: data
71                .pending_auth_token
72                .clone()
73                .filter(|value| !value.trim().is_empty()),
74        }
75    }
76
77    fn is_empty(&self) -> bool {
78        self.jwt.is_none() && self.refresh_token.is_none() && self.pending_auth_token.is_none()
79    }
80
81    fn apply_to(&self, data: &mut Credentials) {
82        data.jwt = self.jwt.clone();
83        data.refresh_token = self.refresh_token.clone();
84        data.pending_auth_token = self.pending_auth_token.clone();
85    }
86}
87
88#[derive(Debug)]
89pub struct CredentialsStore {
90    path: PathBuf,
91    data: Credentials,
92}
93
94impl CredentialsStore {
95    pub fn load_or_default() -> Result<Self> {
96        let path = Self::path()?;
97        if !path.exists() {
98            let store = Self {
99                path,
100                data: Credentials::default(),
101            };
102            store.save()?;
103            return Ok(store);
104        }
105
106        let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
107        let mut data: Credentials = serde_json::from_str(&raw).context("parse credentials json")?;
108        let mut changed = false;
109        if data.installation_id.is_none() {
110            data.installation_id = Some(new_installation_id());
111            changed = true;
112        }
113
114        let file_secrets = KeyringSecrets::from_credentials(&data);
115        match load_secrets_from_keyring()? {
116            Some(secrets) => secrets.apply_to(&mut data),
117            None if !file_secrets.is_empty() => {
118                save_secrets_to_keyring(&file_secrets)?;
119                file_secrets.apply_to(&mut data);
120                changed = true;
121            }
122            None => {
123                data.jwt = None;
124                data.refresh_token = None;
125                data.pending_auth_token = None;
126            }
127        }
128
129        let store = Self { path, data };
130        if changed || !file_secrets.is_empty() {
131            store.save()?;
132        }
133        Ok(store)
134    }
135
136    pub fn save(&self) -> Result<()> {
137        let secrets = KeyringSecrets::from_credentials(&self.data);
138        if secrets.is_empty() {
139            clear_secrets_from_keyring()?;
140        } else {
141            save_secrets_to_keyring(&secrets)?;
142        }
143
144        if let Some(parent) = self.path.parent() {
145            fs::create_dir_all(parent).context("create credentials folder")?;
146        }
147        let raw = serde_json::to_string_pretty(&self.serializable_data())
148            .context("serialize credentials")?;
149        let tmp = self.path.with_extension("tmp");
150        let mut file = secure_temp_file(&tmp)?;
151        io::Write::write_all(&mut file, raw.as_bytes())
152            .with_context(|| format!("write {}", tmp.display()))?;
153        file.sync_all().context("sync credentials temp file")?;
154        drop(file);
155        fs::rename(&tmp, &self.path).with_context(|| {
156            format!(
157                "rename credentials temp file {} to {}",
158                tmp.display(),
159                self.path.display()
160            )
161        })?;
162        set_permissions_0600(&self.path)?;
163        Ok(())
164    }
165
166    pub fn clear(&self) -> Result<()> {
167        clear_secrets_from_keyring()?;
168        if self.path.exists() {
169            fs::remove_file(&self.path)
170                .with_context(|| format!("remove {}", self.path.display()))?;
171        }
172        Ok(())
173    }
174
175    pub fn credentials(&self) -> &Credentials {
176        &self.data
177    }
178
179    pub fn has_jwt(&self) -> bool {
180        self.data
181            .jwt
182            .as_ref()
183            .is_some_and(|jwt| !jwt.trim().is_empty())
184    }
185
186    pub fn jwt_expires_at(&self) -> Option<i64> {
187        self.data
188            .jwt_expires_at
189            .as_ref()
190            .and_then(|value| value.trim().parse::<i64>().ok())
191    }
192
193    pub fn clear_session(&mut self) {
194        self.data.jwt = None;
195        self.data.refresh_token = None;
196        self.data.jwt_expires_at = None;
197    }
198
199    pub fn set_pending_auth_token(&mut self, token: String) {
200        self.data.pending_auth_token = Some(token);
201    }
202
203    pub fn pending_auth_token(&self) -> Option<&str> {
204        self.data
205            .pending_auth_token
206            .as_deref()
207            .filter(|v| !v.trim().is_empty())
208    }
209
210    pub fn clear_pending_auth_token(&mut self) {
211        self.data.pending_auth_token = None;
212    }
213
214    pub fn oauth_client_id(&self) -> Option<&str> {
215        self.data
216            .oauth_client_id
217            .as_deref()
218            .filter(|v| !v.trim().is_empty())
219    }
220
221    pub fn set_oauth_client_id(&mut self, client_id: String) {
222        if client_id.trim().is_empty() {
223            self.data.oauth_client_id = None;
224        } else {
225            self.data.oauth_client_id = Some(client_id);
226        }
227    }
228
229    pub fn set_session(&mut self, jwt: String, refresh_token: Option<String>, ttl_seconds: i64) {
230        self.data.jwt = Some(jwt);
231        self.data.refresh_token = refresh_token.filter(|value| !value.trim().is_empty());
232        let ttl_seconds = ttl_seconds.max(0);
233        self.data.jwt_expires_at = Some((unix_timestamp_seconds() + ttl_seconds).to_string());
234    }
235
236    pub fn set_jwt(&mut self, jwt: String) {
237        self.data.jwt = Some(jwt);
238    }
239
240    pub fn set_refresh_token(&mut self, token: String) {
241        self.data.refresh_token = Some(token);
242    }
243
244    pub fn set_access_time(&mut self, created_at: String) {
245        self.data.created_at = Some(created_at);
246    }
247
248    pub fn installation_id(&self) -> &str {
249        self.data.installation_id.as_deref().unwrap_or_default()
250    }
251
252    pub fn agent_name(&self) -> Option<&str> {
253        self.data
254            .agent_name
255            .as_deref()
256            .filter(|v| !v.trim().is_empty())
257    }
258
259    pub fn set_agent_name(&mut self, name: Option<String>) {
260        self.data.agent_name = name.filter(|v| !v.trim().is_empty());
261    }
262
263    fn serializable_data(&self) -> Credentials {
264        let mut data = self.data.clone();
265        data.jwt = None;
266        data.refresh_token = None;
267        data.pending_auth_token = None;
268        data
269    }
270}
271
272pub fn unix_timestamp_seconds() -> i64 {
273    use std::time::{SystemTime, UNIX_EPOCH};
274
275    SystemTime::now()
276        .duration_since(UNIX_EPOCH)
277        .ok()
278        .map(|duration| duration.as_secs())
279        .unwrap_or_default() as i64
280}
281
282impl Default for CredentialsStore {
283    fn default() -> Self {
284        let dir = if cfg!(debug_assertions) {
285            ".agentis-pay-dev"
286        } else {
287            ".agentis-pay"
288        };
289        Self::load_or_default().unwrap_or_else(|_| Self {
290            path: PathBuf::from(dir).join(CREDENTIALS_FILE),
291            data: Credentials::default(),
292        })
293    }
294}
295
296fn set_permissions_0600(path: &Path) -> Result<(), io::Error> {
297    #[cfg(unix)]
298    {
299        use std::os::unix::fs::PermissionsExt;
300        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
301    }
302
303    #[cfg(not(unix))]
304    {
305        Ok(())
306    }
307}
308
309fn secure_temp_file(path: &Path) -> Result<std::fs::File> {
310    let mut options = OpenOptions::new();
311    options.create(true).write(true).truncate(true);
312
313    #[cfg(unix)]
314    {
315        use std::os::unix::fs::OpenOptionsExt;
316        options.mode(0o600);
317    }
318
319    options
320        .open(path)
321        .with_context(|| format!("create {}", path.display()))
322}
323
324impl CredentialsStore {
325    fn path() -> Result<PathBuf> {
326        let home = home_dir().ok_or_else(|| anyhow!("cannot resolve home directory"))?;
327        let dir = if cfg!(debug_assertions) {
328            ".agentis-pay-dev"
329        } else {
330            ".agentis-pay"
331        };
332        Ok(home.join(dir).join(CREDENTIALS_FILE))
333    }
334}
335
336fn keyring_service() -> &'static str {
337    if cfg!(debug_assertions) {
338        "agentis-pay-dev"
339    } else {
340        "agentis-pay"
341    }
342}
343
344fn init_keyring() -> Result<()> {
345    static INIT: OnceLock<std::result::Result<(), String>> = OnceLock::new();
346
347    INIT.get_or_init(|| {
348        #[cfg(test)]
349        {
350            keyring::use_sample_store(&HashMap::from([("persist", "false")]))
351                .map_err(|e| e.to_string())
352        }
353
354        #[cfg(not(test))]
355        {
356            if cfg!(debug_assertions) && std::env::var_os(SAMPLE_KEYRING_ENV).is_some() {
357                eprintln!(
358                    "Warning: using insecure sample keyring store because {SAMPLE_KEYRING_ENV} is set in a debug build."
359                );
360                keyring::use_sample_store(&HashMap::from([("persist", "false")]))
361                    .map_err(|e| e.to_string())
362            } else {
363                keyring::use_native_store(false).map_err(|e| e.to_string())
364            }
365        }
366    })
367    .clone()
368    .map_err(|e| anyhow!("initialize native credential store: {e}"))
369}
370
371fn keyring_entry() -> Result<Entry> {
372    init_keyring()?;
373    Entry::new(keyring_service(), KEYRING_USERNAME)
374        .map_err(|e| anyhow!("create keyring entry: {e}"))
375}
376
377fn load_secrets_from_keyring() -> Result<Option<KeyringSecrets>> {
378    let entry = keyring_entry()?;
379    match entry.get_password() {
380        Ok(raw) => serde_json::from_str(&raw)
381            .context("parse keyring credential payload")
382            .map(Some),
383        Err(KeyringError::NoEntry) => Ok(None),
384        Err(error) => Err(anyhow!("read from native credential store: {error}")),
385    }
386}
387
388fn save_secrets_to_keyring(secrets: &KeyringSecrets) -> Result<()> {
389    let entry = keyring_entry()?;
390    let raw = serde_json::to_string(secrets).context("serialize keyring credential payload")?;
391    entry
392        .set_password(&raw)
393        .map_err(|e| anyhow!("write to native credential store: {e}"))
394}
395
396fn clear_secrets_from_keyring() -> Result<()> {
397    let entry = keyring_entry()?;
398    match entry.delete_credential() {
399        Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
400        Err(error) => Err(anyhow!("delete from native credential store: {error}")),
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn unique_path(label: &str) -> PathBuf {
409        std::env::temp_dir().join(format!("agentis-pay-auth-{label}-{}.json", Uuid::now_v7()))
410    }
411
412    #[test]
413    fn save_scrubs_sensitive_fields_from_file() {
414        let path = unique_path("scrub");
415        let mut store = CredentialsStore {
416            path: path.clone(),
417            data: Credentials::default(),
418        };
419
420        store.set_session(
421            "jwt-token".to_string(),
422            Some("refresh-token".to_string()),
423            3600,
424        );
425        store.set_pending_auth_token("pending-token".to_string());
426        store.set_oauth_client_id("client-id".to_string());
427        store.set_agent_name(Some("agent-name".to_string()));
428        store.save().expect("save credentials");
429
430        let raw = fs::read_to_string(&path).expect("read credentials file");
431        assert!(!raw.contains("jwt-token"));
432        assert!(!raw.contains("refresh-token"));
433        assert!(!raw.contains("pending-token"));
434        assert!(raw.contains("client-id"));
435        assert!(raw.contains("agent-name"));
436
437        let loaded = CredentialsStore {
438            path,
439            data: Credentials::default(),
440        };
441        loaded.clear().expect("clear credentials");
442    }
443
444    #[test]
445    fn load_migrates_legacy_file_secrets_into_keyring() {
446        let path = unique_path("migrate");
447        let legacy = Credentials {
448            jwt: Some("legacy-jwt".to_string()),
449            refresh_token: Some("legacy-refresh".to_string()),
450            created_at: None,
451            jwt_expires_at: Some("123".to_string()),
452            installation_id: Some(new_installation_id()),
453            pending_auth_token: Some("legacy-pending".to_string()),
454            oauth_client_id: Some("legacy-client".to_string()),
455            agent_name: Some("legacy-agent".to_string()),
456        };
457        fs::write(
458            &path,
459            serde_json::to_string_pretty(&legacy).expect("serialize legacy"),
460        )
461        .expect("write legacy credentials");
462
463        let store = CredentialsStore::load_or_default_from_path(path.clone())
464            .expect("load migrated credentials");
465        assert_eq!(store.credentials().jwt.as_deref(), Some("legacy-jwt"));
466        assert_eq!(
467            store.credentials().refresh_token.as_deref(),
468            Some("legacy-refresh")
469        );
470        assert_eq!(
471            store.credentials().pending_auth_token.as_deref(),
472            Some("legacy-pending")
473        );
474
475        let raw = fs::read_to_string(&path).expect("read migrated file");
476        assert!(!raw.contains("legacy-jwt"));
477        assert!(!raw.contains("legacy-refresh"));
478        assert!(!raw.contains("legacy-pending"));
479
480        store.clear().expect("clear credentials");
481    }
482
483    impl CredentialsStore {
484        fn load_or_default_from_path(path: PathBuf) -> Result<Self> {
485            if !path.exists() {
486                let store = Self {
487                    path,
488                    data: Credentials::default(),
489                };
490                store.save()?;
491                return Ok(store);
492            }
493
494            let raw =
495                fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
496            let mut data: Credentials =
497                serde_json::from_str(&raw).context("parse credentials json")?;
498            let mut changed = false;
499            if data.installation_id.is_none() {
500                data.installation_id = Some(new_installation_id());
501                changed = true;
502            }
503
504            let file_secrets = KeyringSecrets::from_credentials(&data);
505            match load_secrets_from_keyring()? {
506                Some(secrets) => secrets.apply_to(&mut data),
507                None if !file_secrets.is_empty() => {
508                    save_secrets_to_keyring(&file_secrets)?;
509                    file_secrets.apply_to(&mut data);
510                    changed = true;
511                }
512                None => {
513                    data.jwt = None;
514                    data.refresh_token = None;
515                    data.pending_auth_token = None;
516                }
517            }
518
519            let store = Self { path, data };
520            if changed || !file_secrets.is_empty() {
521                store.save()?;
522            }
523            Ok(store)
524        }
525    }
526}