agent_core/core/auth/
storage.rs1use std::path::PathBuf;
2
3use super::{AuthFile, OAuthCredentials};
4
5pub fn auth_file_path() -> PathBuf {
7 crate::config::resolve_read_path("auth.json")
8}
9
10pub fn load_auth() -> std::result::Result<Option<AuthFile>, String> {
12 let path = auth_file_path();
13 if !path.exists() {
14 return Ok(None);
15 }
16 let content = std::fs::read_to_string(&path)
17 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
18 let auth: AuthFile = serde_json::from_str(&content)
19 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
20 Ok(Some(auth))
21}
22
23pub fn load_provider_auth(provider: &str) -> std::result::Result<Option<OAuthCredentials>, String> {
25 let path = auth_file_path();
26 if !path.exists() {
27 return Ok(None);
28 }
29 let content = std::fs::read_to_string(&path)
30 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
31 let value: serde_json::Value = serde_json::from_str(&content)
32 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
33 let Some(raw) = value.get(provider) else {
34 return Ok(None);
35 };
36 let creds: OAuthCredentials = serde_json::from_value(raw.clone())
37 .map_err(|e| format!("Failed to parse {} credential: {}", provider, e))?;
38 Ok(Some(creds))
39}
40
41pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
43 save_provider_auth("anthropic", creds)
44}
45
46pub fn save_provider_auth(provider: &str, creds: &OAuthCredentials) -> std::result::Result<(), String> {
48 let path = crate::config::resolve_write_path("auth.json");
49 save_provider_auth_at(&path, provider, creds)
50}
51
52fn save_provider_auth_at(
56 path: &std::path::Path,
57 provider: &str,
58 creds: &OAuthCredentials,
59) -> std::result::Result<(), String> {
60 use fs4::fs_std::FileExt;
61
62 if let Some(parent) = path.parent() {
64 std::fs::create_dir_all(parent)
65 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
66 }
67
68 let lock_path = path.with_extension("json.lock");
73 let lock_file = std::fs::OpenOptions::new()
74 .create(true)
75 .truncate(true)
76 .write(true)
77 .open(&lock_path)
78 .map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
79 FileExt::lock_exclusive(&lock_file)
80 .map_err(|e| format!("Failed to lock {}: {}", lock_path.display(), e))?;
81
82 let mut root = if path.exists() {
83 let content = std::fs::read_to_string(path)
84 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
85 match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content) {
91 Ok(map) => map,
92 Err(e) => {
93 tracing::warn!(
94 path = %path.display(),
95 error = %e,
96 "auth.json could not be parsed as a JSON object; replacing with a fresh structure"
97 );
98 let ts = std::time::SystemTime::now()
100 .duration_since(std::time::UNIX_EPOCH)
101 .map(|d| d.as_secs())
102 .unwrap_or(0);
103 let backup = path.with_extension(format!("json.corrupt.{}", ts));
104 match std::fs::copy(path, &backup) {
105 Ok(_) => {
106 eprintln!(
107 "[warn] auth.json was corrupt and has been reset. Backup saved to: {}",
108 backup.display()
109 );
110 }
111 Err(copy_err) => {
112 eprintln!(
113 "[warn] auth.json was corrupt and has been reset, but backup failed: {}",
114 copy_err
115 );
116 }
117 }
118 serde_json::Map::new()
119 }
120 }
121 } else {
122 serde_json::Map::new()
123 };
124
125 root.insert(
126 provider.to_string(),
127 serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?,
128 );
129
130 let json = serde_json::to_string_pretty(&root)
131 .map_err(|e| format!("Failed to serialize auth: {}", e))?;
132
133 let tmp_path = path.with_extension("json.tmp");
138 {
139 use std::io::Write;
140 let mut file = std::fs::OpenOptions::new()
141 .write(true)
142 .create(true)
143 .truncate(true)
144 .open(&tmp_path)
145 .map_err(|e| format!("Failed to create {}: {}", tmp_path.display(), e))?;
146
147 #[cfg(unix)]
148 {
149 use std::os::unix::fs::PermissionsExt;
150 let perms = std::fs::Permissions::from_mode(0o600);
151 file.set_permissions(perms)
152 .map_err(|e| format!("Failed to set permissions on {}: {}", tmp_path.display(), e))?;
153 }
154
155 file.write_all(json.as_bytes())
156 .map_err(|e| format!("Failed to write {}: {}", tmp_path.display(), e))?;
157 file.sync_all()
158 .map_err(|e| format!("Failed to fsync {}: {}", tmp_path.display(), e))?;
159 }
160
161 std::fs::rename(&tmp_path, path)
162 .map_err(|e| format!("Failed to atomically replace {}: {}", path.display(), e))?;
163
164 Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn fresh_creds() -> OAuthCredentials {
172 OAuthCredentials {
173 auth_type: "oauth".to_string(),
174 refresh: "r".to_string(),
175 access: "a".to_string(),
176 expires: 1,
177 account_id: None,
178 }
179 }
180
181 #[test]
182 fn save_provider_auth_at_creates_file_when_absent() {
183 let dir = tempfile::tempdir().expect("tempdir");
184 let path = dir.path().join("auth.json");
185 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
186 assert!(path.exists());
187 let content = std::fs::read_to_string(&path).unwrap();
188 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
189 assert!(parsed.get("openai-codex").is_some());
190 }
191
192 #[test]
193 fn save_provider_auth_at_preserves_other_providers() {
194 let dir = tempfile::tempdir().expect("tempdir");
195 let path = dir.path().join("auth.json");
196 std::fs::write(
197 &path,
198 r#"{"anthropic":{"type":"oauth","refresh":"r2","access":"a2","expires":2}}"#,
199 )
200 .unwrap();
201 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
202 let content = std::fs::read_to_string(&path).unwrap();
203 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
204 assert!(parsed.get("anthropic").is_some(), "must keep anthropic entry");
205 assert!(parsed.get("openai-codex").is_some());
206 }
207
208 #[test]
209 fn save_provider_auth_at_recovers_from_corrupt_file() {
210 let dir = tempfile::tempdir().expect("tempdir");
215 let path = dir.path().join("auth.json");
216 std::fs::write(&path, "this is not json {{{").unwrap();
217 save_provider_auth_at(&path, "openai-codex", &fresh_creds())
218 .expect("save must succeed even on corrupt input");
219 let content = std::fs::read_to_string(&path).unwrap();
220 let parsed: serde_json::Value = serde_json::from_str(&content)
221 .expect("file must now contain valid JSON");
222 assert!(parsed.get("openai-codex").is_some());
223 assert!(
224 parsed.get("anthropic").is_none(),
225 "corrupt fallback discards old (unrecoverable) entries"
226 );
227 }
228
229 #[test]
230 fn save_provider_auth_at_recovers_from_array_root() {
231 let dir = tempfile::tempdir().expect("tempdir");
234 let path = dir.path().join("auth.json");
235 std::fs::write(&path, "[1, 2, 3]").unwrap();
236 save_provider_auth_at(&path, "openai-codex", &fresh_creds())
237 .expect("save must succeed against non-object root");
238 let parsed: serde_json::Value =
239 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
240 assert!(parsed.is_object());
241 assert!(parsed.get("openai-codex").is_some());
242 }
243}