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 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}