Skip to main content

agentis_pay_shared/
auth.rs

1use std::fs::{self, OpenOptions};
2use std::io;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow};
6use dirs::home_dir;
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10const CREDENTIALS_FILE: &str = "credentials.json";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Credentials {
14    pub jwt: Option<String>,
15    pub refresh_token: Option<String>,
16    pub created_at: Option<String>,
17    pub jwt_expires_at: Option<String>,
18    pub installation_id: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub pending_auth_token: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub oauth_client_id: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub agent_name: Option<String>,
25}
26
27fn new_installation_id() -> String {
28    Uuid::now_v7().to_string()
29}
30
31impl Default for Credentials {
32    fn default() -> Self {
33        Self {
34            jwt: None,
35            refresh_token: None,
36            created_at: None,
37            jwt_expires_at: None,
38            installation_id: Some(new_installation_id()),
39            pending_auth_token: None,
40            oauth_client_id: None,
41            agent_name: None,
42        }
43    }
44}
45
46#[derive(Debug)]
47pub struct CredentialsStore {
48    path: PathBuf,
49    data: Credentials,
50}
51
52impl CredentialsStore {
53    pub fn load_or_default() -> Result<Self> {
54        let path = Self::path()?;
55        if !path.exists() {
56            let store = Self {
57                path,
58                data: Credentials::default(),
59            };
60            store.save()?;
61            return Ok(store);
62        }
63
64        let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
65        let mut data: Credentials = serde_json::from_str(&raw).context("parse credentials json")?;
66        let mut changed = false;
67        if data.installation_id.is_none() {
68            data.installation_id = Some(new_installation_id());
69            changed = true;
70        }
71
72        let store = Self { path, data };
73        if changed {
74            store.save()?;
75        }
76        Ok(store)
77    }
78
79    pub fn save(&self) -> Result<()> {
80        if let Some(parent) = self.path.parent() {
81            fs::create_dir_all(parent).context("create credentials folder")?;
82        }
83        let raw = serde_json::to_string_pretty(&self.data).context("serialize credentials")?;
84        let tmp = self.path.with_extension("tmp");
85        let mut file = secure_temp_file(&tmp)?;
86        io::Write::write_all(&mut file, raw.as_bytes())
87            .with_context(|| format!("write {}", tmp.display()))?;
88        file.sync_all().context("sync credentials temp file")?;
89        drop(file);
90        fs::rename(&tmp, &self.path).with_context(|| {
91            format!(
92                "rename credentials temp file {} to {}",
93                tmp.display(),
94                self.path.display()
95            )
96        })?;
97        set_permissions_0600(&self.path)?;
98        Ok(())
99    }
100
101    pub fn clear(&self) -> Result<()> {
102        if self.path.exists() {
103            fs::remove_file(&self.path)
104                .with_context(|| format!("remove {}", self.path.display()))?;
105        }
106        Ok(())
107    }
108
109    pub fn credentials(&self) -> &Credentials {
110        &self.data
111    }
112
113    pub fn has_jwt(&self) -> bool {
114        self.data
115            .jwt
116            .as_ref()
117            .is_some_and(|jwt| !jwt.trim().is_empty())
118    }
119
120    pub fn jwt_expires_at(&self) -> Option<i64> {
121        self.data
122            .jwt_expires_at
123            .as_ref()
124            .and_then(|value| value.trim().parse::<i64>().ok())
125    }
126
127    pub fn clear_session(&mut self) {
128        self.data.jwt = None;
129        self.data.refresh_token = None;
130        self.data.jwt_expires_at = None;
131    }
132
133    pub fn set_pending_auth_token(&mut self, token: String) {
134        self.data.pending_auth_token = Some(token);
135    }
136
137    pub fn pending_auth_token(&self) -> Option<&str> {
138        self.data
139            .pending_auth_token
140            .as_deref()
141            .filter(|v| !v.trim().is_empty())
142    }
143
144    pub fn clear_pending_auth_token(&mut self) {
145        self.data.pending_auth_token = None;
146    }
147
148    pub fn oauth_client_id(&self) -> Option<&str> {
149        self.data
150            .oauth_client_id
151            .as_deref()
152            .filter(|v| !v.trim().is_empty())
153    }
154
155    pub fn set_oauth_client_id(&mut self, client_id: String) {
156        if client_id.trim().is_empty() {
157            self.data.oauth_client_id = None;
158        } else {
159            self.data.oauth_client_id = Some(client_id);
160        }
161    }
162
163    pub fn set_session(&mut self, jwt: String, refresh_token: Option<String>, ttl_seconds: i64) {
164        self.data.jwt = Some(jwt);
165        self.data.refresh_token = refresh_token.filter(|value| !value.trim().is_empty());
166        let ttl_seconds = ttl_seconds.max(0);
167        self.data.jwt_expires_at = Some((unix_timestamp_seconds() + ttl_seconds).to_string());
168    }
169
170    pub fn set_jwt(&mut self, jwt: String) {
171        self.data.jwt = Some(jwt);
172    }
173
174    pub fn set_refresh_token(&mut self, token: String) {
175        self.data.refresh_token = Some(token);
176    }
177
178    pub fn set_access_time(&mut self, created_at: String) {
179        self.data.created_at = Some(created_at);
180    }
181
182    pub fn installation_id(&self) -> &str {
183        self.data.installation_id.as_deref().unwrap_or_default()
184    }
185
186    pub fn agent_name(&self) -> Option<&str> {
187        self.data
188            .agent_name
189            .as_deref()
190            .filter(|v| !v.trim().is_empty())
191    }
192
193    pub fn set_agent_name(&mut self, name: Option<String>) {
194        self.data.agent_name = name.filter(|v| !v.trim().is_empty());
195    }
196}
197
198pub fn unix_timestamp_seconds() -> i64 {
199    use std::time::{SystemTime, UNIX_EPOCH};
200
201    SystemTime::now()
202        .duration_since(UNIX_EPOCH)
203        .ok()
204        .map(|duration| duration.as_secs())
205        .unwrap_or_default() as i64
206}
207
208impl Default for CredentialsStore {
209    fn default() -> Self {
210        let dir = if cfg!(debug_assertions) {
211            ".agentis-pay-dev"
212        } else {
213            ".agentis-pay"
214        };
215        Self::load_or_default().unwrap_or_else(|_| Self {
216            path: PathBuf::from(dir).join(CREDENTIALS_FILE),
217            data: Credentials::default(),
218        })
219    }
220}
221
222fn set_permissions_0600(path: &Path) -> Result<(), io::Error> {
223    #[cfg(unix)]
224    {
225        use std::os::unix::fs::PermissionsExt;
226        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
227    }
228
229    #[cfg(not(unix))]
230    {
231        Ok(())
232    }
233}
234
235fn secure_temp_file(path: &Path) -> Result<std::fs::File> {
236    let mut options = OpenOptions::new();
237    options.create(true).write(true).truncate(true);
238
239    #[cfg(unix)]
240    {
241        use std::os::unix::fs::OpenOptionsExt;
242        options.mode(0o600);
243    }
244
245    options
246        .open(path)
247        .with_context(|| format!("create {}", path.display()))
248}
249
250impl CredentialsStore {
251    fn path() -> Result<PathBuf> {
252        let home = home_dir().ok_or_else(|| anyhow!("cannot resolve home directory"))?;
253        let dir = if cfg!(debug_assertions) {
254            ".agentis-pay-dev"
255        } else {
256            ".agentis-pay"
257        };
258        Ok(home.join(dir).join(CREDENTIALS_FILE))
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn unique_path(label: &str) -> PathBuf {
267        std::env::temp_dir().join(format!("agentis-pay-auth-{label}-{}.json", Uuid::now_v7()))
268    }
269
270    #[test]
271    fn save_persists_all_fields_to_file() {
272        let path = unique_path("persist");
273        let mut store = CredentialsStore {
274            path: path.clone(),
275            data: Credentials::default(),
276        };
277
278        store.set_session(
279            "jwt-token".to_string(),
280            Some("refresh-token".to_string()),
281            3600,
282        );
283        store.set_pending_auth_token("pending-token".to_string());
284        store.set_oauth_client_id("client-id".to_string());
285        store.set_agent_name(Some("agent-name".to_string()));
286        store.save().expect("save credentials");
287
288        let raw = fs::read_to_string(&path).expect("read credentials file");
289        assert!(raw.contains("jwt-token"));
290        assert!(raw.contains("refresh-token"));
291        assert!(raw.contains("pending-token"));
292        assert!(raw.contains("client-id"));
293        assert!(raw.contains("agent-name"));
294
295        store.clear().expect("clear credentials");
296    }
297
298    #[test]
299    fn load_round_trips_all_fields() {
300        let path = unique_path("roundtrip");
301        let data = Credentials {
302            jwt: Some("my-jwt".to_string()),
303            refresh_token: Some("my-refresh".to_string()),
304            created_at: None,
305            jwt_expires_at: Some("123".to_string()),
306            installation_id: Some(new_installation_id()),
307            pending_auth_token: Some("my-pending".to_string()),
308            oauth_client_id: Some("my-client".to_string()),
309            agent_name: Some("my-agent".to_string()),
310        };
311        fs::write(
312            &path,
313            serde_json::to_string_pretty(&data).expect("serialize"),
314        )
315        .expect("write credentials");
316
317        let store =
318            CredentialsStore::load_or_default_from_path(path.clone()).expect("load credentials");
319        assert_eq!(store.credentials().jwt.as_deref(), Some("my-jwt"));
320        assert_eq!(
321            store.credentials().refresh_token.as_deref(),
322            Some("my-refresh")
323        );
324        assert_eq!(
325            store.credentials().pending_auth_token.as_deref(),
326            Some("my-pending")
327        );
328        assert_eq!(
329            store.credentials().oauth_client_id.as_deref(),
330            Some("my-client")
331        );
332        assert_eq!(store.credentials().agent_name.as_deref(), Some("my-agent"));
333
334        store.clear().expect("clear credentials");
335    }
336
337    impl CredentialsStore {
338        fn load_or_default_from_path(path: PathBuf) -> Result<Self> {
339            if !path.exists() {
340                let store = Self {
341                    path,
342                    data: Credentials::default(),
343                };
344                store.save()?;
345                return Ok(store);
346            }
347
348            let raw =
349                fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
350            let mut data: Credentials =
351                serde_json::from_str(&raw).context("parse credentials json")?;
352            if data.installation_id.is_none() {
353                data.installation_id = Some(new_installation_id());
354            }
355
356            Ok(Self { path, data })
357        }
358    }
359}