From 4a42c4e1420e24cd74528398f7892d68c1407b3a Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 27 Oct 2025 19:41:49 -0700 Subject: [PATCH] [Auth] Choose which auth storage to use based on config (#5792) This PR is a follow-up to #5591. It allows users to choose which auth storage mode they want by using the new `cli_auth_credentials_store_mode` config. --- .../app-server/src/codex_message_processor.rs | 7 +- codex-rs/app-server/src/message_processor.rs | 6 +- .../app-server/tests/common/auth_fixtures.rs | 9 +- codex-rs/app-server/tests/suite/login.rs | 8 +- .../app-server/tests/suite/rate_limits.rs | 2 + codex-rs/app-server/tests/suite/user_info.rs | 2 + codex-rs/chatgpt/src/apply_command.rs | 3 +- codex-rs/chatgpt/src/chatgpt_client.rs | 3 +- codex-rs/chatgpt/src/chatgpt_token.rs | 8 +- codex-rs/cli/src/login.rs | 22 ++- codex-rs/cloud-tasks/src/lib.rs | 11 +- codex-rs/cloud-tasks/src/util.rs | 9 +- codex-rs/core/Cargo.toml | 7 +- codex-rs/core/src/auth.rs | 135 +++++++++++++----- codex-rs/core/src/codex.rs | 12 +- codex-rs/core/src/config.rs | 62 ++++++++ codex-rs/core/tests/suite/client.rs | 12 +- codex-rs/exec/src/lib.rs | 6 +- codex-rs/keyring-store/Cargo.toml | 7 +- codex-rs/login/src/device_code_auth.rs | 1 + codex-rs/login/src/server.rs | 8 +- .../login/tests/suite/device_code_login.rs | 32 +++-- .../login/tests/suite/login_server_e2e.rs | 6 + codex-rs/mcp-server/src/message_processor.rs | 6 +- codex-rs/tui/src/chatwidget.rs | 5 +- codex-rs/tui/src/lib.rs | 8 +- codex-rs/tui/src/onboarding/auth.rs | 18 ++- .../tui/src/onboarding/onboarding_screen.rs | 2 + codex-rs/tui/src/status/helpers.rs | 3 +- docs/config.md | 21 ++- 30 files changed, 361 insertions(+), 80 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fc79d2096..4f8c066f7 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -323,7 +323,11 @@ impl CodexMessageProcessor { } } - match login_with_api_key(&self.config.codex_home, ¶ms.api_key) { + match login_with_api_key( + &self.config.codex_home, + ¶ms.api_key, + self.config.cli_auth_credentials_store_mode, + ) { Ok(()) => { self.auth_manager.reload(); self.outgoing @@ -367,6 +371,7 @@ impl CodexMessageProcessor { config.codex_home.clone(), CLIENT_ID.to_string(), config.forced_chatgpt_workspace_id.clone(), + config.cli_auth_credentials_store_mode, ) }; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index a2c192cf2..7693cc2ff 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -37,7 +37,11 @@ impl MessageProcessor { feedback: CodexFeedback, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = AuthManager::shared(config.codex_home.clone(), false); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); let conversation_manager = Arc::new(ConversationManager::new( auth_manager.clone(), SessionSource::VSCode, diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 594b621d3..071a920b8 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -6,6 +6,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::DateTime; use chrono::Utc; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthDotJson; use codex_core::auth::save_auth; use codex_core::token_data::TokenData; @@ -108,7 +109,11 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { Ok(format!("{header_b64}.{payload_b64}.{signature_b64}")) } -pub fn write_chatgpt_auth(codex_home: &Path, fixture: ChatGptAuthFixture) -> Result<()> { +pub fn write_chatgpt_auth( + codex_home: &Path, + fixture: ChatGptAuthFixture, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> Result<()> { let id_token_raw = encode_id_token(&fixture.claims)?; let id_token = parse_id_token(&id_token_raw).context("parse id token")?; let tokens = TokenData { @@ -126,5 +131,5 @@ pub fn write_chatgpt_auth(codex_home: &Path, fixture: ChatGptAuthFixture) -> Res last_refresh, }; - save_auth(codex_home, &auth).context("write auth.json") + save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") } diff --git a/codex-rs/app-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs index d4d6374bc..471ffea8d 100644 --- a/codex-rs/app-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; use codex_login::login_with_api_key; use serial_test::serial; use tempfile::TempDir; @@ -45,7 +46,12 @@ stream_max_retries = 0 async fn logout_chatgpt_removes_auth() { let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); create_config_toml(codex_home.path()).expect("write config.toml"); - login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key"); + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + ) + .expect("seed api key"); assert!(codex_home.path().join("auth.json").exists()); let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]) diff --git a/codex-rs/app-server/tests/suite/rate_limits.rs b/codex-rs/app-server/tests/suite/rate_limits.rs index 302fd6034..65e929461 100644 --- a/codex-rs/app-server/tests/suite/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/rate_limits.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use pretty_assertions::assert_eq; @@ -106,6 +107,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") .plan_type("pro"), + AuthCredentialsStoreMode::File, ) .context("write chatgpt auth")?; diff --git a/codex-rs/app-server/tests/suite/user_info.rs b/codex-rs/app-server/tests/suite/user_info.rs index b730b77fe..849a22ac4 100644 --- a/codex-rs/app-server/tests/suite/user_info.rs +++ b/codex-rs/app-server/tests/suite/user_info.rs @@ -7,6 +7,7 @@ use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::UserInfoResponse; +use codex_core::auth::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -22,6 +23,7 @@ async fn user_info_returns_email_from_auth_json() { ChatGptAuthFixture::new("access") .refresh_token("refresh") .email("user@example.com"), + AuthCredentialsStoreMode::File, ) .expect("write chatgpt auth"); diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index ea7c69d57..e1289c213 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -32,7 +32,8 @@ pub async fn run_apply_command( ) .await?; - init_chatgpt_token_from_auth(&config.codex_home).await?; + init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) + .await?; let task_response = get_task(&config, apply_cli.task_id).await?; apply_diff_from_task(task_response, cwd).await diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index d450f7c59..752863198 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -13,7 +13,8 @@ pub(crate) async fn chatgpt_get_request( path: String, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; - init_chatgpt_token_from_auth(&config.codex_home).await?; + init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) + .await?; // Make direct HTTP request to ChatGPT backend API with the token let client = create_client(); diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index 70c6940f7..e8879ad21 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::sync::LazyLock; use std::sync::RwLock; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::token_data::TokenData; static CHATGPT_TOKEN: LazyLock>> = LazyLock::new(|| RwLock::new(None)); @@ -18,8 +19,11 @@ 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_auth_storage(codex_home)?; +pub async fn init_chatgpt_token_from_auth( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let auth = CodexAuth::from_auth_storage(codex_home, auth_credentials_store_mode)?; 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 bbb58f226..6681ab20c 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,6 +1,7 @@ use codex_app_server_protocol::AuthMode; use codex_common::CliConfigOverrides; use codex_core::CodexAuth; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::logout; @@ -17,11 +18,13 @@ use std::path::PathBuf; pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { let opts = ServerOptions::new( codex_home, CLIENT_ID.to_string(), forced_chatgpt_workspace_id, + cli_auth_credentials_store_mode, ); let server = run_login_server(opts)?; @@ -43,7 +46,13 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - match login_with_chatgpt(config.codex_home, forced_chatgpt_workspace_id).await { + match login_with_chatgpt( + config.codex_home, + forced_chatgpt_workspace_id, + config.cli_auth_credentials_store_mode, + ) + .await + { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); @@ -66,7 +75,11 @@ pub async fn run_login_with_api_key( std::process::exit(1); } - match login_with_api_key(&config.codex_home, &api_key) { + match login_with_api_key( + &config.codex_home, + &api_key, + config.cli_auth_credentials_store_mode, + ) { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); @@ -121,6 +134,7 @@ pub async fn run_login_with_device_code( config.codex_home, client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, + config.cli_auth_credentials_store_mode, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -140,7 +154,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_auth_storage(&config.codex_home) { + match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) { Ok(Some(auth)) => match auth.mode { AuthMode::ApiKey => match auth.get_token().await { Ok(api_key) => { @@ -171,7 +185,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match logout(&config.codex_home) { + match logout(&config.codex_home, config.cli_auth_credentials_store_mode) { Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 6087cbea5..a9bf0a8e1 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -58,7 +58,16 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result let auth = match codex_core::config::find_codex_home() .ok() - .map(|home| codex_login::AuthManager::new(home, false)) + .map(|home| { + let store_mode = codex_core::config::Config::load_from_base_config_with_overrides( + codex_core::config::ConfigToml::default(), + codex_core::config::ConfigOverrides::default(), + home.clone(), + ) + .map(|cfg| cfg.cli_auth_credentials_store_mode) + .unwrap_or_default(); + codex_login::AuthManager::new(home, false, store_mode) + }) .and_then(|am| am.auth()) { Some(auth) => auth, diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 5d160e54f..ff17265f3 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -70,7 +70,14 @@ pub async fn build_chatgpt_headers() -> HeaderMap { HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), ); if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new(home, false); + let store_mode = codex_core::config::Config::load_from_base_config_with_overrides( + codex_core::config::ConfigToml::default(), + codex_core::config::ConfigOverrides::default(), + home.clone(), + ) + .map(|cfg| cfg.cli_auth_credentials_store_mode) + .unwrap_or_default(); + let am = codex_login::AuthManager::new(home, false, store_mode); if let Some(auth) = am.auth() && let Ok(tok) = auth.get_token().await && !tok.is_empty() diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 1962411b6..3fc6ccd09 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -39,7 +39,12 @@ eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } indexmap = { workspace = true } -keyring = { workspace = true } +keyring = { workspace = true, features = [ + "apple-native", + "crypto-rust", + "linux-native-async-persistent", + "windows-native", +] } libc = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 49f9b7781..85fb17219 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -79,8 +79,11 @@ impl CodexAuth { } /// Loads the available auth information from auth storage. - pub fn from_auth_storage(codex_home: &Path) -> std::io::Result> { - load_auth(codex_home, false) + pub fn from_auth_storage( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> std::io::Result> { + load_auth(codex_home, false, auth_credentials_store_mode) } pub async fn get_token_data(&self) -> Result { @@ -217,36 +220,55 @@ pub fn read_codex_api_key_from_env() -> Option { /// 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 storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); +pub fn logout( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); storage.delete() } /// Writes an `auth.json` that contains only the API key. -pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { +pub fn login_with_api_key( + codex_home: &Path, + api_key: &str, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { let auth_dot_json = AuthDotJson { openai_api_key: Some(api_key.to_string()), tokens: None, last_refresh: None, }; - save_auth(codex_home, &auth_dot_json) + save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } /// 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); +pub fn save_auth( + codex_home: &Path, + auth: &AuthDotJson, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); 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); +pub fn load_auth_dot_json( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result> { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); storage.load() } pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { - let Some(auth) = load_auth(&config.codex_home, true)? else { + let Some(auth) = load_auth( + &config.codex_home, + true, + config.cli_auth_credentials_store_mode, + )? + else { return Ok(()); }; @@ -265,7 +287,11 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> }; if let Some(message) = method_violation { - return logout_with_message(&config.codex_home, message); + return logout_with_message( + &config.codex_home, + message, + config.cli_auth_credentials_store_mode, + ); } } @@ -282,6 +308,7 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> format!( "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." ), + config.cli_auth_credentials_store_mode, ); } }; @@ -297,15 +324,23 @@ pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." ), }; - return logout_with_message(&config.codex_home, message); + return logout_with_message( + &config.codex_home, + message, + config.cli_auth_credentials_store_mode, + ); } } Ok(()) } -fn logout_with_message(codex_home: &Path, message: String) -> std::io::Result<()> { - match logout(codex_home) { +fn logout_with_message( + codex_home: &Path, + message: String, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + match logout(codex_home, auth_credentials_store_mode) { Ok(_) => Err(std::io::Error::other(message)), Err(err) => Err(std::io::Error::other(format!( "{message}. Failed to remove auth.json: {err}" @@ -316,6 +351,7 @@ fn logout_with_message(codex_home: &Path, message: String) -> std::io::Result<() fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result> { if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { let client = crate::default_client::create_client(); @@ -325,7 +361,7 @@ fn load_auth( ))); } - let storage = create_auth_storage(codex_home.to_path_buf(), AuthCredentialsStoreMode::File); + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); let client = crate::default_client::create_client(); let auth_dot_json = match storage.load()? { @@ -512,7 +548,8 @@ mod tests { ) .unwrap(); - super::login_with_api_key(dir.path(), "sk-new").expect("login_with_api_key should succeed"); + super::login_with_api_key(dir.path(), "sk-new", AuthCredentialsStoreMode::File) + .expect("login_with_api_key should succeed"); let storage = FileAuthStorage::new(dir.path().to_path_buf()); let auth = storage @@ -525,7 +562,8 @@ mod tests { #[test] fn missing_auth_json_returns_none() { let dir = tempdir().unwrap(); - let auth = CodexAuth::from_auth_storage(dir.path()).expect("call should succeed"); + let auth = CodexAuth::from_auth_storage(dir.path(), AuthCredentialsStoreMode::File) + .expect("call should succeed"); assert_eq!(auth, None); } @@ -549,7 +587,9 @@ mod tests { auth_dot_json, storage: _, .. - } = super::load_auth(codex_home.path(), false).unwrap().unwrap(); + } = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .unwrap() + .unwrap(); assert_eq!(None, api_key); assert_eq!(AuthMode::ChatGPT, mode); @@ -590,7 +630,9 @@ mod tests { ) .unwrap(); - let auth = super::load_auth(dir.path(), false).unwrap().unwrap(); + let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File) + .unwrap() + .unwrap(); assert_eq!(auth.mode, AuthMode::ApiKey); assert_eq!(auth.api_key, Some("sk-test-key".to_string())); @@ -605,10 +647,10 @@ mod tests { tokens: None, last_refresh: None, }; - super::save_auth(dir.path(), &auth_dot_json)?; + super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); assert!(auth_file.exists()); - assert!(logout(dir.path())?); + assert!(logout(dir.path(), AuthCredentialsStoreMode::File)?); assert!(!auth_file.exists()); Ok(()) } @@ -717,7 +759,8 @@ mod tests { #[tokio::test] async fn enforce_login_restrictions_logs_out_for_method_mismatch() { let codex_home = tempdir().unwrap(); - login_with_api_key(codex_home.path(), "sk-test").expect("seed api key"); + login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) + .expect("seed api key"); let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None); @@ -786,7 +829,8 @@ mod tests { async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() { let codex_home = tempdir().unwrap(); - login_with_api_key(codex_home.path(), "sk-test").expect("seed api key"); + login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) + .expect("seed api key"); let config = build_config(codex_home.path(), None, Some("org_mine".to_string())); @@ -830,6 +874,7 @@ pub struct AuthManager { codex_home: PathBuf, inner: RwLock, enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, } impl AuthManager { @@ -837,14 +882,23 @@ impl AuthManager { /// preferred auth method. Errors loading auth are swallowed; `auth()` will /// simply return `None` in that case so callers can treat it as an /// unauthenticated state. - pub fn new(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Self { - let auth = load_auth(&codex_home, enable_codex_api_key_env) - .ok() - .flatten(); + pub fn new( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> Self { + let auth = load_auth( + &codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + ) + .ok() + .flatten(); Self { codex_home, inner: RwLock::new(CachedAuth { auth }), enable_codex_api_key_env, + auth_credentials_store_mode, } } @@ -855,6 +909,7 @@ impl AuthManager { codex_home: PathBuf::new(), inner: RwLock::new(cached), enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, }) } @@ -866,9 +921,13 @@ impl AuthManager { /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub fn reload(&self) -> bool { - let new_auth = load_auth(&self.codex_home, self.enable_codex_api_key_env) - .ok() - .flatten(); + let new_auth = load_auth( + &self.codex_home, + self.enable_codex_api_key_env, + self.auth_credentials_store_mode, + ) + .ok() + .flatten(); if let Ok(mut guard) = self.inner.write() { let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); guard.auth = new_auth; @@ -887,8 +946,16 @@ impl AuthManager { } /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Arc { - Arc::new(Self::new(codex_home, enable_codex_api_key_env)) + pub fn shared( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> Arc { + Arc::new(Self::new( + codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + )) } /// Attempt to refresh the current auth token (if any). On success, reload @@ -916,7 +983,7 @@ impl AuthManager { /// reloads the in‑memory auth cache so callers immediately observe the /// unauthenticated state. pub fn logout(&self) -> std::io::Result { - let removed = super::auth::logout(&self.codex_home)?; + let removed = super::auth::logout(&self.codex_home, self.auth_credentials_store_mode)?; // Always reload to clear any cached auth (even if file absent). self.reload(); Ok(removed) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1e33335f4..59d7332ec 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2525,7 +2525,11 @@ mod tests { let config = Arc::new(config); let conversation_id = ConversationId::default(); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); - let auth_manager = AuthManager::shared(config.cwd.clone(), false); + let auth_manager = AuthManager::shared( + config.cwd.clone(), + false, + config.cli_auth_credentials_store_mode, + ); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), @@ -2594,7 +2598,11 @@ mod tests { let config = Arc::new(config); let conversation_id = ConversationId::default(); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); - let auth_manager = AuthManager::shared(config.cwd.clone(), false); + let auth_manager = AuthManager::shared( + config.cwd.clone(), + false, + config.cli_auth_credentials_store_mode, + ); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 380978987..f671e8dd3 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,3 +1,4 @@ +use crate::auth::AuthCredentialsStoreMode; use crate::config_loader::LoadedConfigLayers; pub use crate::config_loader::load_config_as_toml; use crate::config_loader::load_config_layers_with_overrides; @@ -160,6 +161,12 @@ pub struct Config { /// resolved against this path. pub cwd: PathBuf, + /// Preferred store for CLI auth credentials. + /// file (default): Use a file in the Codex home directory. + /// keyring: Use an OS-specific keyring service. + /// auto: Use the OS-specific keyring service if available, otherwise use a file. + pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + /// Definition for MCP servers that Codex can reach out to for tool calls. pub mcp_servers: HashMap, @@ -873,6 +880,13 @@ pub struct ConfigToml { #[serde(default)] pub forced_login_method: Option, + /// Preferred backend for storing CLI auth credentials. + /// file (default): Use a file in the Codex home directory. + /// keyring: Use an OS-specific keyring service. + /// auto: Use the keyring if available, otherwise use a file. + #[serde(default)] + pub cli_auth_credentials_store: Option, + /// Definition for MCP servers that Codex can reach out to for tool calls. #[serde(default)] pub mcp_servers: HashMap, @@ -1381,6 +1395,9 @@ impl Config { notify: cfg.notify, user_instructions, base_instructions, + // The config.toml omits "_mode" because it's a config file. However, "_mode" + // is important in code to differentiate the mode from the store implementation. + cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), mcp_servers: cfg.mcp_servers, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. @@ -1803,6 +1820,47 @@ trust_level = "trusted" Ok(()) } + #[test] + fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml::default(); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.cli_auth_credentials_store_mode, + AuthCredentialsStoreMode::File, + ); + + Ok(()) + } + + #[test] + fn config_honors_explicit_keyring_auth_store_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.cli_auth_credentials_store_mode, + AuthCredentialsStoreMode::Keyring, + ); + + Ok(()) + } + #[test] fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3025,6 +3083,7 @@ model_verbosity = "high" user_instructions: None, notify: None, cwd: fixture.cwd(), + cli_auth_credentials_store_mode: Default::default(), mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), @@ -3095,6 +3154,7 @@ model_verbosity = "high" user_instructions: None, notify: None, cwd: fixture.cwd(), + cli_auth_credentials_store_mode: Default::default(), mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), @@ -3180,6 +3240,7 @@ model_verbosity = "high" user_instructions: None, notify: None, cwd: fixture.cwd(), + cli_auth_credentials_store_mode: Default::default(), mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), @@ -3251,6 +3312,7 @@ model_verbosity = "high" user_instructions: None, notify: None, cwd: fixture.cwd(), + cli_auth_credentials_store_mode: Default::default(), mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c8ebdcb2c..02e9cdc36 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -12,6 +12,7 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; use codex_core::WireApi; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; use codex_core::error::CodexErr; use codex_core::model_family::find_family_for_model; @@ -525,11 +526,12 @@ 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_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}"), - }; + let auth_manager = + match CodexAuth::from_auth_storage(codex_home.path(), AuthCredentialsStoreMode::File) { + 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}"), + }; let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec); let NewConversation { conversation: codex, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 10a437616..559fd9800 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -249,7 +249,11 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any std::process::exit(1); } - let auth_manager = AuthManager::shared(config.codex_home.clone(), true); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + true, + config.cli_auth_credentials_store_mode, + ); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); // Handle resume subcommand by resolving a rollout path and using explicit resume API. diff --git a/codex-rs/keyring-store/Cargo.toml b/codex-rs/keyring-store/Cargo.toml index 94d3d5449..f662e5d4f 100644 --- a/codex-rs/keyring-store/Cargo.toml +++ b/codex-rs/keyring-store/Cargo.toml @@ -7,5 +7,10 @@ version = { workspace = true } workspace = true [dependencies] -keyring = { workspace = true } +keyring = { workspace = true, features = [ + "apple-native", + "crypto-rust", + "linux-native-async-persistent", + "windows-native", +] } tracing = { workspace = true } diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index f1999a830..acaf30ba0 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -199,6 +199,7 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { tokens.id_token, tokens.access_token, tokens.refresh_token, + opts.cli_auth_credentials_store_mode, ) .await } diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 36186ec01..999c19072 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -14,6 +14,7 @@ use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use base64::Engine; use chrono::Utc; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthDotJson; use codex_core::auth::save_auth; use codex_core::default_client::originator; @@ -39,6 +40,7 @@ pub struct ServerOptions { pub open_browser: bool, pub force_state: Option, pub forced_chatgpt_workspace_id: Option, + pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, } impl ServerOptions { @@ -46,6 +48,7 @@ impl ServerOptions { codex_home: PathBuf, client_id: String, forced_chatgpt_workspace_id: Option, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> Self { Self { codex_home, @@ -55,6 +58,7 @@ impl ServerOptions { open_browser: true, force_state: None, forced_chatgpt_workspace_id, + cli_auth_credentials_store_mode, } } } @@ -270,6 +274,7 @@ async fn process_request( tokens.id_token.clone(), tokens.access_token.clone(), tokens.refresh_token.clone(), + opts.cli_auth_credentials_store_mode, ) .await { @@ -536,6 +541,7 @@ pub(crate) async fn persist_tokens_async( id_token: String, access_token: String, refresh_token: String, + auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> io::Result<()> { // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); @@ -557,7 +563,7 @@ pub(crate) async fn persist_tokens_async( tokens: Some(tokens), last_refresh: Some(Utc::now()), }; - save_auth(&codex_home, &auth) + save_auth(&codex_home, &auth, auth_credentials_store_mode) }) .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 195d72573..266930e41 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -3,6 +3,7 @@ use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::load_auth_dot_json; use codex_login::ServerOptions; use codex_login::run_device_code_login; @@ -96,11 +97,16 @@ async fn mock_oauth_token_single(server: &MockServer, jwt: String) { .await; } -fn server_opts(codex_home: &tempfile::TempDir, issuer: String) -> ServerOptions { +fn server_opts( + codex_home: &tempfile::TempDir, + issuer: String, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> ServerOptions { let mut opts = ServerOptions::new( codex_home.path().to_path_buf(), "client-id".to_string(), None, + cli_auth_credentials_store_mode, ); opts.issuer = issuer; opts.open_browser = false; @@ -127,13 +133,13 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { mock_oauth_token_single(&mock_server, jwt.clone()).await; let issuer = mock_server.uri(); - let opts = server_opts(&codex_home, issuer); + let opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); run_device_code_login(opts) .await .expect("device code login integration should succeed"); - let auth = load_auth_dot_json(codex_home.path()) + let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) .context("auth.json should load after login succeeds")? .context("auth.json written")?; // assert_eq!(auth.openai_api_key.as_deref(), Some("api-key-321")); @@ -166,7 +172,7 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { mock_oauth_token_single(&mock_server, jwt).await; let issuer = mock_server.uri(); - let mut opts = server_opts(&codex_home, issuer); + let mut opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); opts.forced_chatgpt_workspace_id = Some("org-required".to_string()); let err = run_device_code_login(opts) @@ -174,8 +180,8 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { .expect_err("device code login should fail when workspace mismatches"); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); - let auth = - load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; + let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) + .context("auth.json should load after login fails")?; assert!( auth.is_none(), "auth.json should not be created when workspace validation fails" @@ -194,7 +200,7 @@ async fn device_code_login_integration_handles_usercode_http_failure() -> anyhow let issuer = mock_server.uri(); - let opts = server_opts(&codex_home, issuer); + let opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); let err = run_device_code_login(opts) .await @@ -205,8 +211,8 @@ async fn device_code_login_integration_handles_usercode_http_failure() -> anyhow "unexpected error: {err:?}" ); - let auth = - load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; + let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) + .context("auth.json should load after login fails")?; assert!( auth.is_none(), "auth.json should not be created when login fails" @@ -237,6 +243,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail codex_home.path().to_path_buf(), "client-id".to_string(), None, + AuthCredentialsStoreMode::File, ); opts.issuer = issuer; opts.open_browser = false; @@ -245,7 +252,7 @@ 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 = load_auth_dot_json(codex_home.path()) + let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) .context("auth.json should load after login succeeds")? .context("auth.json written")?; assert!(auth.openai_api_key.is_none()); @@ -286,6 +293,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result codex_home.path().to_path_buf(), "client-id".to_string(), None, + AuthCredentialsStoreMode::File, ); opts.issuer = issuer; opts.open_browser = false; @@ -300,8 +308,8 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result "Expected an authorization_declined / 400 / 404 error, got {err:?}" ); - let auth = - load_auth_dot_json(codex_home.path()).context("auth.json should load after login fails")?; + let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) + .context("auth.json should load after login fails")?; assert!( auth.is_none(), "auth.json should not be created when device auth fails" diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 3a5f869b5..73cb8bd42 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; +use codex_core::auth::AuthCredentialsStoreMode; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; @@ -110,6 +111,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { let opts = ServerOptions { codex_home: server_home, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -170,6 +172,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { let server_home = codex_home.clone(); let opts = ServerOptions { codex_home: server_home, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -208,6 +211,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { let opts = ServerOptions { codex_home: codex_home.clone(), + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -263,6 +267,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let first_opts = ServerOptions { codex_home: first_codex_home, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), port: 0, @@ -282,6 +287,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let second_opts = ServerOptions { codex_home: second_codex_home, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: login_port, diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index b21d5a650..81eb80764 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -53,7 +53,11 @@ impl MessageProcessor { config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = AuthManager::shared(config.codex_home.clone(), false); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); let conversation_manager = Arc::new(ConversationManager::new(auth_manager, SessionSource::Mcp)); Self { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f59666057..e1255c84d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1219,7 +1219,10 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ExitRequest); } SlashCommand::Logout => { - if let Err(e) = codex_core::auth::logout(&self.config.codex_home) { + if let Err(e) = codex_core::auth::logout( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { tracing::error!("failed to logout: {e}"); } self.app_event_tx.send(AppEvent::ExitRequest); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 542bc4647..028bf68e8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -315,7 +315,11 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); - let auth_manager = AuthManager::shared(initial_config.codex_home.clone(), false); + let auth_manager = AuthManager::shared( + initial_config.codex_home.clone(), + false, + initial_config.cli_auth_credentials_store_mode, + ); let login_status = get_login_status(&initial_config); let should_show_trust_screen = should_show_trust_screen(&initial_config); let should_show_windows_wsl_screen = @@ -476,7 +480,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_auth_storage(&codex_home) { + match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) { Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 05687bb1b..56527ac8e 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use codex_core::AuthManager; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; @@ -148,6 +149,7 @@ pub(crate) struct AuthModeWidget { pub error: Option, pub sign_in_state: Arc>, pub codex_home: PathBuf, + pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub login_status: LoginStatus, pub auth_manager: Arc, pub forced_chatgpt_workspace_id: Option, @@ -512,7 +514,11 @@ impl AuthModeWidget { self.disallow_api_login(); return; } - match login_with_api_key(&self.codex_home, &api_key) { + match login_with_api_key( + &self.codex_home, + &api_key, + self.cli_auth_credentials_store_mode, + ) { Ok(()) => { self.error = None; self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey); @@ -553,6 +559,7 @@ impl AuthModeWidget { self.codex_home.clone(), CLIENT_ID.to_string(), self.forced_chatgpt_workspace_id.clone(), + self.cli_auth_credentials_store_mode, ); match run_login_server(opts) { Ok(child) => { @@ -640,6 +647,8 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; + use codex_core::auth::AuthCredentialsStoreMode; + fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { let codex_home = TempDir::new().unwrap(); let codex_home_path = codex_home.path().to_path_buf(); @@ -649,8 +658,13 @@ mod tests { error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home_path.clone(), + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, login_status: LoginStatus::NotAuthenticated, - auth_manager: AuthManager::shared(codex_home_path, false), + auth_manager: AuthManager::shared( + codex_home_path, + false, + AuthCredentialsStoreMode::File, + ), forced_chatgpt_workspace_id: None, forced_login_method: Some(ForcedLoginMethod::Chatgpt), }; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 369e6ddbb..709f158b8 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -87,6 +87,7 @@ impl OnboardingScreen { let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let forced_login_method = config.forced_login_method; let codex_home = config.codex_home; + let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode; let mut steps: Vec = Vec::new(); if show_windows_wsl_screen { steps.push(Step::Windows(WindowsSetupWidget::new(codex_home.clone()))); @@ -106,6 +107,7 @@ impl OnboardingScreen { error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home.clone(), + cli_auth_credentials_store_mode, login_status, auth_manager, forced_chatgpt_workspace_id, diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index e039f0e89..fdbaabd38 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -83,7 +83,8 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String { } pub(crate) fn compose_account_display(config: &Config) -> Option { - let auth = load_auth_dot_json(&config.codex_home).ok()??; + let auth = + load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode).ok()??; if let Some(tokens) = auth.tokens.as_ref() { let info = &tokens.id_token; diff --git a/docs/config.md b/docs/config.md index 9223f48d2..0d81a64ef 100644 --- a/docs/config.md +++ b/docs/config.md @@ -836,7 +836,9 @@ notifications = [ "agent-turn-complete", "approval-requested" ] > [!NOTE] > `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together. -## Forcing a login method +## Authentication and authorization + +### Forcing a login method To force users on a given machine to use a specific login method or workspace, use a combination of [managed configurations](https://developers.openai.com/codex/security#managed-configuration) as well as either or both of the following fields: @@ -852,6 +854,22 @@ If the active credentials don't match the config, the user will be logged out an If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, API key login will still work. +### Control where login credentials are stored + +```toml +cli_auth_credentials_store = "keyring" +``` + +Valid values: + +- `file` (default) – Store credentials in `auth.json` under `$CODEX_HOME`. +- `keyring` – Store credentials in the operating system keyring via the [`keyring` crate](https://crates.io/crates/keyring); the CLI reports an error if secure storage is unavailable. Backends by OS: + - macOS: macOS Keychain + - Windows: Windows Credential Manager + - Linux: DBus‑based Secret Service, the kernel keyutils, or a combination + - FreeBSD/OpenBSD: DBus‑based Secret Service +- `auto` – Save credentials to the operating system keyring when available; otherwise, fall back to `auth.json` under `$CODEX_HOME`. + ## Config reference | Key | Type / Values | Notes | @@ -910,4 +928,5 @@ If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, AP | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | | `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. | | `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. | +| `cli_auth_credentials_store` | `file` \| `keyring` \| `auto` | Where to store CLI login credentials (default: `file`). | | `tools.view_image` | boolean | Enable the `view_image` tool so Codex can attach local image files from the workspace (default: false). |