Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 286 additions & 3 deletions code-rs/core/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,18 +436,70 @@ pub fn get_auth_file(code_home: &Path) -> PathBuf {

/// Delete the auth.json file inside `code_home` if it exists. Returns `Ok(true)`
/// if a file was removed, `Ok(false)` if no auth file was present.
pub fn logout(code_home: &Path) -> std::io::Result<bool> {
pub fn remove_auth_file(code_home: &Path) -> std::io::Result<bool> {
let auth_file = get_auth_file(code_home);
let removed = match std::fs::remove_file(&auth_file) {
Ok(_) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
Err(err) => return Err(err),
};

let _ = crate::auth_accounts::set_active_account_id(code_home, None)?;
Ok(removed)
}

/// Log out of the current account. This removes auth.json and disconnects the
/// active stored account while preserving unrelated stored accounts.
pub fn logout(code_home: &Path) -> std::io::Result<bool> {
let auth_file = get_auth_file(code_home);
let current_auth = try_read_auth_json(&auth_file).ok();
let removed = remove_auth_file(code_home)?;
let active_account_id = crate::auth_accounts::get_active_account_id(code_home)?;

let removed_account = if let Some(account_id) = active_account_id {
let removed = crate::auth_accounts::remove_account(code_home, &account_id)?.is_some();
let removed_matching = if let Some(auth) = &current_auth {
remove_account_matching_auth(code_home, auth)?
} else {
false
};
removed || removed_matching
} else if let Some(auth) = &current_auth {
remove_account_matching_auth(code_home, auth)?
} else {
let _ = crate::auth_accounts::set_active_account_id(code_home, None)?;
false
};
Ok(removed || removed_account)
}

fn remove_account_matching_auth(code_home: &Path, auth: &AuthDotJson) -> std::io::Result<bool> {
let AuthDotJson {
auth_mode,
openai_api_key,
tokens,
last_refresh: _,
} = auth;
if let Some(mode) = auth_mode.or_else(|| {
if tokens.is_some() {
Some(AuthMode::ChatGPT)
} else if openai_api_key.is_some() {
Some(AuthMode::ApiKey)
} else {
None
}
}) {
Ok(crate::auth_accounts::remove_account_matching_credentials(
code_home,
mode,
openai_api_key.as_deref(),
tokens.as_ref(),
)?
.is_some())
} else {
let _ = crate::auth_accounts::set_active_account_id(code_home, None)?;
Ok(false)
}
}

/// Writes an `auth.json` that contains only the API key. Intended for CLI use.
pub fn login_with_api_key(code_home: &Path, api_key: &str) -> std::io::Result<()> {
let auth_dot_json = AuthDotJson {
Expand Down Expand Up @@ -1336,6 +1388,237 @@ mod tests {
Ok(())
}

#[test]
fn remove_auth_file_does_not_touch_stored_accounts() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let active = crate::auth_accounts::upsert_api_key_account(
dir.path(),
"sk-active".to_string(),
Some("Active".to_string()),
true,
)?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: active.openai_api_key.clone(),
tokens: None,
last_refresh: None,
};
write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?;

let removed = remove_auth_file(dir.path())?;

assert!(removed);
assert!(!dir.path().join("auth.json").exists());
assert_eq!(
crate::auth_accounts::get_active_account_id(dir.path())?.as_deref(),
Some(active.id.as_str())
);
assert!(crate::auth_accounts::find_account(dir.path(), &active.id)?.is_some());
Ok(())
}

#[test]
fn logout_removes_only_active_stored_account() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let active = crate::auth_accounts::upsert_api_key_account(
dir.path(),
"sk-active".to_string(),
Some("Active".to_string()),
true,
)?;
let other = crate::auth_accounts::upsert_api_key_account(
dir.path(),
"sk-other".to_string(),
Some("Other".to_string()),
false,
)?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: active.openai_api_key.clone(),
tokens: None,
last_refresh: None,
};
write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?;

let removed = logout(dir.path())?;

assert!(removed);
assert!(!dir.path().join("auth.json").exists());
assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &active.id)?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &other.id)?.is_some());
let accounts = crate::auth_accounts::list_accounts(dir.path())?;
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].id, other.id);
Ok(())
}

#[test]
fn logout_removes_stale_active_chatgpt_account() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let stale_account = crate::auth_accounts::upsert_chatgpt_account(
dir.path(),
token_data_for_access("expired-access".to_string()),
Utc::now() - chrono::Duration::days(3),
Some("Stale".to_string()),
true,
)?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ChatGPT),
openai_api_key: None,
tokens: stale_account.tokens.clone(),
last_refresh: stale_account.last_refresh,
};
write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?;

let removed = logout(dir.path())?;

assert!(removed);
assert!(crate::auth_accounts::find_account(dir.path(), &stale_account.id)?.is_none());
assert!(crate::auth_accounts::list_accounts(dir.path())?.is_empty());
Ok(())
}

#[test]
fn logout_matches_auth_json_when_active_account_missing() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let stale_account = crate::auth_accounts::upsert_chatgpt_account(
dir.path(),
token_data_for_access("expired-access".to_string()),
Utc::now() - chrono::Duration::days(3),
Some("Stale".to_string()),
false,
)?;
let other = crate::auth_accounts::upsert_api_key_account(
dir.path(),
"sk-other".to_string(),
Some("Other".to_string()),
false,
)?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ChatGPT),
openai_api_key: None,
tokens: stale_account.tokens.clone(),
last_refresh: stale_account.last_refresh,
};
write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?;

