diff --git a/README.md b/README.md index 6787defb..44b1c1ed 100644 --- a/README.md +++ b/README.md @@ -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 # Reauthenticate an existing account, preserving bindings cc-switch auth list # List signed-in accounts cc-switch auth default # Set the default account cc-switch auth remove # 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 `, 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. diff --git a/README_ZH.md b/README_ZH.md index 91ce9c5a..6b4e089f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -318,11 +318,14 @@ cc-switch provider export --output ~/.claude/settings-demo.json # 自定义 ```bash cc-switch auth status # 查看托管账号状态 cc-switch auth login # 使用 ChatGPT/Codex OAuth 登录 +cc-switch auth login --account-id # 重新认证已有账号,保留供应商绑定 cc-switch auth list # 列出已登录账号 cc-switch auth default # 设置默认账号 cc-switch auth remove # 移除账号 ``` +同一 ChatGPT workspace 的不同成员使用独立的本地账号 ID。旧版托管账号升级后需要重新认证:运行 `cc-switch auth list` 获取 ID,再执行 `cc-switch auth login --account-id `;也可以在 TUI 的账号操作菜单选择「重新认证」。请登录原账号对应的 workspace 和用户,原有供应商绑定会保留。 + ### 🛠️ MCP 服务器管理 跨 Claude、Codex、Gemini、OpenCode 与 Hermes 管理模型上下文协议服务器。 diff --git a/docs/maintenance/codex-oauth-identity-alignment.md b/docs/maintenance/codex-oauth-identity-alignment.md new file mode 100644 index 00000000..7c7d171a --- /dev/null +++ b/docs/maintenance/codex-oauth-identity-alignment.md @@ -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. diff --git a/src-tauri/src/cli/commands/auth.rs b/src-tauri/src/cli/commands/auth.rs index 824e9c25..c556b2b1 100644 --- a/src-tauri/src/cli/commands/auth.rs +++ b/src-tauri/src/cli/commands/auth.rs @@ -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, /// Print machine-readable JSON #[arg(long)] json: bool, @@ -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), @@ -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 { @@ -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}"); diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index bf99f692..ebcd3f68 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -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() { "设为默认" diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 68f9d248..9972619b 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -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"]); diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 8a779da4..97c15f24 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -156,6 +156,10 @@ pub enum Action { }, ManagedAuthStartLogin { auth_provider: String, + account_id: Option, + }, + ManagedAuthCancelLogin { + device_code: String, }, ManagedAuthSetDefault { auth_provider: String, diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index 1a3b18ef..1f6c1532 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -1290,6 +1290,7 @@ impl App { } Action::ManagedAuthStartLogin { + account_id: None, auth_provider: "codex_oauth".to_string(), } } @@ -1330,6 +1331,7 @@ impl App { } Action::ManagedAuthStartLogin { + account_id: None, auth_provider: "codex_oauth".to_string(), } } diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs index e5f2703e..3fe35901 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs @@ -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 { diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index c86fd7b2..c3803328 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -816,7 +816,7 @@ impl App { return None; }; - *selected = (*selected).min(1); + *selected = (*selected).min(2); Some(match key.code { KeyCode::Esc => { @@ -828,7 +828,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(1); + *selected = (*selected + 1).min(2); Action::None } KeyCode::Enter => { @@ -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 diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 1050e5b0..798c3ee1 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -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(), @@ -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(), @@ -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()); @@ -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)); @@ -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!( @@ -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(), diff --git a/src-tauri/src/cli/tui/mod.rs b/src-tauri/src/cli/tui/mod.rs index 1894e473..04126bd0 100644 --- a/src-tauri/src/cli/tui/mod.rs +++ b/src-tauri/src/cli/tui/mod.rs @@ -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 { .. } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 898f8936..fcbb4eda 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -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, diff --git a/src-tauri/src/cli/tui/runtime_actions/settings.rs b/src-tauri/src/cli/tui/runtime_actions/settings.rs index 5a166fbb..45b112ca 100644 --- a/src-tauri/src/cli/tui/runtime_actions/settings.rs +++ b/src-tauri/src/cli/tui/runtime_actions/settings.rs @@ -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, ) -> Result<(), AppError> { let Some(tx) = ctx.managed_auth_req_tx else { ctx.app.push_toast( @@ -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()), diff --git a/src-tauri/src/cli/tui/runtime_systems/types.rs b/src-tauri/src/cli/tui/runtime_systems/types.rs index 65f94bbb..15dd7ab0 100644 --- a/src-tauri/src/cli/tui/runtime_systems/types.rs +++ b/src-tauri/src/cli/tui/runtime_systems/types.rs @@ -585,6 +585,7 @@ pub(crate) enum ManagedAuthReq { }, StartLogin { auth_provider: String, + account_id: Option, }, PollLogin { auth_provider: String, diff --git a/src-tauri/src/cli/tui/runtime_systems/workers.rs b/src-tauri/src/cli/tui/runtime_systems/workers.rs index 6efb91f2..ab738d5a 100644 --- a/src-tauri/src/cli/tui/runtime_systems/workers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/workers.rs @@ -707,10 +707,12 @@ fn managed_auth_worker_loop(rx: mpsc::Receiver, 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, @@ -751,8 +753,14 @@ fn managed_auth_worker_loop(rx: mpsc::Receiver, 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, diff --git a/src-tauri/src/cli/tui/ui/config.rs b/src-tauri/src/cli/tui/ui/config.rs index e05eae14..252b47f2 100644 --- a/src-tauri/src/cli/tui/ui/config.rs +++ b/src-tauri/src/cli/tui/ui/config.rs @@ -3873,6 +3873,13 @@ fn managed_account_list_item( )); } + if account.reauth_required { + title_spans.push(Span::raw(format!( + " {}", + texts::tui_managed_auth_reauth_required() + ))); + } + ListItem::new(vec![ Line::from(title_spans), Line::from(vec![ diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index 6ec0042a..f06a0073 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -861,6 +861,7 @@ pub(super) fn render_managed_account_action_picker_overlay( let actions = [ texts::tui_key_set_default().to_string(), texts::tui_key_delete().to_string(), + texts::tui_managed_auth_reauthenticate().to_string(), ]; let body = overlay_frame( @@ -887,7 +888,7 @@ pub(super) fn render_managed_account_action_picker_overlay( .highlight_style(selection_style(theme)) .highlight_symbol(highlight_symbol(theme)); let mut state = ListState::default(); - state.select(Some(selected.min(1))); + state.select(Some(selected.min(2))); frame.render_stateful_widget(list, body, &mut state); } diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index e6938451..542833e3 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -4074,6 +4074,7 @@ fn managed_auth_status() -> crate::services::ManagedAuthStatus { 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(), @@ -4082,6 +4083,7 @@ fn managed_auth_status() -> crate::services::ManagedAuthStatus { 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(), diff --git a/src-tauri/src/proxy/forwarder/request_builder.rs b/src-tauri/src/proxy/forwarder/request_builder.rs index 3150c5e1..c371d12f 100644 --- a/src-tauri/src/proxy/forwarder/request_builder.rs +++ b/src-tauri/src/proxy/forwarder/request_builder.rs @@ -552,6 +552,7 @@ async fn build_request( adapter.build_url(base_url, endpoint) }; let mut request = client.post(url); + let mut codex_oauth_workspace = None; for (key, value) in headers { if codex_responses_to_anthropic { @@ -646,20 +647,24 @@ async fn build_request( .as_ref() .and_then(|meta| meta.managed_account_id_for("codex_oauth")); - match match &account_id { - Some(id) => CodexOAuthService::get_valid_token_for_account(id).await, - None => CodexOAuthService::get_valid_token().await, - } { + // Resolve the local binding once; defaults may change concurrently. + let resolved_account_id = match account_id { + Some(id) => id, + None => CodexOAuthService::default_account_id() + .await + .ok_or_else(|| { + ProxyError::AuthError("Codex OAuth 认证失败: 无可用的 ChatGPT 账号".into()) + })?, + }; + match CodexOAuthService::get_valid_token_for_account(&resolved_account_id).await { Ok(token) => { effective_auth.api_key = token; request = adapter.add_auth_headers(request, &effective_auth); - let resolved_account_id = match account_id { - Some(id) => Some(id), - None => CodexOAuthService::default_account_id().await, - }; - if let Some(account_id) = resolved_account_id { - request = request.header("ChatGPT-Account-Id", account_id); - } + let workspace = CodexOAuthService::manager() + .chatgpt_account_id_for_account(&resolved_account_id) + .await + .map_err(|error| ProxyError::AuthError(error.to_string()))?; + codex_oauth_workspace = Some(workspace); if let Some(session_id) = client_session_id { for (name, value) in build_codex_oauth_session_headers(session_id) { request = request.header(name, value); @@ -723,6 +728,14 @@ async fn build_request( .and_then(|meta| meta.local_proxy_request_overrides.as_ref()), ); } + // Match upstream: the bound workspace wins over incoming and local headers. + if let Some(workspace) = codex_oauth_workspace { + replacements.insert( + "chatgpt-account-id", + reqwest::header::HeaderValue::from_str(&workspace) + .map_err(|error| ProxyError::AuthError(error.to_string()))?, + ); + } request = request.headers(replacements); reject_proxy_placeholder_for_managed_account_upstream(&request)?; diff --git a/src-tauri/src/proxy/forwarder/tests/request_building.rs b/src-tauri/src/proxy/forwarder/tests/request_building.rs index 1fad5af0..2fa724ea 100644 --- a/src-tauri/src/proxy/forwarder/tests/request_building.rs +++ b/src-tauri/src/proxy/forwarder/tests/request_building.rs @@ -2366,3 +2366,41 @@ fn request_body_json(request: &reqwest::Request) -> Value { .expect("request should have JSON body bytes"); serde_json::from_slice(bytes).expect("parse request JSON body") } + +#[tokio::test] +async fn codex_oauth_shared_workspace_uses_bound_members_token_and_workspace_header() { + let _lock = lock_test_home_and_settings(); + let _guard = CodexOAuthService::test_empty_manager().await.unwrap(); + let manager = CodexOAuthService::manager(); + for (id, token) in [("local-a", "token-a"), ("local-b", "token-b")] { + let id_token = crate::codex_config::test_codex_id_token(id); + manager + .add_test_account_with_workspace_and_access_token( + id, + "shared-workspace", + token, + Some(&id_token), + ) + .await + .unwrap(); + } + manager.set_default_account("local-b").await.unwrap(); + for (id, token) in [("local-a", "Bearer token-a"), ("local-b", "Bearer token-b")] { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer other-member"), + ); + headers.insert( + "chatgpt-account-id", + HeaderValue::from_static("wrong-workspace"), + ); + let request = + build_request(&AppType::Claude, &codex_oauth_provider(Some(id)), headers).await; + assert_eq!(header_value(&request, "authorization"), Some(token)); + assert_eq!( + header_value(&request, "chatgpt-account-id"), + Some("shared-workspace") + ); + } +} diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs index 387718e1..d69d50c4 100644 --- a/src-tauri/src/proxy/providers/codex_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -1,47 +1,124 @@ +//! Codex OAuth Authentication Module +//! +//! 实现 OpenAI ChatGPT Plus/Pro 订阅的 OAuth Device Code 流程。 +//! 支持多账号管理,每个 Provider 可关联不同的 ChatGPT 账号。 +//! +//! ## 认证流程 +//! 1. 启动 Device Code 流程,获取 device_auth_id 和 user_code +//! 2. 用户在浏览器中完成 ChatGPT 授权 +//! 3. 轮询获取 authorization_code 和 code_verifier(注意:verifier 由服务端返回) +//! 4. 使用 code + verifier 换取 access_token + refresh_token + id_token +//! 5. 自动刷新 access_token(到期前 60 秒) +//! +//! ## 多账号支持 +//! - 每个 ChatGPT 账号独立存储 refresh_token +//! - Provider 通过 meta.authBinding 关联账号(auth_provider = "codex_oauth") +//! - 本地账号 ID 用于绑定和缓存;chatgpt_account_id 仅表示上游 workspace + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; +use std::time::Duration; use tokio::sync::{Mutex, RwLock}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedAuthAccount { + pub id: String, + pub login: String, + pub avatar_url: Option, + pub authenticated_at: i64, + #[serde(default)] + pub reauth_required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedAuthDeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, +} +/// OpenAI OAuth 客户端 ID(OpenCode 使用,与官方 Codex CLI 相同) const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +/// Device Code 启动 URL const DEVICE_AUTH_USERCODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode"; + +/// Device Code 轮询 URL const DEVICE_AUTH_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token"; + +/// OAuth Token URL(用于 code 换 token 和 refresh token) const OAUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; + +/// Device Code 验证 URL(向用户展示) const DEVICE_VERIFICATION_URL: &str = "https://auth.openai.com/codex/device"; + +/// Device Code 流程的 redirect_uri(OpenAI 服务端约定) const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; + +/// Token 刷新提前量(毫秒) const TOKEN_REFRESH_BUFFER_MS: i64 = 60_000; + +/// OAuth token/device 端点的单请求超时。共享 HTTP client 默认 600s 超时是给 +/// 大模型流式响应用的,对认证请求过长;网络卡住时应尽快失败而非长时间阻塞。 +const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Device Code 默认有效时长(秒),OpenAI 文档约定 15 分钟 const DEVICE_CODE_DEFAULT_EXPIRES_IN: u64 = 900; + +/// 轮询间隔安全余量(秒) const POLLING_SAFETY_MARGIN_SECS: u64 = 3; + +/// User-Agent const CODEX_USER_AGENT: &str = "cc-switch-codex-oauth"; // Shared by model discovery and generation: ChatGPT gates models by this -// client identity. gpt-6-astra requires >= 0.153.0 in the rust-v0.153.4 catalog. -// Bump together when a new model raises its minimal_client_version. +// client identity. GPT-6.1 Sol enters the ChatGPT-account catalog at 0.159.0. +// Bump together when a new model raises its minimal_client_version or catalog gate. pub(crate) const CODEX_OAUTH_ORIGINATOR: &str = "codex_cli_rs"; pub(crate) const CODEX_OAUTH_CLIENT_VERSION: &str = "0.159.0"; +/// Codex OAuth 错误 #[derive(Debug, thiserror::Error)] pub enum CodexOAuthError { #[error("等待用户授权中")] AuthorizationPending, + #[error("用户拒绝授权")] - #[allow(dead_code)] AccessDenied, + #[error("Device Code 已过期")] ExpiredToken, + #[error("OAuth Token 获取失败: {0}")] TokenFetchFailed(String), + + #[error("codex_oauth_duplicate_account")] + DuplicateAccount, + #[error("Refresh Token 失效或已过期")] RefreshTokenInvalid, + #[error("网络错误: {0}")] NetworkError(String), + #[error("解析错误: {0}")] ParseError(String), + #[error("IO 错误: {0}")] IoError(String), + #[error("账号不存在: {0}")] AccountNotFound(String), } @@ -58,31 +135,7 @@ impl From for CodexOAuthError { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManagedAuthAccount { - pub id: String, - pub login: String, - pub avatar_url: Option, - pub authenticated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManagedAuthDeviceCodeResponse { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - pub expires_in: u64, - pub interval: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CodexOAuthStatus { - pub accounts: Vec, - pub default_account_id: Option, - pub authenticated: bool, - pub username: Option, -} - +/// OpenAI Device Code 响应 #[derive(Debug, Clone, Deserialize)] struct DeviceCodeResponse { device_auth_id: String, @@ -93,12 +146,14 @@ struct DeviceCodeResponse { expires_in: Option, } +/// OpenAI Device Code 轮询响应(成功) #[derive(Debug, Clone, Deserialize)] struct DevicePollSuccess { authorization_code: String, code_verifier: String, } +/// OAuth Token 响应 #[derive(Debug, Clone, Deserialize)] struct OAuthTokenResponse { access_token: String, @@ -109,34 +164,33 @@ struct OAuthTokenResponse { expires_in: Option, } +/// 解析后的 JWT claims(仅关心 chatgpt_account_id 等字段) #[derive(Debug, Clone, Default, Deserialize)] struct IdTokenClaims { #[serde(default)] chatgpt_account_id: Option, #[serde(default)] email: Option, - #[serde(default)] - organizations: Vec, #[serde(default, rename = "https://api.openai.com/auth")] openai_auth: Option, } -#[derive(Debug, Clone, Default, Deserialize)] -struct OrgClaim { - #[serde(default)] - id: Option, -} - #[derive(Debug, Clone, Default, Deserialize)] struct OpenAiAuthClaim { #[serde(default)] chatgpt_account_id: Option, } +/// 缓存的 access_token(含过期时间) #[derive(Debug, Clone)] struct CachedAccessToken { token: String, + /// 过期时间戳(毫秒) expires_at_ms: i64, + /// 获取(刷新)时间戳(毫秒)。用于写入托管 auth.json 的 `last_refresh`, + /// 使其如实反映 access_token 的真实获取时间,而非写盘时刻——否则 Codex CLI + /// 会误判一个旧 token 是刚刷新的。 + obtained_at_ms: i64, } impl CachedAccessToken { @@ -146,35 +200,109 @@ impl CachedAccessToken { } } +/// 进行中的 Device Code 条目,带过期时间以便清理放弃的登录流程 #[derive(Debug, Clone)] struct PendingDeviceCode { user_code: String, + /// Unix 毫秒时间戳,超时后可清理 expires_at_ms: i64, + /// 仅重新认证时设置;登录完成后原位更新该本地账号,保留 provider 绑定。 + target_account_id: Option, + /// 同一目标账号只允许最新启动的重新认证流程提交。 + target_generation: Option, +} + +#[derive(Default)] +struct AccountLoginContext<'a> { + target_account_id: Option<&'a str>, + pending_device_code: Option<&'a str>, + target_generation: Option, } +/// 持久化的账号数据 #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexAccountData { + /// 本地稳定账号 ID(同时作为 HashMap 的 key) pub account_id: String, + /// 上游 ChatGPT workspace ID(用于 chatgpt-account-id 请求头) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chatgpt_account_id: Option, + /// 账号邮箱(如果可获取) #[serde(default, skip_serializing_if = "Option::is_none")] pub email: Option, + /// Refresh Token(持久化) pub refresh_token: String, + /// 认证时间戳(秒) pub authenticated_at: i64, + /// ChatGPT id_token(JWT,持久化)。用于让托管写入的 Codex auth.json + /// 与原生浏览器登录保持一致的 tokens 字段形状;刷新时若返回新值则更新。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id_token: Option, + /// 最近一次取得或采纳这组 OAuth token 的时间。用于在 Codex CLI 与 + /// cc-switch 都可能轮换 refresh_token 时拒绝从 live 采纳更旧的一代。 + #[serde(default)] + pub token_updated_at_ms: i64, } +/// 公开的账号信息(返回给前端,复用 ManagedAuthAccount 结构) impl From<&CodexAccountData> for ManagedAuthAccount { fn from(data: &CodexAccountData) -> Self { - Self { + ManagedAuthAccount { id: data.account_id.clone(), - login: data - .email - .clone() - .unwrap_or_else(|| format!("ChatGPT ({})", &data.account_id)), + // 用 email 作为显示名(若无则用上游 workspace ID) + login: data.email.clone().unwrap_or_else(|| { + format!( + "ChatGPT ({})", + data.chatgpt_account_id + .as_deref() + .unwrap_or(&data.account_id) + ) + }), avatar_url: None, authenticated_at: data.authenticated_at, + // 旧账号可能缺少 id_token 或独立的上游 workspace 字段;两者都需要 + // 重新登录后才能安全参与本地 ID → workspace 的托管绑定。 + reauth_required: data + .id_token + .as_deref() + .and_then(crate::codex_config::extract_codex_id_token_user_identity) + .is_none() + || data.chatgpt_account_id.is_none(), + } + } +} + +impl CodexAccountData { + fn apply_refreshed_tokens(&mut self, tokens: &OAuthTokenResponse) -> bool { + let refreshed_account_id = extract_account_metadata_from_tokens(tokens).0; + let mut changed = false; + if let Some(account_id) = refreshed_account_id { + // A missing workspace marks a quarantined pre-v2 record. Ordinary + // refresh cannot prove which same-workspace user an old binding + // originally represented; only explicit targeted reauth may fill it. + if self.chatgpt_account_id.is_some() + && self.chatgpt_account_id.as_deref() != Some(&account_id) + { + self.chatgpt_account_id = Some(account_id); + changed = true; + } + } + if let Some(refresh_token) = tokens + .refresh_token + .as_ref() + .filter(|token| !token.trim().is_empty()) + { + if self.refresh_token != *refresh_token { + self.refresh_token = refresh_token.clone(); + changed = true; + } } + + changed } } +/// 持久化存储结构(v2) #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct CodexOAuthStore { #[serde(default)] @@ -185,25 +313,48 @@ struct CodexOAuthStore { default_account_id: Option, } +/// Codex OAuth 认证管理器(多账号) pub struct CodexOAuthManager { - accounts: std::sync::Arc>>, - default_account_id: std::sync::Arc>>, - access_tokens: std::sync::Arc>>, - refresh_locks: std::sync::Arc>>>>, - pending_device_codes: std::sync::Arc>>, + accounts: Arc>>, + default_account_id: Arc>>, + /// 内存缓存的 access_token(不持久化) + access_tokens: Arc>>, + /// 每个账号的刷新锁 + refresh_locks: Arc>>>>, + /// 普通 token 解析/采纳持读锁,账号删除/清空持写锁。删除因此会等待 + /// 已在飞 refresh 完成,也不会因过早清理 refresh_locks 产生第二把账号锁。 + lifecycle_lock: Arc>, + /// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms} + /// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长 + pending_device_codes: Arc>>, + /// 清除全部认证时递增,使已经在网络请求中的登录流程无法重新登记。 + login_epoch: AtomicU64, + /// 每个目标账号最新一次定向重新认证的 generation。 + target_login_generations: Arc>>, + next_target_login_generation: AtomicU64, storage_path: PathBuf, + /// 持久化串行锁:`save_to_disk` 与 `clear_auth` 的「快照+写盘/删文件」都在此锁内 + /// 完成。此前由外层 `RwLock` 的写锁隐式串行化;去掉外层锁后 + /// 需要它防止并发保存/清除交错,导致已删账号被旧快照复活。 + storage_lock: Arc>, } impl CodexOAuthManager { pub fn new(data_dir: PathBuf) -> Self { let storage_path = data_dir.join("codex_oauth_auth.json"); + let manager = Self { - accounts: std::sync::Arc::new(RwLock::new(HashMap::new())), - default_account_id: std::sync::Arc::new(RwLock::new(None)), - access_tokens: std::sync::Arc::new(RwLock::new(HashMap::new())), - refresh_locks: std::sync::Arc::new(RwLock::new(HashMap::new())), - pending_device_codes: std::sync::Arc::new(RwLock::new(HashMap::new())), + accounts: Arc::new(RwLock::new(HashMap::new())), + default_account_id: Arc::new(RwLock::new(None)), + access_tokens: Arc::new(RwLock::new(HashMap::new())), + refresh_locks: Arc::new(RwLock::new(HashMap::new())), + lifecycle_lock: Arc::new(RwLock::new(())), + pending_device_codes: Arc::new(RwLock::new(HashMap::new())), + login_epoch: AtomicU64::new(0), + target_login_generations: Arc::new(RwLock::new(HashMap::new())), + next_target_login_generation: AtomicU64::new(0), storage_path, + storage_lock: Arc::new(Mutex::new(())), }; if let Err(e) = manager.load_from_disk_sync() { @@ -213,11 +364,47 @@ impl CodexOAuthManager { manager } + // ==================== 设备码流程 ==================== + + /// 启动 Device Code 流程 + /// + /// 返回 ManagedAuthDeviceCodeResponse 复用现有前端结构,但字段含义对应 OpenAI 的字段: + /// - device_code = device_auth_id + /// - user_code = user_code + /// - verification_uri = https://auth.openai.com/codex/device pub async fn start_device_flow( &self, + target_account_id: Option<&str>, ) -> Result { + log::info!("[CodexOAuth] 启动 Device Code 流程"); + let login_epoch = self.login_epoch.load(Ordering::Acquire); + let target_account_id = target_account_id + .map(str::trim) + .filter(|account_id| !account_id.is_empty()) + .map(str::to_string); + let target_generation = if let Some(account_id) = target_account_id.as_deref() { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } + drop(accounts); + let generation = self + .next_target_login_generation + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + let mut generations = self.target_login_generations.write().await; + generations + .entry(account_id.to_string()) + .and_modify(|current| *current = (*current).max(generation)) + .or_insert(generation); + Some(generation) + } else { + None + }; + let response = crate::proxy::http_client::get() .post(DEVICE_AUTH_USERCODE_URL) + .timeout(OAUTH_HTTP_TIMEOUT) .header("Content-Type", "application/json") .header("User-Agent", CODEX_USER_AGENT) .json(&serde_json::json!({ "client_id": CODEX_CLIENT_ID })) @@ -241,18 +428,20 @@ impl CodexOAuthManager { let expires_in = device.expires_in.unwrap_or(DEVICE_CODE_DEFAULT_EXPIRES_IN); let expires_at_ms = chrono::Utc::now().timestamp_millis() + (expires_in as i64) * 1000; - { - let mut pending = self.pending_device_codes.write().await; - let now_ms = chrono::Utc::now().timestamp_millis(); - pending.retain(|_, entry| entry.expires_at_ms > now_ms); - pending.insert( - device.device_auth_id.clone(), - PendingDeviceCode { - user_code: device.user_code.clone(), - expires_at_ms, - }, - ); - } + self.register_pending_device_code( + device.device_auth_id.clone(), + device.user_code.clone(), + expires_at_ms, + login_epoch, + target_account_id, + target_generation, + ) + .await?; + + log::info!( + "[CodexOAuth] 获取 Device Code 成功,user_code: {}", + device.user_code + ); Ok(ManagedAuthDeviceCodeResponse { device_code: device.device_auth_id, @@ -263,15 +452,60 @@ impl CodexOAuthManager { }) } - pub async fn poll_for_token( + async fn register_pending_device_code( + &self, + device_auth_id: String, + user_code: String, + expires_at_ms: i64, + login_epoch: u64, + target_account_id: Option, + target_generation: Option, + ) -> Result<(), CodexOAuthError> { + let mut pending = self.pending_device_codes.write().await; + if self.login_epoch.load(Ordering::Acquire) != login_epoch { + return Err(CodexOAuthError::ExpiredToken); + } + + let now_ms = chrono::Utc::now().timestamp_millis(); + pending.retain(|_, entry| entry.expires_at_ms > now_ms); + pending.insert( + device_auth_id, + PendingDeviceCode { + user_code, + expires_at_ms, + target_account_id, + target_generation, + }, + ); + Ok(()) + } + + pub async fn cancel_device_flow(&self, device_code: &str) -> bool { + self.pending_device_codes + .write() + .await + .remove(device_code) + .is_some() + } + + /// 轮询 Device Code 状态 + /// + /// 接收 device_code(即 device_auth_id),返回 Some(account) 表示授权成功 + pub async fn poll_for_token( &self, device_code: &str, - ) -> Result, CodexOAuthError> { + before_commit: BeforeCommit, + ) -> Result, CodexOAuthError> + where + BeforeCommit: FnOnce() -> CommitFuture, + CommitFuture: std::future::Future, + { let entry = { let pending = self.pending_device_codes.read().await; pending.get(device_code).cloned() - } - .ok_or_else(|| { + }; + + let entry = entry.ok_or_else(|| { CodexOAuthError::TokenFetchFailed( "未找到对应的 user_code,请重新启动登录流程".to_string(), ) @@ -283,24 +517,33 @@ impl CodexOAuthManager { return Err(CodexOAuthError::ExpiredToken); } + let user_code = entry.user_code.clone(); + + log::debug!("[CodexOAuth] 轮询 Device Code"); + let poll_response = crate::proxy::http_client::get() .post(DEVICE_AUTH_TOKEN_URL) + .timeout(OAUTH_HTTP_TIMEOUT) .header("Content-Type", "application/json") .header("User-Agent", CODEX_USER_AGENT) .json(&serde_json::json!({ "device_auth_id": device_code, - "user_code": entry.user_code, + "user_code": user_code, })) .send() .await?; let status = poll_response.status(); + + // 403/404 表示用户未完成授权,继续轮询 if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::NOT_FOUND { return Err(CodexOAuthError::AuthorizationPending); } + if status == reqwest::StatusCode::GONE { return Err(CodexOAuthError::ExpiredToken); } + if !status.is_success() { let text = poll_response.text().await.unwrap_or_default(); return Err(CodexOAuthError::TokenFetchFailed(format!( @@ -313,41 +556,67 @@ impl CodexOAuthManager { .await .map_err(|e| CodexOAuthError::ParseError(e.to_string()))?; + log::info!("[CodexOAuth] 用户已授权,正在换取 OAuth Token"); + + // 用 authorization_code + code_verifier 换 token let tokens = self .exchange_code_for_tokens(&success.authorization_code, &success.code_verifier) .await?; - { - let mut pending = self.pending_device_codes.write().await; - pending.remove(device_code); - } - let refresh_token = tokens.refresh_token.clone().ok_or_else(|| { CodexOAuthError::TokenFetchFailed("响应缺少 refresh_token".to_string()) })?; - let (account_id, email) = extract_identity_from_tokens(&tokens); - let account_id = account_id.ok_or_else(|| { - CodexOAuthError::ParseError("无法从 token 中提取 account_id".to_string()) + + let (chatgpt_account_id, email) = extract_account_metadata_from_tokens(&tokens); + let chatgpt_account_id = chatgpt_account_id.ok_or_else(|| { + CodexOAuthError::ParseError("无法从 token 中提取 chatgpt_account_id".to_string()) })?; - { - let mut tokens_cache = self.access_tokens.write().await; - tokens_cache.insert( - account_id.clone(), - CachedAccessToken { - token: tokens.access_token.clone(), - expires_at_ms: compute_expires_at_ms(tokens.expires_in), - }, - ); + let id_token = tokens + .id_token + .clone() + .filter(|token| !token.trim().is_empty()) + .ok_or_else(|| { + CodexOAuthError::TokenFetchFailed( + "登录响应缺少 id_token,账号未保存,请重新登录".to_string(), + ) + })?; + if crate::codex_config::extract_codex_id_token_subject(&id_token).is_none() { + return Err(CodexOAuthError::TokenFetchFailed( + "登录响应无法确认稳定用户身份,账号未保存,请重新登录".to_string(), + )); } + let obtained_at_ms = chrono::Utc::now().timestamp_millis(); + // Provider switching and managed live-auth writes use the same guard. + // Acquire it only after the network exchange succeeds so ordinary + // authorization-pending polls never block provider operations. + let _commit_guard = before_commit().await; + // 登录提交与该账号的 refresh/adopt 共用一把 generation 锁;账号和 + // access cache 一次写入,旧刷新响应因此不能覆盖新登录链。 let account = self - .add_account_internal(account_id, refresh_token, email) + .add_account_internal( + chatgpt_account_id, + refresh_token, + email, + Some(id_token), + Some(CachedAccessToken { + token: tokens.access_token.clone(), + expires_at_ms: compute_expires_at_ms(tokens.expires_in), + obtained_at_ms, + }), + AccountLoginContext { + target_account_id: entry.target_account_id.as_deref(), + pending_device_code: Some(device_code), + target_generation: entry.target_generation, + }, + ) .await?; Ok(Some(account)) } + /// 用 authorization_code + code_verifier 换取 tokens async fn exchange_code_for_tokens( &self, code: &str, @@ -355,6 +624,7 @@ impl CodexOAuthManager { ) -> Result { let response = crate::proxy::http_client::get() .post(OAUTH_TOKEN_URL) + .timeout(OAUTH_HTTP_TIMEOUT) .header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", CODEX_USER_AGENT) .form(&[ @@ -381,12 +651,14 @@ impl CodexOAuthManager { .map_err(|e| CodexOAuthError::ParseError(e.to_string())) } + /// 用 refresh_token 刷新 access_token async fn refresh_with_token( &self, refresh_token: &str, ) -> Result { let response = crate::proxy::http_client::get() .post(OAUTH_TOKEN_URL) + .timeout(OAUTH_HTTP_TIMEOUT) .header("Content-Type", "application/x-www-form-urlencoded") .header("User-Agent", CODEX_USER_AGENT) .form(&[ @@ -399,12 +671,22 @@ impl CodexOAuthManager { .await?; let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err(CodexOAuthError::RefreshTokenInvalid); - } - if !status.is_success() { let text = response.text().await.unwrap_or_default(); + let refresh_error_code = extract_refresh_error_code(&text); + if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + || matches!( + refresh_error_code.as_deref(), + Some( + "refresh_token_expired" + | "refresh_token_reused" + | "refresh_token_invalidated" + ) + ) + { + return Err(CodexOAuthError::RefreshTokenInvalid); + } return Err(CodexOAuthError::TokenFetchFailed(format!( "Refresh 失败: {status} - {text}" ))); @@ -416,27 +698,88 @@ impl CodexOAuthManager { .map_err(|e| CodexOAuthError::ParseError(e.to_string())) } + // ==================== Token 获取(含自动刷新) ==================== + + /// 获取指定账号的有效 access_token(必要时自动刷新) pub async fn get_valid_token_for_account( &self, account_id: &str, ) -> Result { + let _lifecycle = self.lifecycle_lock.read().await; + self.ensure_account_ready_for_use(account_id).await?; + Ok(self.resolve_valid_cached_token(account_id).await?.token) + } + + async fn ensure_account_ready_for_use(&self, account_id: &str) -> Result<(), CodexOAuthError> { + let accounts = self.accounts.read().await; + let account = accounts + .get(account_id) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?; + if account + .id_token + .as_deref() + .and_then(crate::codex_config::extract_codex_id_token_user_identity) + .is_none() + || account.chatgpt_account_id.is_none() + { + return Err(CodexOAuthError::ParseError(format!( + "账号 {account_id} 缺少 id_token 中可证明的用户身份或 workspace,请重新认证" + ))); + } + Ok(()) + } + + /// 解析账号的有效缓存 token(含真实获取时间),必要时刷新。 + /// + /// 返回完整 `CachedAccessToken`,使 token 与其 `obtained_at_ms` 天然配套(写托管 + /// auth.json 的 `last_refresh` 直接取用),避免分两次读缓存造成的错配。 + /// + /// 并发正确性:调用方持 lifecycle 读锁;刷新在 account refresh mutex 下先短暂 + /// 提交 accounts → access_tokens,释放这些锁后再持久化。`save_to_disk` 的实际 + /// 持久化锁序是 storage_lock → accounts/default。remove/clear 持 lifecycle 写锁, + /// 因而会等待在飞刷新并阻断同 account_id 的 ABA 重建。 + async fn resolve_valid_cached_token( + &self, + account_id: &str, + ) -> Result { + // 快路径:确认账号存在后读缓存 { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } let tokens = self.access_tokens.read().await; if let Some(cached) = tokens.get(account_id) { if !cached.is_expiring_soon() { - return Ok(cached.token.clone()); + return Ok(cached.clone()); } } } + log::info!("[CodexOAuth] 账号 {account_id} 的 access_token 需要刷新"); + let refresh_lock = self.get_refresh_lock(account_id).await; let _guard = refresh_lock.lock().await; + self.resolve_valid_cached_token_under_lock(account_id).await + } + /// Resolve a token while the caller owns this account's refresh mutex. + /// Keeping this separate lets the full auth-bundle path hold one generation + /// lock across access/id/refresh reads without recursively locking the mutex. + async fn resolve_valid_cached_token_under_lock( + &self, + account_id: &str, + ) -> Result { + // double-check(同样在 accounts 读锁下) { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } let tokens = self.access_tokens.read().await; if let Some(cached) = tokens.get(account_id) { if !cached.is_expiring_soon() { - return Ok(cached.token.clone()); + return Ok(cached.clone()); } } } @@ -451,34 +794,67 @@ impl CodexOAuthManager { let new_tokens = self.refresh_with_token(&refresh_token).await?; - if let Some(new_refresh) = new_tokens.refresh_token.clone() { - if new_refresh != refresh_token { - let mut accounts = self.accounts.write().await; - if let Some(account) = accounts.get_mut(account_id) { - account.refresh_token = new_refresh; + let obtained_at_ms = chrono::Utc::now().timestamp_millis(); + + // 如果服务端返回了新的 refresh_token 或 id_token,更新存储 + let mut needs_save = false; + { + let mut accounts = self.accounts.write().await; + let account = accounts + .get_mut(account_id) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?; + // Device re-login and CLI-token adoption use the same account lock, + // but keep a generation CAS here as defense in depth: a response for + // R0 must never overwrite a newly committed R1/N0 chain. + if account.refresh_token != refresh_token { + return Err(CodexOAuthError::TokenFetchFailed( + "账号凭据已更新,已丢弃旧刷新响应".to_string(), + )); + } + if account.apply_refreshed_tokens(&new_tokens) { + needs_save = true; + } + // 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失, + // 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。 + if let Some(new_id_token) = new_tokens + .id_token + .clone() + .filter(|token| !token.trim().is_empty()) + { + if account.id_token.as_deref() != Some(new_id_token.as_str()) { + account.id_token = Some(new_id_token); + needs_save = true; } - drop(accounts); - self.save_to_disk().await?; } + if account.token_updated_at_ms != obtained_at_ms { + account.token_updated_at_ms = obtained_at_ms; + needs_save = true; + } + }; + if needs_save { + self.save_to_disk().await?; } + let cached = CachedAccessToken { + token: new_tokens.access_token.clone(), + expires_at_ms: compute_expires_at_ms(new_tokens.expires_in), + obtained_at_ms, + }; - let access_token = new_tokens.access_token.clone(); - let expires_at_ms = compute_expires_at_ms(new_tokens.expires_in); - + // 在 accounts 读锁下确认账号仍存在,再写缓存:与 remove/clear(持 accounts + // 写锁并原子清缓存)互斥,杜绝把已删账号的 token 写回缓存。 { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } let mut tokens = self.access_tokens.write().await; - tokens.insert( - account_id.to_string(), - CachedAccessToken { - token: access_token.clone(), - expires_at_ms, - }, - ); + tokens.insert(account_id.to_string(), cached.clone()); } - Ok(access_token) + Ok(cached) } + /// 获取默认账号的有效 token pub async fn get_valid_token(&self) -> Result { match self.resolve_default_account_id().await { Some(id) => self.get_valid_token_for_account(&id).await, @@ -488,11 +864,27 @@ impl CodexOAuthManager { } } + /// 获取默认账号 ID(热路径使用,避免克隆整个账号 HashMap) pub async fn default_account_id(&self) -> Option { self.resolve_default_account_id().await } - #[allow(dead_code)] + /// 将本地账号 ID 解析为上游 ChatGPT workspace ID。 + pub async fn chatgpt_account_id_for_account( + &self, + account_id: &str, + ) -> Result { + let accounts = self.accounts.read().await; + let account = accounts + .get(account_id) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?; + account.chatgpt_account_id.clone().ok_or_else(|| { + CodexOAuthError::ParseError("账号缺少 chatgpt_account_id,请重新认证".to_string()) + }) + } + + // ==================== 多账号管理 ==================== + pub async fn list_accounts(&self) -> Vec { let accounts = self.accounts.read().await.clone(); let default_id = self.resolve_default_account_id().await; @@ -500,15 +892,30 @@ impl CodexOAuthManager { } pub async fn remove_account(&self, account_id: &str) -> Result<(), CodexOAuthError> { + log::info!("[CodexOAuth] 移除账号: {account_id}"); + // Wait for all in-flight refresh/adopt operations before deleting. New + // token work is blocked until the account, cache, lock and disk state + // have been removed as one lifecycle transition. + let _lifecycle = self.lifecycle_lock.write().await; + + if !self.accounts.read().await.contains_key(account_id) { + return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); + } { + // 在 accounts 写锁内原子清除该账号的 token 缓存(accounts -> access_tokens + // 顺序),确保不存在「账号已删但缓存仍在」的窗口。 let mut accounts = self.accounts.write().await; - if accounts.remove(account_id).is_none() { - return Err(CodexOAuthError::AccountNotFound(account_id.to_string())); - } + accounts.remove(account_id); + self.access_tokens.write().await.remove(account_id); } - - self.access_tokens.write().await.remove(account_id); - self.refresh_locks.write().await.remove(account_id); + { + let mut locks = self.refresh_locks.write().await; + locks.remove(account_id); + } + self.target_login_generations + .write() + .await + .remove(account_id); { let accounts = self.accounts.read().await; @@ -523,6 +930,7 @@ impl CodexOAuthManager { } pub async fn set_default_account(&self, account_id: &str) -> Result<(), CodexOAuthError> { + let _lifecycle = self.lifecycle_lock.read().await; { let accounts = self.accounts.read().await; if !accounts.contains_key(account_id) { @@ -530,17 +938,49 @@ impl CodexOAuthManager { } } - *self.default_account_id.write().await = Some(account_id.to_string()); + { + let mut default = self.default_account_id.write().await; + *default = Some(account_id.to_string()); + } + self.save_to_disk().await?; Ok(()) } pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> { - self.accounts.write().await.clear(); - *self.default_account_id.write().await = None; - self.access_tokens.write().await.clear(); - self.refresh_locks.write().await.clear(); - self.pending_device_codes.write().await.clear(); + log::info!("[CodexOAuth] 清除所有认证"); + + // Acquire lifecycle before storage. Refresh follows lifecycle(read) -> + // account mutex -> storage, so this fixed order cannot deadlock and the + // write guard guarantees no refresh can recreate live/disk state after + // the clear has committed. + let _lifecycle = self.lifecycle_lock.write().await; + + // 与 save_to_disk 共用持久化锁:确保「清内存 + 删文件」相对于并发保存原子, + // 不会被一个持有旧快照的 save 复活已清除的账号。 + let _persist = self.storage_lock.lock().await; + + { + // 在 accounts 写锁内原子清除 accounts 与 token 缓存(accounts -> + // access_tokens 顺序),杜绝「账号已清但缓存仍在」及并发 refresh 回填。 + let mut accounts = self.accounts.write().await; + accounts.clear(); + self.access_tokens.write().await.clear(); + } + { + let mut default = self.default_account_id.write().await; + *default = None; + } + { + let mut locks = self.refresh_locks.write().await; + locks.clear(); + } + self.target_login_generations.write().await.clear(); + { + let mut pending = self.pending_device_codes.write().await; + self.login_epoch.fetch_add(1, Ordering::AcqRel); + pending.clear(); + } if self.storage_path.exists() { std::fs::remove_file(&self.storage_path)?; @@ -549,11 +989,12 @@ impl CodexOAuthManager { Ok(()) } - #[allow(dead_code)] pub async fn is_authenticated(&self) -> bool { - !self.accounts.read().await.is_empty() + let accounts = self.accounts.read().await; + !accounts.is_empty() } + /// 获取认证状态摘要(与 Copilot 的格式保持一致,便于复用前端) pub async fn get_status(&self) -> CodexOAuthStatus { let accounts_map = self.accounts.read().await.clone(); let default_id = self.resolve_default_account_id().await; @@ -573,36 +1014,343 @@ impl CodexOAuthManager { } } - async fn add_account_internal( + #[cfg(test)] + pub(crate) async fn add_test_account_with_access_token( &self, - account_id: String, - refresh_token: String, - email: Option, - ) -> Result { - let now = chrono::Utc::now().timestamp(); - let data = CodexAccountData { - account_id: account_id.clone(), - email, - refresh_token, - authenticated_at: now, - }; - let account = ManagedAuthAccount::from(&data); - - self.accounts.write().await.insert(account_id.clone(), data); - { - let mut default = self.default_account_id.write().await; - if default.is_none() { - *default = Some(account_id); - } - } + account_id: &str, + access_token: &str, + id_token: Option<&str>, + ) -> Result<(), CodexOAuthError> { + self.add_test_account_with_workspace_and_access_token( + account_id, + account_id, + access_token, + id_token, + ) + .await + } - self.save_to_disk().await?; - Ok(account) + #[cfg(test)] + pub(crate) async fn add_test_account_with_user_identity( + &self, + account_id: &str, + access_token: &str, + subject: &str, + ) -> Result<(), CodexOAuthError> { + let id_token = crate::codex_config::test_codex_id_token(subject); + self.add_test_account_with_access_token(account_id, access_token, Some(&id_token)) + .await } - fn fallback_default_account_id(accounts: &HashMap) -> Option { - accounts - .iter() + #[cfg(test)] + pub(crate) async fn add_test_account_with_workspace_and_access_token( + &self, + account_id: &str, + chatgpt_account_id: &str, + access_token: &str, + id_token: Option<&str>, + ) -> Result<(), CodexOAuthError> { + let obtained_at_ms = chrono::Utc::now().timestamp_millis(); + let data = CodexAccountData { + account_id: account_id.to_string(), + chatgpt_account_id: Some(chatgpt_account_id.to_string()), + email: Some(format!("{account_id}@example.test")), + refresh_token: "test-refresh-token".to_string(), + authenticated_at: chrono::Utc::now().timestamp(), + id_token: id_token.map(|token| token.to_string()), + token_updated_at_ms: obtained_at_ms, + }; + { + let mut accounts = self.accounts.write().await; + accounts.insert(account_id.to_string(), data); + self.access_tokens.write().await.insert( + account_id.to_string(), + CachedAccessToken { + token: access_token.to_string(), + expires_at_ms: obtained_at_ms + 3_600_000, + obtained_at_ms, + }, + ); + } + { + let mut default = self.default_account_id.write().await; + if default.is_none() { + *default = Some(account_id.to_string()); + } + } + self.save_to_disk().await?; + + Ok(()) + } + + #[cfg(test)] + pub(crate) async fn test_cache_access_token(&self, account_id: &str, token: &str) { + assert!(self.accounts.read().await.contains_key(account_id)); + let now = chrono::Utc::now().timestamp_millis(); + self.access_tokens.write().await.insert( + account_id.to_string(), + CachedAccessToken { + token: token.to_string(), + expires_at_ms: now + 3_600_000, + obtained_at_ms: now, + }, + ); + } + + #[cfg(test)] + pub(crate) async fn test_refresh_token_for_account(&self, account_id: &str) -> Option { + self.accounts + .read() + .await + .get(account_id) + .map(|account| account.refresh_token.clone()) + } + + #[cfg(test)] + pub(crate) async fn test_set_token_updated_at_ms( + &self, + account_id: &str, + token_updated_at_ms: i64, + ) { + self.accounts + .write() + .await + .get_mut(account_id) + .expect("test account present") + .token_updated_at_ms = token_updated_at_ms; + } + + #[cfg(test)] + pub(crate) async fn seed_account_for_tests( + &self, + account_id: &str, + refresh_token: &str, + email: Option<&str>, + access_token: Option<&str>, + expires_at_ms: Option, + ) -> Result<(), CodexOAuthError> { + let id_token = crate::codex_config::test_codex_id_token(account_id); + self.add_test_account_with_access_token( + account_id, + access_token.unwrap_or("test-token"), + Some(&id_token), + ) + .await?; + { + let mut accounts = self.accounts.write().await; + let account = accounts.get_mut(account_id).unwrap(); + account.refresh_token = refresh_token.to_string(); + account.email = email.map(str::to_string); + } + let mut tokens = self.access_tokens.write().await; + if access_token.is_none() { + tokens.remove(account_id); + } else if let Some(expires) = expires_at_ms { + tokens.get_mut(account_id).unwrap().expires_at_ms = expires; + } + drop(tokens); + self.save_to_disk().await + } + + // ==================== 内部方法 ==================== + + async fn add_account_internal( + &self, + chatgpt_account_id: String, + refresh_token: String, + email: Option, + id_token: Option, + initial_access_token: Option, + context: AccountLoginContext<'_>, + ) -> Result { + let _lifecycle = self.lifecycle_lock.read().await; + let target_account_id = context + .target_account_id + .map(str::trim) + .filter(|account_id| !account_id.is_empty()) + .map(str::to_string); + let replacing_existing = target_account_id.is_some(); + let account_id = target_account_id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let refresh_lock = if replacing_existing { + Some(self.get_refresh_lock(&account_id).await) + } else { + None + }; + let _refresh_guard = match refresh_lock.as_ref() { + Some(lock) => Some(lock.lock().await), + None => None, + }; + let now = chrono::Utc::now().timestamp(); + let now_ms = chrono::Utc::now().timestamp_millis(); + + if replacing_existing { + let accounts = self.accounts.read().await; + let existing = accounts + .get(&account_id) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.clone()))?; + let expected_workspace = existing + .chatgpt_account_id + .as_deref() + .unwrap_or(existing.account_id.as_str()); + if expected_workspace != chatgpt_account_id { + return Err(CodexOAuthError::TokenFetchFailed(format!( + "重新登录的 ChatGPT workspace 与账号 {account_id} 不一致" + ))); + } + + let new_id_token = id_token.as_deref().ok_or_else(|| { + CodexOAuthError::TokenFetchFailed( + "重新登录未返回 id_token,原账号保持不变".to_string(), + ) + })?; + let existing_subject = existing + .id_token + .as_deref() + .and_then(crate::codex_config::extract_codex_id_token_subject); + let new_subject = crate::codex_config::extract_codex_id_token_subject(new_id_token); + let user_identity_matches = match existing_subject.as_deref() { + Some(existing) if new_subject.as_deref() == Some(existing) => true, + Some(_) => { + return Err(CodexOAuthError::TokenFetchFailed(format!( + "重新登录的 ChatGPT 用户与账号 {account_id} 不一致" + ))); + } + None => { + let existing_email = existing + .email + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let new_email = email + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + matches!( + (existing_email, new_email), + (Some(existing), Some(new)) if existing.eq_ignore_ascii_case(new) + ) + } + }; + if !user_identity_matches { + return Err(CodexOAuthError::TokenFetchFailed(format!( + "无法确认重新登录的 ChatGPT 用户属于账号 {account_id},原账号保持不变" + ))); + } + } + + let data = CodexAccountData { + account_id: account_id.clone(), + chatgpt_account_id: Some(chatgpt_account_id), + email, + refresh_token, + authenticated_at: now, + id_token, + token_updated_at_ms: now_ms, + }; + + let account = ManagedAuthAccount::from(&data); + + // Linearize cancel/newer-flow against the actual commit, after waiting + // for the account lock. Holding both guards through persistence means + // cancellation cannot report success after this point, while a cancel + // or newer generation that won the race makes this flow fail closed. + let _pending_commit_guard = if let Some(device_code) = context.pending_device_code { + let generations = self.target_login_generations.read().await; + let mut pending_codes = self.pending_device_codes.write().await; + let pending = pending_codes + .get(device_code) + .ok_or(CodexOAuthError::ExpiredToken)?; + let generation_matches = match ( + pending.target_account_id.as_deref(), + pending.target_generation, + ) { + (Some(account_id), Some(generation)) => { + context.target_account_id == Some(account_id) + && context.target_generation == Some(generation) + && generations.get(account_id) == Some(&generation) + } + (None, None) => { + context.target_account_id.is_none() && context.target_generation.is_none() + } + _ => false, + }; + if pending.expires_at_ms <= chrono::Utc::now().timestamp_millis() || !generation_matches + { + return Err(CodexOAuthError::ExpiredToken); + } + pending_codes.remove(device_code); + Some((generations, pending_codes)) + } else { + None + }; + + // Persist a prospective snapshot before publishing new credentials to + // readers. A failed atomic write therefore leaves the target account + // and its access-token cache untouched. + let _persist = self.storage_lock.lock().await; + let mut persisted_accounts = self.accounts.read().await.clone(); + let new_identity = data + .id_token + .as_deref() + .and_then(crate::codex_config::extract_codex_id_token_user_identity); + let duplicate_exists = new_identity.as_deref().is_some_and(|new_identity| { + persisted_accounts + .iter() + .filter(|(existing_id, _)| existing_id.as_str() != account_id.as_str()) + .any(|(_, existing)| { + existing + .chatgpt_account_id + .as_deref() + .unwrap_or(existing.account_id.as_str()) + == data + .chatgpt_account_id + .as_deref() + .unwrap_or(data.account_id.as_str()) + && existing + .id_token + .as_deref() + .and_then(crate::codex_config::extract_codex_id_token_user_identity) + .as_deref() + == Some(new_identity) + }) + }); + if duplicate_exists { + return Err(CodexOAuthError::DuplicateAccount); + } + persisted_accounts.insert(account_id.clone(), data.clone()); + let persisted_default = self + .resolve_default_account_id() + .await + .or_else(|| Some(account_id.clone())); + let store = CodexOAuthStore { + version: 2, + accounts: persisted_accounts, + default_account_id: persisted_default, + }; + let content = serde_json::to_string_pretty(&store) + .map_err(|error| CodexOAuthError::ParseError(error.to_string()))?; + self.write_store_atomic(&content)?; + + { + let mut accounts = self.accounts.write().await; + accounts.insert(account_id.clone(), data); + let mut access_tokens = self.access_tokens.write().await; + if let Some(cached) = initial_access_token { + access_tokens.insert(account_id.clone(), cached); + } else { + access_tokens.remove(&account_id); + } + } + let mut default = self.default_account_id.write().await; + if default.is_none() { + *default = Some(account_id); + } + Ok(account) + } + + fn fallback_default_account_id(accounts: &HashMap) -> Option { + accounts + .iter() .max_by(|(id_a, a), (id_b, b)| { a.authenticated_at .cmp(&b.authenticated_at) @@ -631,27 +1379,29 @@ impl CodexOAuthManager { async fn resolve_default_account_id(&self) -> Option { let stored = self.default_account_id.read().await.clone(); let accounts = self.accounts.read().await; + if let Some(id) = stored { if accounts.contains_key(&id) { return Some(id); } } + Self::fallback_default_account_id(&accounts) } - async fn get_refresh_lock(&self, account_id: &str) -> std::sync::Arc> { + async fn get_refresh_lock(&self, account_id: &str) -> Arc> { { let locks = self.refresh_locks.read().await; if let Some(lock) = locks.get(account_id) { - return std::sync::Arc::clone(lock); + return Arc::clone(lock); } } let mut locks = self.refresh_locks.write().await; - std::sync::Arc::clone( + Arc::clone( locks .entry(account_id.to_string()) - .or_insert_with(|| std::sync::Arc::new(Mutex::new(()))), + .or_insert_with(|| Arc::new(Mutex::new(()))), ) } @@ -679,6 +1429,7 @@ impl CodexOAuthManager { #[cfg(unix)] { use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = fs::OpenOptions::new() .create_new(true) .write(true) @@ -686,6 +1437,7 @@ impl CodexOAuthManager { .open(&tmp_path)?; file.write_all(content.as_bytes())?; file.flush()?; + fs::rename(&tmp_path, &self.storage_path)?; fs::set_permissions(&self.storage_path, fs::Permissions::from_mode(0o600))?; } @@ -698,6 +1450,7 @@ impl CodexOAuthManager { .open(&tmp_path)?; file.write_all(content.as_bytes())?; file.flush()?; + if self.storage_path.exists() { let _ = fs::remove_file(&self.storage_path); } @@ -718,6 +1471,7 @@ impl CodexOAuthManager { if let Ok(mut accounts) = self.accounts.try_write() { *accounts = store.accounts; + log::info!("[CodexOAuth] 从磁盘加载 {} 个账号", accounts.len()); } if let Ok(mut default) = self.default_account_id.try_write() { *default = store.default_account_id; @@ -732,51 +1486,46 @@ impl CodexOAuthManager { } async fn save_to_disk(&self) -> Result<(), CodexOAuthError> { + // 串行化「快照 + 写盘」:在持久化锁内取快照,确保并发保存/清除不会用 + // 陈旧快照覆盖,避免已删账号被复活。 + let _persist = self.storage_lock.lock().await; let accounts = self.accounts.read().await.clone(); let default = self.resolve_default_account_id().await; + let store = CodexOAuthStore { - version: 1, + version: 2, accounts, default_account_id: default, }; let content = serde_json::to_string_pretty(&store) .map_err(|e| CodexOAuthError::ParseError(e.to_string()))?; - self.write_store_atomic(&content)?; - Ok(()) - } - #[cfg(test)] - pub(crate) async fn seed_account_for_tests( - &self, - account_id: &str, - refresh_token: &str, - email: Option<&str>, - access_token: Option<&str>, - expires_at_ms: Option, - ) -> Result<(), CodexOAuthError> { - self.add_account_internal( - account_id.to_string(), - refresh_token.to_string(), - email.map(str::to_string), - ) - .await?; + self.write_store_atomic(&content)?; - if let Some(access_token) = access_token { - self.access_tokens.write().await.insert( - account_id.to_string(), - CachedAccessToken { - token: access_token.to_string(), - expires_at_ms: expires_at_ms - .unwrap_or_else(|| chrono::Utc::now().timestamp_millis() + 3_600_000), - }, - ); - } + log::info!( + "[CodexOAuth] 保存到磁盘成功({} 个账号)", + store.accounts.len() + ); Ok(()) } } +/// Codex OAuth 状态摘要 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexOAuthStatus { + pub accounts: Vec, + pub default_account_id: Option, + pub authenticated: bool, + pub username: Option, +} + +// ==================== 工具函数 ==================== + +/// 解析 OpenAI Device Code 响应中的 interval 字段 +/// +/// 服务端可能返回字符串或数字,需要兼容 fn parse_interval(value: Option<&serde_json::Value>) -> u64 { let raw = match value { Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or(5), @@ -786,12 +1535,27 @@ fn parse_interval(value: Option<&serde_json::Value>) -> u64 { raw.max(1) + POLLING_SAFETY_MARGIN_SECS } +/// 从 expires_in(秒)计算过期时间戳(毫秒) fn compute_expires_at_ms(expires_in: Option) -> i64 { let now_ms = chrono::Utc::now().timestamp_millis(); let secs = expires_in.unwrap_or(3600); now_ms + secs * 1000 } +fn extract_refresh_error_code(body: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(body).ok()?; + value + .get("error") + .and_then(|error| match error { + serde_json::Value::Object(object) => object.get("code").and_then(|code| code.as_str()), + serde_json::Value::String(code) => Some(code.as_str()), + _ => None, + }) + .or_else(|| value.get("code").and_then(|code| code.as_str())) + .map(|code| code.to_ascii_lowercase()) +} + +/// 解析 JWT 中的 claims fn parse_jwt_claims(token: &str) -> Option { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { @@ -801,38 +1565,33 @@ fn parse_jwt_claims(token: &str) -> Option { serde_json::from_slice(&decoded).ok() } -fn extract_identity_from_tokens(tokens: &OAuthTokenResponse) -> (Option, Option) { +/// 从 token 响应中提取 (chatgpt_account_id, email) +fn extract_account_metadata_from_tokens( + tokens: &OAuthTokenResponse, +) -> (Option, Option) { let mut account_id: Option = None; let mut email: Option = None; if let Some(id_token) = tokens.id_token.as_deref() { if let Some(claims) = parse_jwt_claims(id_token) { - account_id = claims - .chatgpt_account_id - .clone() - .or_else(|| { - claims - .openai_auth - .as_ref() - .and_then(|a| a.chatgpt_account_id.clone()) - }) - .or_else(|| claims.organizations.first().and_then(|o| o.id.clone())); + account_id = claims.chatgpt_account_id.clone().or_else(|| { + claims + .openai_auth + .as_ref() + .and_then(|a| a.chatgpt_account_id.clone()) + }); email = claims.email.clone(); } } if account_id.is_none() { if let Some(claims) = parse_jwt_claims(&tokens.access_token) { - account_id = claims - .chatgpt_account_id - .clone() - .or_else(|| { - claims - .openai_auth - .as_ref() - .and_then(|a| a.chatgpt_account_id.clone()) - }) - .or_else(|| claims.organizations.first().and_then(|o| o.id.clone())); + account_id = claims.chatgpt_account_id.clone().or_else(|| { + claims + .openai_auth + .as_ref() + .and_then(|a| a.chatgpt_account_id.clone()) + }); if email.is_none() { email = claims.email.clone(); } @@ -858,9 +1617,87 @@ mod tests { assert_eq!(parse_interval(Some(&v)), 10 + POLLING_SAFETY_MARGIN_SECS); } + #[test] + fn test_parse_interval_default() { + assert_eq!(parse_interval(None), 5 + POLLING_SAFETY_MARGIN_SECS); + } + + #[test] + fn test_parse_interval_min() { + let v = serde_json::Value::Number(serde_json::Number::from(0)); + // 0 应被提升到 1 + assert_eq!(parse_interval(Some(&v)), 1 + POLLING_SAFETY_MARGIN_SECS); + } + + #[test] + fn test_compute_expires_at_ms() { + let result = compute_expires_at_ms(Some(3600)); + let now = chrono::Utc::now().timestamp_millis(); + // 应在未来约 3600 秒处(允许少量误差) + assert!(result > now + 3500 * 1000); + assert!(result < now + 3700 * 1000); + } + + #[test] + fn test_compute_expires_at_ms_default() { + let result = compute_expires_at_ms(None); + let now = chrono::Utc::now().timestamp_millis(); + assert!(result > now); + } + + #[test] + fn test_cached_token_expiring_soon() { + let now = chrono::Utc::now().timestamp_millis(); + // 30 秒后过期 - 在缓冲期内 + let expiring = CachedAccessToken { + token: "t".to_string(), + expires_at_ms: now + 30_000, + obtained_at_ms: now, + }; + assert!(expiring.is_expiring_soon()); + + // 1 小时后过期 - 不在缓冲期内 + let valid = CachedAccessToken { + token: "t".to_string(), + expires_at_ms: now + 3_600_000, + obtained_at_ms: now, + }; + assert!(!valid.is_expiring_soon()); + } + #[test] fn test_parse_jwt_claims_invalid() { assert!(parse_jwt_claims("not-a-jwt").is_none()); + assert!(parse_jwt_claims("only.two").is_none()); + } + + #[test] + fn test_parse_jwt_claims_valid() { + // Header: {"alg":"none"} + // Payload: {"chatgpt_account_id":"acc-123","email":"test@example.com"} + // Signature: empty + let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}"); + let payload = URL_SAFE_NO_PAD + .encode(b"{\"chatgpt_account_id\":\"acc-123\",\"email\":\"test@example.com\"}"); + let jwt = format!("{header}.{payload}."); + let claims = parse_jwt_claims(&jwt).unwrap(); + assert_eq!(claims.chatgpt_account_id.as_deref(), Some("acc-123")); + assert_eq!(claims.email.as_deref(), Some("test@example.com")); + } + + #[test] + fn test_extract_account_metadata_does_not_use_organization_id() { + let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}"); + let payload = URL_SAFE_NO_PAD.encode(b"{\"organizations\":[{\"id\":\"org-456\"}]}"); + let jwt = format!("{header}.{payload}."); + let tokens = OAuthTokenResponse { + access_token: jwt, + refresh_token: None, + id_token: None, + expires_in: None, + }; + + assert_eq!(extract_account_metadata_from_tokens(&tokens), (None, None)); } #[tokio::test] @@ -875,70 +1712,886 @@ mod tests { async fn test_manager_save_and_load() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().to_path_buf(); - { + + // Manually inject an account through internal methods + let account_id = { let manager = CodexOAuthManager::new(path.clone()); - manager + let account = manager .add_account_internal( - "acc-123".to_string(), + "workspace-123".to_string(), "rt-secret".to_string(), Some("user@example.com".to_string()), + None, + None, + AccountLoginContext::default(), ) .await .unwrap(); - } + account.id + }; + + // New manager should load from disk let manager2 = CodexOAuthManager::new(path); let accounts = manager2.list_accounts().await; assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, "acc-123"); + assert_eq!(accounts[0].id, account_id); + assert_eq!( + manager2 + .chatgpt_account_id_for_account(&account_id) + .await + .unwrap(), + "workspace-123" + ); } #[tokio::test] - async fn test_remove_account_rehomes_default_account() { + async fn test_same_workspace_accounts_are_stored_separately() { let temp = tempfile::tempdir().unwrap(); - let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + let path = temp.path().to_path_buf(); + let manager = CodexOAuthManager::new(path.clone()); + + let first = manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-first".to_string(), + Some("first@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("user-a")), + None, + AccountLoginContext::default(), + ) + .await + .unwrap(); + let second = manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-second".to_string(), + Some("second@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("user-b")), + None, + AccountLoginContext::default(), + ) + .await + .unwrap(); + + assert_ne!(first.id, second.id); + assert_eq!(manager.list_accounts().await.len(), 2); + assert_eq!( + manager + .chatgpt_account_id_for_account(&first.id) + .await + .unwrap(), + "shared-workspace" + ); + assert_eq!( + manager + .chatgpt_account_id_for_account(&second.id) + .await + .unwrap(), + "shared-workspace" + ); + + let accounts = manager.accounts.read().await; + assert_eq!(accounts.get(&first.id).unwrap().refresh_token, "rt-first"); + assert_eq!(accounts.get(&second.id).unwrap().refresh_token, "rt-second"); + drop(accounts); + drop(manager); + + let manager = CodexOAuthManager::new(path); + assert_eq!(manager.list_accounts().await.len(), 2); + let accounts = manager.accounts.read().await; + assert_eq!(accounts.get(&first.id).unwrap().refresh_token, "rt-first"); + assert_eq!(accounts.get(&second.id).unwrap().refresh_token, "rt-second"); + } + + #[tokio::test] + async fn concurrent_duplicate_workspace_and_user_identity_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().to_path_buf(); + let manager = CodexOAuthManager::new(path.clone()); + let first_token = crate::codex_config::test_codex_id_token("same-user"); + let second_token = first_token.clone(); + + let first = manager.add_account_internal( + "shared-workspace".to_string(), + "rt-first".to_string(), + Some("first@example.com".to_string()), + Some(first_token), + None, + AccountLoginContext::default(), + ); + let second = manager.add_account_internal( + "shared-workspace".to_string(), + "rt-second".to_string(), + Some("second@example.com".to_string()), + Some(second_token), + None, + AccountLoginContext::default(), + ); + let (first_result, second_result) = tokio::join!(first, second); + + assert_eq!( + [first_result.is_ok(), second_result.is_ok()] + .into_iter() + .filter(|success| *success) + .count(), + 1 + ); + assert!( + matches!(first_result, Err(CodexOAuthError::DuplicateAccount)) + || matches!(second_result, Err(CodexOAuthError::DuplicateAccount)) + ); + assert_eq!(manager.list_accounts().await.len(), 1); + assert!(manager.refresh_locks.read().await.is_empty()); + drop(manager); + + let reloaded = CodexOAuthManager::new(path); + assert_eq!(reloaded.list_accounts().await.len(), 1); + } + #[tokio::test] + async fn duplicate_legacy_workspace_and_user_identity_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().to_path_buf(); + let manager = CodexOAuthManager::new(path.clone()); + let id_token = crate::codex_config::test_codex_id_token("same-user"); + let legacy = serde_json::json!({ + "version": 1, + "accounts": { + "shared-workspace": { + "account_id": "shared-workspace", + "email": "legacy@example.com", + "refresh_token": "rt-legacy", + "id_token": id_token, + "authenticated_at": 1 + } + }, + "default_account_id": "shared-workspace" + }); manager - .seed_account_for_tests("acc-123", "rt-1", Some("a@example.com"), Some("at-1"), None) + .write_store_atomic(&serde_json::to_string(&legacy).unwrap()) + .unwrap(); + drop(manager); + + let manager = CodexOAuthManager::new(path); + assert!(matches!( + manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-duplicate".to_string(), + Some("current@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("same-user")), + None, + AccountLoginContext::default(), + ) + .await, + Err(CodexOAuthError::DuplicateAccount) + )); + assert_eq!(manager.list_accounts().await.len(), 1); + assert!(manager.refresh_locks.read().await.is_empty()); + } + + #[tokio::test] + async fn targeted_reauth_rechecks_duplicates_outside_the_target() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + let target = manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-legacy".to_string(), + Some("legacy@example.com".to_string()), + None, + None, + AccountLoginContext::default(), + ) + .await + .unwrap(); + let id_token = crate::codex_config::test_codex_id_token("same-user"); + manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-current".to_string(), + Some("current@example.com".to_string()), + Some(id_token.clone()), + None, + AccountLoginContext::default(), + ) + .await + .unwrap(); + + assert!(matches!( + manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-targeted".to_string(), + Some("legacy@example.com".to_string()), + Some(id_token), + None, + AccountLoginContext { + target_account_id: Some(&target.id), + ..Default::default() + }, + ) + .await, + Err(CodexOAuthError::DuplicateAccount) + )); + assert_eq!( + manager + .accounts + .read() + .await + .get(&target.id) + .expect("target account remains") + .refresh_token, + "rt-legacy" + ); + } + + #[tokio::test] + async fn targeted_reauth_updates_account_in_place_and_preserves_binding_id() { + let temp = tempfile::tempdir().unwrap(); + let mut manager = CodexOAuthManager::new(temp.path().to_path_buf()); + let user_a_id_token = crate::codex_config::test_codex_id_token("user-a"); + let existing = manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-old".to_string(), + Some("user@example.com".to_string()), + None, + None, + AccountLoginContext::default(), + ) + .await + .unwrap(); + + let refreshed = manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-new".to_string(), + Some("user@example.com".to_string()), + Some(user_a_id_token.clone()), + Some(CachedAccessToken { + token: "access-new".to_string(), + expires_at_ms: i64::MAX, + obtained_at_ms: 42, + }), + AccountLoginContext { + target_account_id: Some(&existing.id), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(refreshed.id, existing.id); + assert_eq!(manager.list_accounts().await.len(), 1); + let accounts = manager.accounts.read().await; + let account = accounts.get(&existing.id).unwrap(); + assert_eq!(account.refresh_token, "rt-new"); + assert_eq!(account.id_token.as_deref(), Some(user_a_id_token.as_str())); + drop(accounts); + assert_eq!( + manager + .access_tokens + .read() + .await + .get(&existing.id) + .map(|token| token.token.as_str()), + Some("access-new") + ); + + let mismatch = manager + .add_account_internal( + "different-workspace".to_string(), + "rt-wrong".to_string(), + None, + Some("id-wrong".to_string()), + None, + AccountLoginContext { + target_account_id: Some(&existing.id), + ..Default::default() + }, + ) + .await; + assert!(matches!( + mismatch, + Err(CodexOAuthError::TokenFetchFailed(_)) + )); + assert_eq!( + manager + .accounts + .read() + .await + .get(&existing.id) + .unwrap() + .refresh_token, + "rt-new" + ); + + let other_user = manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-other-user".to_string(), + Some("user@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("user-b")), + None, + AccountLoginContext { + target_account_id: Some(&existing.id), + ..Default::default() + }, + ) + .await; + assert!(matches!( + other_user, + Err(CodexOAuthError::TokenFetchFailed(_)) + )); + assert_eq!( + manager + .accounts + .read() + .await + .get(&existing.id) + .unwrap() + .refresh_token, + "rt-new" + ); + + // Make the configured store path unwritable as a file. Reauth must not + // publish the new generation in memory when the atomic write fails. + manager.storage_path = temp.path().to_path_buf(); + let write_failure = manager + .add_account_internal( + "shared-workspace".to_string(), + "rt-uncommitted".to_string(), + Some("user@example.com".to_string()), + Some(user_a_id_token), + Some(CachedAccessToken { + token: "access-uncommitted".to_string(), + expires_at_ms: i64::MAX, + obtained_at_ms: 43, + }), + AccountLoginContext { + target_account_id: Some(&existing.id), + ..Default::default() + }, + ) + .await; + assert!(matches!(write_failure, Err(CodexOAuthError::IoError(_)))); + assert_eq!( + manager + .accounts + .read() + .await + .get(&existing.id) + .unwrap() + .refresh_token, + "rt-new" + ); + assert_eq!( + manager + .access_tokens + .read() + .await + .get(&existing.id) + .map(|token| token.token.as_str()), + Some("access-new") + ); + } + + #[tokio::test] + async fn targeted_reauth_accepts_email_change_for_same_subject() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + let id_token = crate::codex_config::test_codex_id_token("stable-user"); + let existing = manager + .add_account_internal( + "workspace".to_string(), + "refresh-old".to_string(), + Some("old@example.com".to_string()), + Some(id_token.clone()), + None, + AccountLoginContext::default(), + ) .await .unwrap(); + manager - .seed_account_for_tests("acc-456", "rt-2", Some("b@example.com"), Some("at-2"), None) + .add_account_internal( + "workspace".to_string(), + "refresh-new".to_string(), + Some("new@example.com".to_string()), + Some(id_token), + None, + AccountLoginContext { + target_account_id: Some(&existing.id), + ..Default::default() + }, + ) .await .unwrap(); - manager.set_default_account("acc-123").await.unwrap(); - manager.remove_account("acc-123").await.unwrap(); + let accounts = manager.accounts.read().await; + let account = accounts.get(&existing.id).unwrap(); + assert_eq!(account.email.as_deref(), Some("new@example.com")); + assert_eq!(account.refresh_token, "refresh-new"); + } + #[tokio::test] + async fn test_v1_store_stays_quarantined_until_targeted_reauth() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().to_path_buf(); + let manager = CodexOAuthManager::new(path.clone()); + let legacy = serde_json::json!({ + "version": 1, + "accounts": { + "legacy-workspace": { + "account_id": "legacy-workspace", + "email": "legacy@example.com", + "refresh_token": "rt-legacy", + "id_token": "legacy-id-token", + "authenticated_at": 1 + } + }, + "default_account_id": "legacy-workspace" + }); + manager + .write_store_atomic(&serde_json::to_string(&legacy).unwrap()) + .unwrap(); + drop(manager); + + let manager = CodexOAuthManager::new(path.clone()); let accounts = manager.list_accounts().await; assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, "acc-456"); + assert_eq!(accounts[0].id, "legacy-workspace"); + assert!(accounts[0].reauth_required); + assert!(matches!( + manager + .chatgpt_account_id_for_account("legacy-workspace") + .await, + Err(CodexOAuthError::ParseError(_)) + )); + assert!(matches!( + manager + .get_valid_token_for_account("legacy-workspace") + .await, + Err(CodexOAuthError::ParseError(_)) + )); + + let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}"); + let unresolved_payload = + URL_SAFE_NO_PAD.encode(b"{\"organizations\":[{\"id\":\"org-456\"}]}"); + let unresolved_tokens = OAuthTokenResponse { + access_token: format!("{header}.{unresolved_payload}."), + refresh_token: Some("rt-rotated".to_string()), + id_token: None, + expires_in: Some(3600), + }; + { + let mut stored = manager.accounts.write().await; + assert!(stored + .get_mut("legacy-workspace") + .unwrap() + .apply_refreshed_tokens(&unresolved_tokens)); + } + manager.save_to_disk().await.unwrap(); + drop(manager); + + let manager = CodexOAuthManager::new(path.clone()); + assert!(manager + .chatgpt_account_id_for_account("legacy-workspace") + .await + .is_err()); + assert_eq!( + manager + .accounts + .read() + .await + .get("legacy-workspace") + .unwrap() + .refresh_token, + "rt-rotated" + ); + + let payload = URL_SAFE_NO_PAD.encode( + b"{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"actual-workspace\"}}", + ); + let refreshed_tokens = OAuthTokenResponse { + access_token: format!("{header}.{payload}."), + refresh_token: None, + id_token: None, + expires_in: Some(3600), + }; + { + let mut stored = manager.accounts.write().await; + assert!(!stored + .get_mut("legacy-workspace") + .unwrap() + .apply_refreshed_tokens(&refreshed_tokens)); + } + manager.save_to_disk().await.unwrap(); + drop(manager); + + let manager = CodexOAuthManager::new(path); assert_eq!( manager.default_account_id().await.as_deref(), - Some("acc-456") + Some("legacy-workspace") + ); + assert!(manager + .chatgpt_account_id_for_account("legacy-workspace") + .await + .is_err()); + assert!(manager.list_accounts().await[0].reauth_required); + assert_eq!( + manager + .accounts + .read() + .await + .get("legacy-workspace") + .unwrap() + .refresh_token, + "rt-rotated" + ); + + manager + .add_account_internal( + "legacy-workspace".to_string(), + "rt-reauthenticated".to_string(), + Some("legacy@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("legacy-user")), + None, + AccountLoginContext { + target_account_id: Some("legacy-workspace"), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + manager + .chatgpt_account_id_for_account("legacy-workspace") + .await + .unwrap(), + "legacy-workspace" ); + assert!(!manager.list_accounts().await[0].reauth_required); } #[tokio::test] - async fn test_set_default_account_reorders_status() { + async fn test_remove_account() { let temp = tempfile::tempdir().unwrap(); let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + let first = manager + .add_account_internal( + "acc-123".to_string(), + "rt".to_string(), + Some("a@example.com".to_string()), + None, + None, + AccountLoginContext::default(), + ) + .await + .unwrap(); + let second = manager + .add_account_internal( + "acc-456".to_string(), + "rt2".to_string(), + Some("b@example.com".to_string()), + None, + None, + AccountLoginContext::default(), + ) + .await + .unwrap(); + + manager.remove_account(&first.id).await.unwrap(); + let accounts = manager.list_accounts().await; + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, second.id); + } + + #[tokio::test] + async fn device_commit_rejects_flow_cleared_during_network_poll() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager.pending_device_codes.write().await.insert( + "device-auth-id".to_string(), + PendingDeviceCode { + user_code: "ABCD-EFGH".to_string(), + expires_at_ms: chrono::Utc::now().timestamp_millis() + 60_000, + target_account_id: None, + target_generation: None, + }, + ); + + manager.clear_auth().await.unwrap(); + let result = manager + .add_account_internal( + "acc-after-clear".to_string(), + "refresh-after-clear".to_string(), + None, + None, + None, + AccountLoginContext { + pending_device_code: Some("device-auth-id"), + ..Default::default() + }, + ) + .await; + + assert!(matches!(result, Err(CodexOAuthError::ExpiredToken))); + assert!(manager.list_accounts().await.is_empty()); + assert!(!manager.storage_path.exists()); + } + + #[tokio::test] + async fn device_start_rejects_flow_cleared_during_network_request() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + let login_epoch = manager.login_epoch.load(Ordering::Acquire); + + manager.clear_auth().await.unwrap(); + let result = manager + .register_pending_device_code( + "stale-device-auth-id".to_string(), + "ABCD-EFGH".to_string(), + chrono::Utc::now().timestamp_millis() + 60_000, + login_epoch, + None, + None, + ) + .await; + + assert!(matches!(result, Err(CodexOAuthError::ExpiredToken))); + assert!(manager.pending_device_codes.read().await.is_empty()); + } + + #[tokio::test] + async fn cancel_device_flow_invalidates_a_pending_commit() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager + .register_pending_device_code( + "cancelled-device-auth-id".to_string(), + "ABCD-EFGH".to_string(), + chrono::Utc::now().timestamp_millis() + 60_000, + manager.login_epoch.load(Ordering::Acquire), + Some("target-account".to_string()), + None, + ) + .await + .unwrap(); + + assert!(manager.cancel_device_flow("cancelled-device-auth-id").await); + + assert!(manager.pending_device_codes.read().await.is_empty()); + } + + #[tokio::test] + async fn cancel_device_flow_invalidates_commit_waiting_for_account_lock() { + let temp = tempfile::tempdir().unwrap(); + let manager = Arc::new(CodexOAuthManager::new(temp.path().to_path_buf())); + let existing = manager + .add_account_internal( + "shared-workspace".to_string(), + "refresh-original".to_string(), + Some("user@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("user-a")), + None, + AccountLoginContext::default(), + ) + .await + .unwrap(); manager - .seed_account_for_tests("acc-123", "rt-1", Some("a@example.com"), Some("at-1"), None) + .target_login_generations + .write() + .await + .insert(existing.id.clone(), 1); + manager + .register_pending_device_code( + "cancelled-waiting-flow".to_string(), + "ABCD-EFGH".to_string(), + chrono::Utc::now().timestamp_millis() + 60_000, + manager.login_epoch.load(Ordering::Acquire), + Some(existing.id.clone()), + Some(1), + ) + .await + .unwrap(); + + let refresh_lock = manager.get_refresh_lock(&existing.id).await; + let refresh_guard = refresh_lock.lock().await; + let commit_manager = Arc::clone(&manager); + let account_id = existing.id.clone(); + let commit = tokio::spawn(async move { + commit_manager + .add_account_internal( + "shared-workspace".to_string(), + "refresh-cancelled".to_string(), + Some("user@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("user-a")), + None, + AccountLoginContext { + target_account_id: Some(&account_id), + pending_device_code: Some("cancelled-waiting-flow"), + target_generation: Some(1), + }, + ) + .await + }); + + for _ in 0..100 { + if Arc::strong_count(&refresh_lock) >= 3 { + break; + } + tokio::task::yield_now().await; + } + assert!( + Arc::strong_count(&refresh_lock) >= 3, + "commit did not reach the account lock" + ); + + assert!(manager.cancel_device_flow("cancelled-waiting-flow").await); + drop(refresh_guard); + + let result = tokio::time::timeout(std::time::Duration::from_secs(1), commit) + .await + .expect("commit should finish") + .expect("commit task should not panic"); + assert!(matches!(result, Err(CodexOAuthError::ExpiredToken))); + assert_eq!( + manager + .accounts + .read() + .await + .get(&existing.id) + .unwrap() + .refresh_token, + "refresh-original" + ); + } + + #[tokio::test] + async fn only_latest_targeted_device_flow_can_commit() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + let existing = manager + .add_account_internal( + "shared-workspace".to_string(), + "refresh-original".to_string(), + Some("user@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("user-a")), + None, + AccountLoginContext::default(), + ) .await .unwrap(); + manager - .seed_account_for_tests("acc-456", "rt-2", Some("b@example.com"), Some("at-2"), None) + .target_login_generations + .write() + .await + .insert(existing.id.clone(), 2); + for (device_code, generation) in [("stale-flow", 1), ("latest-flow", 2)] { + manager.pending_device_codes.write().await.insert( + device_code.to_string(), + PendingDeviceCode { + user_code: "ABCD-EFGH".to_string(), + expires_at_ms: chrono::Utc::now().timestamp_millis() + 60_000, + target_account_id: Some(existing.id.clone()), + target_generation: Some(generation), + }, + ); + } + + let stale = manager + .add_account_internal( + "shared-workspace".to_string(), + "refresh-stale".to_string(), + Some("user@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("user-a")), + None, + AccountLoginContext { + target_account_id: Some(&existing.id), + pending_device_code: Some("stale-flow"), + target_generation: Some(1), + }, + ) + .await; + assert!(matches!(stale, Err(CodexOAuthError::ExpiredToken))); + + manager + .add_account_internal( + "shared-workspace".to_string(), + "refresh-latest".to_string(), + Some("user@example.com".to_string()), + Some(crate::codex_config::test_codex_id_token("user-a")), + None, + AccountLoginContext { + target_account_id: Some(&existing.id), + pending_device_code: Some("latest-flow"), + target_generation: Some(2), + }, + ) .await .unwrap(); + assert_eq!( + manager + .accounts + .read() + .await + .get(&existing.id) + .unwrap() + .refresh_token, + "refresh-latest" + ); + } + + #[tokio::test] + async fn device_flow_cannot_commit_after_expiry() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager.pending_device_codes.write().await.insert( + "expired-flow".to_string(), + PendingDeviceCode { + user_code: "ABCD-EFGH".to_string(), + expires_at_ms: chrono::Utc::now().timestamp_millis() - 1, + target_account_id: None, + target_generation: None, + }, + ); + + let result = manager + .add_account_internal( + "workspace".to_string(), + "refresh".to_string(), + None, + Some(crate::codex_config::test_codex_id_token("user")), + None, + AccountLoginContext { + pending_device_code: Some("expired-flow"), + ..Default::default() + }, + ) + .await; - manager.set_default_account("acc-456").await.unwrap(); + assert!(matches!(result, Err(CodexOAuthError::ExpiredToken))); + assert!(manager.list_accounts().await.is_empty()); + } - let status = manager.get_status().await; - assert_eq!(status.default_account_id.as_deref(), Some("acc-456")); + #[test] + fn refresh_error_code_accepts_openai_error_shapes() { + assert_eq!( + extract_refresh_error_code(r#"{"error":{"code":"refresh_token_reused"}}"#).as_deref(), + Some("refresh_token_reused") + ); + assert_eq!( + extract_refresh_error_code(r#"{"error":"refresh_token_expired"}"#).as_deref(), + Some("refresh_token_expired") + ); assert_eq!( - status.accounts.first().map(|account| account.id.as_str()), - Some("acc-456") + extract_refresh_error_code(r#"{"code":"REFRESH_TOKEN_INVALIDATED"}"#).as_deref(), + Some("refresh_token_invalidated") ); + assert_eq!(extract_refresh_error_code("not json"), None); } } diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index 3120c2ce..1f963d9f 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -11,6 +11,8 @@ pub struct ManagedAuthAccount { pub avatar_url: Option, pub authenticated_at: i64, pub is_default: bool, + #[serde(default)] + pub reauth_required: bool, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] @@ -51,6 +53,7 @@ fn map_account( login: account.login, avatar_url: account.avatar_url, authenticated_at: account.authenticated_at, + reauth_required: account.reauth_required, } } @@ -72,9 +75,16 @@ pub struct AuthService; impl AuthService { pub async fn start_login(auth_provider: &str) -> Result { + Self::start_login_for_account(auth_provider, None).await + } + + pub async fn start_login_for_account( + auth_provider: &str, + account_id: Option<&str>, + ) -> Result { let auth_provider = ensure_auth_provider(auth_provider)?; match auth_provider { - AUTH_PROVIDER_CODEX_OAUTH => CodexOAuthService::start_device_flow() + AUTH_PROVIDER_CODEX_OAUTH => CodexOAuthService::start_device_flow(account_id) .await .map(|response| map_device_code_response(auth_provider, response)) .map_err(|error| error.to_string()), @@ -98,6 +108,10 @@ impl AuthService { })) } Err(CodexOAuthError::AuthorizationPending) => Ok(None), + Err(CodexOAuthError::DuplicateAccount) => Err( + "This account is already added. Reauthenticate the existing account instead." + .to_string(), + ), Err(error) => Err(error.to_string()), }, _ => unreachable!(), diff --git a/src-tauri/src/services/codex_oauth.rs b/src-tauri/src/services/codex_oauth.rs index c2129520..6b6df12e 100644 --- a/src-tauri/src/services/codex_oauth.rs +++ b/src-tauri/src/services/codex_oauth.rs @@ -122,14 +122,18 @@ impl CodexOAuthService { *guard = None; } - pub async fn start_device_flow() -> Result { - Self::manager().start_device_flow().await + pub async fn start_device_flow( + target_account_id: Option<&str>, + ) -> Result { + Self::manager().start_device_flow(target_account_id).await } pub async fn poll_for_token( device_code: &str, ) -> Result, CodexOAuthError> { - Self::manager().poll_for_token(device_code).await + Self::manager() + .poll_for_token(device_code, || async {}) + .await } pub async fn get_valid_token_for_account(account_id: &str) -> Result { @@ -189,9 +193,19 @@ impl CodexOAuthService { } }; + let workspace = match manager.chatgpt_account_id_for_account(&account_id).await { + Ok(workspace) => workspace, + Err(error) => { + return SubscriptionQuota::error( + "codex_oauth", + CredentialStatus::Expired, + error.to_string(), + ) + } + }; query_codex_quota( &token, - Some(&account_id), + Some(&workspace), "codex_oauth", "Codex OAuth access token expired or rejected. Please re-login via cc-switch.", ) @@ -220,9 +234,13 @@ impl CodexOAuthService { .await .map_err(|error| format!("Codex OAuth token unavailable: {error}"))?; + let workspace = manager + .chatgpt_account_id_for_account(&account_id) + .await + .map_err(|e| e.to_string())?; crate::services::subscription::consume_codex_reset_credit( &token, - Some(&account_id), + Some(&workspace), credit_id, "codex_oauth", ) @@ -250,7 +268,11 @@ impl CodexOAuthService { .await .map_err(|error| format!("Codex OAuth token unavailable: {error}"))?; - crate::services::codex_oauth_models::fetch_models_with_token(&token, &account_id).await + let workspace = manager + .chatgpt_account_id_for_account(&account_id) + .await + .map_err(|e| e.to_string())?; + crate::services::codex_oauth_models::fetch_models_with_token(&token, &workspace).await } #[cfg(test)]