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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,14 @@ Manage ChatGPT/Codex OAuth accounts locally and reuse them across provider profi
```bash
cc-switch auth status # Show managed account status
cc-switch auth login # Sign in with ChatGPT/Codex OAuth
cc-switch auth login --account-id <id> # Reauthenticate an existing account, preserving bindings
cc-switch auth list # List signed-in accounts
cc-switch auth default <account-id> # Set the default account
cc-switch auth remove <account-id> # Remove an account
```

Members of the same ChatGPT workspace have separate local account IDs. Managed accounts saved by older versions require reauthentication: find the ID with `cc-switch auth list`, then run `cc-switch auth login --account-id <id>`, or choose **Reauthenticate** in the TUI account menu. Sign in as the original user in the same workspace to preserve provider bindings.

### 🛠️ MCP Server Management

Manage Model Context Protocol servers across Claude, Codex, Gemini, OpenCode, and Hermes.
Expand Down
3 changes: 3 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,14 @@ cc-switch provider export <id> --output ~/.claude/settings-demo.json # 自定义
```bash
cc-switch auth status # 查看托管账号状态
cc-switch auth login # 使用 ChatGPT/Codex OAuth 登录
cc-switch auth login --account-id <id> # 重新认证已有账号,保留供应商绑定
cc-switch auth list # 列出已登录账号
cc-switch auth default <account-id> # 设置默认账号
cc-switch auth remove <account-id> # 移除账号
```

同一 ChatGPT workspace 的不同成员使用独立的本地账号 ID。旧版托管账号升级后需要重新认证:运行 `cc-switch auth list` 获取 ID,再执行 `cc-switch auth login --account-id <id>`;也可以在 TUI 的账号操作菜单选择「重新认证」。请登录原账号对应的 workspace 和用户,原有供应商绑定会保留。

### 🛠️ MCP 服务器管理

跨 Claude、Codex、Gemini、OpenCode 与 Hermes 管理模型上下文协议服务器。
Expand Down
11 changes: 11 additions & 0 deletions docs/maintenance/codex-oauth-identity-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Codex OAuth account identity

Source: farion1231/cc-switch PR #6780 (c2ec78dd471eff5187f586b8bae73e9aa9b63b65), with the current manager at 28dc5198115575ced2e3502f52181e7d211859c9 for follow-up duplicate-account and login lifecycle handling.

The CLI uses the upstream manager's UUID local IDs, separate workspace IDs, OIDC subject checks, v2 account store, legacy-account quarantine, targeted reauthentication, duplicate checks, account/lifecycle/storage locks, and pending-flow cancellation/generation checks. The upstream identity and persistence regression tests are retained.

CLI adapters expose reauthentication through `auth login --account-id` and the account action menu. Requests, model discovery, quota inspection and quota reset resolve the workspace from the same selected local account used to obtain the token. Proxy tests cover two local members of the same workspace and replacement of incoming credentials with the bound member's credentials.

The CLI currently supports managed Codex OAuth as a Claude proxy provider. It does not implement the desktop's managed native Codex auth.json activation/ownership workflow. Consequently native auth.json token adoption, synchronization, removal and official native-bearer passthrough validation are not imported. Native Codex login behavior is unchanged. The device-flow before-commit callback is a no-op because there is no native-auth publication to coordinate; account publication remains protected by the upstream manager locks.

