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}