use std::path::PathBuf;
use super::{AuthFile, OAuthCredentials};
pub fn auth_file_path() -> PathBuf {
crate::config::resolve_read_path("auth.json")
}
pub fn load_auth() -> std::result::Result<Option<AuthFile>, String> {
let path = auth_file_path();
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let auth: AuthFile = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
Ok(Some(auth))
}
pub fn load_provider_auth(provider: &str) -> std::result::Result<Option<OAuthCredentials>, String> {
let path = auth_file_path();
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
let Some(raw) = value.get(provider) else {
return Ok(None);
};
let creds: OAuthCredentials = serde_json::from_value(raw.clone())
.map_err(|e| format!("Failed to parse {} credential: {}", provider, e))?;
Ok(Some(creds))
}
pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
save_provider_auth("anthropic", creds)
}
pub fn save_provider_auth(provider: &str, creds: &OAuthCredentials) -> std::result::Result<(), String> {
let path = crate::config::resolve_write_path("auth.json");
save_provider_auth_at(&path, provider, creds)
}
fn save_provider_auth_at(
path: &std::path::Path,
provider: &str,
creds: &OAuthCredentials,
) -> std::result::Result<(), String> {
use fs4::fs_std::FileExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
}
let lock_path = path.with_extension("json.lock");
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.open(&lock_path)
.map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
FileExt::lock_exclusive(&lock_file)
.map_err(|e| format!("Failed to lock {}: {}", lock_path.display(), e))?;
let mut root = if path.exists() {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content) {
Ok(map) => map,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"auth.json could not be parsed as a JSON object; replacing with a fresh structure"
);
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let backup = path.with_extension(format!("json.corrupt.{}", ts));
match std::fs::copy(path, &backup) {
Ok(_) => {
eprintln!(
"[warn] auth.json was corrupt and has been reset. Backup saved to: {}",
backup.display()
);
}
Err(copy_err) => {
eprintln!(
"[warn] auth.json was corrupt and has been reset, but backup failed: {}",
copy_err
);
}
}
serde_json::Map::new()
}
}
} else {
serde_json::Map::new()
};
root.insert(
provider.to_string(),
serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?,
);
let json = serde_json::to_string_pretty(&root)
.map_err(|e| format!("Failed to serialize auth: {}", e))?;
let tmp_path = path.with_extension("json.tmp");
{
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
.map_err(|e| format!("Failed to create {}: {}", tmp_path.display(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
file.set_permissions(perms)
.map_err(|e| format!("Failed to set permissions on {}: {}", tmp_path.display(), e))?;
}
file.write_all(json.as_bytes())
.map_err(|e| format!("Failed to write {}: {}", tmp_path.display(), e))?;
file.sync_all()
.map_err(|e| format!("Failed to fsync {}: {}", tmp_path.display(), e))?;
}
std::fs::rename(&tmp_path, path)
.map_err(|e| format!("Failed to atomically replace {}: {}", path.display(), e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_creds() -> OAuthCredentials {
OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "r".to_string(),
access: "a".to_string(),
expires: 1,
account_id: None,
}
}
#[test]
fn save_provider_auth_at_creates_file_when_absent() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(parsed.get("openai-codex").is_some());
}
#[test]
fn save_provider_auth_at_preserves_other_providers() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
std::fs::write(
&path,
r#"{"anthropic":{"type":"oauth","refresh":"r2","access":"a2","expires":2}}"#,
)
.unwrap();
save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
let content = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(parsed.get("anthropic").is_some(), "must keep anthropic entry");
assert!(parsed.get("openai-codex").is_some());
}
#[test]
fn save_provider_auth_at_recovers_from_corrupt_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
std::fs::write(&path, "this is not json {{{").unwrap();
save_provider_auth_at(&path, "openai-codex", &fresh_creds())
.expect("save must succeed even on corrupt input");
let content = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content)
.expect("file must now contain valid JSON");
assert!(parsed.get("openai-codex").is_some());
assert!(
parsed.get("anthropic").is_none(),
"corrupt fallback discards old (unrecoverable) entries"
);
}
#[test]
fn save_provider_auth_at_recovers_from_array_root() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
std::fs::write(&path, "[1, 2, 3]").unwrap();
save_provider_auth_at(&path, "openai-codex", &fresh_creds())
.expect("save must succeed against non-object root");
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(parsed.is_object());
assert!(parsed.get("openai-codex").is_some());
}
}