diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1dba61a5..fc79d209 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -68,9 +68,7 @@ use codex_core::NewConversation; use codex_core::RolloutRecorder; use codex_core::SessionMeta; use codex_core::auth::CLIENT_ID; -use codex_core::auth::get_auth_file; use codex_core::auth::login_with_api_key; -use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -671,12 +669,8 @@ impl CodexMessageProcessor { } async fn get_user_info(&self, request_id: RequestId) { - // Read alleged user email from auth.json (best-effort; not verified). - let auth_path = get_auth_file(&self.config.codex_home); - let alleged_user_email = match try_read_auth_json(&auth_path) { - Ok(auth) => auth.tokens.and_then(|t| t.id_token.email), - Err(_) => None, - }; + // Read alleged user email from cached auth (best-effort; not verified). + let alleged_user_email = self.auth_manager.auth().and_then(|a| a.get_account_email()); let response = UserInfoResponse { alleged_user_email }; self.outgoing.send_response(request_id, response).await; diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index bc1df74d..594b621d 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -7,8 +7,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::DateTime; use chrono::Utc; use codex_core::auth::AuthDotJson; -use codex_core::auth::get_auth_file; -use codex_core::auth::write_auth_json; +use codex_core::auth::save_auth; use codex_core::token_data::TokenData; use codex_core::token_data::parse_id_token; use serde_json::json; @@ -127,5 +126,5 @@ pub fn write_chatgpt_auth(codex_home: &Path, fixture: ChatGptAuthFixture) -> Res last_refresh, }; - write_auth_json(&get_auth_file(codex_home), &auth).context("write auth.json") + save_auth(codex_home, &auth).context("write auth.json") } diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index ce9b7475..70c6940f 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -19,7 +19,7 @@ pub fn set_chatgpt_token_data(value: TokenData) { /// Initialize the ChatGPT token from auth.json file pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> { - let auth = CodexAuth::from_codex_home(codex_home)?; + let auth = CodexAuth::from_auth_storage(codex_home)?; if let Some(auth) = auth { let token_data = auth.get_token_data().await?; set_chatgpt_token_data(token_data); diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 2e2f3f07..bbb58f22 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -140,7 +140,7 @@ pub async fn run_login_with_device_code( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match CodexAuth::from_codex_home(&config.codex_home) { + match CodexAuth::from_auth_storage(&config.codex_home) { Ok(Some(auth)) => match auth.mode { AuthMode::ApiKey => match auth.get_token().await { Ok(api_key) => { diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index d122d338..49f9b778 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1,16 +1,12 @@ -use chrono::DateTime; +mod storage; + use chrono::Utc; use serde::Deserialize; use serde::Serialize; #[cfg(test)] use serial_test::serial; use std::env; -use std::fs::File; -use std::fs::OpenOptions; -use std::io::Read; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; +use std::fmt::Debug; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -20,6 +16,10 @@ use std::time::Duration; use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; +pub use crate::auth::storage::AuthCredentialsStoreMode; +pub use crate::auth::storage::AuthDotJson; +use crate::auth::storage::AuthStorageBackend; +use crate::auth::storage::create_auth_storage; use crate::config::Config; use crate::default_client::CodexHttpClient; use crate::token_data::PlanType; @@ -32,7 +32,7 @@ pub struct CodexAuth { pub(crate) api_key: Option, pub(crate) auth_dot_json: Arc>>, - pub(crate) auth_file: PathBuf, + storage: Arc, pub(crate) client: CodexHttpClient, } @@ -56,7 +56,7 @@ impl CodexAuth { .map_err(std::io::Error::other)?; let updated = update_tokens( - &self.auth_file, + &self.storage, refresh_response.id_token, refresh_response.access_token, refresh_response.refresh_token, @@ -78,8 +78,8 @@ impl CodexAuth { Ok(access) } - /// Loads the available auth information from the auth.json. - pub fn from_codex_home(codex_home: &Path) -> std::io::Result> { + /// Loads the available auth information from auth storage. + pub fn from_auth_storage(codex_home: &Path) -> std::io::Result> { load_auth(codex_home, false) } @@ -103,7 +103,7 @@ impl CodexAuth { .map_err(std::io::Error::other)?; let updated_auth_dot_json = update_tokens( - &self.auth_file, + &self.storage, refresh_response.id_token, refresh_response.access_token, refresh_response.refresh_token, @@ -177,7 +177,7 @@ impl CodexAuth { Self { api_key: None, mode: AuthMode::ChatGPT, - auth_file: PathBuf::new(), + storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), auth_dot_json, client: crate::default_client::create_client(), } @@ -187,7 +187,7 @@ impl CodexAuth { Self { api_key: Some(api_key.to_owned()), mode: AuthMode::ApiKey, - auth_file: PathBuf::new(), + storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), auth_dot_json: Arc::new(Mutex::new(None)), client, } @@ -215,19 +215,11 @@ pub fn read_codex_api_key_from_env() -> Option { .filter(|value| !value.is_empty()) } -pub fn get_auth_file(codex_home: &Path) -> PathBuf { - codex_home.join("auth.json") -} - /// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` /// if a file was removed, `Ok(false)` if no auth file was present. pub fn logout(codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - match std::fs::remove_file(&auth_file) { - Ok(_) => Ok(true), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(err), - } + let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); + storage.delete() } /// Writes an `auth.json` that contains only the API key. @@ -237,7 +229,20 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<( tokens: None, last_refresh: None, }; - write_auth_json(&get_auth_file(codex_home), &auth_dot_json) + save_auth(codex_home, &auth_dot_json) +} + +/// Persist the provided auth payload using the specified backend. +pub fn save_auth(codex_home: &Path, auth: &AuthDotJson) -> std::io::Result<()> { + let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); + storage.save(auth) +} + +/// Load CLI auth data using the configured credential store backend. +/// Returns `None` when no credentials are stored. +pub fn load_auth_dot_json(codex_home: &Path) -> std::io::Result> { + let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); + storage.load() } pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { @@ -320,12 +325,12 @@ fn load_auth( ))); } - let auth_file = get_auth_file(codex_home); + let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); + let client = crate::default_client::create_client(); - let auth_dot_json = match try_read_auth_json(&auth_file) { - Ok(auth) => auth, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err), + let auth_dot_json = match storage.load()? { + Some(auth) => auth, + None => return Ok(None), }; let AuthDotJson { @@ -342,7 +347,7 @@ fn load_auth( Ok(Some(CodexAuth { api_key: None, mode: AuthMode::ChatGPT, - auth_file, + storage: storage.clone(), auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { openai_api_key: None, tokens, @@ -352,41 +357,15 @@ fn load_auth( })) } -/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. -/// Returns the full AuthDotJson structure after refreshing if necessary. -pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { - let mut file = File::open(auth_file)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - - Ok(auth_dot_json) -} - -pub fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { - if let Some(parent) = auth_file.parent() { - std::fs::create_dir_all(parent)?; - } - let json_data = serde_json::to_string_pretty(auth_dot_json)?; - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - let mut file = options.open(auth_file)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - Ok(()) -} - async fn update_tokens( - auth_file: &Path, + storage: &Arc, id_token: Option, access_token: Option, refresh_token: Option, ) -> std::io::Result { - let mut auth_dot_json = try_read_auth_json(auth_file)?; + let mut auth_dot_json = storage + .load()? + .ok_or(std::io::Error::other("Token data is not available."))?; let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); if let Some(id_token) = id_token { @@ -399,7 +378,7 @@ async fn update_tokens( tokens.refresh_token = refresh_token; } auth_dot_json.last_refresh = Some(Utc::now()); - write_auth_json(auth_file, &auth_dot_json)?; + storage.save(&auth_dot_json)?; Ok(auth_dot_json) } @@ -452,19 +431,6 @@ struct RefreshResponse { refresh_token: Option, } -/// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -pub struct AuthDotJson { - #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_refresh: Option>, -} - // Shared constant for token refresh (client id used for oauth token refresh flow) pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -479,12 +445,15 @@ struct CachedAuth { #[cfg(test)] mod tests { use super::*; + use crate::auth::storage::FileAuthStorage; + use crate::auth::storage::get_auth_file; use crate::config::Config; use crate::config::ConfigOverrides; use crate::config::ConfigToml; use crate::token_data::IdTokenInfo; use crate::token_data::KnownPlan; use crate::token_data::PlanType; + use base64::Engine; use codex_protocol::config_types::ForcedLoginMethod; use pretty_assertions::assert_eq; @@ -492,27 +461,6 @@ mod tests { use serde_json::json; use tempfile::tempdir; - #[tokio::test] - async fn roundtrip_auth_dot_json() { - let codex_home = tempdir().unwrap(); - let _ = write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - chatgpt_account_id: None, - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let file = get_auth_file(codex_home.path()); - let auth_dot_json = try_read_auth_json(&file).unwrap(); - write_auth_json(&file, &auth_dot_json).unwrap(); - - let same_auth_dot_json = try_read_auth_json(&file).unwrap(); - assert_eq!(auth_dot_json, same_auth_dot_json); - } - #[tokio::test] async fn refresh_without_id_token() { let codex_home = tempdir().unwrap(); @@ -526,9 +474,12 @@ mod tests { ) .expect("failed to write auth file"); - let auth_file = super::get_auth_file(codex_home.path()); + let storage = create_auth_storage( + codex_home.path().to_path_buf(), + AuthCredentialsStoreMode::File, + ); let updated = super::update_tokens( - auth_file.as_path(), + &storage, None, Some("new-access-token".to_string()), Some("new-refresh-token".to_string()), @@ -563,7 +514,10 @@ mod tests { super::login_with_api_key(dir.path(), "sk-new").expect("login_with_api_key should succeed"); - let auth = super::try_read_auth_json(&auth_path).expect("auth.json should parse"); + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); assert_eq!(auth.openai_api_key.as_deref(), Some("sk-new")); assert!(auth.tokens.is_none(), "tokens should be cleared"); } @@ -571,7 +525,7 @@ mod tests { #[test] fn missing_auth_json_returns_none() { let dir = tempdir().unwrap(); - let auth = CodexAuth::from_codex_home(dir.path()).expect("call should succeed"); + let auth = CodexAuth::from_auth_storage(dir.path()).expect("call should succeed"); assert_eq!(auth, None); } @@ -593,7 +547,7 @@ mod tests { api_key, mode, auth_dot_json, - auth_file: _, + storage: _, .. } = super::load_auth(codex_home.path(), false).unwrap().unwrap(); assert_eq!(None, api_key); @@ -651,11 +605,11 @@ mod tests { tokens: None, last_refresh: None, }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - assert!(dir.path().join("auth.json").exists()); - let removed = logout(dir.path())?; - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); + super::save_auth(dir.path(), &auth_dot_json)?; + let auth_file = get_auth_file(dir.path()); + assert!(auth_file.exists()); + assert!(logout(dir.path())?); + assert!(!auth_file.exists()); Ok(()) } diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs new file mode 100644 index 00000000..508adc89 --- /dev/null +++ b/codex-rs/core/src/auth/storage.rs @@ -0,0 +1,191 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use std::fmt::Debug; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::token_data::TokenData; + +/// Determine where Codex should store CLI auth credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthCredentialsStoreMode { + #[default] + /// Persist credentials in CODEX_HOME/auth.json. + File, + // TODO: Implement keyring support. +} + +/// Expected structure for $CODEX_HOME/auth.json. +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +pub struct AuthDotJson { + #[serde(rename = "OPENAI_API_KEY")] + pub openai_api_key: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_refresh: Option>, +} + +pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { + codex_home.join("auth.json") +} + +pub(super) fn delete_file_if_exists(codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + match std::fs::remove_file(&auth_file) { + Ok(()) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + +pub(super) trait AuthStorageBackend: Debug + Send + Sync { + fn load(&self) -> std::io::Result>; + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()>; + fn delete(&self) -> std::io::Result; +} + +#[derive(Clone, Debug)] +pub(super) struct FileAuthStorage { + codex_home: PathBuf, +} + +impl FileAuthStorage { + pub(super) fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + /// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. + /// Returns the full AuthDotJson structure after refreshing if necessary. + pub(super) fn try_read_auth_json(&self, auth_file: &Path) -> std::io::Result { + let mut file = File::open(auth_file)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; + + Ok(auth_dot_json) + } +} + +impl AuthStorageBackend for FileAuthStorage { + fn load(&self) -> std::io::Result> { + let auth_file = get_auth_file(&self.codex_home); + let auth_dot_json = match self.try_read_auth_json(&auth_file) { + Ok(auth) => auth, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + Ok(Some(auth_dot_json)) + } + + fn save(&self, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { + let auth_file = get_auth_file(&self.codex_home); + + if let Some(parent) = auth_file.parent() { + std::fs::create_dir_all(parent)?; + } + let json_data = serde_json::to_string_pretty(auth_dot_json)?; + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(auth_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + Ok(()) + } + + fn delete(&self) -> std::io::Result { + delete_file_if_exists(&self.codex_home) + } +} + +pub(super) fn create_auth_storage( + codex_home: PathBuf, + mode: AuthCredentialsStoreMode, +) -> Arc { + match mode { + AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Context; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[tokio::test] + async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { + let codex_home = tempdir().unwrap(); + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + openai_api_key: Some("test-key".to_string()), + tokens: None, + last_refresh: Some(Utc::now()), + }; + + storage + .save(&auth_dot_json) + .context("failed to save auth file")?; + + let loaded = storage.load().context("failed to load auth file")?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) + } + + #[tokio::test] + async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { + let codex_home = tempdir().unwrap(); + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + openai_api_key: Some("test-key".to_string()), + tokens: None, + last_refresh: Some(Utc::now()), + }; + + let file = get_auth_file(codex_home.path()); + storage + .save(&auth_dot_json) + .context("failed to save auth file")?; + + let same_auth_dot_json = storage + .try_read_auth_json(&file) + .context("failed to read auth file after save")?; + assert_eq!(auth_dot_json, same_auth_dot_json); + Ok(()) + } + + #[test] + fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { + let dir = tempdir()?; + let auth_dot_json = AuthDotJson { + openai_api_key: Some("sk-test-key".to_string()), + tokens: None, + last_refresh: None, + }; + let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); + storage.save(&auth_dot_json)?; + assert!(dir.path().join("auth.json").exists()); + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let removed = storage.delete()?; + assert!(removed); + assert!(!dir.path().join("auth.json").exists()); + Ok(()) + } +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e302a1c1..c8ebdcb2 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -525,7 +525,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let auth_manager = match CodexAuth::from_codex_home(codex_home.path()) { + let auth_manager = match CodexAuth::from_auth_storage(codex_home.path()) { Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), Ok(None) => panic!("No CodexAuth found in codex_home"), Err(e) => panic!("Failed to load CodexAuth: {e}"), diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 0a64c61e..ac2cd28b 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -16,9 +16,7 @@ pub use codex_core::auth::AuthDotJson; pub use codex_core::auth::CLIENT_ID; pub use codex_core::auth::CODEX_API_KEY_ENV_VAR; pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR; -pub use codex_core::auth::get_auth_file; pub use codex_core::auth::login_with_api_key; pub use codex_core::auth::logout; -pub use codex_core::auth::try_read_auth_json; -pub use codex_core::auth::write_auth_json; +pub use codex_core::auth::save_auth; pub use codex_core::token_data::TokenData; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index e0f508e8..36186ec0 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -15,7 +15,7 @@ use crate::pkce::generate_pkce; use base64::Engine; use chrono::Utc; use codex_core::auth::AuthDotJson; -use codex_core::auth::get_auth_file; +use codex_core::auth::save_auth; use codex_core::default_client::originator; use codex_core::token_data::TokenData; use codex_core::token_data::parse_id_token; @@ -540,13 +540,6 @@ pub(crate) async fn persist_tokens_async( // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); tokio::task::spawn_blocking(move || { - let auth_file = get_auth_file(&codex_home); - if let Some(parent) = auth_file.parent() - && !parent.exists() - { - std::fs::create_dir_all(parent).map_err(io::Error::other)?; - } - let mut tokens = TokenData { id_token: parse_id_token(&id_token).map_err(io::Error::other)?, access_token, @@ -564,7 +557,7 @@ pub(crate) async fn persist_tokens_async( tokens: Some(tokens), last_refresh: Some(Utc::now()), }; - codex_core::auth::write_auth_json(&auth_file, &auth) + save_auth(&codex_home, &auth) }) .await .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 416921bf..195d7257 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -1,9 +1,9 @@ #![allow(clippy::unwrap_used)] +use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use codex_core::auth::get_auth_file; -use codex_core::auth::try_read_auth_json; +use codex_core::auth::load_auth_dot_json; use codex_login::ServerOptions; use codex_login::run_device_code_login; use serde_json::json; @@ -108,8 +108,8 @@ fn server_opts(codex_home: &tempfile::TempDir, issuer: String) -> ServerOptions } #[tokio::test] -async fn device_code_login_integration_succeeds() { - skip_if_no_network!(); +async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); let codex_home = tempdir().unwrap(); let mock_server = MockServer::start().await; @@ -133,19 +133,21 @@ async fn device_code_login_integration_succeeds() { .await .expect("device code login integration should succeed"); - let auth_path = get_auth_file(codex_home.path()); - let auth = try_read_auth_json(&auth_path).expect("auth.json written"); + let auth = load_auth_dot_json(codex_home.path()) + .context("auth.json should load after login succeeds")? + .context("auth.json written")?; // assert_eq!(auth.openai_api_key.as_deref(), Some("api-key-321")); let tokens = auth.tokens.expect("tokens persisted"); assert_eq!(tokens.access_token, "access-token-123"); assert_eq!(tokens.refresh_token, "refresh-token-123"); assert_eq!(tokens.id_token.raw_jwt, jwt); assert_eq!(tokens.account_id.as_deref(), Some("acct_321")); + Ok(()) } #[tokio::test] -async fn device_code_login_rejects_workspace_mismatch() { - skip_if_no_network!(); +async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); let codex_home = tempdir().unwrap(); let mock_server = MockServer::start().await; @@ -172,16 +174,18 @@ async fn device_code_login_rejects_workspace_mismatch() { .expect_err("device code login should fail when workspace mismatches"); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); - let auth_path = get_auth_file(codex_home.path()); + let auth = + load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; assert!( - !auth_path.exists(), + auth.is_none(), "auth.json should not be created when workspace validation fails" ); + Ok(()) } #[tokio::test] -async fn device_code_login_integration_handles_usercode_http_failure() { - skip_if_no_network!(); +async fn device_code_login_integration_handles_usercode_http_failure() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); let codex_home = tempdir().unwrap(); let mock_server = MockServer::start().await; @@ -201,13 +205,19 @@ async fn device_code_login_integration_handles_usercode_http_failure() { "unexpected error: {err:?}" ); - let auth_path = get_auth_file(codex_home.path()); - assert!(!auth_path.exists()); + let auth = + load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; + assert!( + auth.is_none(), + "auth.json should not be created when login fails" + ); + Ok(()) } #[tokio::test] -async fn device_code_login_integration_persists_without_api_key_on_exchange_failure() { - skip_if_no_network!(); +async fn device_code_login_integration_persists_without_api_key_on_exchange_failure() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); let codex_home = tempdir().unwrap(); @@ -235,18 +245,20 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail .await .expect("device login should succeed without API key exchange"); - let auth_path = get_auth_file(codex_home.path()); - let auth = try_read_auth_json(&auth_path).expect("auth.json written"); + let auth = load_auth_dot_json(codex_home.path()) + .context("auth.json should load after login succeeds")? + .context("auth.json written")?; assert!(auth.openai_api_key.is_none()); let tokens = auth.tokens.expect("tokens persisted"); assert_eq!(tokens.access_token, "access-token-123"); assert_eq!(tokens.refresh_token, "refresh-token-123"); assert_eq!(tokens.id_token.raw_jwt, jwt); + Ok(()) } #[tokio::test] -async fn device_code_login_integration_handles_error_payload() { - skip_if_no_network!(); +async fn device_code_login_integration_handles_error_payload() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); let codex_home = tempdir().unwrap(); @@ -288,9 +300,11 @@ async fn device_code_login_integration_handles_error_payload() { "Expected an authorization_declined / 400 / 404 error, got {err:?}" ); - let auth_path = get_auth_file(codex_home.path()); + let auth = + load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; assert!( - !auth_path.exists(), + auth.is_none(), "auth.json should not be created when device auth fails" ); + Ok(()) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9030b7f6..ea19d51a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -476,7 +476,7 @@ fn get_login_status(config: &Config) -> LoginStatus { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - match CodexAuth::from_codex_home(&codex_home) { + match CodexAuth::from_auth_storage(&codex_home) { Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 59362e2e..e039f0e8 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -2,8 +2,7 @@ use crate::exec_command::relativize_to_home; use crate::text_formatting; use chrono::DateTime; use chrono::Local; -use codex_core::auth::get_auth_file; -use codex_core::auth::try_read_auth_json; +use codex_core::auth::load_auth_dot_json; use codex_core::config::Config; use codex_core::project_doc::discover_project_doc_paths; use std::path::Path; @@ -84,8 +83,7 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String { } pub(crate) fn compose_account_display(config: &Config) -> Option { - let auth_file = get_auth_file(&config.codex_home); - let auth = try_read_auth_json(&auth_file).ok()?; + let auth = load_auth_dot_json(&config.codex_home).ok()??; if let Some(tokens) = auth.tokens.as_ref() { let info = &tokens.id_token;