From 3f91bb08061f4a88877ed2bcd939c5fee31b6151 Mon Sep 17 00:00:00 2001 From: celia-oai Date: Mon, 25 May 2026 21:57:51 -0700 Subject: [PATCH] changes --- .../request_processors/account_processor.rs | 2 + .../app-server/tests/common/auth_fixtures.rs | 9 +- codex-rs/cli/src/doctor.rs | 17 ++- codex-rs/cli/src/login.rs | 17 ++- codex-rs/cloud-config/src/bundle_loader.rs | 2 + codex-rs/cloud-tasks/src/util.rs | 3 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/mod.rs | 15 ++ codex-rs/exec/src/lib.rs | 1 + codex-rs/features/src/lib.rs | 8 ++ codex-rs/features/src/tests.rs | 10 ++ codex-rs/login/src/auth/auth_tests.rs | 60 ++++++-- codex-rs/login/src/auth/manager.rs | 120 ++++++++++++++-- codex-rs/login/src/auth/storage.rs | 128 ++++++++++++++++-- codex-rs/login/src/auth/storage_tests.rs | 120 +++++++++++++--- codex-rs/login/src/device_code_auth.rs | 1 + codex-rs/login/src/lib.rs | 1 + codex-rs/login/src/server.rs | 32 ++++- codex-rs/login/tests/suite/auth_refresh.rs | 20 ++- .../login/tests/suite/device_code_login.rs | 48 +++++-- .../login/tests/suite/login_server_e2e.rs | 10 ++ codex-rs/login/tests/suite/logout.rs | 20 ++- codex-rs/models-manager/src/manager_tests.rs | 2 + codex-rs/secrets/src/lib.rs | 2 +- codex-rs/tui/src/lib.rs | 1 + 25 files changed, 577 insertions(+), 78 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index a4bbe42d61..9c78759d8d 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -286,6 +286,7 @@ impl AccountRequestProcessor { &self.config.codex_home, ¶ms.api_key, self.config.cli_auth_credentials_store_mode, + self.config.cli_auth_keyring_backend_kind(), ) { Ok(()) => { self.auth_manager.reload().await; @@ -334,6 +335,7 @@ impl AccountRequestProcessor { CLIENT_ID.to_string(), config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, + config.cli_auth_keyring_backend_kind(), ) }; #[cfg(debug_assertions)] diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index cf78e788de..2ba1bcec29 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -9,6 +9,7 @@ use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; +use codex_login::CliAuthKeyringBackendKind; use codex_login::save_auth; use codex_login::token_data::TokenData; use codex_login::token_data::parse_chatgpt_jwt_claims; @@ -167,5 +168,11 @@ pub fn write_chatgpt_auth( personal_access_token: None, }; - save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") + save_auth( + codex_home, + &auth, + cli_auth_credentials_store_mode, + CliAuthKeyringBackendKind::default(), + ) + .context("write auth.json") } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index abb5daf973..f851179361 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1196,7 +1196,11 @@ fn auth_check(config: &Config) -> DoctorCheck { return check; } - match load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) { + match load_auth_dot_json( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.cli_auth_keyring_backend_kind(), + ) { Ok(Some(auth)) => { details.push(format!("stored auth mode: {}", stored_auth_mode(&auth))); details.push(format!("stored API key: {}", auth.openai_api_key.is_some())); @@ -2519,10 +2523,13 @@ impl ProviderAuthReachabilityMode { } fn provider_reachability_plan(config: &Config) -> ReachabilityPlan { - let stored_auth = - load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) - .ok() - .flatten(); + let stored_auth = load_auth_dot_json( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.cli_auth_keyring_backend_kind(), + ) + .ok() + .flatten(); let mode = provider_auth_reachability_mode_from_auth( config.model_provider.requires_openai_auth, env_var_present, diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index b3fc1bd544..c341242dff 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; use codex_login::CLIENT_ID; +use codex_login::CliAuthKeyringBackendKind; use codex_login::CodexAuth; use codex_login::ServerOptions; use codex_login::login_with_access_token; @@ -117,12 +118,14 @@ pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result<()> { let opts = ServerOptions::new( codex_home, CLIENT_ID.to_string(), forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, + cli_auth_keyring_backend_kind, ); let server = run_login_server(opts)?; @@ -147,6 +150,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> config.codex_home.to_path_buf(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + config.cli_auth_keyring_backend_kind(), ) .await { @@ -178,6 +182,7 @@ pub async fn run_login_with_api_key( &config.codex_home, &api_key, config.cli_auth_credentials_store_mode, + config.cli_auth_keyring_backend_kind(), ) { Ok(_) => { eprintln!("{LOGIN_SUCCESS_MESSAGE}"); @@ -208,6 +213,7 @@ pub async fn run_login_with_access_token( &access_token, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), + config.cli_auth_keyring_backend_kind(), ) .await { @@ -282,6 +288,7 @@ pub async fn run_login_with_device_code( client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + config.cli_auth_keyring_backend_kind(), ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -321,6 +328,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + config.cli_auth_keyring_backend_kind(), ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -369,6 +377,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), + config.cli_auth_keyring_backend_kind(), ) .await { @@ -410,7 +419,13 @@ 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_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { + match logout_with_revoke( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.cli_auth_keyring_backend_kind(), + ) + .await + { Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index c269460c1f..2e9a1175bd 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -6,6 +6,7 @@ use codex_config::CloudConfigBundleLoadErrorCode; use codex_config::CloudConfigBundleLoader; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthManager; +use codex_login::CliAuthKeyringBackendKind; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -62,6 +63,7 @@ pub async fn cloud_config_bundle_loader_for_storage( enable_codex_api_key_env, credentials_store_mode, Some(chatgpt_base_url.clone()), + CliAuthKeyringBackendKind::default(), ) .await; cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 9a5056aa66..3ffde2d0dd 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -49,7 +49,8 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option CliAuthKeyringBackendKind { + Config::cli_auth_keyring_backend_kind(self) + } + fn forced_chatgpt_workspace_id(&self) -> Option> { self.forced_chatgpt_workspace_id.clone() } @@ -1351,6 +1356,16 @@ impl Config { workspace_roots } + pub fn cli_auth_keyring_backend_kind(&self) -> CliAuthKeyringBackendKind { + // TODO: Migrate all keyring-backed CLI auth storage to the secrets + // backend after validating this path on Windows. + if self.features.enabled(Feature::SecretAuthStorage) { + CliAuthKeyringBackendKind::Secrets + } else { + CliAuthKeyringBackendKind::Direct + } + } + pub fn to_models_manager_config(&self) -> ModelsManagerConfig { ModelsManagerConfig { model_context_window: self.model_context_window, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index fbecfe5620..63dd23b9ab 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -466,6 +466,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, + keyring_backend_kind: config.cli_auth_keyring_backend_kind(), forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index b30c255165..d398144a08 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -81,6 +81,8 @@ pub enum Feature { ShellTool, /// Enable Claude-style lifecycle hooks loaded from hooks.json files. CodexHooks, + /// Store CLI auth in the encrypted local secrets backend when keyring storage is selected. + SecretAuthStorage, // Experimental /// Enable JavaScript code mode backed by the in-process V8 runtime. @@ -744,6 +746,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::SecretAuthStorage, + key: "secret_auth_storage", + stage: Stage::Stable, + default_enabled: cfg!(windows), + }, FeatureSpec { id: Feature::UnifiedExec, key: "unified_exec", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index b7d158ca36..869054e4b6 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -199,6 +199,16 @@ fn tool_search_is_removed_and_disabled_by_default() { assert_eq!(feature_for_key("tool_search"), Some(Feature::ToolSearch)); } +#[test] +fn secret_auth_storage_defaults_to_windows_only() { + assert_eq!(Feature::SecretAuthStorage.stage(), Stage::Stable); + assert_eq!(Feature::SecretAuthStorage.default_enabled(), cfg!(windows)); + assert_eq!( + feature_for_key("secret_auth_storage"), + Some(Feature::SecretAuthStorage) + ); +} + #[test] fn browser_controls_are_stable_and_enabled_by_default() { assert_eq!(Feature::InAppBrowser.stage(), Stage::Stable); diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index e7b5502ebc..6d5df6a8d3 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -43,6 +43,7 @@ async fn refresh_without_id_token() { let storage = create_auth_storage( codex_home.path().to_path_buf(), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), ); let updated = super::persist_tokens( &storage, @@ -77,8 +78,13 @@ fn login_with_api_key_overwrites_existing_auth_json() { ) .unwrap(); - super::login_with_api_key(dir.path(), "sk-new", AuthCredentialsStoreMode::File) - .expect("login_with_api_key should succeed"); + super::login_with_api_key( + dir.path(), + "sk-new", + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .expect("login_with_api_key should succeed"); let storage = FileAuthStorage::new(dir.path().to_path_buf()); let auth = storage @@ -109,6 +115,7 @@ async fn login_with_access_token_writes_only_token() { &agent_identity, AuthCredentialsStoreMode::File, Some(&chatgpt_base_url), + CliAuthKeyringBackendKind::default(), ) .await .expect("login_with_access_token should succeed"); @@ -214,6 +221,7 @@ async fn login_with_access_token_rejects_invalid_jwt() { "not-a-jwt", AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::default(), ) .await .expect_err("invalid access token should fail"); @@ -244,6 +252,7 @@ async fn login_with_access_token_rejects_unsigned_jwt() { &agent_identity, AuthCredentialsStoreMode::File, Some(&chatgpt_base_url), + CliAuthKeyringBackendKind::default(), ) .await .expect_err("unsigned access token should fail"); @@ -264,6 +273,7 @@ async fn missing_auth_json_returns_none() { dir.path(), AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::default(), ) .await .expect("call should succeed"); @@ -290,6 +300,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .unwrap() @@ -347,6 +358,7 @@ async fn loads_api_key_from_auth_json() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .unwrap() @@ -368,10 +380,19 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> { agent_identity: None, personal_access_token: None, }; - super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; + super::save_auth( + dir.path(), + &auth_dot_json, + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + )?; let auth_file = get_auth_file(dir.path()); assert!(auth_file.exists()); - assert!(logout(dir.path(), AuthCredentialsStoreMode::File)?); + assert!(logout( + dir.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + )?); assert!(!auth_file.exists()); Ok(()) } @@ -384,6 +405,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::default(), ) .await; let managed = UnauthorizedRecovery { @@ -425,6 +447,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -443,6 +466,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { updated_auth_dot_json, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .expect("updated auth should parse"); @@ -744,6 +768,7 @@ async fn build_config( AuthConfig { codex_home: codex_home.to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: CliAuthKeyringBackendKind::Direct, forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, @@ -826,6 +851,7 @@ async fn load_auth_reads_access_token_from_env() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, Some(&chatgpt_base_url), + CliAuthKeyringBackendKind::Direct, ) .await .expect("env auth should load") @@ -949,6 +975,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*enable_codex_api_key_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .expect("env auth should load") @@ -962,8 +989,13 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { async fn enforce_login_restrictions_logs_out_for_method_mismatch() { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); - login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) - .expect("seed api key"); + login_with_api_key( + codex_home.path(), + "sk-test", + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .expect("seed api key"); let config = build_config( codex_home.path(), @@ -1114,12 +1146,14 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat personal_access_token: None, }, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), ) .expect("seed agent identity auth"); let config = AuthConfig { codex_home: codex_home.path().to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: CliAuthKeyringBackendKind::Direct, forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), @@ -1143,8 +1177,13 @@ async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_f { let codex_home = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); - login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) - .expect("seed api key"); + login_with_api_key( + codex_home.path(), + "sk-test", + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .expect("seed api key"); let config = build_config( codex_home.path(), @@ -1368,6 +1407,7 @@ async fn plan_type_maps_known_plan() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -1396,6 +1436,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -1427,6 +1468,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -1458,6 +1500,7 @@ async fn plan_type_maps_unknown_to_unknown() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .expect("load auth") @@ -1486,6 +1529,7 @@ async fn missing_plan_type_maps_to_unknown() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::Direct, ) .await .expect("load auth") diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 9e255a99a3..90629dd194 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -33,6 +33,7 @@ pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; +pub use crate::auth::storage::CliAuthKeyringBackendKind; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; use crate::default_client::build_reqwest_client; @@ -210,6 +211,7 @@ impl CodexAuth { auth_dot_json: AuthDotJson, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); let client = create_client(); @@ -244,7 +246,11 @@ impl CodexAuth { match auth_mode { ApiAuthMode::Chatgpt => { - let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + storage_mode, + keyring_backend_kind, + ); Ok(Self::Chatgpt(ChatgptAuth { state, storage })) } ApiAuthMode::ChatgptAuthTokens => { @@ -262,12 +268,14 @@ impl CodexAuth { codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result> { load_auth( codex_home, /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, chatgpt_base_url, + keyring_backend_kind, ) .await } @@ -483,6 +491,7 @@ impl CodexAuth { let storage = create_auth_storage( PathBuf::from(format!("dummy-chatgpt-auth-{dummy_auth_id}")), AuthCredentialsStoreMode::Ephemeral, + CliAuthKeyringBackendKind::default(), ); Self::Chatgpt(ChatgptAuth { state, storage }) } @@ -556,20 +565,27 @@ async fn verified_agent_identity_record( pub fn logout( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result { - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + auth_credentials_store_mode, + keyring_backend_kind, + ); storage.delete() } pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result { AuthManager::new( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, /*chatgpt_base_url*/ None, + keyring_backend_kind, ) .await .logout_with_revoke() @@ -581,6 +597,7 @@ pub fn login_with_api_key( codex_home: &Path, api_key: &str, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result<()> { let auth_dot_json = AuthDotJson { auth_mode: Some(ApiAuthMode::ApiKey), @@ -590,7 +607,12 @@ pub fn login_with_api_key( agent_identity: None, personal_access_token: None, }; - save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) + save_auth( + codex_home, + &auth_dot_json, + auth_credentials_store_mode, + keyring_backend_kind, + ) } /// Writes an `auth.json` that contains only the access token. @@ -599,6 +621,7 @@ pub async fn login_with_access_token( access_token: &str, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result<()> { let auth_dot_json = match classify_codex_access_token(access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { @@ -630,7 +653,12 @@ pub async fn login_with_access_token( } } }; - save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) + save_auth( + codex_home, + &auth_dot_json, + auth_credentials_store_mode, + keyring_backend_kind, + ) } /// Writes an in-memory auth payload for externally managed ChatGPT tokens. @@ -649,6 +677,7 @@ pub fn login_with_chatgpt_auth_tokens( codex_home, &auth_dot_json, AuthCredentialsStoreMode::Ephemeral, + CliAuthKeyringBackendKind::default(), ) } @@ -657,8 +686,13 @@ pub fn save_auth( codex_home: &Path, auth: &AuthDotJson, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result<()> { - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + auth_credentials_store_mode, + keyring_backend_kind, + ); storage.save(auth) } @@ -670,8 +704,13 @@ pub fn save_auth( pub fn load_auth_dot_json( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result> { - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + auth_credentials_store_mode, + keyring_backend_kind, + ); storage.load() } @@ -679,6 +718,7 @@ pub fn load_auth_dot_json( pub struct AuthConfig { pub codex_home: PathBuf, pub auth_credentials_store_mode: AuthCredentialsStoreMode, + pub keyring_backend_kind: CliAuthKeyringBackendKind, pub forced_login_method: Option, pub chatgpt_base_url: Option, pub forced_chatgpt_workspace_id: Option>, @@ -690,6 +730,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< /*enable_codex_api_key_env*/ true, config.auth_credentials_store_mode, config.chatgpt_base_url.as_deref(), + config.keyring_backend_kind, ) .await? else { @@ -721,6 +762,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< &config.codex_home, message, config.auth_credentials_store_mode, + config.keyring_backend_kind, ); } } @@ -739,6 +781,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." ), config.auth_credentials_store_mode, + config.keyring_backend_kind, ); } }; @@ -766,6 +809,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< &config.codex_home, message, config.auth_credentials_store_mode, + config.keyring_backend_kind, ); } } @@ -777,10 +821,15 @@ fn logout_with_message( codex_home: &Path, message: String, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result<()> { // External auth tokens live in the ephemeral store, but persistent auth may still exist // from earlier logins. Clear both so a forced logout truly removes all active auth. - let removal_result = logout_all_stores(codex_home, auth_credentials_store_mode); + let removal_result = logout_all_stores( + codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + ); let error_message = match removal_result { Ok(_) => message, Err(err) => format!("{message}. Failed to remove auth.json: {err}"), @@ -791,12 +840,25 @@ fn logout_with_message( fn logout_all_stores( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result { if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { - return logout(codex_home, AuthCredentialsStoreMode::Ephemeral); + return logout( + codex_home, + AuthCredentialsStoreMode::Ephemeral, + CliAuthKeyringBackendKind::default(), + ); } - let removed_ephemeral = logout(codex_home, AuthCredentialsStoreMode::Ephemeral)?; - let removed_managed = logout(codex_home, auth_credentials_store_mode)?; + let removed_ephemeral = logout( + codex_home, + AuthCredentialsStoreMode::Ephemeral, + CliAuthKeyringBackendKind::default(), + )?; + let removed_managed = logout( + codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + )?; Ok(removed_ephemeral || removed_managed) } @@ -805,6 +867,7 @@ async fn load_auth( enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -816,6 +879,7 @@ async fn load_auth( let ephemeral_storage = create_auth_storage( codex_home.to_path_buf(), AuthCredentialsStoreMode::Ephemeral, + CliAuthKeyringBackendKind::default(), ); if let Some(auth_dot_json) = ephemeral_storage.load()? { let auth = CodexAuth::from_auth_dot_json( @@ -823,6 +887,7 @@ async fn load_auth( auth_dot_json, AuthCredentialsStoreMode::Ephemeral, chatgpt_base_url, + keyring_backend_kind, ) .await?; return Ok(Some(auth)); @@ -849,7 +914,11 @@ async fn load_auth( } // Fall back to the configured persistent store (file/keyring/auto) for managed auth. - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let storage = create_auth_storage( + codex_home.to_path_buf(), + auth_credentials_store_mode, + keyring_backend_kind, + ); let auth_dot_json = match storage.load()? { Some(auth) => auth, None => return Ok(None), @@ -860,6 +929,7 @@ async fn load_auth( auth_dot_json, auth_credentials_store_mode, chatgpt_base_url, + keyring_backend_kind, ) .await?; Ok(Some(auth)) @@ -1349,6 +1419,7 @@ pub struct AuthManager { auth_change_tx: watch::Sender, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, forced_chatgpt_workspace_id: RwLock>>, chatgpt_base_url: Option, refresh_lock: Semaphore, @@ -1368,6 +1439,9 @@ pub trait AuthManagerConfig { /// Returns the CLI auth credential storage mode for auth loading. fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode; + /// Returns the backend to use when CLI auth keyring storage is selected. + fn cli_auth_keyring_backend_kind(&self) -> CliAuthKeyringBackendKind; + /// Returns the workspace IDs that ChatGPT auth should be restricted to, if any. fn forced_chatgpt_workspace_id(&self) -> Option>; @@ -1385,6 +1459,7 @@ impl Debug for AuthManager { "auth_credentials_store_mode", &self.auth_credentials_store_mode, ) + .field("keyring_backend_kind", &self.keyring_backend_kind) .field( "forced_chatgpt_workspace_id", &self.forced_chatgpt_workspace_id, @@ -1405,12 +1480,14 @@ impl AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> Self { let managed_auth = load_auth( &codex_home, enable_codex_api_key_env, auth_credentials_store_mode, chatgpt_base_url.as_deref(), + keyring_backend_kind, ) .await .ok() @@ -1425,6 +1502,7 @@ impl AuthManager { auth_change_tx, enable_codex_api_key_env, auth_credentials_store_mode, + keyring_backend_kind, forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1446,6 +1524,7 @@ impl AuthManager { auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: CliAuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1466,6 +1545,7 @@ impl AuthManager { auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: CliAuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1484,6 +1564,7 @@ impl AuthManager { auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, + keyring_backend_kind: CliAuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1629,6 +1710,7 @@ impl AuthManager { self.enable_codex_api_key_env, self.auth_credentials_store_mode, self.chatgpt_base_url.as_deref(), + self.keyring_backend_kind, ) .await .ok() @@ -1702,6 +1784,7 @@ impl AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> Arc { Arc::new( Self::new( @@ -1709,6 +1792,7 @@ impl AuthManager { enable_codex_api_key_env, auth_credentials_store_mode, chatgpt_base_url, + keyring_backend_kind, ) .await, ) @@ -1724,6 +1808,7 @@ impl AuthManager { enable_codex_api_key_env, config.cli_auth_credentials_store_mode(), Some(config.chatgpt_base_url()), + config.cli_auth_keyring_backend_kind(), ) .await; auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id()); @@ -1864,7 +1949,11 @@ impl AuthManager { /// reloads the in‑memory auth cache so callers immediately observe the /// unauthenticated state. pub async fn logout(&self) -> std::io::Result { - let removed = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; + let removed = logout_all_stores( + &self.codex_home, + self.auth_credentials_store_mode, + self.keyring_backend_kind, + )?; // Always reload to clear any cached auth (even if file absent). self.reload().await; Ok(removed) @@ -1877,7 +1966,11 @@ impl AuthManager { if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } - let result = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; + let result = logout_all_stores( + &self.codex_home, + self.auth_credentials_store_mode, + self.keyring_backend_kind, + )?; // Always reload to clear any cached auth (even if file absent). self.reload().await; Ok(result) @@ -1980,6 +2073,7 @@ impl AuthManager { &self.codex_home, &auth_dot_json, AuthCredentialsStoreMode::Ephemeral, + CliAuthKeyringBackendKind::default(), ) .map_err(RefreshTokenError::Transient)?; self.reload().await; diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 2add9d52eb..3709e484f4 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -106,6 +106,25 @@ pub(super) trait AuthStorageBackend: Debug + Send + Sync { fn delete(&self) -> std::io::Result; } +/// Backend used when CLI auth storage is configured for keyring-backed persistence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CliAuthKeyringBackendKind { + /// Store the serialized auth payload directly in the OS keyring. + Direct, + /// Store auth in the local encrypted secrets file, with the file key in the OS keyring. + Secrets, +} + +impl Default for CliAuthKeyringBackendKind { + fn default() -> Self { + if cfg!(windows) { + Self::Secrets + } else { + Self::Direct + } + } +} + #[derive(Clone, Debug)] pub(super) struct FileAuthStorage { codex_home: PathBuf, @@ -167,6 +186,7 @@ static CLI_AUTH_SECRET_NAME: Lazy = Lazy::new(|| match SecretName::n Ok(name) => name, Err(err) => unreachable!("CLI_AUTH should be a valid secret name: {err}"), }); +const KEYRING_SERVICE: &str = "Codex Auth"; // turns codex_home path into a stable, short key string fn compute_store_key(codex_home: &Path) -> std::io::Result { @@ -183,12 +203,71 @@ fn compute_store_key(codex_home: &Path) -> std::io::Result { } #[derive(Clone, Debug)] -struct KeyringAuthStorage { +struct DirectKeyringAuthStorage { + codex_home: PathBuf, + keyring_store: Arc, +} + +impl DirectKeyringAuthStorage { + fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + Self { + codex_home, + keyring_store, + } + } + + fn account(&self) -> std::io::Result { + compute_store_key(&self.codex_home) + } +} + +impl AuthStorageBackend for DirectKeyringAuthStorage { + fn load(&self) -> std::io::Result> { + let account = self.account()?; + match self + .keyring_store + .load(KEYRING_SERVICE, &account) + .map_err(|err| std::io::Error::other(err.message()))? + { + Some(serialized) => serde_json::from_str(&serialized).map(Some).map_err(|err| { + std::io::Error::other(format!( + "failed to deserialize CLI auth from keyring: {err}" + )) + }), + None => Ok(None), + } + } + + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { + let account = self.account()?; + let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?; + self.keyring_store + .save(KEYRING_SERVICE, &account, &serialized) + .map_err(|err| std::io::Error::other(err.message()))?; + if let Err(err) = delete_file_if_exists(&self.codex_home) { + warn!("failed to remove CLI auth fallback file: {err}"); + } + Ok(()) + } + + fn delete(&self) -> std::io::Result { + let account = self.account()?; + let keyring_removed = self + .keyring_store + .delete(KEYRING_SERVICE, &account) + .map_err(|err| std::io::Error::other(err.message()))?; + let file_removed = delete_file_if_exists(&self.codex_home)?; + Ok(keyring_removed || file_removed) + } +} + +#[derive(Clone, Debug)] +struct SecretsKeyringAuthStorage { codex_home: PathBuf, secrets_backend: LocalSecretsBackend, } -impl KeyringAuthStorage { +impl SecretsKeyringAuthStorage { fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { let secrets_backend = LocalSecretsBackend::new(codex_home.clone(), keyring_store); Self { @@ -198,7 +277,7 @@ impl KeyringAuthStorage { } } -impl AuthStorageBackend for KeyringAuthStorage { +impl AuthStorageBackend for SecretsKeyringAuthStorage { fn load(&self) -> std::io::Result> { match self .secrets_backend @@ -237,14 +316,22 @@ impl AuthStorageBackend for KeyringAuthStorage { #[derive(Clone, Debug)] struct AutoAuthStorage { - keyring_storage: Arc, + keyring_storage: Arc, file_storage: Arc, } impl AutoAuthStorage { - fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + fn new( + codex_home: PathBuf, + keyring_store: Arc, + keyring_backend_kind: CliAuthKeyringBackendKind, + ) -> Self { Self { - keyring_storage: Arc::new(KeyringAuthStorage::new(codex_home.clone(), keyring_store)), + keyring_storage: create_keyring_auth_storage( + codex_home.clone(), + keyring_store, + keyring_backend_kind, + ), file_storage: Arc::new(FileAuthStorage::new(codex_home)), } } @@ -324,26 +411,47 @@ impl AuthStorageBackend for EphemeralAuthStorage { pub(super) fn create_auth_storage( codex_home: PathBuf, mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> Arc { let keyring_store: Arc = Arc::new(DefaultKeyringStore); - create_auth_storage_with_keyring_store(codex_home, mode, keyring_store) + create_auth_storage_with_store(codex_home, mode, keyring_store, keyring_backend_kind) } -fn create_auth_storage_with_keyring_store( +fn create_auth_storage_with_store( codex_home: PathBuf, mode: AuthCredentialsStoreMode, keyring_store: Arc, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> Arc { match mode { AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)), AuthCredentialsStoreMode::Keyring => { - Arc::new(KeyringAuthStorage::new(codex_home, keyring_store)) + create_keyring_auth_storage(codex_home, keyring_store, keyring_backend_kind) } - AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(codex_home, keyring_store)), + AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new( + codex_home, + keyring_store, + keyring_backend_kind, + )), AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)), } } +fn create_keyring_auth_storage( + codex_home: PathBuf, + keyring_store: Arc, + keyring_backend_kind: CliAuthKeyringBackendKind, +) -> Arc { + match keyring_backend_kind { + CliAuthKeyringBackendKind::Direct => { + Arc::new(DirectKeyringAuthStorage::new(codex_home, keyring_store)) + } + CliAuthKeyringBackendKind::Secrets => { + Arc::new(SecretsKeyringAuthStorage::new(codex_home, keyring_store)) + } + } +} + #[cfg(test)] #[path = "storage_tests.rs"] mod tests; diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index f81fc73354..7f269917d7 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -149,7 +149,11 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { agent_identity: None, personal_access_token: None, }; - let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); + let storage = create_auth_storage( + dir.path().to_path_buf(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ); storage.save(&auth_dot_json)?; assert!(dir.path().join("auth.json").exists()); let storage = FileAuthStorage::new(dir.path().to_path_buf()); @@ -165,6 +169,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> let storage = create_auth_storage( dir.path().to_path_buf(), AuthCredentialsStoreMode::Ephemeral, + CliAuthKeyringBackendKind::default(), ); let auth_dot_json = AuthDotJson { auth_mode: Some(AuthMode::ApiKey), @@ -187,7 +192,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> Ok(()) } -fn seed_keyring_and_fallback_auth_file_for_delete( +fn seed_secrets_backend_and_fallback_auth_file_for_delete( mock_keyring: &MockKeyringStore, codex_home: &Path, auth: &AuthDotJson, @@ -204,7 +209,7 @@ fn seed_keyring_and_fallback_auth_file_for_delete( Ok(auth_file) } -fn seed_keyring_with_auth( +fn seed_secrets_backend_with_auth( mock_keyring: &MockKeyringStore, codex_home: &Path, auth: &AuthDotJson, @@ -309,10 +314,10 @@ fn jwt_with_payload(payload: serde_json::Value) -> String { } #[test] -fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { +fn secrets_keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( + let storage = SecretsKeyringAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), ); @@ -324,7 +329,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { agent_identity: None, personal_access_token: None, }; - seed_keyring_with_auth(&mock_keyring, codex_home.path(), &expected)?; + seed_secrets_backend_with_auth(&mock_keyring, codex_home.path(), &expected)?; let loaded = storage.load()?; assert_eq!(Some(expected), loaded); @@ -342,10 +347,76 @@ fn keyring_auth_storage_compute_store_key_for_home_directory() -> anyhow::Result } #[test] -fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> { +fn direct_keyring_auth_storage_saves_legacy_keyring_entry() -> anyhow::Result<()> { let codex_home = tempdir()?; let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( + let storage = DirectKeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write(&auth_file, "stale")?; + let auth = auth_with_prefix("direct"); + + storage.save(&auth)?; + + let legacy_key = compute_store_key(codex_home.path())?; + let saved_value = mock_keyring + .saved_value(&legacy_key) + .context("direct keyring auth entry should exist")?; + assert_eq!(saved_value, serde_json::to_string(&auth)?); + assert!(!encrypted_auth_file(codex_home.path()).exists()); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after keyring save" + ); + assert_eq!(storage.load()?, Some(auth)); + Ok(()) +} + +#[test] +fn factory_uses_secrets_backend_only_when_requested() -> anyhow::Result<()> { + let direct_home = tempdir()?; + let direct_keyring = MockKeyringStore::default(); + let direct_storage = create_auth_storage_with_store( + direct_home.path().to_path_buf(), + AuthCredentialsStoreMode::Keyring, + Arc::new(direct_keyring.clone()), + CliAuthKeyringBackendKind::Direct, + ); + let direct_auth = auth_with_prefix("factory-direct"); + direct_storage.save(&direct_auth)?; + assert!( + direct_keyring + .saved_value(&compute_store_key(direct_home.path())?) + .is_some() + ); + assert!(!encrypted_auth_file(direct_home.path()).exists()); + + let secrets_home = tempdir()?; + let secrets_keyring = MockKeyringStore::default(); + let secrets_storage = create_auth_storage_with_store( + secrets_home.path().to_path_buf(), + AuthCredentialsStoreMode::Keyring, + Arc::new(secrets_keyring.clone()), + CliAuthKeyringBackendKind::Secrets, + ); + let secrets_auth = auth_with_prefix("factory-secrets"); + secrets_storage.save(&secrets_auth)?; + assert!( + secrets_keyring + .saved_value(&compute_secrets_keyring_account(secrets_home.path())?) + .is_some() + ); + assert!(encrypted_auth_file(secrets_home.path()).exists()); + Ok(()) +} + +#[test] +fn secrets_keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = SecretsKeyringAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), ); @@ -372,16 +443,19 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res } #[test] -fn keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { +fn secrets_keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { let codex_home = tempdir()?; let mock_keyring = MockKeyringStore::default(); - let storage = KeyringAuthStorage::new( + let storage = SecretsKeyringAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), ); let auth = auth_with_prefix("to-delete"); - let auth_file = - seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), &auth)?; + let auth_file = seed_secrets_backend_and_fallback_auth_file_for_delete( + &mock_keyring, + codex_home.path(), + &auth, + )?; let removed = storage.delete()?; @@ -401,9 +475,10 @@ fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> { let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + CliAuthKeyringBackendKind::Secrets, ); let keyring_auth = auth_with_prefix("keyring"); - seed_keyring_with_auth(&mock_keyring, codex_home.path(), &keyring_auth)?; + seed_secrets_backend_with_auth(&mock_keyring, codex_home.path(), &keyring_auth)?; let file_auth = auth_with_prefix("file"); storage.file_storage.save(&file_auth)?; @@ -417,7 +492,11 @@ fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> { fn auto_auth_storage_load_uses_file_when_keyring_empty() -> anyhow::Result<()> { let codex_home = tempdir()?; let mock_keyring = MockKeyringStore::default(); - let storage = AutoAuthStorage::new(codex_home.path().to_path_buf(), Arc::new(mock_keyring)); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring), + CliAuthKeyringBackendKind::Secrets, + ); let expected = auth_with_prefix("file-only"); storage.file_storage.save(&expected)?; @@ -434,11 +513,12 @@ fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()> let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + CliAuthKeyringBackendKind::Secrets, ); let key = compute_secrets_keyring_account(codex_home.path())?; let encrypted = auth_with_prefix("encrypted"); - seed_keyring_with_auth(&mock_keyring, codex_home.path(), &encrypted)?; + seed_secrets_backend_with_auth(&mock_keyring, codex_home.path(), &encrypted)?; mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "load".into())); let expected = auth_with_prefix("fallback"); @@ -456,6 +536,7 @@ fn auto_auth_storage_save_prefers_keyring() -> anyhow::Result<()> { let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + CliAuthKeyringBackendKind::Secrets, ); let stale = auth_with_prefix("stale"); storage.file_storage.save(&stale)?; @@ -474,6 +555,7 @@ fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()> let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + CliAuthKeyringBackendKind::Secrets, ); let key = compute_secrets_keyring_account(codex_home.path())?; mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into())); @@ -505,10 +587,14 @@ fn auto_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { let storage = AutoAuthStorage::new( codex_home.path().to_path_buf(), Arc::new(mock_keyring.clone()), + CliAuthKeyringBackendKind::Secrets, ); let auth = auth_with_prefix("to-delete"); - let auth_file = - seed_keyring_and_fallback_auth_file_for_delete(&mock_keyring, codex_home.path(), &auth)?; + let auth_file = seed_secrets_backend_and_fallback_auth_file_for_delete( + &mock_keyring, + codex_home.path(), + &auth, + )?; let removed = storage.delete()?; diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 4b9cb7c321..894c80fab4 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -217,6 +217,7 @@ pub async fn complete_device_code_login( tokens.access_token, tokens.refresh_token, opts.cli_auth_credentials_store_mode, + opts.cli_auth_keyring_backend_kind, ) .await } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 990cf8b80e..be6c427671 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -24,6 +24,7 @@ pub use auth::AuthManagerConfig; pub use auth::CLIENT_ID; pub use auth::CODEX_ACCESS_TOKEN_ENV_VAR; pub use auth::CODEX_API_KEY_ENV_VAR; +pub use auth::CliAuthKeyringBackendKind; pub use auth::CodexAuth; pub use auth::ExternalAuth; pub use auth::ExternalAuthChatgptMetadata; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 5a1833b58c..5929474ab8 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -25,6 +25,7 @@ use std::thread; use std::time::Duration; use crate::auth::AuthDotJson; +use crate::auth::CliAuthKeyringBackendKind; use crate::auth::load_auth_dot_json; use crate::auth::revoke_auth_tokens; use crate::auth::save_auth; @@ -72,6 +73,7 @@ pub struct ServerOptions { pub forced_chatgpt_workspace_id: Option>, pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + pub cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind, } impl ServerOptions { @@ -81,6 +83,7 @@ impl ServerOptions { client_id: String, forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind, ) -> Self { Self { codex_home, @@ -92,6 +95,7 @@ impl ServerOptions { forced_chatgpt_workspace_id, codex_streamlined_login: false, cli_auth_credentials_store_mode, + cli_auth_keyring_backend_kind, } } } @@ -362,6 +366,7 @@ async fn process_request( tokens.access_token.clone(), tokens.refresh_token.clone(), opts.cli_auth_credentials_store_mode, + opts.cli_auth_keyring_backend_kind, ) .await { @@ -793,11 +798,16 @@ pub(crate) async fn persist_tokens_async( access_token: String, refresh_token: String, auth_credentials_store_mode: AuthCredentialsStoreMode, + keyring_backend_kind: CliAuthKeyringBackendKind, ) -> io::Result<()> { // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); let (previous_auth, auth) = tokio::task::spawn_blocking(move || { - let previous_auth = match load_auth_dot_json(&codex_home, auth_credentials_store_mode) { + let previous_auth = match load_auth_dot_json( + &codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + ) { Ok(auth) => auth, Err(err) => { warn!("failed to load previous auth before saving new login: {err}"); @@ -824,7 +834,12 @@ pub(crate) async fn persist_tokens_async( agent_identity: None, personal_access_token: None, }; - save_auth(&codex_home, &auth, auth_credentials_store_mode)?; + save_auth( + &codex_home, + &auth, + auth_credentials_store_mode, + keyring_backend_kind, + )?; Ok::<_, io::Error>((previous_auth, auth)) }) .await @@ -1186,6 +1201,7 @@ mod tests { use wiremock::matchers::path; use crate::auth::AuthDotJson; + use crate::auth::CliAuthKeyringBackendKind; use crate::auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use crate::auth::load_auth_dot_json; use crate::auth::save_auth; @@ -1233,6 +1249,7 @@ mod tests { codex_home.path(), &chatgpt_auth("old-access", "old-refresh", "old-account"), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; persist_tokens_async( @@ -1242,11 +1259,16 @@ mod tests { "new-access".to_string(), "new-refresh".to_string(), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::Direct, ) .await?; - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)? - .context("auth.json should exist after login")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + )? + .context("auth.json should exist after login")?; assert_eq!( auth.tokens.context("new tokens should be persisted")?, TokenData { @@ -1293,6 +1315,7 @@ mod tests { codex_home.path(), &chatgpt_auth("old-access", "shared-refresh", "old-account"), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; persist_tokens_async( @@ -1302,6 +1325,7 @@ mod tests { "new-access".to_string(), "shared-refresh".to_string(), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::Direct, ) .await?; diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index b30d738cd4..33bdf646bb 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; use codex_login::AuthManager; +use codex_login::CliAuthKeyringBackendKind; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::RefreshTokenError; use codex_login::load_auth_dot_json; @@ -291,6 +292,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; ctx.auth_manager @@ -359,6 +361,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; let err = ctx @@ -531,6 +534,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; let cached_auth = ctx @@ -596,6 +600,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; let cached_auth = ctx @@ -860,6 +865,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; ctx.auth_manager @@ -985,6 +991,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; let cached_before = ctx @@ -1082,6 +1089,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { ctx.codex_home.path(), &disk_auth, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; let cached_before = ctx @@ -1173,6 +1181,7 @@ impl RefreshTokenTestContext { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::default(), ) .await; @@ -1184,9 +1193,13 @@ impl RefreshTokenTestContext { } fn load_auth(&self) -> Result { - load_auth_dot_json(self.codex_home.path(), AuthCredentialsStoreMode::File) - .context("load auth.json")? - .context("auth.json should exist") + load_auth_dot_json( + self.codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .context("load auth.json")? + .context("auth.json should exist") } async fn write_auth(&self, auth_dot_json: &AuthDotJson) -> Result<()> { @@ -1194,6 +1207,7 @@ impl RefreshTokenTestContext { self.codex_home.path(), auth_dot_json, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; self.auth_manager.reload().await; Ok(()) diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index e575e6b8c7..bccc401c81 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -4,6 +4,7 @@ use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::CliAuthKeyringBackendKind; use codex_login::ServerOptions; use codex_login::auth::load_auth_dot_json; use codex_login::run_device_code_login; @@ -110,6 +111,7 @@ fn server_opts( "client-id".to_string(), /*forced_chatgpt_workspace_id*/ None, cli_auth_credentials_store_mode, + CliAuthKeyringBackendKind::default(), ); opts.issuer = issuer; opts.open_browser = false; @@ -147,9 +149,13 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { .await .expect("device code login integration should succeed"); - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login succeeds")? - .context("auth.json written")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .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"); @@ -193,8 +199,12 @@ 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(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login fails")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .context("auth.json should load after login fails")?; assert!( auth.is_none(), "auth.json should not be created when workspace validation fails" @@ -224,8 +234,12 @@ async fn device_code_login_integration_handles_usercode_http_failure() -> anyhow "unexpected error: {err:?}" ); - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login fails")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .context("auth.json should load after login fails")?; assert!( auth.is_none(), "auth.json should not be created when login fails" @@ -262,6 +276,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail "client-id".to_string(), /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), ); opts.issuer = issuer; opts.open_browser = false; @@ -270,9 +285,13 @@ 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(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login succeeds")? - .context("auth.json written")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .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"); @@ -312,6 +331,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result "client-id".to_string(), /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), ); opts.issuer = issuer; opts.open_browser = false; @@ -326,8 +346,12 @@ 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(), AuthCredentialsStoreMode::File) - .context("auth.json should load after login fails")?; + let auth = load_auth_dot_json( + codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .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 bc7bb401e8..0b0319002b 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -9,6 +9,7 @@ use std::time::Duration; use anyhow::Result; use base64::Engine; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::CliAuthKeyringBackendKind; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; @@ -128,6 +129,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, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; assert!( @@ -190,6 +192,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { force_state: Some(state), forced_chatgpt_workspace_id: None, codex_streamlined_login: false, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -233,6 +236,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< WORKSPACE_ID_SECOND_ALLOWED.to_string(), ]), codex_streamlined_login: false, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; let auth_url = Url::parse(&server.auth_url)?; @@ -271,6 +275,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, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; assert!( @@ -331,6 +336,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, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -399,6 +405,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, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind::Direct, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -499,6 +506,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> codex_login::CLIENT_ID.to_string(), /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), ); opts.issuer = issuer; opts.open_browser = false; @@ -544,6 +552,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, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind::Direct, }; let first_server = run_login_server(first_opts)?; @@ -565,6 +574,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, + cli_auth_keyring_backend_kind: CliAuthKeyringBackendKind::Direct, }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 2dd1bef83f..a1700bdfa1 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -6,6 +6,7 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; use codex_login::AuthManager; use codex_login::CLIENT_ID; +use codex_login::CliAuthKeyringBackendKind; use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::logout_with_revoke; use codex_login::save_auth; @@ -50,9 +51,15 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< codex_home.path(), &chatgpt_auth(), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; - let removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + let removed = logout_with_revoke( + codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .await?; assert!(removed); assert!(!codex_home.path().join("auth.json").exists()); @@ -102,9 +109,15 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { codex_home.path(), &chatgpt_auth(), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; - let removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + let removed = logout_with_revoke( + codex_home.path(), + AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), + ) + .await?; assert!(removed); assert!(!codex_home.path().join("auth.json").exists()); @@ -137,18 +150,21 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { codex_home.path(), &chatgpt_auth_with_refresh_token(REFRESH_TOKEN), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; let manager = AuthManager::new( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::default(), ) .await; save_auth( codex_home.path(), &chatgpt_auth_with_refresh_token("newer-disk-refresh-token"), AuthCredentialsStoreMode::File, + CliAuthKeyringBackendKind::default(), )?; let removed = manager.logout_with_revoke().await?; diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index 2e39ef3160..3fc25ddd6c 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -4,6 +4,7 @@ use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_login::AuthCredentialsStoreMode; use codex_login::AuthManager; +use codex_login::CliAuthKeyringBackendKind; use codex_login::CodexAuth; use codex_login::ExternalAuth; use codex_login::ExternalAuthRefreshContext; @@ -224,6 +225,7 @@ c2ln", codex_home, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + CliAuthKeyringBackendKind::default(), ) .await .expect("auth should load") diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs index 280c723d36..b8a85ef6ac 100644 --- a/codex-rs/secrets/src/lib.rs +++ b/codex-rs/secrets/src/lib.rs @@ -19,7 +19,7 @@ mod sanitizer; pub use local::LocalSecretsBackend; pub use sanitizer::redact_secrets; -const KEYRING_SERVICE: &str = "codex"; +const KEYRING_SERVICE: &str = "Codex Auth"; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct SecretName(String); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 04633a8038..0c759f3237 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1227,6 +1227,7 @@ pub async fn run_main( if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, + keyring_backend_kind: config.cli_auth_keyring_backend_kind(), forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()),