use std::path::{Path, PathBuf};
use super::account::{Account, AccountSummary, CredentialRef};
use super::provider::OAuthProviderId;
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> {
load_provider_auth_at(&auth_file_path(), provider)
}
pub(crate) fn load_provider_auth_at(
path: &Path,
provider: &str,
) -> std::result::Result<Option<OAuthCredentials>, String> {
load_provider_auth_typed_at(path, provider).map_err(String::from)
}
pub(crate) fn load_provider_auth_typed_at(
path: &Path,
provider: &str,
) -> std::result::Result<Option<OAuthCredentials>, StoreError> {
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| corrupt_store(path, &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 load_credential(
cred: &CredentialRef,
) -> std::result::Result<Option<OAuthCredentials>, String> {
load_provider_auth(&cred.storage_key())
}
pub fn save_credential(
cred: &CredentialRef,
creds: &OAuthCredentials,
) -> std::result::Result<(), String> {
let path = crate::config::resolve_write_path("auth.json");
save_credential_at(&path, cred, creds)
}
pub(crate) fn save_credential_at(
path: &Path,
cred: &CredentialRef,
creds: &OAuthCredentials,
) -> std::result::Result<(), String> {
let encoded =
serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
let fields = encoded
.as_object()
.cloned()
.ok_or_else(|| "credential did not serialize to an object".to_string())?;
let key = cred.storage_key();
with_locked_root(path, false, |root| {
let mut slot = root
.remove(&key)
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
if let Some(target) = slot.as_object_mut() {
for (k, v) in &fields {
target.insert(k.clone(), v.clone());
}
}
root.insert(key.clone(), slot);
Ok((true, ()))
})
}
pub fn remove_credential(cred: &CredentialRef) -> std::result::Result<bool, String> {
let read_path = auth_file_path();
let write_path = crate::config::resolve_write_path("auth.json");
if read_path != write_path {
return Err(format!(
"{} is stored in {} which the active profile inherits; re-run without --profile \
(or with the profile that owns that file) to remove it",
cred,
read_path.display()
));
}
remove_key_at(&write_path, &cred.storage_key())
}
pub(crate) fn remove_key_at(path: &Path, key: &str) -> std::result::Result<bool, String> {
let removed = if path.exists() {
with_locked_root(path, false, |root| {
let removed = root.remove(key).is_some();
Ok((removed, removed))
})?
} else {
false
};
let _ = std::fs::remove_file(refresh_pending_path(path, key));
Ok(removed)
}
pub(crate) fn refresh_pending_path(auth_path: &Path, key: &str) -> PathBuf {
let file_name = auth_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("auth.json");
auth_path.with_file_name(format!("{file_name}.{key}.refresh.pending"))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountMetadata {
pub identity: Option<String>,
pub added_at: Option<u64>,
}
pub fn save_account_metadata(
cred: &CredentialRef,
metadata: &AccountMetadata,
) -> std::result::Result<(), String> {
let path = crate::config::resolve_write_path("auth.json");
save_account_metadata_at(&path, cred, metadata)
}
pub(crate) fn save_account_metadata_at(
path: &Path,
cred: &CredentialRef,
metadata: &AccountMetadata,
) -> std::result::Result<(), String> {
let key = cred.storage_key();
with_locked_root(path, false, |root| {
let Some(entry) = root.get_mut(&key).and_then(serde_json::Value::as_object_mut) else {
return Err(format!("no credential stored for {key}"));
};
match &cred.account {
Account::Default => {
entry.remove("label");
}
Account::Named(label) => {
entry.insert("label".into(), serde_json::Value::String(label.as_str().into()));
}
}
match &metadata.identity {
Some(identity) if !identity.trim().is_empty() => {
entry.insert(
"identity".into(),
serde_json::Value::String(identity.trim().to_string()),
);
}
_ => {}
}
if let Some(added_at) = metadata.added_at {
entry.insert("addedAt".into(), serde_json::Value::from(added_at));
}
Ok((true, ()))
})
}
pub fn set_slot_identity(
cred: &CredentialRef,
account_id: &str,
identity: Option<&str>,
) -> std::result::Result<(), String> {
let read_path = auth_file_path();
let write_path = crate::config::resolve_write_path("auth.json");
if read_path != write_path {
return Err(format!(
"{} is stored in {} which the active profile inherits; re-run without --profile \
(or with the profile that owns that file) to record its identity",
cred,
read_path.display()
));
}
set_slot_identity_at(&write_path, cred, account_id, identity)
}
pub(crate) fn set_slot_identity_at(
path: &Path,
cred: &CredentialRef,
account_id: &str,
identity: Option<&str>,
) -> std::result::Result<(), String> {
let account_id = account_id.trim();
if account_id.is_empty() {
return Err("account id is empty; nothing recorded".into());
}
let key = cred.storage_key();
if !path.exists() {
return Err(format!("no credential stored for {key}"));
}
let identity = identity.map(str::trim).filter(|s| !s.is_empty());
with_locked_root(path, false, |root| {
let Some(entry) = root
.get_mut(&key)
.and_then(serde_json::Value::as_object_mut)
else {
return Err(format!("no credential stored for {key}"));
};
if entry.get("type").and_then(|t| t.as_str()) != Some("oauth") {
return Err(format!(
"{key} is not an OAuth credential; nothing recorded"
));
}
entry.insert(
"accountId".into(),
serde_json::Value::String(account_id.to_string()),
);
if let Some(identity) = identity {
entry.insert(
"identity".into(),
serde_json::Value::String(identity.to_string()),
);
}
Ok((true, ()))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CasOutcome {
Saved,
Removed,
Replaced,
StoreMissing,
}
#[cfg(test)]
pub(crate) fn save_provider_auth_if_refresh_matches_at(
path: &Path,
key: &str,
expected_refresh: &str,
creds: &OAuthCredentials,
) -> std::result::Result<CasOutcome, String> {
save_provider_auth_if_refresh_matches_typed_at(path, key, expected_refresh, creds, STORE_LOCK_WAIT)
.map_err(String::from)
}
pub(crate) fn save_provider_auth_if_refresh_matches_typed_at(
path: &Path,
key: &str,
expected_refresh: &str,
creds: &OAuthCredentials,
wait: std::time::Duration,
) -> std::result::Result<CasOutcome, StoreError> {
let encoded =
serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
let fields = encoded
.as_object()
.cloned()
.ok_or_else(|| "credential did not serialize to an object".to_string())?;
if !path.exists() {
return Ok(CasOutcome::StoreMissing);
}
with_locked_root_typed(path, false, wait, |root| {
let Some(entry) = root.get_mut(key).and_then(serde_json::Value::as_object_mut) else {
return Ok((false, CasOutcome::Removed));
};
let stored_refresh = entry.get("refresh").and_then(|v| v.as_str()).unwrap_or("");
if stored_refresh != expected_refresh {
return Ok((false, CasOutcome::Replaced));
}
for (k, v) in &fields {
entry.insert(k.clone(), v.clone());
}
Ok((true, CasOutcome::Saved))
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountInventory {
pub accounts: Vec<AccountSummary>,
pub malformed_keys: Vec<String>,
pub duplicate_identity_keys: Vec<String>,
}
pub fn list_accounts(provider: OAuthProviderId) -> std::result::Result<Vec<AccountSummary>, String> {
Ok(list_accounts_detailed_at(&auth_file_path(), Some(provider))?.accounts)
}
pub fn list_all_accounts() -> std::result::Result<Vec<AccountSummary>, String> {
Ok(list_accounts_detailed_at(&auth_file_path(), None)?.accounts)
}
pub fn list_accounts_detailed(
provider: Option<OAuthProviderId>,
) -> std::result::Result<AccountInventory, String> {
list_accounts_detailed_at(&auth_file_path(), provider)
}
pub(crate) fn list_accounts_detailed_at(
path: &Path,
provider: Option<OAuthProviderId>,
) -> std::result::Result<AccountInventory, String> {
let mut inventory = AccountInventory::default();
if !path.exists() {
return Ok(inventory);
}
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let root: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
for (key, entry) in &root {
let belongs = match provider {
Some(p) => CredentialRef::key_belongs_to(key, p),
None => super::provider::DESCRIPTORS
.iter()
.any(|d| CredentialRef::key_belongs_to(key, d.id)),
};
if !belongs {
continue;
}
let Some(cred) = CredentialRef::parse_storage_key(key) else {
tracing::warn!(key_len = key.len(), "ignoring malformed account key in auth.json");
inventory.malformed_keys.push(display_key(key));
continue;
};
let Some(obj) = entry.as_object() else {
inventory.malformed_keys.push(display_key(key));
continue;
};
if obj.get("type").and_then(|t| t.as_str()) != Some("oauth") {
continue;
}
let token_present = |field: &str| {
obj.get(field)
.and_then(|v| v.as_str())
.is_some_and(|v| !v.is_empty())
};
let tokens_typed = ["access", "refresh"]
.iter()
.all(|f| matches!(obj.get(*f), None | Some(serde_json::Value::String(_))));
if !tokens_typed || !(token_present("access") || token_present("refresh")) {
tracing::warn!(key_len = key.len(), "oauth entry without usable token fields");
inventory.malformed_keys.push(display_key(key));
continue;
}
inventory.accounts.push(AccountSummary {
identity: obj
.get("identity")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string()),
expires: obj.get("expires").and_then(|v| v.as_u64()).unwrap_or(0),
added_at: obj.get("addedAt").and_then(|v| v.as_u64()),
..AccountSummary::new(cred.provider, &cred.account)
.with_account_id(obj.get("accountId").and_then(|v| v.as_str()))
});
}
inventory
.accounts
.sort_by(|a, b| (&a.provider, a.label != "default", &a.label).cmp(&(&b.provider, b.label != "default", &b.label)));
let mut seen: std::collections::BTreeMap<(String, String), Vec<String>> =
std::collections::BTreeMap::new();
for (key, entry) in &root {
let Some(cred) = CredentialRef::parse_storage_key(key) else {
continue;
};
if let Some(id) = entry
.get("accountId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
if provider.is_none_or(|p| p == cred.provider) {
seen.entry((cred.provider.as_str().to_string(), id.to_string()))
.or_default()
.push(key.clone());
}
}
}
for keys in seen.into_values() {
if keys.len() > 1 {
inventory.duplicate_identity_keys.extend(keys);
}
}
inventory.duplicate_identity_keys.sort();
Ok(inventory)
}
fn display_key(key: &str) -> String {
crate::truncate_str(key, 64).to_string()
}
pub fn any_oauth_credential_present() -> std::result::Result<bool, String> {
any_oauth_credential_present_at(&auth_file_path())
}
pub(crate) fn any_oauth_credential_present_at(path: &Path) -> std::result::Result<bool, String> {
Ok(!list_accounts_detailed_at(path, None)?.accounts.is_empty())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdentityEvidence {
NoEvidence,
NoDuplicate,
Duplicate(Account),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoginPersistOutcome {
pub evidence: IdentityEvidence,
pub saved: bool,
}
pub fn save_credential_unless_duplicate(
cred: &CredentialRef,
creds: &OAuthCredentials,
metadata: &AccountMetadata,
) -> std::result::Result<LoginPersistOutcome, String> {
let read_path = auth_file_path();
let write_path = crate::config::resolve_write_path("auth.json");
if read_path != write_path && read_path.exists() {
return Err(format!(
"the active profile has no auth.json of its own and inherits {}; logging in here would \
create a profile store that hides every inherited account (tokens are never copied). \
Re-run without --profile to add the account to the shared store.",
read_path.display()
));
}
save_credential_unless_duplicate_at(&write_path, cred, creds, metadata)
}
pub(crate) fn save_credential_unless_duplicate_at(
path: &Path,
cred: &CredentialRef,
creds: &OAuthCredentials,
metadata: &AccountMetadata,
) -> std::result::Result<LoginPersistOutcome, String> {
let encoded =
serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
let fields = encoded
.as_object()
.cloned()
.ok_or_else(|| "credential did not serialize to an object".to_string())?;
let key = cred.storage_key();
let account_id = creds
.account_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
with_locked_root(path, false, |root| {
let mut duplicate: Option<Account> = None;
for (other_key, entry) in root.iter() {
if other_key == &key {
continue;
}
let Some(other) = CredentialRef::parse_storage_key(other_key) else {
continue;
};
if other.provider != cred.provider {
continue;
}
let stored_id = entry
.get("accountId")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
let stored_refresh = entry.get("refresh").and_then(|v| v.as_str()).unwrap_or("");
let same_seat = account_id.is_some_and(|id| id == stored_id)
|| (!creds.refresh.is_empty() && creds.refresh == stored_refresh);
if same_seat {
duplicate = Some(other.account);
break;
}
}
if let Some(account) = duplicate {
return Ok((
false,
LoginPersistOutcome {
evidence: IdentityEvidence::Duplicate(account),
saved: false,
},
));
}
let mut slot = root
.remove(&key)
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
if let Some(target) = slot.as_object_mut() {
for (k, v) in &fields {
target.insert(k.clone(), v.clone());
}
if account_id.is_none() {
target.remove("accountId");
}
match &cred.account {
Account::Default => {
target.remove("label");
}
Account::Named(label) => {
target.insert("label".into(), serde_json::Value::String(label.as_str().into()));
}
}
match metadata.identity.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(identity) => {
target.insert("identity".into(), serde_json::Value::String(identity.into()));
}
None => {
target.remove("identity");
}
}
match metadata.added_at {
Some(added_at) => {
target.insert("addedAt".into(), serde_json::Value::from(added_at));
}
None => {
target.remove("addedAt");
}
}
}
root.insert(key.clone(), slot);
Ok((
true,
LoginPersistOutcome {
evidence: if account_id.is_some() {
IdentityEvidence::NoDuplicate
} else {
IdentityEvidence::NoEvidence
},
saved: true,
},
))
})
}
pub fn find_duplicate_identity(
provider: OAuthProviderId,
account_id: &str,
except: &Account,
) -> std::result::Result<Option<Account>, String> {
find_duplicate_identity_at(&auth_file_path(), provider, account_id, except)
}
pub(crate) fn find_duplicate_identity_at(
path: &Path,
provider: OAuthProviderId,
account_id: &str,
except: &Account,
) -> std::result::Result<Option<Account>, String> {
let account_id = account_id.trim();
if account_id.is_empty() || !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let root: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
for (key, entry) in &root {
let Some(cred) = CredentialRef::parse_storage_key(key) else {
continue;
};
if cred.provider != provider || &cred.account == except {
continue;
}
let stored = entry
.get("accountId")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if !stored.is_empty() && stored == account_id {
return Ok(Some(cred.account));
}
}
Ok(None)
}
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)
}
pub fn load_cloud_state(provider: &str) -> std::result::Result<Option<serde_json::Value>, String> {
let path = auth_file_path();
if !path.exists() {
return Ok(None);
}
let root: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?,
)
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
Ok(root
.get(provider)
.and_then(|v| v.get("cloud_state"))
.cloned())
}
pub fn save_cloud_state(
provider: &str,
state: &serde_json::Value,
) -> std::result::Result<(), String> {
let path = crate::config::resolve_write_path("auth.json");
let mut fields = serde_json::Map::new();
fields.insert("type".into(), serde_json::Value::String("cloud".into()));
fields.insert("cloud_state".into(), state.clone());
save_provider_fields_at(&path, provider, &fields)
}
pub fn load_static_key(provider: &str) -> std::result::Result<Option<String>, String> {
load_static_key_at(&auth_file_path(), provider)
}
pub(crate) fn load_static_key_at(
path: &std::path::Path,
provider: &str,
) -> std::result::Result<Option<String>, String> {
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(entry) = value.get(provider) else {
return Ok(None);
};
if entry.get("type").and_then(|t| t.as_str()) != Some("api_key") {
return Ok(None);
}
Ok(entry
.get("key")
.and_then(|k| k.as_str())
.filter(|k| !k.is_empty())
.map(str::to_string))
}
pub fn save_static_key(provider: &str, key: &str) -> std::result::Result<(), String> {
let path = crate::config::resolve_write_path("auth.json");
save_static_key_at(&path, provider, key)
}
pub(crate) fn save_static_key_at(
path: &std::path::Path,
provider: &str,
key: &str,
) -> std::result::Result<(), String> {
let mut fields = serde_json::Map::new();
fields.insert(
"type".to_string(),
serde_json::Value::String("api_key".to_string()),
);
fields.insert(
"key".to_string(),
serde_json::Value::String(key.to_string()),
);
save_provider_fields_at(path, provider, &fields)
}
pub(crate) fn save_provider_auth_typed(
provider: &str,
creds: &OAuthCredentials,
) -> std::result::Result<(), StoreError> {
let path = crate::config::resolve_write_path("auth.json");
save_provider_auth_typed_at(&path, provider, creds)
}
pub(crate) fn save_provider_auth_typed_at(
path: &std::path::Path,
provider: &str,
creds: &OAuthCredentials,
) -> std::result::Result<(), StoreError> {
let encoded =
serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
let fields = encoded
.as_object()
.ok_or_else(|| "credential did not serialize to an object".to_string())?;
with_locked_root_typed(path, true, STORE_LOCK_WAIT, |root| {
merge_provider_fields(root, provider, fields);
Ok((true, ()))
})
}
pub(crate) fn save_provider_auth_at(
path: &std::path::Path,
provider: &str,
creds: &OAuthCredentials,
) -> std::result::Result<(), String> {
let encoded =
serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
let fields = encoded
.as_object()
.ok_or_else(|| "credential did not serialize to an object".to_string())?;
save_provider_fields_at(path, provider, fields)
}
fn save_provider_fields_at(
path: &std::path::Path,
provider: &str,
fields: &serde_json::Map<String, serde_json::Value>,
) -> std::result::Result<(), String> {
with_locked_root(path, true, |root| {
merge_provider_fields(root, provider, fields);
Ok((true, ()))
})
}
fn merge_provider_fields(
root: &mut serde_json::Map<String, serde_json::Value>,
provider: &str,
fields: &serde_json::Map<String, serde_json::Value>,
) {
let mut provider_value = root
.remove(provider)
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
if let Some(target) = provider_value.as_object_mut() {
for (key, value) in fields {
target.insert(key.clone(), value.clone());
}
}
root.insert(provider.to_string(), provider_value);
}
fn with_locked_root<T>(
path: &std::path::Path,
recover_corrupt: bool,
edit: impl FnOnce(
&mut serde_json::Map<String, serde_json::Value>,
) -> std::result::Result<(bool, T), String>,
) -> std::result::Result<T, String> {
with_locked_root_typed(path, recover_corrupt, STORE_LOCK_WAIT, edit).map_err(String::from)
}
pub(crate) const STORE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(15);
#[derive(Debug)]
pub(crate) enum StoreError {
Busy(String),
Corrupt(String),
Failed(String),
}
impl From<StoreError> for String {
fn from(e: StoreError) -> Self {
match e {
StoreError::Busy(m) | StoreError::Corrupt(m) | StoreError::Failed(m) => m,
}
}
}
pub(crate) fn corrupt_store(path: &Path, err: &serde_json::Error) -> StoreError {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let p = path.display().to_string();
StoreError::Corrupt(format!(
"{p} is not valid JSON ({err}); synaps will not overwrite it because it may hold other \
accounts. Move it aside with `mv {} {}`, then log in again (or repair it by hand).",
shell_quote(&p),
shell_quote(&format!("{p}.broken-{ts}"))
))
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
impl From<String> for StoreError {
fn from(m: String) -> Self {
StoreError::Failed(m)
}
}
fn lock_store_exclusive(
lock_file: &std::fs::File,
lock_path: &std::path::Path,
wait: std::time::Duration,
) -> std::result::Result<(), StoreError> {
use fs4::fs_std::FileExt;
let deadline = std::time::Instant::now() + wait;
loop {
match FileExt::try_lock_exclusive(lock_file) {
Ok(true) => return Ok(()),
Ok(false) => {}
Err(e) => {
return Err(StoreError::Failed(format!(
"Failed to lock {}: {}",
lock_path.display(),
e
)))
}
}
if std::time::Instant::now() >= deadline {
return Err(StoreError::Busy(format!(
"timed out after {}s waiting for {} (another synaps process holds it)",
wait.as_secs(),
lock_path.display()
)));
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
fn with_locked_root_typed<T>(
path: &std::path::Path,
recover_corrupt: bool,
wait: std::time::Duration,
edit: impl FnOnce(
&mut serde_json::Map<String, serde_json::Value>,
) -> std::result::Result<(bool, T), String>,
) -> std::result::Result<T, StoreError> {
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)
.truncate(true)
.write(true)
.open(&lock_path)
.map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
lock_store_exclusive(&lock_file, &lock_path, wait)?;
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) if !recover_corrupt || !content.trim().is_empty() => {
return Err(corrupt_store(path, &e));
}
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()
};
let (write, result) = edit(&mut root)?;
if !write {
return Ok(result);
}
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(result)
}
#[cfg(test)]
pub(super) fn save_provider_auth_at_test_hook(
path: &std::path::Path,
provider: &str,
creds: &OAuthCredentials,
) -> std::result::Result<(), String> {
save_provider_auth_at(path, provider, creds)
}
#[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_preserves_unknown_nested_provider_metadata() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
std::fs::write(&path, r#"{"openai-codex":{"type":"oauth","refresh":"old","access":"old","expires":1,"metadata":{"tenant":"t1","flags":{"beta":true}}}}"#).unwrap();
save_provider_auth_at(&path, "openai-codex", &fresh_creds()).unwrap();
let value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(value["openai-codex"]["metadata"]["tenant"], "t1");
assert_eq!(value["openai-codex"]["metadata"]["flags"]["beta"], true);
assert_eq!(value["openai-codex"]["access"], "a");
}
#[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 legacy_save_refuses_to_reset_a_corrupt_store() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
let corrupt = r#"{"anthropic":{"type":"oauth","refresh":"r1"},"anthropic@work":{"type":"oau"#;
std::fs::write(&path, corrupt).unwrap();
for save in [
save_provider_auth_at(&path, "openai-codex", &fresh_creds()),
save_static_key_at(&path, "groq", "gsk-1"),
] {
let err = save.expect_err("must not reset a corrupt store");
assert!(err.contains("not valid JSON"), "{err}");
assert!(err.contains("other accounts"), "{err}");
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), corrupt, "file untouched");
let backups = std::fs::read_dir(dir.path())
.unwrap()
.filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().contains(".corrupt."))
.count();
assert_eq!(backups, 0, "nothing reset, nothing to back up");
}
#[test]
fn legacy_save_refuses_a_non_object_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_err("a non-object root may still hold data");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "[1, 2, 3]");
}
#[test]
fn legacy_save_resets_an_empty_store_with_a_backup() {
for content in ["", " \n\t "] {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
std::fs::write(&path, content).unwrap();
save_provider_auth_at(&path, "openai-codex", &fresh_creds())
.expect("empty store is reset");
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(parsed.get("openai-codex").is_some());
let backups = std::fs::read_dir(dir.path())
.unwrap()
.filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().contains(".corrupt."))
.count();
assert_eq!(backups, 1, "{content:?}");
}
}
#[test]
fn static_key_save_preserves_oauth_entries_and_roundtrips() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
save_static_key_at(&path, "groq", "gsk-secret-1").expect("save static key");
assert_eq!(
load_static_key_at(&path, "groq").unwrap().as_deref(),
Some("gsk-secret-1")
);
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
parsed["anthropic"]["refresh"], "r",
"OAuth entry must survive"
);
assert_eq!(parsed["groq"]["type"], "api_key");
}
#[test]
fn oauth_save_preserves_static_key_entries() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_static_key_at(&path, "openrouter", "sk-or-1").expect("save static key");
save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save oauth");
assert_eq!(
load_static_key_at(&path, "openrouter").unwrap().as_deref(),
Some("sk-or-1")
);
}
#[test]
fn static_key_upsert_preserves_entry_metadata() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
std::fs::write(
&path,
r#"{"groq":{"type":"api_key","key":"old","note":{"source":"migrated"}}}"#,
)
.unwrap();
save_static_key_at(&path, "groq", "new-key").expect("upsert");
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(parsed["groq"]["key"], "new-key");
assert_eq!(parsed["groq"]["note"]["source"], "migrated");
}
#[test]
fn load_static_key_ignores_oauth_entries() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
assert_eq!(load_static_key_at(&path, "anthropic").unwrap(), None);
assert_eq!(load_static_key_at(&path, "missing").unwrap(), None);
}
#[test]
fn bug159_anthropic_login_preserves_existing_openai_codex() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
let codex_creds = OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "codex-refresh".to_string(),
access: "codex-access".to_string(),
expires: 9_999_999_999_000,
account_id: Some("acct_codex_123".to_string()),
};
save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
let anthropic_creds = OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "anth-refresh".to_string(),
access: "anth-access".to_string(),
expires: 9_999_999_999_000,
account_id: None,
};
save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
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(),
"anthropic credential must be present after login"
);
assert!(
parsed.get("openai-codex").is_some(),
"bug #159: openai-codex credential must NOT be wiped when logging in as anthropic"
);
assert_eq!(
parsed["openai-codex"]["refresh"].as_str(),
Some("codex-refresh"),
"openai-codex refresh token must be unchanged"
);
assert_eq!(
parsed["anthropic"]["refresh"].as_str(),
Some("anth-refresh"),
"anthropic refresh token must be correctly saved"
);
}
#[test]
fn bug159_openai_codex_login_preserves_existing_anthropic() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
let anthropic_creds = OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "anth-refresh-first".to_string(),
access: "anth-access-first".to_string(),
expires: 9_999_999_999_000,
account_id: None,
};
save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
let codex_creds = OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "codex-refresh-second".to_string(),
access: "codex-access-second".to_string(),
expires: 9_999_999_999_000,
account_id: Some("acct_456".to_string()),
};
save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
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(),
"anthropic credential must survive openai-codex login"
);
assert!(
parsed.get("openai-codex").is_some(),
"openai-codex credential must be present"
);
assert_eq!(
parsed["anthropic"]["refresh"].as_str(),
Some("anth-refresh-first"),
"anthropic refresh token must be unchanged"
);
}
#[test]
fn bug159_third_provider_login_preserves_both_existing_providers() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at(
&path,
"anthropic",
&OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "anth-r".to_string(),
access: "anth-a".to_string(),
expires: 1,
account_id: None,
},
)
.expect("save anthropic");
save_provider_auth_at(
&path,
"openai-codex",
&OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "codex-r".to_string(),
access: "codex-a".to_string(),
expires: 1,
account_id: Some("acct_codex".to_string()),
},
)
.expect("save openai-codex");
save_provider_auth_at(
&path,
"future-provider",
&OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "future-r".to_string(),
access: "future-a".to_string(),
expires: 1,
account_id: None,
},
)
.expect("save future-provider");
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(), "anthropic must survive");
assert!(
parsed.get("openai-codex").is_some(),
"openai-codex must survive"
);
assert!(
parsed.get("future-provider").is_some(),
"future-provider must be present"
);
}
#[test]
fn bug159_same_provider_login_upserts_in_place() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at(
&path,
"anthropic",
&OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "old-refresh".to_string(),
access: "old-access".to_string(),
expires: 1,
account_id: None,
},
)
.expect("initial save");
save_provider_auth_at(
&path,
"anthropic",
&OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: "new-refresh".to_string(),
access: "new-access".to_string(),
expires: 2,
account_id: None,
},
)
.expect("upsert save");
let content = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
let obj = parsed.as_object().unwrap();
assert_eq!(obj.len(), 1, "only one anthropic entry — no duplicates");
assert_eq!(
parsed["anthropic"]["refresh"].as_str(),
Some("new-refresh"),
"credential must be updated to latest value"
);
}
fn creds(refresh: &str, account_id: Option<&str>) -> OAuthCredentials {
OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: refresh.to_string(),
access: format!("{refresh}-access"),
expires: 5,
account_id: account_id.map(str::to_string),
}
}
fn named(provider: OAuthProviderId, label: &str) -> CredentialRef {
CredentialRef::new(provider, Account::named(label).unwrap())
}
fn read_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
#[test]
fn named_slot_save_never_touches_default_slot() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(
&path,
r#"{"openai-codex":{"type":"oauth","refresh":"legacy-r","access":"legacy-a","expires":1,"accountId":"acct-legacy"}}"#,
)
.unwrap();
let astra2 = named(OAuthProviderId::OpenAiCodex, "astra2");
save_provider_auth_at(&path, &astra2.storage_key(), &creds("r2", Some("acct-2"))).unwrap();
let root = read_root(&path);
assert_eq!(root["openai-codex"]["refresh"], "legacy-r", "default slot untouched");
assert_eq!(root["openai-codex"]["accountId"], "acct-legacy");
assert_eq!(root["openai-codex@astra2"]["refresh"], "r2");
assert_eq!(root.len(), 2);
let legacy = load_provider_auth_at(&path, "openai-codex").unwrap().unwrap();
assert_eq!(legacy.refresh, "legacy-r");
let slot = load_provider_auth_at(&path, &astra2.storage_key()).unwrap().unwrap();
assert_eq!(slot.refresh, "r2");
assert!(load_provider_auth_at(&path, "openai-codex@missing").unwrap().is_none());
}
#[test]
fn list_accounts_reports_default_named_and_malformed_without_secrets() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(
&path,
r#"{
"openai-codex": {"type":"oauth","refresh":"R0","access":"A0","expires":10,"accountId":"2b2f0000-1111"},
"openai-codex@astra2": {"type":"oauth","refresh":"R1","access":"A1","expires":20,"accountId":"7f530000-2222","label":"astra2","identity":"a@example.com","addedAt":99},
"openai-codex@Bad Label": {"type":"oauth","refresh":"R2","access":"A2","expires":0},
"openai-codex@default": {"type":"oauth","refresh":"R3","access":"A3","expires":0},
"openai-codex@notoken": {"type":"oauth","expires":5},
"openai-codex@badtype": {"type":"oauth","refresh":{"nested":"SECRET-VALUE"},"access":"A5","expires":5},
"openai-codex@empty": {"type":"oauth","refresh":"","access":"","expires":5},
"anthropic": {"type":"oauth","refresh":"R4","access":"A4","expires":30},
"groq": {"type":"api_key","key":"gsk-secret"},
"aws-bedrock": {"type":"cloud","cloud_state":{}}
}"#,
)
.unwrap();
let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::OpenAiCodex)).unwrap();
assert_eq!(inv.accounts.len(), 2, "{:?}", inv.accounts);
assert_eq!(inv.accounts[0].label, "default");
assert_eq!(inv.accounts[0].account_id_prefix.as_deref(), Some("2b2f0000"));
assert_eq!(
inv.accounts[0].seat_fingerprint,
super::super::account::seat_fingerprint(OAuthProviderId::OpenAiCodex, "2b2f0000-1111")
);
assert_ne!(inv.accounts[0].seat_fingerprint, inv.accounts[1].seat_fingerprint);
assert_eq!(inv.accounts[0].expires, 10);
assert_eq!(inv.accounts[1].label, "astra2");
assert_eq!(inv.accounts[1].identity.as_deref(), Some("a@example.com"));
assert_eq!(inv.accounts[1].added_at, Some(99));
assert_eq!(inv.malformed_keys.len(), 5, "{:?}", inv.malformed_keys);
for key in [
"openai-codex@Bad Label",
"openai-codex@default",
"openai-codex@notoken",
"openai-codex@badtype",
"openai-codex@empty",
] {
assert!(inv.malformed_keys.contains(&key.to_string()), "{key}");
}
let diag = format!("{:?}", inv);
assert!(!diag.contains("SECRET-VALUE"), "diagnostics must not dump values");
let all = list_accounts_detailed_at(&path, None).unwrap();
assert_eq!(all.accounts.len(), 3);
assert!(all.accounts.iter().all(|a| a.provider != "groq" && a.provider != "aws-bedrock"));
let json = serde_json::to_string(&all.accounts).unwrap();
for secret in ["R0", "R1", "R4", "A0", "A1", "A4", "A5", "gsk-secret", "SECRET-VALUE"] {
assert!(!json.contains(secret), "listing leaked {secret}: {json}");
}
assert!(!json.contains("2b2f0000-1111"), "full account id must not be exposed");
assert!(any_oauth_credential_present_at(&path).unwrap());
}
#[test]
fn any_oauth_credential_present_accepts_non_anthropic_only_and_rejects_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
assert!(!any_oauth_credential_present_at(&path).unwrap(), "missing file");
std::fs::write(&path, r#"{"groq":{"type":"api_key","key":"k"}}"#).unwrap();
assert!(!any_oauth_credential_present_at(&path).unwrap(), "static keys are not OAuth");
std::fs::write(
&path,
r#"{"kimi-code@m27":{"type":"oauth","refresh":"r","access":"a","expires":1}}"#,
)
.unwrap();
assert!(any_oauth_credential_present_at(&path).unwrap(), "named Kimi-only install");
std::fs::write(&path, "not json").unwrap();
assert!(any_oauth_credential_present_at(&path).is_err());
}
#[test]
fn remove_credential_removes_exactly_one_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
save_provider_auth_at(&path, "openai-codex", &creds("r0", None)).unwrap();
save_provider_auth_at(&path, "openai-codex@a", &creds("ra", None)).unwrap();
save_provider_auth_at(&path, "openai-codex@b", &creds("rb", None)).unwrap();
assert!(remove_key_at(&path, "openai-codex@a").unwrap());
let root = read_root(&path);
assert_eq!(root.len(), 2);
assert_eq!(root["openai-codex"]["refresh"], "r0");
assert_eq!(root["openai-codex@b"]["refresh"], "rb");
assert!(!remove_key_at(&path, "openai-codex@a").unwrap(), "idempotent");
assert!(!path.with_extension("json.tmp").exists());
std::fs::write(&path, "garbage{").unwrap();
assert!(remove_key_at(&path, "openai-codex").is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "garbage{");
}
#[test]
fn metadata_round_trip_and_preserved_across_refresh_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let cred = named(OAuthProviderId::Anthropic, "work");
save_provider_auth_at(&path, &cred.storage_key(), &creds("r1", None)).unwrap();
save_account_metadata_at(
&path,
&cred,
&AccountMetadata {
identity: Some("me@example.com".into()),
added_at: Some(1234),
},
)
.unwrap();
save_provider_auth_at(&path, &cred.storage_key(), &creds("r2", None)).unwrap();
let root = read_root(&path);
assert_eq!(root["anthropic@work"]["label"], "work");
assert_eq!(root["anthropic@work"]["identity"], "me@example.com");
assert_eq!(root["anthropic@work"]["addedAt"], 1234);
assert_eq!(root["anthropic@work"]["refresh"], "r2");
assert!(save_account_metadata_at(
&path,
&named(OAuthProviderId::Anthropic, "nope"),
&AccountMetadata::default()
)
.is_err());
assert!(read_root(&path).get("anthropic@nope").is_none());
}
#[test]
fn set_slot_identity_sets_only_identity_fields_on_existing_slot() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(
&path,
r#"{
"anthropic@claude1": {"type":"oauth","refresh":"R-claude1","access":"A-claude1","expires":1234567,"label":"claude1","addedAt":99,"identity":"old@example.com","metadata":{"tenant":"t1"}},
"anthropic": {"type":"oauth","refresh":"R-default","access":"A-default","expires":7},
"groq": {"type":"api_key","key":"gsk-secret"}
}"#,
)
.unwrap();
let before = read_root(&path);
let cred = named(OAuthProviderId::Anthropic, "claude1");
set_slot_identity_at(
&path,
&cred,
" b8a1448d-0000-4000-8000-00000000000a ",
Some(" jr@example.com "),
)
.unwrap();
let after = read_root(&path);
let slot = after["anthropic@claude1"].as_object().unwrap();
assert_eq!(
slot["accountId"], "b8a1448d-0000-4000-8000-00000000000a",
"trimmed"
);
assert_eq!(slot["identity"], "jr@example.com", "trimmed");
for field in [
"type", "refresh", "access", "expires", "label", "addedAt", "metadata",
] {
assert_eq!(
slot.get(field),
before["anthropic@claude1"].get(field),
"{field} must be untouched"
);
}
assert_eq!(
slot.len(),
before["anthropic@claude1"].as_object().unwrap().len() + 1,
"exactly one new key (accountId); identity was overwritten in place"
);
assert_eq!(after["anthropic"], before["anthropic"]);
assert_eq!(after["groq"], before["groq"]);
assert_eq!(after.len(), before.len());
let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::Anthropic)).unwrap();
let row = inv.accounts.iter().find(|a| a.label == "claude1").unwrap();
assert_eq!(row.account_id_prefix.as_deref(), Some("b8a1448d"));
assert_eq!(row.identity.as_deref(), Some("jr@example.com"));
assert!(row.seat_fingerprint.is_some());
set_slot_identity_at(&path, &cred, "other-seat", None).unwrap();
let slot = &read_root(&path)["anthropic@claude1"];
assert_eq!(slot["accountId"], "other-seat");
assert_eq!(slot["identity"], "jr@example.com");
set_slot_identity_at(&path, &cred, "other-seat", Some(" ")).unwrap();
assert_eq!(
read_root(&path)["anthropic@claude1"]["identity"],
"jr@example.com"
);
set_slot_identity_at(
&path,
&CredentialRef::default_for(OAuthProviderId::Anthropic),
"other-seat",
None,
)
.unwrap();
let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::Anthropic)).unwrap();
assert_eq!(
inv.duplicate_identity_keys,
vec!["anthropic".to_string(), "anthropic@claude1".to_string()]
);
assert_eq!(read_root(&path)["anthropic"]["refresh"], "R-default");
}
#[test]
fn set_slot_identity_fails_closed_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let cred = named(OAuthProviderId::Anthropic, "claude1");
assert!(set_slot_identity_at(&path, &cred, "seat", None).is_err());
assert!(!path.exists());
assert!(!path.with_extension("json.lock").exists());
std::fs::write(
&path,
r#"{"anthropic":{"type":"oauth","refresh":"R","access":"A","expires":1},"groq":{"type":"api_key","key":"k"}}"#,
)
.unwrap();
let before = std::fs::read_to_string(&path).unwrap();
assert!(set_slot_identity_at(&path, &cred, "seat", Some("x@example.com")).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
assert!(
read_root(&path).get("anthropic@claude1").is_none(),
"no phantom slot"
);
let default = CredentialRef::default_for(OAuthProviderId::Anthropic);
assert!(set_slot_identity_at(&path, &default, "", None).is_err());
assert!(set_slot_identity_at(&path, &default, " ", Some("x@example.com")).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
assert!(read_root(&path)["anthropic"].get("accountId").is_none());
std::fs::write(&path, "{{not json").unwrap();
assert!(set_slot_identity_at(&path, &default, "seat", None).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
}
#[test]
fn cas_save_saved_removed_replaced() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let key = "openai-codex@astra2";
save_provider_auth_at(&path, key, &creds("old", Some("acct-1"))).unwrap();
let rotated = creds("old-rotated", None);
assert_eq!(
save_provider_auth_if_refresh_matches_at(&path, key, "old", &rotated).unwrap(),
CasOutcome::Saved
);
let root = read_root(&path);
assert_eq!(root[key]["refresh"], "old-rotated");
assert_eq!(root[key]["accountId"], "acct-1", "accountId preserved");
save_provider_auth_at(&path, key, &creds("fresh-login", Some("acct-9"))).unwrap();
assert_eq!(
save_provider_auth_if_refresh_matches_at(&path, key, "old-rotated", &creds("stale", None))
.unwrap(),
CasOutcome::Replaced
);
assert_eq!(read_root(&path)[key]["refresh"], "fresh-login");
remove_key_at(&path, key).unwrap();
assert_eq!(
save_provider_auth_if_refresh_matches_at(&path, key, "fresh-login", &creds("x", None))
.unwrap(),
CasOutcome::Removed
);
assert!(read_root(&path).get(key).is_none());
std::fs::remove_file(&path).unwrap();
assert_eq!(
save_provider_auth_if_refresh_matches_at(&path, key, "x", &creds("x", None)).unwrap(),
CasOutcome::StoreMissing
);
assert!(!path.exists());
}
#[test]
fn locked_login_persist_refuses_duplicate_seat_and_writes_metadata_atomically() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
let astra2 = named(OAuthProviderId::OpenAiCodex, "astra2");
let meta = AccountMetadata {
identity: Some("a@example.com".into()),
added_at: Some(100),
};
let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("rx", Some("acct-A")), &meta)
.unwrap();
assert_eq!(out.evidence, IdentityEvidence::Duplicate(Account::Default));
assert!(!out.saved);
assert!(read_root(&path).get("openai-codex@astra2").is_none());
let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("r0", None), &meta).unwrap();
assert_eq!(out.evidence, IdentityEvidence::Duplicate(Account::Default));
assert!(!out.saved);
let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("r2", Some("acct-B")), &meta)
.unwrap();
assert_eq!(out.evidence, IdentityEvidence::NoDuplicate);
assert!(out.saved);
let root = read_root(&path);
assert_eq!(root["openai-codex@astra2"]["label"], "astra2");
assert_eq!(root["openai-codex@astra2"]["identity"], "a@example.com");
assert_eq!(root["openai-codex@astra2"]["addedAt"], 100);
assert_eq!(root["openai-codex"]["refresh"], "r0", "default slot untouched");
let out = save_credential_unless_duplicate_at(
&path,
&astra2,
&creds("r3", None),
&AccountMetadata {
identity: None,
added_at: Some(200),
},
)
.unwrap();
assert_eq!(out.evidence, IdentityEvidence::NoEvidence);
assert!(out.saved);
let root = read_root(&path);
assert!(root["openai-codex@astra2"].get("identity").is_none());
assert!(root["openai-codex@astra2"].get("accountId").is_none());
assert_eq!(root["openai-codex@astra2"]["addedAt"], 200);
assert_eq!(root["openai-codex@astra2"]["refresh"], "r3");
std::fs::write(&path, "{{not json").unwrap();
assert!(save_credential_unless_duplicate_at(&path, &astra2, &creds("r4", None), &meta).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
assert!(save_credential_at(&path, &astra2, &creds("r4", None)).is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
}
#[test]
fn inventory_flags_duplicate_seats() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
save_provider_auth_at(&path, "openai-codex@b", &creds("rb", Some("acct-A"))).unwrap();
save_provider_auth_at(&path, "openai-codex@c", &creds("rc", Some("acct-C"))).unwrap();
let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::OpenAiCodex)).unwrap();
assert_eq!(
inv.duplicate_identity_keys,
vec!["openai-codex".to_string(), "openai-codex@b".to_string()]
);
}
#[test]
fn duplicate_identity_detects_same_seat_under_other_label() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
save_provider_auth_at(&path, "openai-codex@b", &creds("rb", Some("acct-B"))).unwrap();
save_provider_auth_at(&path, "anthropic@x", &creds("rx", Some("acct-A"))).unwrap();
let dup = find_duplicate_identity_at(
&path,
OAuthProviderId::OpenAiCodex,
"acct-A",
&Account::named("new").unwrap(),
)
.unwrap();
assert_eq!(dup, Some(Account::Default));
assert_eq!(
find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "acct-A", &Account::Default)
.unwrap(),
None
);
assert_eq!(
find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "acct-Z", &Account::Default)
.unwrap(),
None
);
assert_eq!(
find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "", &Account::Default)
.unwrap(),
None
);
}
}
#[cfg(test)]
mod store_lock_wait_tests {
use super::*;
use std::time::{Duration, Instant};
fn hold_store_lock(path: &Path) -> std::fs::File {
use fs4::fs_std::FileExt;
let lock = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path.with_extension("json.lock"))
.unwrap();
assert!(FileExt::try_lock_exclusive(&lock).unwrap());
lock
}
#[test]
fn store_lock_wait_has_a_deadline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, "{}").unwrap();
let _held = hold_store_lock(&path);
let started = Instant::now();
let err = with_locked_root_typed(&path, false, Duration::from_millis(200), |_| {
Ok((false, ()))
})
.unwrap_err();
assert!(matches!(err, StoreError::Busy(_)), "{err:?}");
assert!(started.elapsed() < Duration::from_secs(5));
assert!(String::from(err).contains("auth.json.lock"));
}
#[test]
fn store_lock_is_taken_once_free() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, "{}").unwrap();
let held = hold_store_lock(&path);
let release = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
drop(held);
});
with_locked_root_typed(&path, false, Duration::from_secs(10), |_| Ok((false, ())))
.expect("lock is acquired after the holder lets go");
release.join().unwrap();
}
#[test]
fn production_store_lock_wait_is_bounded() {
assert!(STORE_LOCK_WAIT >= Duration::from_secs(5));
assert!(STORE_LOCK_WAIT <= Duration::from_secs(30));
}
}
#[cfg(test)]
mod corrupt_store_error_tests {
use super::*;
fn assert_actionable(msg: &str, path: &Path) {
let p = path.display().to_string();
assert!(msg.contains(&format!("mv '{p}' '{p}.broken-")), "{msg}");
assert!(msg.contains("not valid JSON"), "{msg}");
assert!(!msg.contains("synaps login"), "{msg}");
}
#[test]
fn corrupt_store_error_names_the_file_and_the_mv_command() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, "{\"anthropic\":{").unwrap();
let cred = CredentialRef::parse_storage_key("anthropic@work").unwrap();
let creds = OAuthCredentials {
auth_type: "oauth".into(),
refresh: "r".into(),
access: "a".into(),
expires: 1,
account_id: None,
};
assert_actionable(&save_provider_auth_at(&path, "kimi-code", &creds).unwrap_err(), &path);
assert_actionable(&save_credential_at(&path, &cred, &creds).unwrap_err(), &path);
for err in [
save_provider_auth_typed_at(&path, "muse", &creds).unwrap_err(),
load_provider_auth_typed_at(&path, "anthropic").unwrap_err(),
] {
let StoreError::Corrupt(msg) = err else {
panic!("expected Corrupt, got {err:?}");
};
assert_actionable(&msg, &path);
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"anthropic\":{");
}
#[test]
fn shell_quoting_survives_a_quote_in_the_path() {
assert_eq!(shell_quote("/a/it's/auth.json"), r#"'/a/it'\''s/auth.json'"#);
}
}
#[cfg(test)]
mod remove_sidecar_tests {
use super::*;
#[test]
fn removing_a_slot_deletes_its_refresh_sidecar() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, r#"{"anthropic@work":{"type":"oauth"},"anthropic":{"type":"oauth"}}"#)
.unwrap();
let work = refresh_pending_path(&path, "anthropic@work");
let other = refresh_pending_path(&path, "anthropic");
assert_eq!(
work,
dir.path().join("auth.json.anthropic@work.refresh.pending")
);
std::fs::write(&work, "{}").unwrap();
std::fs::write(&other, "{}").unwrap();
assert!(remove_key_at(&path, "anthropic@work").unwrap());
assert!(!work.exists(), "removed slot's sidecar is gone");
assert!(other.exists(), "other slots keep theirs");
std::fs::write(&work, "{}").unwrap();
assert!(!remove_key_at(&path, "anthropic@work").unwrap());
assert!(!work.exists());
}
}