Existing upstream refresh and cross-process manager behavior is retained; this change does not add a separate identity algorithm or change the SQLite schema. Existing managed accounts must be reauthenticated explicitly; ambiguous legacy rows are not silently assigned to a workspace member.
30 changes: 26 additions & 4 deletions src-tauri/src/cli/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub enum AuthCommand {
},
/// Sign in to ChatGPT with the Codex OAuth device flow
Login {
/// Reauthenticate this local account while preserving provider bindings
#[arg(long)]
account_id: Option<String>,
/// Print machine-readable JSON
#[arg(long)]
json: bool,
Expand Down Expand Up @@ -77,7 +80,7 @@ pub fn execute(cmd: AuthCommand) -> Result<(), AppError> {
match cmd {
AuthCommand::Status { json } => status(&runtime, json),
AuthCommand::List { json } => list_accounts(&runtime, json),
AuthCommand::Login { json } => login(&runtime, json),
AuthCommand::Login { json, account_id } => login(&runtime, json, account_id.as_deref()),
AuthCommand::Default { account_id } => set_default(&runtime, &account_id),
AuthCommand::Remove { account_id, yes } => remove_account(&runtime, &account_id, yes),
AuthCommand::Logout { yes } => logout(&runtime, yes),
Expand Down Expand Up @@ -158,9 +161,16 @@ fn list_accounts(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), Ap
Ok(())
}

fn login(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError> {
fn login(
runtime: &tokio::runtime::Runtime,
json: bool,
account_id: Option<&str>,
) -> Result<(), AppError> {
let device = runtime
.block_on(AuthService::start_login(AUTH_PROVIDER_CODEX_OAUTH))
.block_on(AuthService::start_login_for_account(
AUTH_PROVIDER_CODEX_OAUTH,
account_id,
))
.map_err(AppError::Message)?;

if json {
Expand Down Expand Up @@ -310,13 +320,25 @@ fn print_device_instructions(device: &ManagedAuthDeviceCodeResponse) {

fn print_accounts(accounts: &[ManagedAuthAccount]) {
let mut table = create_table();
table.set_header(vec!["Default", "Login", "Account ID", "Authenticated At"]);
table.set_header(vec![
"Default",
"Login",
"Account ID",
"Authenticated At",
"Status",
]);
for account in accounts {
table.add_row(vec![
if account.is_default { "yes" } else { "" }.to_string(),
account.login.clone(),
account.id.clone(),
account.authenticated_at.to_string(),
if account.reauth_required {
"Reauthentication required"
} else {
"Ready"
}
.to_string(),
]);
}
println!("{table}");
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/cli/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4038,6 +4038,22 @@ pub mod texts {
}
}

pub fn tui_managed_auth_reauthenticate() -> &'static str {
if is_chinese() {
"重新认证"
} else {
"Reauthenticate"
}
}

pub fn tui_managed_auth_reauth_required() -> &'static str {
if is_chinese() {
"需要重新认证"
} else {
"Reauthentication required"
}
}

pub fn tui_key_set_default() -> &'static str {
if is_chinese() {
"设为默认"
Expand Down
10 changes: 9 additions & 1 deletion src-tauri/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,13 +702,21 @@ mod tests {
let cli = Cli::parse_from(["cc-switch", "auth", "login", "--json"]);

match cli.command {
Some(Commands::Auth(super::commands::auth::AuthCommand::Login { json })) => {
Some(Commands::Auth(super::commands::auth::AuthCommand::Login { json, .. })) => {
assert!(json);
}
_ => panic!("expected auth login command"),
}
}

#[test]
fn parses_auth_targeted_reauthentication() {
let cli = Cli::parse_from(["cc-switch", "auth", "login", "--account-id", "local-user"]);
assert!(
matches!(cli.command, Some(Commands::Auth(super::commands::auth::AuthCommand::Login { account_id: Some(id), .. })) if id == "local-user")
);
}

#[test]
fn rejects_auth_login_no_poll_dead_end() {
let result = Cli::try_parse_from(["cc-switch", "auth", "login", "--no-poll"]);
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/cli/tui/app/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ pub enum Action {
},
ManagedAuthStartLogin {
auth_provider: String,
account_id: Option<String>,
},
ManagedAuthCancelLogin {
device_code: String,
},
ManagedAuthSetDefault {
auth_provider: String,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/cli/tui/app/content_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,7 @@ impl App {
}

Action::ManagedAuthStartLogin {
account_id: None,
auth_provider: "codex_oauth".to_string(),
}
}
Expand Down Expand Up @@ -1330,6 +1331,7 @@ impl App {
}

Action::ManagedAuthStartLogin {
account_id: None,
auth_provider: "codex_oauth".to_string(),
}
}
Expand Down
8 changes: 7 additions & 1 deletion src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,14 @@ impl App {
ConfirmAction::UsageQueryNotice => Action::ConfirmUsageQueryNotice,
ConfirmAction::RebuildCodexUsage => Action::UsageRebuildCodex,
ConfirmAction::ManagedAuthCancelLogin => {
let device_code = self
.managed_auth_login
.as_ref()
.map(|login| login.device_code.clone());
self.cancel_managed_auth_login();
Action::None
device_code
.map(|device_code| Action::ManagedAuthCancelLogin { device_code })
.unwrap_or(Action::None)
}
ConfirmAction::ProxyEnableAndAutoFailover { app_type } => {
Action::EnableProxyAndAutoFailover {
Expand Down
10 changes: 7 additions & 3 deletions src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@ impl App {
return None;
};

*selected = (*selected).min(1);
*selected = (*selected).min(2);

Some(match key.code {
KeyCode::Esc => {
Expand All @@ -828,7 +828,7 @@ impl App {
Action::None
}
KeyCode::Down => {
*selected = (*selected + 1).min(1);
*selected = (*selected + 1).min(2);
Action::None
}
KeyCode::Enter => {
Expand All @@ -839,10 +839,14 @@ impl App {
auth_provider,
account_id,
},
_ => Action::ManagedAuthRemove {
1 => Action::ManagedAuthRemove {
auth_provider,
account_id,
},
_ => Action::ManagedAuthStartLogin {
auth_provider,
account_id: Some(account_id),
},
};
self.overlay = Overlay::None;
action
Expand Down
27 changes: 24 additions & 3 deletions src-tauri/src/cli/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ mod tests {
migration_error: None,
accounts: vec![
crate::services::ManagedAuthAccount {
reauth_required: false,
id: "acc-default".to_string(),
provider: "codex_oauth".to_string(),
login: "default@example.com".to_string(),
Expand All @@ -104,6 +105,7 @@ mod tests {
is_default: true,
},
crate::services::ManagedAuthAccount {
reauth_required: false,
id: "acc-alt".to_string(),
provider: "codex_oauth".to_string(),
login: "alt@example.com".to_string(),
Expand Down Expand Up @@ -10677,13 +10679,13 @@ mod tests {
let action = app.on_key(key(KeyCode::Enter), &UiData::default());
assert!(matches!(
action,
Action::ManagedAuthStartLogin { auth_provider } if auth_provider == "codex_oauth"
Action::ManagedAuthStartLogin { auth_provider, .. } if auth_provider == "codex_oauth"
));

let action = app.on_key(key(KeyCode::Char('a')), &UiData::default());
assert!(matches!(
action,
Action::ManagedAuthStartLogin { auth_provider } if auth_provider == "codex_oauth"
Action::ManagedAuthStartLogin { auth_provider, .. } if auth_provider == "codex_oauth"
));

app.managed_auth_status = Some(managed_auth_status());
Expand Down Expand Up @@ -10713,6 +10715,22 @@ mod tests {
));
}

#[test]
fn managed_account_menu_can_reauthenticate_selected_id() {
let mut app = App::new(Some(AppType::Claude));
app.overlay = Overlay::ManagedAccountActionPicker {
auth_provider: "codex_oauth".into(),
account_id: "local-member".into(),
selected: 0,
};
app.on_key(key(KeyCode::Down), &UiData::default());
app.on_key(key(KeyCode::Down), &UiData::default());
let action = app.on_key(key(KeyCode::Enter), &UiData::default());
assert!(
matches!(action, Action::ManagedAuthStartLogin { account_id: Some(id), .. } if id == "local-member")
);
}

#[test]
fn managed_auth_login_uses_toast_and_esc_confirmation() {
let mut app = App::new(Some(AppType::Claude));
Expand Down Expand Up @@ -10761,7 +10779,9 @@ mod tests {
let action = app.on_key(key(KeyCode::Esc), &UiData::default());
assert!(matches!(action, Action::None));
let action = app.on_key(key(KeyCode::Enter), &UiData::default());
assert!(matches!(action, Action::None));
assert!(
matches!(action, Action::ManagedAuthCancelLogin { device_code } if device_code == managed_auth_device().device_code)
);
assert!(app.managed_auth_login.is_none());
assert!(matches!(app.overlay, Overlay::None));
assert!(matches!(
Expand All @@ -10777,6 +10797,7 @@ mod tests {
auth_provider: "codex_oauth".to_string(),
device_code: "device-1".to_string(),
result: Ok(Some(crate::services::ManagedAuthAccount {
reauth_required: false,
id: "acc-new".to_string(),
provider: "codex_oauth".to_string(),
login: "new@example.com".to_string(),
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/cli/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2220,6 +2220,7 @@ fn cache_invalidation_for_action(action: &Action) -> CacheInvalidation {
| Action::UsageLogDetailRefresh { .. }
| Action::ManagedAuthRefresh { .. }
| Action::ManagedAuthStartLogin { .. }
| Action::ManagedAuthCancelLogin { .. }
| Action::ManagedAuthSetDefault { .. }
| Action::ManagedAuthRemove { .. }
| Action::SkillsInstall { .. }
Expand Down
15 changes: 13 additions & 2 deletions src-tauri/src/cli/tui/runtime_actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,8 +1125,19 @@ pub(crate) fn handle_action(
Action::ManagedAuthRefresh { auth_provider } => {
settings::managed_auth_refresh(&mut ctx, auth_provider)
}
Action::ManagedAuthStartLogin { auth_provider } => {
settings::managed_auth_start_login(&mut ctx, auth_provider)
Action::ManagedAuthStartLogin {
auth_provider,
account_id,
} => settings::managed_auth_start_login(&mut ctx, auth_provider, account_id),
Action::ManagedAuthCancelLogin { device_code } => {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| AppError::Message(e.to_string()))?;
rt.block_on(
crate::services::CodexOAuthService::manager().cancel_device_flow(&device_code),
);
Ok(())
}
Action::ManagedAuthSetDefault {
auth_provider,
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/cli/tui/runtime_actions/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ pub(super) fn managed_auth_refresh(
pub(super) fn managed_auth_start_login(
ctx: &mut RuntimeActionContext<'_>,
auth_provider: String,
account_id: Option<String>,
) -> Result<(), AppError> {
let Some(tx) = ctx.managed_auth_req_tx else {
ctx.app.push_toast(
Expand All @@ -445,7 +446,10 @@ pub(super) fn managed_auth_start_login(
};

ctx.app.managed_auth_loading = true;
if let Err(err) = tx.send(ManagedAuthReq::StartLogin { auth_provider }) {
if let Err(err) = tx.send(ManagedAuthReq::StartLogin {
auth_provider,
account_id,
}) {
ctx.app.managed_auth_loading = false;
ctx.app.push_toast(
texts::tui_toast_managed_auth_request_failed(&err.to_string()),
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/cli/tui/runtime_systems/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ pub(crate) enum ManagedAuthReq {
},
StartLogin {
auth_provider: String,
account_id: Option<String>,
},
PollLogin {
auth_provider: String,
Expand Down
20 changes: 14 additions & 6 deletions src-tauri/src/cli/tui/runtime_systems/workers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,10 +707,12 @@ fn managed_auth_worker_loop(rx: mpsc::Receiver<ManagedAuthReq>, tx: mpsc::Sender
auth_provider,
result: Err(err.clone()),
},
ManagedAuthReq::StartLogin { auth_provider } => ManagedAuthMsg::LoginStarted {
auth_provider,
result: Err(err.clone()),
},
ManagedAuthReq::StartLogin { auth_provider, .. } => {
ManagedAuthMsg::LoginStarted {
auth_provider,
result: Err(err.clone()),
}
}
ManagedAuthReq::PollLogin {
auth_provider,
device_code,
Expand Down Expand Up @@ -751,8 +753,14 @@ fn managed_auth_worker_loop(rx: mpsc::Receiver<ManagedAuthReq>, tx: mpsc::Sender
result,
});
}
ManagedAuthReq::StartLogin { auth_provider } => {
let result = rt.block_on(crate::services::AuthService::start_login(&auth_provider));
ManagedAuthReq::StartLogin {
auth_provider,
account_id,
} => {
let result = rt.block_on(crate::services::AuthService::start_login_for_account(
&auth_provider,
account_id.as_deref(),
));
let _ = tx.send(ManagedAuthMsg::LoginStarted {
auth_provider,
result,
Expand Down
Loading
Loading