feat: use encrypted local secrets for CLI auth (#27539)

## Why

Windows Credential Manager limits generic credential blobs to 2,560
bytes. Large serialized ChatGPT auth payloads can exceed that limit, so
keyring-mode CLI auth needs a backend that keeps only the encryption key
in the OS keyring and stores the payload in Codex's encrypted
local-secrets file.

This is the third PR in the encrypted-auth stack:

1. #27504 — feature and config selection
2. #27535 — auth-specific local-secrets namespaces
3. This PR — CLI auth implementation and activation
4. MCP OAuth implementation and activation

## What Changed

- Added encrypted CLI-auth storage using the `CliAuth` secrets
namespace.
- Preserved direct keyring storage for platforms/configurations where it
remains selected.
- Selected the backend consistently for login, logout, refresh,
device-code login, auth loading, and login restrictions.
- Threaded resolved bootstrap/full config through CLI, exec, TUI,
app-server account handling, cloud config, and cloud tasks.
- Removed stale `auth.json` fallback data after successful encrypted
saves and removed encrypted, direct-keyring, and fallback data during
logout.
- Added storage and integration coverage for both direct and encrypted
keyring modes.

MCP OAuth persistence is intentionally left to the next PR.

## Validation

- `just test -p codex-login` — 131 passed
- `just test -p codex-cli` — 280 passed
- `just test -p codex-app-server v2::account` — 25 passed
- `just test -p codex-cloud-config service` — 21 passed, 7 skipped
- `just fix -p codex-login`
- `just fix -p codex-cli`
- `just fmt`
This commit is contained in:
Celia Chen authored and GitHub committed 2026-06-12 21:23:50 +00:00
1 parent 576f603440
commit 56c97e3b5c
37 files changed
+857 -164

No files matched your search

+15 -9
View File
@@ -7,7 +7,9 @@ use crate::legacy_core::check_execpolicy_for_warnings;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options;
use crate::legacy_core::config::ConfigTomlLoadResult;
use crate::legacy_core::config::load_config_toml_with_layer_stack;
use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::config::resolve_profile_v2_config_path;
use crate::legacy_core::format_exec_policy_error_with_source;
@@ -937,7 +939,7 @@ pub async fn run_main(
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
let bootstrap_config_toml = load_config_toml_or_exit(
let bootstrap_config = load_bootstrap_config_or_exit(
&codex_home,
config_cwd.as_ref(),
cli_kv_overrides.clone(),
@@ -946,6 +948,7 @@ pub async fn run_main(
CloudConfigBundleLoader::default(),
)
.await;
let bootstrap_config_toml = &bootstrap_config.config_toml;
let chatgpt_base_url = bootstrap_config_toml
.chatgpt_base_url
@@ -957,6 +960,7 @@ pub async fn run_main(
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
)
.await;
@@ -969,12 +973,12 @@ pub async fn run_main(
let mut manually_selected_oss_provider = None;
let model_provider_override = if cli.oss {
let config_toml_with_cloud_config;
let bootstrap_config_with_cloud_config;
let config_toml_for_oss = if cli.oss_provider.is_none() {
// The first load intentionally skips cloud config so we can read
// auth/base-url settings needed to fetch the bundle. If OSS mode
// needs a default provider from config, reload with the bundle.
config_toml_with_cloud_config = load_config_toml_or_exit(
bootstrap_config_with_cloud_config = load_bootstrap_config_or_exit(
&codex_home,
config_cwd.as_ref(),
cli_kv_overrides.clone(),
@@ -983,9 +987,9 @@ pub async fn run_main(
cloud_config_bundle.clone(),
)
.await;
&config_toml_with_cloud_config
&bootstrap_config_with_cloud_config.config_toml
} else {
&bootstrap_config_toml
bootstrap_config_toml
};
let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss);
@@ -1151,6 +1155,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.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()),
@@ -1419,6 +1424,7 @@ async fn run_ratatui_app(
initial_config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
initial_config.cli_auth_credentials_store_mode,
initial_config.auth_keyring_backend_kind(),
initial_config.chatgpt_base_url.clone(),
)
.await;
@@ -1917,15 +1923,15 @@ async fn load_config_or_exit_with_fallback_cwd(
}
#[allow(clippy::print_stderr)]
async fn load_config_toml_or_exit(
async fn load_bootstrap_config_or_exit(
codex_home: &Path,
cwd: Option<&AbsolutePathBuf>,
cli_kv_overrides: Vec<(String, codex_config::TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_config_bundle: CloudConfigBundleLoader,
) -> codex_config::config_toml::ConfigToml {
match load_config_as_toml_with_cli_and_load_options(
) -> ConfigTomlLoadResult {
match load_config_toml_with_layer_stack(
codex_home,
cwd,
cli_kv_overrides,
+16 -5
View File
@@ -4,6 +4,7 @@ use std::path::Path;
use codex_app_server_protocol::AuthMode;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::load_auth_dot_json;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -18,9 +19,13 @@ pub(crate) fn load_local_chatgpt_auth(
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&[String]>,
) -> Result<LocalChatgptAuth, String> {
let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode)
.map_err(|err| format!("failed to load local auth: {err}"))?
.ok_or_else(|| "no local auth available".to_string())?;
let auth = load_auth_dot_json(
codex_home,
auth_credentials_store_mode,
AuthKeyringBackendKind::default(),
)
.map_err(|err| format!("failed to load local auth: {err}"))?
.ok_or_else(|| "no local auth available".to_string())?;
if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() {
return Err("local auth is not a ChatGPT login".to_string());
}
@@ -112,8 +117,13 @@ mod tests {
personal_access_token: None,
bedrock_api_key: None,
};
save_auth(codex_home, &auth, AuthCredentialsStoreMode::File)
.expect("chatgpt auth should save");
save_auth(
codex_home,
&auth,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)
.expect("chatgpt auth should save");
}
#[test]
@@ -162,6 +172,7 @@ mod tests {
bedrock_api_key: None,
},
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)
.expect("api key auth should save");
+2
View File
@@ -1014,6 +1014,7 @@ mod tests {
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use pretty_assertions::assert_eq;
use std::sync::Arc;
@@ -1037,6 +1038,7 @@ mod tests {
codex_home_path.clone(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
"https://chatgpt.com/backend-api/".to_string(),
)
.await,
+6 -3
View File
@@ -11,7 +11,8 @@ use crate::Cli;
use crate::app_server_session::AppServerSession;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options;
use crate::legacy_core::config::load_config_toml_with_layer_stack;
use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::config::resolve_profile_v2_config_path;
use codex_app_server_protocol::Thread as AppServerThread;
@@ -310,7 +311,7 @@ async fn start_app_server_for_archive_command(
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
let config_toml = load_config_as_toml_with_cli_and_load_options(
let bootstrap_config = load_config_toml_with_layer_stack(
codex_home.as_path(),
config_cwd.as_ref(),
cli_kv_overrides.clone(),
@@ -322,6 +323,7 @@ async fn start_app_server_for_archive_command(
)
.await
.wrap_err("failed to load config.toml")?;
let config_toml = &bootstrap_config.config_toml;
let chatgpt_base_url = config_toml
.chatgpt_base_url
.clone()
@@ -330,12 +332,13 @@ async fn start_app_server_for_archive_command(
codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config_toml.cli_auth_credentials_store.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
)
.await;
let model_provider = if cli.oss {
resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml)
resolve_oss_provider(cli.oss_provider.as_deref(), config_toml)
} else {
None
};