let removed = logout(dir.path())?;

assert!(removed);
assert!(crate::auth_accounts::find_account(dir.path(), &stale_account.id)?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &other.id)?.is_some());
Ok(())
}

#[test]
fn logout_matches_auth_json_when_active_pointer_is_stale() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let stale_account = crate::auth_accounts::upsert_chatgpt_account(
dir.path(),
token_data_for_access("expired-access".to_string()),
Utc::now() - chrono::Duration::days(3),
Some("Stale".to_string()),
false,
)?;
let other = crate::auth_accounts::upsert_api_key_account(
dir.path(),
"sk-other".to_string(),
Some("Other".to_string()),
false,
)?;
let _ = crate::auth_accounts::set_active_account_id(
dir.path(),
Some("missing-account".to_string()),
)?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ChatGPT),
openai_api_key: None,
tokens: stale_account.tokens.clone(),
last_refresh: stale_account.last_refresh,
};
write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?;

let removed = logout(dir.path())?;

assert!(removed);
assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &stale_account.id)?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &other.id)?.is_some());
Ok(())
}

#[test]
fn logout_removes_auth_json_account_when_active_points_elsewhere() -> Result<(), std::io::Error>
{
let dir = tempdir()?;
let active = crate::auth_accounts::upsert_api_key_account(
dir.path(),
"sk-active".to_string(),
Some("Active".to_string()),
true,
)?;
let stale_account = crate::auth_accounts::upsert_chatgpt_account(
dir.path(),
token_data_for_access("expired-access".to_string()),
Utc::now() - chrono::Duration::days(3),
Some("Stale".to_string()),
false,
)?;
let other = crate::auth_accounts::upsert_api_key_account(
dir.path(),
"sk-other".to_string(),
Some("Other".to_string()),
false,
)?;
let auth_dot_json = AuthDotJson {
auth_mode: Some(AuthMode::ChatGPT),
openai_api_key: None,
tokens: stale_account.tokens.clone(),
last_refresh: stale_account.last_refresh,
};
write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?;

let removed = logout(dir.path())?;

assert!(removed);
assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &active.id)?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &stale_account.id)?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &other.id)?.is_some());
Ok(())
}

#[test]
fn logout_removes_active_stored_account_without_auth_json() -> Result<(), std::io::Error> {
let dir = tempdir()?;
let active = crate::auth_accounts::upsert_api_key_account(
dir.path(),
"sk-active".to_string(),
Some("Active".to_string()),
true,
)?;

let removed = logout(dir.path())?;

assert!(removed);
assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none());
assert!(crate::auth_accounts::find_account(dir.path(), &active.id)?.is_none());
Ok(())
}

#[test]
fn logout_without_auth_or_active_account_returns_false() -> Result<(), std::io::Error> {
let dir = tempdir()?;

let removed = logout(dir.path())?;

assert!(!removed);
assert!(crate::auth_accounts::get_active_account_id(dir.path())?.is_none());
assert!(crate::auth_accounts::list_accounts(dir.path())?.is_empty());
Ok(())
}

fn assert_permanent(body: &str, status: StatusCode) {
let err = classify_refresh_failure(status, body);
assert!(err.is_permanent(), "expected permanent error, got {:?}", err.kind);
Expand Down
37 changes: 37 additions & 0 deletions code-rs/core/src/auth_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,43 @@ pub fn remove_account(code_home: &Path, account_id: &str) -> io::Result<Option<S
Ok(removed)
}

pub fn remove_account_matching_credentials(
code_home: &Path,
mode: AuthMode,
openai_api_key: Option<&str>,
tokens: Option<&TokenData>,
) -> io::Result<Option<StoredAccount>> {
let path = accounts_file_path(code_home);
let mut data = read_accounts_file(&path)?;

let removed = match mode {
AuthMode::ApiKey => openai_api_key.and_then(|api_key| {
data.accounts
.iter()
.position(|account| match_api_key_account(account, api_key))
}),
AuthMode::ChatGPT | AuthMode::ChatgptAuthTokens => tokens.and_then(|tokens| {
data.accounts
.iter()
.position(|account| match_chatgpt_account(account, tokens))
}),
}
.map(|pos| data.accounts.remove(pos));

if let Some(removed) = &removed {
if data
.active_account_id
.as_ref()
.is_some_and(|active| active == &removed.id)
{
data.active_account_id = None;
}
}

write_accounts_file(&path, &data)?;
Ok(removed)
}

pub fn upsert_api_key_account(
code_home: &Path,
api_key: String,
Expand Down
10 changes: 9 additions & 1 deletion code-rs/tui/src/bottom_pane/login_accounts_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,15 @@ impl LoginAccountsState {
.as_ref()
.is_some_and(|id| id == &account_id);
if removed_active {
let _ = auth::logout(&self.code_home);
if let Err(err) = auth::remove_auth_file(&self.code_home) {
self.feedback = Some(Feedback {
message: format!("Failed to remove active auth: {err}"),
is_error: true,
});
self.mode = ViewMode::List;
self.reload_accounts();
return;
}
}
self.feedback = Some(Feedback {
message: "Account disconnected".to_string(),
Expand Down