Enforce managed auth across bootstrap and app-server

This commit is contained in:
Abhinav Vedmala
2026-06-16 07:39:50 +00:00
parent 50d33d5c6c
commit bc371f1074
14 changed files with 215 additions and 22 deletions
+1
View File
@@ -372,6 +372,7 @@ pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle>
}
async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
crate::enforce_auth_restrictions(&args.config).await;
let channel_capacity = args.channel_capacity.max(1);
let installation_id = resolve_installation_id(&args.config.codex_home).await?;
let (client_tx, mut client_rx) = mpsc::channel::<InProcessClientMessage>(channel_capacity);
+7
View File
@@ -739,6 +739,7 @@ pub async fn run_main_with_transport_options(
}
drop(unix_socket_startup_lock);
enforce_auth_restrictions(&config).await;
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
@@ -1171,6 +1172,12 @@ pub async fn run_main_with_transport_options(
Ok(())
}
async fn enforce_auth_restrictions(config: &Config) {
if let Err(err) = codex_login::enforce_login_restrictions(&config.auth_config()).await {
tracing::warn!("applied auth restrictions after logging out: {err}");
}
}
struct SqliteRecoveryNotice {
details: String,
}
@@ -509,6 +509,7 @@ impl MessageProcessor {
outgoing.clone(),
config_manager.clone(),
thread_manager.clone(),
account_processor.clone(),
analytics_events_client,
);
let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new(
@@ -365,6 +365,7 @@ use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_login::complete_device_code_login;
use codex_login::enforce_login_restrictions;
use codex_login::login_with_api_key;
use codex_login::oauth_client_id;
use codex_login::request_device_code;
@@ -84,6 +84,21 @@ impl AccountRequestProcessor {
}
}
async fn load_latest_config(&self) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
.map_err(|err| internal_error(format!("failed to reload auth configuration: {err}")))
}
pub(crate) async fn refresh_auth_restrictions(&self, config: &Config) -> std::io::Result<()> {
let result = enforce_login_restrictions(&config.auth_config()).await;
self.auth_manager
.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone());
self.auth_manager.reload().await;
result
}
pub(crate) async fn login_account(
&self,
request_id: ConnectionRequestId,
@@ -270,14 +285,12 @@ impl AccountRequestProcessor {
&self,
params: &LoginApiKeyParams,
) -> std::result::Result<(), JSONRPCErrorError> {
let config = self.load_latest_config().await?;
if self.auth_manager.is_external_chatgpt_auth_active() {
return Err(self.external_auth_active_error());
}
if matches!(
self.config.forced_login_method,
Some(ForcedLoginMethod::Chatgpt)
) {
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) {
return Err(invalid_request(
"API key login is disabled. Use ChatGPT login instead.",
));
@@ -292,10 +305,10 @@ impl AccountRequestProcessor {
}
match login_with_api_key(
&self.config.codex_home,
&config.codex_home,
&params.api_key,
self.config.cli_auth_credentials_store_mode,
self.config.auth_keyring_backend_kind(),
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
) {
Ok(()) => {
self.auth_manager.reload().await;
@@ -323,8 +336,8 @@ impl AccountRequestProcessor {
async fn login_chatgpt_common(
&self,
codex_streamlined_login: bool,
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
let config = self.config.as_ref();
) -> std::result::Result<(LoginServerOptions, String), JSONRPCErrorError> {
let config = self.load_latest_config().await?;
if self.auth_manager.is_external_chatgpt_auth_active() {
return Err(self.external_auth_active_error());
@@ -358,7 +371,7 @@ impl AccountRequestProcessor {
opts
};
Ok(opts)
Ok((opts, config.chatgpt_base_url.clone()))
}
fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError {
@@ -383,7 +396,7 @@ impl AccountRequestProcessor {
&self,
codex_streamlined_login: bool,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common(codex_streamlined_login).await?;
let (opts, chatgpt_base_url) = self.login_chatgpt_common(codex_streamlined_login).await?;
let server = run_login_server(opts)
.map_err(|err| internal_error(format!("failed to start login server: {err}")))?;
let login_id = Uuid::new_v4();
@@ -404,7 +417,6 @@ impl AccountRequestProcessor {
let outgoing_clone = self.outgoing.clone();
let config_manager = self.config_manager.clone();
let thread_manager = Arc::clone(&self.thread_manager);
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
let active_login = self.active_login.clone();
let auth_url = server.auth_url.clone();
tokio::spawn(async move {
@@ -454,7 +466,7 @@ impl AccountRequestProcessor {
async fn login_chatgpt_device_code_response(
&self,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self
let (opts, chatgpt_base_url) = self
.login_chatgpt_common(/*codex_streamlined_login*/ false)
.await?;
let device_code = request_device_code(&opts)
@@ -480,7 +492,6 @@ impl AccountRequestProcessor {
let outgoing_clone = self.outgoing.clone();
let config_manager = self.config_manager.clone();
let thread_manager = Arc::clone(&self.thread_manager);
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
let active_login = self.active_login.clone();
tokio::spawn(async move {
let (success, error_msg) = tokio::select! {
@@ -573,10 +584,8 @@ impl AccountRequestProcessor {
chatgpt_account_id: String,
chatgpt_plan_type: Option<String>,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
if matches!(
self.config.forced_login_method,
Some(ForcedLoginMethod::Api)
) {
let config = self.load_latest_config().await?;
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
return Err(invalid_request(
"External ChatGPT auth is disabled. Use API key login instead.",
));
@@ -590,7 +599,7 @@ impl AccountRequestProcessor {
}
}
if let Some(expected_workspaces) = self.config.forced_chatgpt_workspace_id.as_deref()
if let Some(expected_workspaces) = config.forced_chatgpt_workspace_id.as_deref()
&& !expected_workspaces.contains(&chatgpt_account_id)
{
return Err(invalid_request(format!(
@@ -599,7 +608,7 @@ impl AccountRequestProcessor {
}
login_with_chatgpt_auth_tokens(
&self.config.codex_home,
&config.codex_home,
&access_token,
&chatgpt_account_id,
chatgpt_plan_type.as_deref(),
@@ -608,7 +617,7 @@ impl AccountRequestProcessor {
self.auth_manager.reload().await;
self.config_manager.replace_cloud_config_bundle_loader(
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
config.chatgpt_base_url.clone(),
);
self.config_manager
.sync_default_client_residency_requirement()
@@ -6,6 +6,7 @@ use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::request_processors::AccountRequestProcessor;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::ComputerUseRequirements;
@@ -59,6 +60,7 @@ pub(crate) struct ConfigRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
thread_manager: Arc<ThreadManager>,
account_processor: AccountRequestProcessor,
analytics_events_client: AnalyticsEventsClient,
}
@@ -67,12 +69,14 @@ impl ConfigRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
thread_manager: Arc<ThreadManager>,
account_processor: AccountRequestProcessor,
analytics_events_client: AnalyticsEventsClient,
) -> Self {
Self {
outgoing,
config_manager,
thread_manager,
account_processor,
analytics_events_client,
}
}
@@ -282,6 +286,13 @@ impl ConfigRequestProcessor {
return;
}
};
if let Err(err) = self
.account_processor
.refresh_auth_restrictions(&next_config)
.await
{
tracing::warn!("applied updated auth restrictions after logging out: {err}");
}
let thread_ids = self.thread_manager.list_thread_ids().await;
for thread_id in thread_ids {
let Ok(thread) = self.thread_manager.get_thread(thread_id).await else {
@@ -17,6 +17,9 @@ use codex_app_server_protocol::CancelLoginAccountResponse;
use codex_app_server_protocol::CancelLoginAccountStatus;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAuthStatusParams;
@@ -27,11 +30,13 @@ use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WriteStatus;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::CLIENT_ID_OVERRIDE_ENV_VAR;
@@ -251,6 +256,97 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn startup_removes_disallowed_stored_auth() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
forced_method: Some("chatgpt".to_string()),
..Default::default()
},
)?;
login_with_api_key(
codex_home.path(),
"sk-test-key",
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
assert!(!codex_home.path().join("auth.json").exists());
let request_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: GetAccountResponse = to_response(response)?;
assert_eq!(response.account, None);
Ok(())
}
#[tokio::test]
async fn config_reload_removes_newly_disallowed_auth() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
login_with_api_key(
codex_home.path(),
"sk-test-key",
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: None,
edits: vec![ConfigEdit {
key_path: "forced_login_method".to_string(),
value: json!("chatgpt"),
merge_strategy: MergeStrategy::Replace,
}],
expected_version: None,
reload_user_config: true,
})
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ConfigWriteResponse = to_response(response)?;
assert_eq!(response.status, WriteStatus::Ok);
assert!(!codex_home.path().join("auth.json").exists());
let request_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: GetAccountResponse = to_response(response)?;
assert_eq!(response.account, None);
Ok(())
}
#[tokio::test]
async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
let codex_home = TempDir::new()?;
+32
View File
@@ -1,11 +1,14 @@
use super::Config;
use super::ConfigTomlLoadResult;
use super::ManagedFeatures;
use codex_config::config_toml::ForcedChatgptWorkspaceIds;
use codex_config::types::AuthKeyringBackendKind;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
use codex_features::Features;
use codex_login::AuthConfig;
use std::path::Path;
impl Config {
pub fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind {
@@ -13,6 +16,35 @@ impl Config {
self.features.enabled(Feature::SecretAuthStorage),
)
}
pub fn auth_config(&self) -> AuthConfig {
AuthConfig {
codex_home: self.codex_home.to_path_buf(),
auth_credentials_store_mode: self.cli_auth_credentials_store_mode,
keyring_backend_kind: self.auth_keyring_backend_kind(),
forced_login_method: self.forced_login_method,
chatgpt_base_url: Some(self.chatgpt_base_url.clone()),
forced_chatgpt_workspace_id: self.forced_chatgpt_workspace_id.clone(),
}
}
}
pub fn bootstrap_auth_config(
codex_home: &Path,
bootstrap_config: &ConfigTomlLoadResult,
) -> std::io::Result<AuthConfig> {
let config = &bootstrap_config.config_toml;
Ok(AuthConfig {
codex_home: codex_home.to_path_buf(),
auth_credentials_store_mode: config.cli_auth_credentials_store.unwrap_or_default(),
keyring_backend_kind: resolve_bootstrap_auth_keyring_backend_kind(bootstrap_config)?,
forced_login_method: config.forced_login_method,
chatgpt_base_url: config.chatgpt_base_url.clone(),
forced_chatgpt_workspace_id: config
.forced_chatgpt_workspace_id
.clone()
.map(ForcedChatgptWorkspaceIds::into_vec),
})
}
/// Resolve the auth keyring backend from a partially loaded bootstrap config.
+14
View File
@@ -11001,6 +11001,13 @@ chatgpt_base_url = "https://user.example/backend-api"
cli_auth_credentials_store = "keyring"
chatgpt_base_url = "https://managed.example/backend-api"
[allowed_login_methods]
chatgpt = true
api = false
[allowed_chatgpt_workspaces]
"123e4567-e89b-42d3-a456-426614174000" = true
[features]
secret_auth_storage = true
"#,
@@ -11024,11 +11031,18 @@ secret_auth_storage = true
(
bootstrap_config.config_toml.cli_auth_credentials_store,
bootstrap_config.config_toml.chatgpt_base_url,
bootstrap_config.config_toml.forced_login_method,
bootstrap_config
.config_toml
.forced_chatgpt_workspace_id
.map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec),
auth_keyring_backend_kind,
),
(
Some(AuthCredentialsStoreMode::Keyring),
Some("https://managed.example/backend-api".to_string()),
Some(ForcedLoginMethod::Chatgpt),
Some(vec!["123e4567-e89b-42d3-a456-426614174000".to_string()]),
AuthKeyringBackendKind::Secrets,
)
);
+10
View File
@@ -25,6 +25,7 @@ use codex_config::ThreadConfigLoader;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES;
use codex_config::config_toml::ForcedChatgptWorkspaceIds;
use codex_config::config_toml::ProjectConfig;
use codex_config::config_toml::RealtimeAudioConfig;
use codex_config::config_toml::RealtimeConfig;
@@ -149,6 +150,7 @@ mod requirements;
mod resolved_permission_profile;
#[cfg(test)]
mod schema;
pub use auth_keyring::bootstrap_auth_config;
pub use auth_keyring::resolve_bootstrap_auth_keyring_backend_kind;
pub use codex_config::ConfigLoadOptions;
pub use codex_config::Constrained;
@@ -1755,6 +1757,14 @@ pub async fn load_bootstrap_config_toml_with_layer_stack(
if let Some(required) = requirements.chatgpt_base_url.as_ref() {
result.config_toml.chatgpt_base_url = Some(required.clone());
}
let (forced_login_method, forced_chatgpt_workspace_id) =
requirements::resolve_auth_restrictions(
&result.config_toml,
result.config_layer_stack.requirements(),
)?;
result.config_toml.forced_login_method = forced_login_method;
result.config_toml.forced_chatgpt_workspace_id =
forced_chatgpt_workspace_id.map(ForcedChatgptWorkspaceIds::Multiple);
Ok(result)
}
+1 -1
View File
@@ -151,7 +151,7 @@ pub(super) fn push_structured_requirement_override_warning(
));
}
fn resolve_auth_restrictions(
pub(super) fn resolve_auth_restrictions(
config: &ConfigToml,
requirements: &ConfigRequirements,
) -> std::io::Result<(Option<ForcedLoginMethod>, Option<Vec<String>>)> {
+2
View File
@@ -63,6 +63,7 @@ use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigTomlLoadResult;
use codex_core::config::bootstrap_auth_config;
use codex_core::config::find_codex_home;
use codex_core::config::load_bootstrap_config_toml_with_layer_stack;
use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind;
@@ -344,6 +345,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
)
.await;
let bootstrap_config_toml = &bootstrap_config.config_toml;
enforce_login_restrictions(&bootstrap_auth_config(&codex_home, &bootstrap_config)?).await?;
let chatgpt_base_url = bootstrap_config_toml
.chatgpt_base_url
+2
View File
@@ -8,6 +8,7 @@ use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::ConfigTomlLoadResult;
use crate::legacy_core::config::bootstrap_auth_config;
use crate::legacy_core::config::load_bootstrap_config_toml_with_layer_stack;
use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use crate::legacy_core::config::resolve_oss_provider;
@@ -951,6 +952,7 @@ pub async fn run_main(
)
.await;
let bootstrap_config_toml = &bootstrap_config.config_toml;
enforce_login_restrictions(&bootstrap_auth_config(&codex_home, &bootstrap_config)?).await?;
let chatgpt_base_url = bootstrap_config_toml
.chatgpt_base_url
@@ -11,6 +11,7 @@ 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::bootstrap_auth_config;
use crate::legacy_core::config::load_bootstrap_config_toml_with_layer_stack;
use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use crate::legacy_core::config::resolve_oss_provider;
@@ -25,6 +26,7 @@ use codex_config::ConfigLoadOptions;
use codex_config::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_login::enforce_login_restrictions;
use codex_protocol::ThreadId;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_home_dir::find_codex_home;
@@ -324,6 +326,11 @@ async fn start_app_server_for_archive_command(
)
.await
.wrap_err("failed to load config.toml")?;
enforce_login_restrictions(&bootstrap_auth_config(
codex_home.as_path(),
&bootstrap_config,
)?)
.await?;
let config_toml = &bootstrap_config.config_toml;
let chatgpt_base_url = config_toml
.chatgpt_base_url