Add app server account session lifecycle

This commit is contained in:
Dhruv
2026-06-04 01:27:23 +05:30
parent 6c1c14fcf8
commit 45de72d42d
9 changed files with 707 additions and 7 deletions
+21 -6
View File
@@ -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": "<uuid>", "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": "<uuid>", "accountId": "<workspace-id>" } }
{ "method": "accountSession/logout", "id": 9, "params": { "sessionId": "<uuid>" } }
```
### 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": { … } } }
```
+549
View File
@@ -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<String>,
sessions: Vec<StoredAccountSession>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct StoredAccountSession {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
auth_json: Option<AuthDotJson>,
email: Option<String>,
user_id: Option<String>,
display_name: Option<String>,
image_url: Option<String>,
plan: Option<String>,
last_used_at: i64,
selected_workspace_account_id: Option<String>,
workspaces: Vec<AccountSessionWorkspace>,
}
#[derive(Default, Deserialize)]
struct AccessTokenClaims {
#[serde(rename = "https://api.openai.com/profile", default)]
profile: AccessTokenProfileClaims,
}
#[derive(Default, Deserialize)]
struct AccessTokenProfileClaims {
#[serde(default)]
email: Option<String>,
#[serde(default)]
image: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
picture: Option<String>,
}
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<AccountSessionsResponse> {
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<AccountSessionsResponse> {
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<AccountSessionsResponse> {
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<AccountSessionsResponse> {
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<bool> {
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<StoredAccountSessions> {
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<bool> {
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<Option<AuthDotJson>> {
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<Option<StoredAccountSessions>> {
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<StoredAccountSession> {
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::<Vec<_>>();
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)
}
}
+1
View File
@@ -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;
@@ -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
}
@@ -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;
@@ -105,6 +105,55 @@ impl AccountRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn add_account_session(
&self,
params: AccountSessionsAddParams,
) -> Result<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, JSONRPCErrorError> {
let response = self
.account_sessions_store()
.logout(&params.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<Option<ClientResponsePayload>, JSONRPCErrorError> {
let response = self
.account_sessions_store()
.switch(&params.session_id, &params.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<ThreadManager>,
@@ -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) => {
+1
View File
@@ -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
}
+8 -1
View File
@@ -71,6 +71,7 @@ pub struct ServerOptions {
pub force_state: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
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?;
@@ -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)?;