agentis_pay_shared/
auth.rs1use 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}