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        let store = Self { path, data };
72        if changed {
73            store.save()?;
74        }
75        Ok(store)
76    }
77
78    pub fn save(&self) -> Result<()> {
79        if let Some(parent) = self.path.parent() {
80            fs::create_dir_all(parent).context("create credentials folder")?;
81        }
82        let raw = serde_json::to_string_pretty(&self.data).context("serialize credentials")?;
83        let tmp = self.path.with_extension("tmp");
84        let mut file = secure_temp_file(&tmp)?;
85        io::Write::write_all(&mut file, raw.as_bytes())
86            .with_context(|| format!("write {}", tmp.display()))?;
87        file.sync_all().context("sync credentials temp file")?;
88        drop(file);
89        fs::rename(&tmp, &self.path).with_context(|| {
90            format!(
91                "rename credentials temp file {} to {}",
92                tmp.display(),
93                self.path.display()
94            )
95        })?;
96        set_permissions_0600(&self.path)?;
97        Ok(())
98    }
99
100    pub fn clear(&self) -> Result<()> {
101        if self.path.exists() {
102            fs::remove_file(&self.path)
103                .with_context(|| format!("remove {}", self.path.display()))?;
104        }
105        Ok(())
106    }
107
108    pub fn credentials(&self) -> &Credentials {
109        &self.data
110    }
111
112    pub fn has_jwt(&self) -> bool {
113        self.data
114            .jwt
115            .as_ref()
116            .is_some_and(|jwt| !jwt.trim().is_empty())
117    }
118
119    pub fn jwt_expires_at(&self) -> Option<i64> {
120        self.data
121            .jwt_expires_at
122            .as_ref()
123            .and_then(|value| value.trim().parse::<i64>().ok())
124    }
125
126    pub fn clear_session(&mut self) {
127        self.data.jwt = None;
128        self.data.refresh_token = None;
129        self.data.jwt_expires_at = None;
130    }
131
132    pub fn set_pending_auth_token(&mut self, token: String) {
133        self.data.pending_auth_token = Some(token);
134    }
135
136    pub fn pending_auth_token(&self) -> Option<&str> {
137        self.data
138            .pending_auth_token
139            .as_deref()
140            .filter(|v| !v.trim().is_empty())
141    }
142
143    pub fn clear_pending_auth_token(&mut self) {
144        self.data.pending_auth_token = None;
145    }
146
147    pub fn oauth_client_id(&self) -> Option<&str> {
148        self.data
149            .oauth_client_id
150            .as_deref()
151            .filter(|v| !v.trim().is_empty())
152    }
153
154    pub fn set_oauth_client_id(&mut self, client_id: String) {
155        if client_id.trim().is_empty() {
156            self.data.oauth_client_id = None;
157        } else {
158            self.data.oauth_client_id = Some(client_id);
159        }
160    }
161
162    pub fn set_session(&mut self, jwt: String, refresh_token: Option<String>, ttl_seconds: i64) {
163        self.data.jwt = Some(jwt);
164        self.data.refresh_token = refresh_token.filter(|value| !value.trim().is_empty());
165        let ttl_seconds = ttl_seconds.max(0);
166        self.data.jwt_expires_at = Some((unix_timestamp_seconds() + ttl_seconds).to_string());
167    }
168
169    pub fn set_jwt(&mut self, jwt: String) {
170        self.data.jwt = Some(jwt);
171    }
172
173    pub fn set_refresh_token(&mut self, token: String) {
174        self.data.refresh_token = Some(token);
175    }
176
177    pub fn set_access_time(&mut self, created_at: String) {
178        self.data.created_at = Some(created_at);
179    }
180
181    pub fn installation_id(&self) -> &str {
182        self.data.installation_id.as_deref().unwrap_or_default()
183    }
184
185    pub fn agent_name(&self) -> Option<&str> {
186        self.data
187            .agent_name
188            .as_deref()
189            .filter(|v| !v.trim().is_empty())
190    }
191
192    pub fn set_agent_name(&mut self, name: Option<String>) {
193        self.data.agent_name = name.filter(|v| !v.trim().is_empty());
194    }
195}
196
197pub fn unix_timestamp_seconds() -> i64 {
198    use std::time::{SystemTime, UNIX_EPOCH};
199
200    SystemTime::now()
201        .duration_since(UNIX_EPOCH)
202        .ok()
203        .map(|duration| duration.as_secs())
204        .unwrap_or_default() as i64
205}
206
207impl Default for CredentialsStore {
208    fn default() -> Self {
209        let dir = if cfg!(debug_assertions) {
210            ".agentis-pay-dev"
211        } else {
212            ".agentis-pay"
213        };
214        Self::load_or_default().unwrap_or_else(|_| Self {
215            path: PathBuf::from(dir).join(CREDENTIALS_FILE),
216            data: Credentials::default(),
217        })
218    }
219}
220
221fn set_permissions_0600(path: &Path) -> Result<(), io::Error> {
222    #[cfg(unix)]
223    {
224        use std::os::unix::fs::PermissionsExt;
225        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
226    }
227
228    #[cfg(not(unix))]
229    {
230        Ok(())
231    }
232}
233
234fn secure_temp_file(path: &Path) -> Result<std::fs::File> {
235    let mut options = OpenOptions::new();
236    options.create(true).write(true).truncate(true);
237
238    #[cfg(unix)]
239    {
240        use std::os::unix::fs::OpenOptionsExt;
241        options.mode(0o600);
242    }
243
244    options
245        .open(path)
246        .with_context(|| format!("create {}", path.display()))
247}
248
249impl CredentialsStore {
250    fn path() -> Result<PathBuf> {
251        let home = home_dir().ok_or_else(|| anyhow!("cannot resolve home directory"))?;
252        let dir = if cfg!(debug_assertions) {
253            ".agentis-pay-dev"
254        } else {
255            ".agentis-pay"
256        };
257        Ok(home.join(dir).join(CREDENTIALS_FILE))
258    }
259}