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
2 changes: 1 addition & 1 deletion docs/wiki/Command-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The installed binary remains authoritative: use `codex-switch --help` and `codex
| Command | Purpose |
|---|---|
| `login [--device] [alias]` | Add or reauthorize a profile through browser PKCE or device-code login. If the alias already exists, it is reauthorized; otherwise a new profile is created. |
| `import <path> [alias]` | Validate and import one `auth.json`, or recursively scan a directory for JSON files. The alias applies to single-file imports only; directories auto-assign aliases. |
| `import <path> [alias]` | Validate and import one `auth.json`, or recursively scan a directory for JSON files. The alias applies to single-file imports only; directories auto-assign aliases. An account that is already saved (same file, or same `account_id` and email) is skipped instead of duplicated, so its single-use refresh token is not spent. |
| `list [-f]` | Show profiles, usage, and availability; `-f` / `--force` bypasses the cache. |
| `use [alias] [--consume-card]` | Switch explicitly, or omit the alias to auto-select with the unified scoring algorithm. When the pool is exhausted, `--consume-card` consumes the earliest-expiring reset card to revive an account (auto-select only; ignored when an alias is given). |
| `launch [alias] [--consume-card] -- [args]` | Start Codex with the best (or specified) profile's auth. Everything after `--` is passed through to Codex. |
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Feature-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Existing `auth.json` files can be imported individually or from a directory. Imp
codex-switch import ~/auth-backups
```

Interactive login deduplicates local profiles by `account_id` first and falls back to email when safe. Import is deliberately create-only and never updates an existing profile: Usage API validation proves that the bearer can access a workspace, but a Team workspace ID can be shared by several users and cannot authorize overwriting another saved credential.
Interactive login deduplicates local profiles by `account_id` first and falls back to email when safe. Import is deliberately create-only and never updates an existing profile: Usage API validation proves that the bearer can access a workspace, but a Team workspace ID can be shared by several users and cannot authorize overwriting another saved credential. For the same reason, import will not write a *second* profile for an account you already have: when the incoming file is byte-identical to a saved profile, or carries the same `account_id` **and** email, the import is skipped before validation so its single-use refresh token is never spent. Use `login <alias>` to refresh an existing profile.

Profile deletion is recoverable. An inactive profile is moved under `deleted-profiles/` after confirmation; the active profile cannot be deleted. See [recovery instructions](Troubleshooting#recover-a-deleted-profile).

Expand Down
31 changes: 31 additions & 0 deletions src/commands/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ pub(crate) async fn import_cmd(path: &str, alias: Option<&str>, json: bool) -> R
alias: imported.alias,
action: imported.action.to_string(),
});
} else if imported.action == "unchanged" {
println!(
"{}",
color::success(&format!(
"Already saved as profile '{}'; skipped {} to protect its single-use refresh \
token. Run `codex-switch login {}` to refresh that profile.",
imported.alias,
imported.source.display(),
imported.alias
))
);
} else {
println!(
"{}",
Expand Down Expand Up @@ -280,6 +291,26 @@ async fn import_one_file(
.as_deref()
.map(profile::alias_from_email);

// Refuse to duplicate an account that is already saved. `import` is
// create-only, so validating (which rotates the single-use refresh token)
// and then writing a second profile would race the two copies into
// `refresh_token_reused`. This runs *before* validation so the source
// token is never spent on a re-import. It only declines — it never
// overwrites — so a conservative match cannot hand credentials to the
// wrong profile.
if let Some(existing) = profile::existing_import_target(source, &val) {
let mut account = source_account;
cache::apply_workspace_name(&mut account);
let usage = cache::get(&existing).unwrap_or_default();
return Ok(profile::ImportSuccess {
source: source.to_path_buf(),
alias: existing,
action: "unchanged",
account,
usage,
});
}

let usage::ImportValidation {
refreshed,
validated_account_id,
Expand Down
50 changes: 50 additions & 0 deletions src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,27 @@ pub fn find_profile_by_identity(identity: &AccountIdentity) -> Option<String> {
exact.or_else(|| email_only.into_iter().next())
}

/// The saved profile that these to-be-imported credentials already belong to,
/// if any.
///
/// `import` is deliberately create-only (see [`save_imported_auth_value`]), so a
/// re-import of an already-saved account would otherwise write a *second*
/// profile for it. Because OpenAI refresh tokens are single-use, the two copies
/// then race: whichever refreshes first rotates the token and the other dies
/// with `refresh_token_reused`, forcing a full re-login. Callers use this to
/// skip such a re-import instead of duplicating the account.
///
/// Detection is intentionally conservative and read-only — it never writes and
/// never overwrites, so a false positive can only decline an import, never hand
/// an account to the wrong profile:
/// - byte-identical to a stored profile ([`find_matching_profile`]), or
/// - the exact same `account_id` **and** `email` as a stored profile
/// ([`find_profile_by_identity_exact`]). Requiring the email too means a
/// shared Team `account_id` alone never matches a different member.
pub fn existing_import_target(source: &Path, val: &serde_json::Value) -> Option<String> {
find_matching_profile(source).or_else(|| find_profile_by_identity_exact(&extract_identity(val)))
}

pub fn alias_from_email(email: &str) -> String {
let base = email.split('@').next().unwrap_or(email);
let alias = base
Expand Down Expand Up @@ -1762,6 +1783,35 @@ mod tests {
));
}

#[test]
fn existing_import_target_matches_saved_account_and_ignores_others() {
let env = TestEnv::new();
// Save a profile for alice / acct_1.
let val = realistic_auth_json("alice@example.com", "acct_1", "acc_a", "ref_a");
let live = crate::auth::codex_auth_path().unwrap();
crate::auth::write_auth(&live, &val).unwrap();
super::cmd_save(Some("alice")).unwrap();

// A fresh dump of the SAME account with rotated tokens (so the bytes,
// and thus the file hash, differ) is still detected by account_id +
// email — this is exactly the re-import that would otherwise duplicate
// the account and spend its single-use refresh token.
let reimport = realistic_auth_json("alice@example.com", "acct_1", "acc_new", "ref_new");
let src = env._home.path().join("incoming.json");
std::fs::write(&src, serde_json::to_vec(&reimport).unwrap()).unwrap();
assert_eq!(
super::existing_import_target(&src, &reimport).as_deref(),
Some("alice"),
"a re-import of a saved account must be detected so it can be skipped"
);

// A genuinely different account is not matched, so it still imports.
let other = realistic_auth_json("bob@example.com", "acct_2", "acc_b", "ref_b");
let other_src = env._home.path().join("other.json");
std::fs::write(&other_src, serde_json::to_vec(&other).unwrap()).unwrap();
assert_eq!(super::existing_import_target(&other_src, &other), None);
}

#[test]
fn detect_new_account_when_no_profiles_exist() {
let _env = TestEnv::new();
Expand Down
Loading