diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dc02ff8c04..5778a56261 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1775,6 +1775,10 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`). - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. +- `accountSession/add` — save the current managed ChatGPT auth session. +- `accountSession/list` — list saved managed ChatGPT auth sessions and optionally refresh their workspace metadata. +- `accountSession/switch` — make a saved ChatGPT auth session and workspace current. +- `accountSession/logout` — revoke and remove one saved ChatGPT auth session. - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). @@ -1860,19 +1864,30 @@ Field notes: { "method": "account/login/completed", "params": { "loginId": "", "success": false, "error": "…" } } ``` -### 6) Logout +### 6) Save and switch ChatGPT account sessions + +Desktop clients can save the current managed ChatGPT auth payload after login, list saved sessions, and switch the active workspace. These methods return the full saved-session snapshot because the number of interactive account sessions is intentionally small. ```json -{ "method": "account/logout", "id": 6 } -{ "id": 6, "result": {} } +{ "method": "accountSession/add", "id": 6, "params": { "switchToAddedAccount": true } } +{ "method": "accountSession/list", "id": 7, "params": { "refreshWorkspaceMetadata": true } } +{ "method": "accountSession/switch", "id": 8, "params": { "sessionId": "", "accountId": "" } } +{ "method": "accountSession/logout", "id": 9, "params": { "sessionId": "" } } +``` + +### 7) Logout + +```json +{ "method": "account/logout", "id": 10 } +{ "id": 10, "result": {} } { "method": "account/updated", "params": { "authMode": null, "planType": null } } ``` -### 7) Rate limits (ChatGPT) +### 8) Rate limits (ChatGPT) ```json -{ "method": "account/rateLimits/read", "id": 7 } -{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } } +{ "method": "account/rateLimits/read", "id": 11 } +{ "id": 11, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } } { "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } ``` diff --git a/codex-rs/app-server/src/account_sessions.rs b/codex-rs/app-server/src/account_sessions.rs new file mode 100644 index 0000000000..0c17908781 --- /dev/null +++ b/codex-rs/app-server/src/account_sessions.rs @@ -0,0 +1,549 @@ +use base64::Engine; +use chrono::Utc; +use codex_app_server_protocol::AccountSession; +use codex_app_server_protocol::AccountSessionWorkspace; +use codex_app_server_protocol::AccountSessionWorkspaceKind; +use codex_app_server_protocol::AccountSessionWorkspaceStatus; +use codex_app_server_protocol::AccountSessionsResponse; +use codex_backend_client::AccountEntry; +use codex_backend_client::Client as BackendClient; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; +use codex_login::CodexAuth; +use codex_login::delete_account_session_auth; +use codex_login::load_account_session_auth; +use codex_login::load_auth_dot_json; +use codex_login::logout; +use codex_login::revoke_auth_tokens; +use codex_login::save_account_session_auth; +use codex_login::save_auth; +use serde::Deserialize; +use serde::Serialize; +use std::fs::OpenOptions; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use uuid::Uuid; + +const ACCOUNT_SESSIONS_FILE: &str = "account-sessions.json"; + +pub(crate) struct AccountSessionsStore<'a> { + codex_home: &'a Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: &'a str, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct StoredAccountSessions { + active_session_id: Option, + sessions: Vec, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct StoredAccountSession { + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + auth_json: Option, + email: Option, + user_id: Option, + display_name: Option, + image_url: Option, + plan: Option, + last_used_at: i64, + selected_workspace_account_id: Option, + workspaces: Vec, +} + +#[derive(Default, Deserialize)] +struct AccessTokenClaims { + #[serde(rename = "https://api.openai.com/profile", default)] + profile: AccessTokenProfileClaims, +} + +#[derive(Default, Deserialize)] +struct AccessTokenProfileClaims { + #[serde(default)] + email: Option, + #[serde(default)] + image: Option, + #[serde(default)] + name: Option, + #[serde(default)] + picture: Option, +} + +impl<'a> AccountSessionsStore<'a> { + pub(crate) fn new( + codex_home: &'a Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: &'a str, + ) -> Self { + Self { + codex_home, + auth_credentials_store_mode, + chatgpt_base_url, + } + } + + pub(crate) async fn add( + &self, + switch_to_added_account: bool, + ) -> std::io::Result { + let mut stored = self.load()?; + let mut auth_json = load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)? + .ok_or_else(|| std::io::Error::other("No active ChatGPT auth session to add"))?; + let mut session = Self::session_from_auth_json(&auth_json) + .ok_or_else(|| std::io::Error::other("No active ChatGPT auth session to add"))?; + self.refresh_workspace_metadata(&mut session, &mut auth_json) + .await; + + let existing_index = stored.sessions.iter().position(|saved| { + session + .email + .as_ref() + .is_some_and(|email| saved.email.as_ref() == Some(email)) + || session + .user_id + .as_ref() + .is_some_and(|user_id| saved.user_id.as_ref() == Some(user_id)) + }); + if let Some(index) = existing_index { + session + .session_id + .clone_from(&stored.sessions[index].session_id); + } + let added_session_id = session.session_id.clone(); + self.save_session_auth(&added_session_id, &auth_json)?; + if let Some(index) = existing_index { + stored.sessions[index] = session; + } else { + stored.sessions.push(session); + } + + if switch_to_added_account || stored.active_session_id.is_none() { + stored.active_session_id = Some(added_session_id); + save_auth( + self.codex_home, + &auth_json, + self.auth_credentials_store_mode, + )?; + } else if let Some(active_session_id) = stored.active_session_id.as_deref() { + self.save_active_session_auth(active_session_id)?; + } + + self.save(&stored)?; + Ok(Self::response(stored)) + } + + pub(crate) async fn list( + &self, + refresh_workspace_metadata: bool, + ) -> std::io::Result { + self.sync_active_auth()?; + let mut stored = self.load()?; + if refresh_workspace_metadata { + for session in &mut stored.sessions { + let Some(mut auth_json) = self.load_session_auth(&session.session_id)? else { + continue; + }; + self.refresh_workspace_metadata(session, &mut auth_json) + .await; + self.save_session_auth(&session.session_id, &auth_json)?; + } + self.save(&stored)?; + } + Ok(Self::response(stored)) + } + + pub(crate) async fn logout( + &self, + session_id: &str, + ) -> std::io::Result { + self.sync_active_auth()?; + let mut stored = self.load()?; + let index = stored + .sessions + .iter() + .position(|session| session.session_id == session_id) + .ok_or_else(|| std::io::Error::other("Saved ChatGPT account session not found"))?; + let removed = stored.sessions.remove(index); + let removed_auth = self.load_session_auth(&removed.session_id)?; + if let Err(err) = revoke_auth_tokens(removed_auth.as_ref()).await { + tracing::warn!("failed to revoke saved account session during logout: {err}"); + } + delete_account_session_auth( + self.codex_home, + &removed.session_id, + self.auth_credentials_store_mode, + )?; + + if stored.active_session_id.as_deref() == Some(session_id) { + let newest = stored + .sessions + .iter() + .max_by_key(|session| session.last_used_at); + stored.active_session_id = newest.map(|session| session.session_id.clone()); + match newest { + Some(session) => self.save_active_session_auth(&session.session_id)?, + None => { + logout(self.codex_home, self.auth_credentials_store_mode)?; + } + } + } + + self.save(&stored)?; + Ok(Self::response(stored)) + } + + pub(crate) fn switch( + &self, + session_id: &str, + account_id: &str, + ) -> std::io::Result { + self.sync_active_auth()?; + let mut stored = self.load()?; + let session = stored + .sessions + .iter_mut() + .find(|session| session.session_id == session_id) + .ok_or_else(|| std::io::Error::other("Saved ChatGPT account session not found"))?; + let mut auth_json = self + .load_session_auth(&session.session_id)? + .ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?; + let tokens = auth_json + .tokens + .as_mut() + .ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?; + tokens.account_id = Some(account_id.to_string()); + session.selected_workspace_account_id = Some(account_id.to_string()); + session.last_used_at = Utc::now().timestamp(); + stored.active_session_id = Some(session_id.to_string()); + self.save_session_auth(session_id, &auth_json)?; + save_auth( + self.codex_home, + &auth_json, + self.auth_credentials_store_mode, + )?; + self.save(&stored)?; + Ok(Self::response(stored)) + } + + pub(crate) fn has_sessions(&self) -> std::io::Result { + Ok(self + .read()? + .is_some_and(|stored| !stored.sessions.is_empty())) + } + + pub(crate) fn sync_active_auth(&self) -> std::io::Result<()> { + let Some(mut stored) = self.read()? else { + return Ok(()); + }; + if self.migrate_legacy_auth(&mut stored)? { + self.save(&stored)?; + } + let Some(active_session_id) = stored.active_session_id.as_ref() else { + return Ok(()); + }; + let Some(auth_json) = + load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)? + else { + return Ok(()); + }; + let Some(session) = stored + .sessions + .iter_mut() + .find(|session| &session.session_id == active_session_id) + else { + return Ok(()); + }; + if !Self::same_identity(session, &auth_json) { + return Ok(()); + } + + let saved_auth_json = self.load_session_auth(&session.session_id)?; + let selected_workspace_account_id = auth_json + .tokens + .as_ref() + .and_then(|tokens| tokens.account_id.clone()); + if saved_auth_json.as_ref() == Some(&auth_json) + && (selected_workspace_account_id.is_none() + || session.selected_workspace_account_id == selected_workspace_account_id) + { + return Ok(()); + } + + self.save_session_auth(&session.session_id, &auth_json)?; + if selected_workspace_account_id.is_some() { + session.selected_workspace_account_id = selected_workspace_account_id; + } + self.save(&stored) + } + + pub(crate) async fn revoke_all_and_clear(&self) -> std::io::Result<()> { + let Some(mut stored) = self.read()? else { + return self.clear(); + }; + self.migrate_legacy_auth(&mut stored)?; + for session in stored.sessions { + let auth_json = self.load_session_auth(&session.session_id)?; + if let Err(err) = revoke_auth_tokens(auth_json.as_ref()).await { + tracing::warn!("failed to revoke saved account session during logout: {err}"); + } + delete_account_session_auth( + self.codex_home, + &session.session_id, + self.auth_credentials_store_mode, + )?; + } + self.clear() + } + + fn load(&self) -> std::io::Result { + match self.read()? { + Some(mut stored) => { + if self.migrate_legacy_auth(&mut stored)? { + self.save(&stored)?; + } + Ok(stored) + } + None => { + let auth_json = + load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)?; + let Some(auth_json) = auth_json else { + return Ok(StoredAccountSessions::default()); + }; + let Some(session) = Self::session_from_auth_json(&auth_json) else { + return Ok(StoredAccountSessions::default()); + }; + self.save_session_auth(&session.session_id, &auth_json)?; + let stored = StoredAccountSessions { + active_session_id: Some(session.session_id.clone()), + sessions: vec![session], + }; + self.save(&stored)?; + Ok(stored) + } + } + } + + fn migrate_legacy_auth(&self, stored: &mut StoredAccountSessions) -> std::io::Result { + let mut migrated = false; + for session in &mut stored.sessions { + if let Some(auth_json) = session.auth_json.take() { + self.save_session_auth(&session.session_id, &auth_json)?; + migrated = true; + } + } + Ok(migrated) + } + + fn load_session_auth(&self, session_id: &str) -> std::io::Result> { + load_account_session_auth( + self.codex_home, + session_id, + self.auth_credentials_store_mode, + ) + } + + fn save_session_auth(&self, session_id: &str, auth_json: &AuthDotJson) -> std::io::Result<()> { + save_account_session_auth( + self.codex_home, + session_id, + auth_json, + self.auth_credentials_store_mode, + ) + } + + fn save_active_session_auth(&self, session_id: &str) -> std::io::Result<()> { + let auth_json = self + .load_session_auth(session_id)? + .ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?; + save_auth( + self.codex_home, + &auth_json, + self.auth_credentials_store_mode, + ) + } + + fn clear(&self) -> std::io::Result<()> { + match std::fs::remove_file(self.path()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } + } + + fn read(&self) -> std::io::Result> { + match std::fs::read_to_string(self.path()) { + Ok(payload) => serde_json::from_str(&payload) + .map(Some) + .map_err(std::io::Error::other), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } + } + + fn save(&self, sessions: &StoredAccountSessions) -> std::io::Result<()> { + let path = self.path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(serde_json::to_string_pretty(sessions)?.as_bytes())?; + file.flush() + } + + async fn refresh_workspace_metadata( + &self, + session: &mut StoredAccountSession, + auth_json: &mut AuthDotJson, + ) { + let Ok(auth) = CodexAuth::from_auth_dot_json( + self.codex_home, + auth_json.clone(), + self.auth_credentials_store_mode, + Some(self.chatgpt_base_url), + ) + .await + else { + return; + }; + let Ok(client) = BackendClient::from_auth(self.chatgpt_base_url, &auth) else { + return; + }; + let Ok(accounts) = client.get_accounts_check().await else { + return; + }; + session.selected_workspace_account_id = session + .selected_workspace_account_id + .clone() + .or(accounts.default_account_id) + .or_else(|| accounts.account_ordering.first().cloned()); + if let Some(account_id) = session.selected_workspace_account_id.as_ref() + && let Some(tokens) = auth_json.tokens.as_mut() + { + tokens.account_id = Some(account_id.clone()); + } + session.workspaces = accounts + .accounts + .into_iter() + .map(Self::workspace_from_account) + .collect(); + } + + fn session_from_auth_json(auth_json: &AuthDotJson) -> Option { + let Some(tokens) = auth_json.tokens.as_ref() else { + return None; + }; + let claims = Self::access_token_claims(&tokens.access_token); + let selected_workspace_account_id = tokens + .account_id + .clone() + .or_else(|| tokens.id_token.chatgpt_account_id.clone()); + let workspaces = selected_workspace_account_id + .as_ref() + .map(|account_id| { + vec![AccountSessionWorkspace { + account_id: account_id.clone(), + name: None, + image_url: None, + kind: None, + status: AccountSessionWorkspaceStatus::Active, + }] + }) + .unwrap_or_default(); + Some(StoredAccountSession { + session_id: Uuid::now_v7().to_string(), + auth_json: None, + email: tokens.id_token.email.clone(), + user_id: tokens.id_token.chatgpt_user_id.clone(), + display_name: claims.profile.name, + image_url: claims.profile.picture.or(claims.profile.image), + plan: tokens.id_token.get_chatgpt_plan_type_raw(), + last_used_at: Utc::now().timestamp(), + selected_workspace_account_id, + workspaces, + }) + } + + fn access_token_claims(access_token: &str) -> AccessTokenClaims { + let Some(payload) = access_token.split('.').nth(1) else { + return AccessTokenClaims::default(); + }; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok() + .and_then(|payload| serde_json::from_slice(&payload).ok()) + .unwrap_or_default() + } + + fn same_identity(session: &StoredAccountSession, auth_json: &AuthDotJson) -> bool { + let Some(tokens) = auth_json.tokens.as_ref() else { + return false; + }; + session + .email + .as_ref() + .zip(tokens.id_token.email.as_ref()) + .is_some_and(|(saved, active)| saved == active) + || session + .user_id + .as_ref() + .zip(tokens.id_token.chatgpt_user_id.as_ref()) + .is_some_and(|(saved, active)| saved == active) + } + + fn workspace_from_account(account: AccountEntry) -> AccountSessionWorkspace { + let kind = match account.structure.as_str() { + "personal" => Some(AccountSessionWorkspaceKind::Personal), + "workspace" => Some(AccountSessionWorkspaceKind::Workspace), + _ => None, + }; + AccountSessionWorkspace { + account_id: account.id, + name: account.name, + image_url: account.profile_picture_url, + kind, + status: AccountSessionWorkspaceStatus::Active, + } + } + + fn response(stored: StoredAccountSessions) -> AccountSessionsResponse { + let active_session_id = stored.active_session_id; + let mut sessions = stored + .sessions + .into_iter() + .map(|session| AccountSession { + is_active: Some(&session.session_id) == active_session_id.as_ref(), + session_id: session.session_id, + email: session.email, + user_id: session.user_id, + display_name: session.display_name, + image_url: session.image_url, + plan: session.plan, + last_used_at: session.last_used_at, + selected_workspace_account_id: session.selected_workspace_account_id, + workspaces: session.workspaces, + }) + .collect::>(); + sessions.sort_by_key(|session| std::cmp::Reverse(session.last_used_at)); + AccountSessionsResponse { + active_session_id, + sessions, + } + } + + fn path(&self) -> PathBuf { + self.codex_home.join(ACCOUNT_SESSIONS_FILE) + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0689a79f00..4546c90e13 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -73,6 +73,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod account_sessions; mod analytics_utils; mod app_server_tracing; mod attestation; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 84f22809ee..256b0a7c67 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1286,6 +1286,18 @@ impl MessageProcessor { ClientRequest::CancelLoginAccount { params, .. } => { self.account_processor.cancel_login_account(params).await } + ClientRequest::AccountSessionsAdd { params, .. } => { + self.account_processor.add_account_session(params).await + } + ClientRequest::AccountSessionsList { params, .. } => { + self.account_processor.list_account_sessions(params).await + } + ClientRequest::AccountSessionsLogout { params, .. } => { + self.account_processor.logout_account_session(params).await + } + ClientRequest::AccountSessionsSwitch { params, .. } => { + self.account_processor.switch_account_session(params).await + } ClientRequest::GetAccount { params, .. } => { self.account_processor.get_account(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 36008ce0b9..12d4f355de 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -1,3 +1,4 @@ +use crate::account_sessions::AccountSessionsStore; use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed; use crate::command_exec::CommandExecManager; @@ -22,6 +23,10 @@ use codex_analytics::InputError; use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AccountSessionsAddParams; +use codex_app_server_protocol::AccountSessionsListParams; +use codex_app_server_protocol::AccountSessionsLogoutParams; +use codex_app_server_protocol::AccountSessionsSwitchParams; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 8ebfc037e1..8e8e120160 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -105,6 +105,55 @@ impl AccountRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn add_account_session( + &self, + params: AccountSessionsAddParams, + ) -> Result, JSONRPCErrorError> { + let response = self + .account_sessions_store() + .add(params.switch_to_added_account) + .await + .map_err(|err| internal_error(format!("failed to add account session: {err}")))?; + self.sync_auth_after_account_session_change().await; + Ok(Some(response.into())) + } + + pub(crate) async fn list_account_sessions( + &self, + params: AccountSessionsListParams, + ) -> Result, JSONRPCErrorError> { + self.account_sessions_store() + .list(params.refresh_workspace_metadata) + .await + .map(|response| Some(response.into())) + .map_err(|err| internal_error(format!("failed to list account sessions: {err}"))) + } + + pub(crate) async fn logout_account_session( + &self, + params: AccountSessionsLogoutParams, + ) -> Result, JSONRPCErrorError> { + let response = self + .account_sessions_store() + .logout(¶ms.session_id) + .await + .map_err(|err| internal_error(format!("failed to log out account session: {err}")))?; + self.sync_auth_after_account_session_change().await; + Ok(Some(response.into())) + } + + pub(crate) async fn switch_account_session( + &self, + params: AccountSessionsSwitchParams, + ) -> Result, JSONRPCErrorError> { + let response = self + .account_sessions_store() + .switch(¶ms.session_id, ¶ms.account_id) + .map_err(|err| internal_error(format!("failed to switch account session: {err}")))?; + self.sync_auth_after_account_session_change().await; + Ok(Some(response.into())) + } + pub(crate) async fn get_account( &self, params: GetAccountParams, @@ -159,6 +208,42 @@ impl AccountRequestProcessor { } } + fn account_sessions_store(&self) -> AccountSessionsStore<'_> { + AccountSessionsStore::new( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + &self.config.chatgpt_base_url, + ) + } + + fn sync_active_account_session(&self) -> Result<(), JSONRPCErrorError> { + self.account_sessions_store() + .sync_active_auth() + .map_err(|err| internal_error(format!("failed to sync active account session: {err}"))) + } + + async fn sync_auth_after_account_session_change(&self) { + self.auth_manager.reload().await; + self.config_manager.replace_cloud_requirements_loader( + self.auth_manager.clone(), + self.config.chatgpt_base_url.clone(), + ); + self.config_manager + .sync_default_client_residency_requirement() + .await; + Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + &self.config_manager, + &self.thread_manager, + self.auth_manager.auth_cached(), + ) + .await; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated( + self.current_account_updated_notification(), + )) + .await; + } + async fn maybe_refresh_remote_installed_plugins_cache_for_current_config( config_manager: &ConfigManager, thread_manager: &Arc, @@ -265,6 +350,8 @@ impl AccountRequestProcessor { )); } + self.sync_active_account_session()?; + // Cancel any active login attempt. { let mut guard = self.active_login.lock().await; @@ -317,9 +404,16 @@ impl AccountRequestProcessor { )); } + self.sync_active_account_session()?; + let revoke_previous_auth = !self + .account_sessions_store() + .has_sessions() + .map_err(|err| internal_error(format!("failed to read account sessions: {err}")))?; + let opts = LoginServerOptions { open_browser: false, codex_streamlined_login, + revoke_previous_auth, ..LoginServerOptions::new( config.codex_home.to_path_buf(), CLIENT_ID.to_string(), @@ -562,6 +656,8 @@ impl AccountRequestProcessor { )); } + self.sync_active_account_session()?; + // Cancel any active login attempt to avoid persisting managed auth state. { let mut guard = self.active_login.lock().await; @@ -676,6 +772,12 @@ impl AccountRequestProcessor { } } + self.sync_active_account_session()?; + self.account_sessions_store() + .revoke_all_and_clear() + .await + .map_err(|err| internal_error(format!("failed to clear account sessions: {err}")))?; + match self.auth_manager.logout_with_revoke().await { Ok(_) => {} Err(err) => { diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 4b9cb7c321..53b2717209 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -217,6 +217,7 @@ pub async fn complete_device_code_login( tokens.access_token, tokens.refresh_token, opts.cli_auth_credentials_store_mode, + opts.revoke_previous_auth, ) .await } diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b72bc946f2..0a1f2cc053 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -71,6 +71,7 @@ pub struct ServerOptions { pub force_state: Option, pub forced_chatgpt_workspace_id: Option>, pub codex_streamlined_login: bool, + pub revoke_previous_auth: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, } @@ -91,6 +92,7 @@ impl ServerOptions { force_state: None, forced_chatgpt_workspace_id, codex_streamlined_login: false, + revoke_previous_auth: true, cli_auth_credentials_store_mode, } } @@ -362,6 +364,7 @@ async fn process_request( tokens.access_token.clone(), tokens.refresh_token.clone(), opts.cli_auth_credentials_store_mode, + opts.revoke_previous_auth, ) .await { @@ -793,6 +796,7 @@ pub(crate) async fn persist_tokens_async( access_token: String, refresh_token: String, auth_credentials_store_mode: AuthCredentialsStoreMode, + revoke_previous_auth: bool, ) -> io::Result<()> { // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); @@ -829,7 +833,8 @@ pub(crate) async fn persist_tokens_async( .await .map_err(|e| io::Error::other(format!("persist task failed: {e}")))??; - if should_revoke_auth_tokens(previous_auth.as_ref(), &auth) + if revoke_previous_auth + && should_revoke_auth_tokens(previous_auth.as_ref(), &auth) && let Err(err) = revoke_auth_tokens(previous_auth.as_ref()).await { warn!("failed to revoke superseded auth tokens after login: {err}"); @@ -1227,6 +1232,7 @@ mod tests { "new-access".to_string(), "new-refresh".to_string(), AuthCredentialsStoreMode::File, + /*revoke_previous_auth*/ true, ) .await?; @@ -1287,6 +1293,7 @@ mod tests { "new-access".to_string(), "shared-refresh".to_string(), AuthCredentialsStoreMode::File, + /*revoke_previous_auth*/ true, ) .await?; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index bc7bb401e8..b5979a0bcd 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -128,6 +128,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { force_state: Some(state), forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]), codex_streamlined_login: false, + revoke_previous_auth: true, }; let server = run_login_server(opts)?; assert!( @@ -190,6 +191,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { force_state: Some(state), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + revoke_previous_auth: true, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -233,6 +235,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< WORKSPACE_ID_SECOND_ALLOWED.to_string(), ]), codex_streamlined_login: false, + revoke_previous_auth: true, }; let server = run_login_server(opts)?; let auth_url = Url::parse(&server.auth_url)?; @@ -271,6 +274,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { force_state: Some(state.clone()), forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), codex_streamlined_login: false, + revoke_previous_auth: true, }; let server = run_login_server(opts)?; assert!( @@ -331,6 +335,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() force_state: Some(state.clone()), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + revoke_previous_auth: true, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -399,6 +404,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< force_state: Some(state.clone()), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + revoke_previous_auth: true, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -544,6 +550,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { force_state: Some("cancel_state".to_string()), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + revoke_previous_auth: true, }; let first_server = run_login_server(first_opts)?; @@ -565,6 +572,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { force_state: Some("cancel_state_2".to_string()), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + revoke_previous_auth: true, }; let second_server = run_login_server(second_opts)?;