[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.
This commit is contained in:
Celia Chen
2025-10-27 19:41:49 -07:00
committed by GitHub
parent 66a4b89822
commit 4a42c4e142
30 changed files with 361 additions and 80 deletions

View File

@@ -323,7 +323,11 @@ impl CodexMessageProcessor {
}
}
match login_with_api_key(&self.config.codex_home, &params.api_key) {
match login_with_api_key(
&self.config.codex_home,
&params.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,
)
};

View File

@@ -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,

View File

@@ -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<String> {
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")
}

View File

@@ -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)])

View File

@@ -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")?;

View File

@@ -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");

View File

@@ -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

View File

@@ -13,7 +13,8 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
path: String,
) -> anyhow::Result<T> {
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();

View File

@@ -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<RwLock<Option<TokenData>>> = 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);

View File

@@ -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<String>,
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);

View File

@@ -58,7 +58,16 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
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,

View File

@@ -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()

View File

@@ -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 }

View File

@@ -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<Option<CodexAuth>> {
load_auth(codex_home, false)
pub fn from_auth_storage(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<Option<CodexAuth>> {
load_auth(codex_home, false, auth_credentials_store_mode)
}
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
@@ -217,36 +220,55 @@ pub fn read_codex_api_key_from_env() -> Option<String> {
/// 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<bool> {
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<bool> {
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<Option<AuthDotJson>> {
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<Option<AuthDotJson>> {
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<Option<CodexAuth>> {
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<CachedAuth>,
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)
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,7 +921,11 @@ 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)
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() {
@@ -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<Self> {
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<Self> {
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 inmemory auth cache so callers immediately observe the
/// unauthenticated state.
pub fn logout(&self) -> std::io::Result<bool> {
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)

View File

@@ -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(),

View File

@@ -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<String, McpServerConfig>,
@@ -873,6 +880,13 @@ pub struct ConfigToml {
#[serde(default)]
pub forced_login_method: Option<ForcedLoginMethod>,
/// 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<AuthCredentialsStoreMode>,
/// Definition for MCP servers that Codex can reach out to for tool calls.
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerConfig>,
@@ -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(),

View File

@@ -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,7 +526,8 @@ 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()) {
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}"),

View File

@@ -249,7 +249,11 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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.

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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<String>,
pub forced_chatgpt_workspace_id: Option<String>,
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<String>,
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}")))?

View File

@@ -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"

View File

@@ -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,

View File

@@ -53,7 +53,11 @@ impl MessageProcessor {
config: Arc<Config>,
) -> 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 {

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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<String>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub login_status: LoginStatus,
pub auth_manager: Arc<AuthManager>,
pub forced_chatgpt_workspace_id: Option<String>,
@@ -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),
};

View File

@@ -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<Step> = 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,

View File

@@ -83,7 +83,8 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String {
}
pub(crate) fn compose_account_display(config: &Config) -> Option<StatusAccountDisplay> {
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;

View File

@@ -836,7 +836,9 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
> [!NOTE] > `tui.notifications` is builtin and limited to the TUI session. For programmatic or crossenvironment notifications—or to integrate with OSspecific notifiers—use the toplevel `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: DBusbased Secret Service, the kernel keyutils, or a combination
- FreeBSD/OpenBSD: DBusbased 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). |