From 2994f545a7eb187847831e809160937dd7b7ec52 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Wed, 5 Aug 2026 18:03:40 +0000 Subject: [PATCH] Enforce managed authentication requirements locally (#37132) ## Why Authentication restrictions must apply before stored or environment-provided credentials can be used, including during bootstrap before cloud requirements are fetched. ## What changed - Add local `requirements.toml` allowlists for login methods and ChatGPT workspaces. Ignore these fields in cloud-provided requirements. - Combine managed workspace allowlists with existing workspace restrictions by intersection, and fail closed when the resulting policy permits no usable login method. - Centralize policy checks in the authentication manager so CLI, TUI, app-server, external-auth, and credential-loading paths consistently reject disallowed authentication before token hydration or network requests. ## Testing - Cover policy composition, workspace intersection, invalid stored and external credentials, bootstrap enforcement, and login endpoint restrictions. GitOrigin-RevId: efef22b248f3c3333e9aa55423e539efa2d2dd48 --- codex-rs/app-server/src/config_manager.rs | 26 +- .../src/config_manager_service_tests.rs | 62 +++- codex-rs/app-server/src/in_process.rs | 1 + codex-rs/app-server/src/lib.rs | 1 + .../request_processors/account_processor.rs | 33 +- .../app-server/tests/suite/strict_config.rs | 33 ++ codex-rs/app-server/tests/suite/v2/account.rs | 45 +++ .../cli/src/debug_sandbox/cloud_config.rs | 24 +- codex-rs/cli/src/login.rs | 66 ++-- codex-rs/cli/src/main.rs | 19 +- codex-rs/cli/src/mcp_cmd/cloud_config.rs | 26 +- codex-rs/cli/src/plugin_cmd.rs | 24 +- codex-rs/cli/tests/login.rs | 15 + codex-rs/cloud-config/src/bundle_loader.rs | 29 +- codex-rs/cloud-tasks/src/util.rs | 17 +- codex-rs/config/src/auth_policy.rs | 61 ++++ codex-rs/config/src/config_requirements.rs | 60 +++- codex-rs/config/src/lib.rs | 2 + codex-rs/config/src/loader/mod.rs | 9 +- .../config/src/requirements_layers/layer.rs | 14 +- .../config/src/requirements_layers/stack.rs | 4 + .../src/requirements_layers/stack_tests.rs | 19 + codex-rs/config/src/state.rs | 3 + codex-rs/core/src/config/auth_keyring.rs | 63 ++++ .../core/src/config/auth_keyring_tests.rs | 66 ++++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 13 +- codex-rs/exec/src/lib.rs | 38 +- codex-rs/login/src/auth/auth_tests.rs | 204 +++++++++++ codex-rs/login/src/auth/manager.rs | 338 +++++++++++++++--- codex-rs/mcp-server/src/lib.rs | 1 + codex-rs/tui/src/debug_config.rs | 2 + codex-rs/tui/src/lib.rs | 65 +--- codex-rs/tui/src/onboarding/auth.rs | 23 +- .../tui/src/onboarding/onboarding_screen.rs | 14 +- codex-rs/tui/src/session_archive_commands.rs | 27 +- 36 files changed, 1121 insertions(+), 328 deletions(-) create mode 100644 codex-rs/config/src/auth_policy.rs diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 49fff59bab..3c172c9461 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -6,6 +6,7 @@ use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_config::loader::load_config_layers_state; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_exec_server::LOCAL_FS; use codex_features::feature_for_key; @@ -171,21 +172,16 @@ impl ConfigManager { } pub(crate) async fn load_default_config(&self) -> std::io::Result { - let mut config = Config::load_default_with_cli_overrides_for_codex_home( - self.codex_home.clone(), - self.current_cli_overrides(), - ) - .await?; - if self.loader_overrides.user_config_path.is_some() - || self.loader_overrides.user_config_profile.is_some() - { - let user_config_path = self.loader_overrides.user_config_path(self.codex_home())?; - config.config_layer_stack = config.config_layer_stack.with_user_config_profile( - &user_config_path, - self.loader_overrides.user_config_profile.as_ref(), - TomlValue::Table(toml::map::Map::new()), - )?; - } + let mut loader_overrides = self.loader_overrides.clone(); + loader_overrides.ignore_user_config = true; + let mut config = ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(self.current_cli_overrides()) + .loader_overrides(loader_overrides) + .fallback_cwd(Some(self.codex_home.clone())) + .cloud_config_bundle(CloudConfigBundleLoader::default()) + .build() + .await?; config.psp = self.psp; self.apply_runtime_feature_enablement(&mut config); self.apply_arg0_paths(&mut config); diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 74f4ad14f3..6ced2a703a 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -671,9 +671,14 @@ async fn write_value_defaults_to_selected_user_config_path() { } #[tokio::test] -async fn load_default_config_preserves_selected_user_config_path_after_load_error() { +async fn load_default_config_preserves_managed_requirements_and_selected_user_config_path() { let tmp = tempdir().expect("tempdir"); std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap(); + std::fs::write( + tmp.path().join("requirements.toml"), + "allowed_login_methods = [\"api\"]\nallowed_chatgpt_workspaces = [\"managed-workspace\"]\n", + ) + .unwrap(); let selected_path = tmp.path().join("work.config.toml"); std::fs::write(&selected_path, "not valid toml").unwrap(); let selected_file = @@ -703,6 +708,61 @@ async fn load_default_config_preserves_selected_user_config_path_after_load_erro config.config_layer_stack.get_user_config_file(), Some(&selected_file) ); + assert_eq!( + config + .config_layer_stack + .requirements() + .managed_auth_policy(), + codex_config::ManagedAuthPolicy { + allowed_login_methods: Some(vec![codex_protocol::config_types::ForcedLoginMethod::Api]), + allowed_chatgpt_workspaces: Some(vec!["managed-workspace".to_string()]), + } + ); +} + +#[tokio::test] +async fn managed_auth_policy_survives_unusable_requirements_file_changes() -> Result<()> { + let tmp = tempdir()?; + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?; + let requirements_path = tmp.path().join("requirements.toml"); + std::fs::write( + &requirements_path, + "allowed_login_methods = [\"api\"]\nallowed_chatgpt_workspaces = [\"startup\"]\n", + )?; + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + Vec::new(), + LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")), + CloudConfigBundleLoader::default(), + ); + let startup = service.load_latest_config(/*fallback_cwd*/ None).await?; + let auth_manager = codex_login::AuthManager::shared_from_config( + &startup, /*enable_codex_api_key_env*/ false, + ) + .await; + std::fs::write( + &requirements_path, + "allowed_login_methods = [\"chatgpt\"]\nallowed_chatgpt_workspaces = []\n", + )?; + for refreshed in [ + service.load_latest_config(/*fallback_cwd*/ None).await?, + service.load_latest_config_for_thread(&startup).await?, + ] { + assert_eq!(refreshed.forced_login_method, None); + assert_eq!(refreshed.forced_chatgpt_workspace_id, None); + } + assert!( + auth_manager.is_login_method_allowed(codex_protocol::config_types::ForcedLoginMethod::Api) + ); + assert!( + !auth_manager + .is_login_method_allowed(codex_protocol::config_types::ForcedLoginMethod::Chatgpt) + ); + assert_eq!( + auth_manager.effective_chatgpt_workspaces(), + Some(vec!["startup".to_string()]) + ); + Ok(()) } #[tokio::test] diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index d5e7178c61..a37dd94a4c 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -403,6 +403,7 @@ async fn run_outbound_router( } async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { + args.config.auth_config().validate()?; 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::(channel_capacity); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 7422476ba5..16d2f7647b 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -542,6 +542,7 @@ pub async fn run_main_with_transport_options( })? } }; + config.auth_config().validate()?; let code_mode_session_provider: Option> = match &runtime_options.code_mode_host_transport { CodeModeHostTransport::Local => None, 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 815a6ee7bd..14551e6c44 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -347,10 +347,10 @@ impl AccountRequestProcessor { return Err(self.external_auth_active_error()); } - if matches!( - self.config.forced_login_method, - Some(ForcedLoginMethod::Chatgpt) - ) { + if !self + .auth_manager + .is_login_method_allowed(ForcedLoginMethod::Api) + { return Err(invalid_request( "API key login is disabled. Use ChatGPT login instead.", )); @@ -402,10 +402,10 @@ impl AccountRequestProcessor { 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 !self + .auth_manager + .is_login_method_allowed(ForcedLoginMethod::Api) + { return Err(invalid_request( "Amazon Bedrock login is disabled. Use ChatGPT login instead.", )); @@ -463,7 +463,10 @@ impl AccountRequestProcessor { return Err(self.external_auth_active_error()); } - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + if !self + .auth_manager + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { return Err(invalid_request( "ChatGPT login is disabled. Use API key login instead.", )); @@ -476,7 +479,7 @@ impl AccountRequestProcessor { ..LoginServerOptions::new( config.codex_home.to_path_buf(), oauth_client_id(), - config.forced_chatgpt_workspace_id.clone(), + self.auth_manager.effective_chatgpt_workspaces(), config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), config.auth_route_config(), @@ -739,10 +742,10 @@ impl AccountRequestProcessor { chatgpt_account_id: String, chatgpt_plan_type: Option, ) -> Result { - if matches!( - self.config.forced_login_method, - Some(ForcedLoginMethod::Api) - ) { + if !self + .auth_manager + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { return Err(invalid_request( "External ChatGPT auth is disabled. Use API key login instead.", )); @@ -756,7 +759,7 @@ impl AccountRequestProcessor { } } - if let Some(expected_workspaces) = self.config.forced_chatgpt_workspace_id.as_deref() + if let Some(expected_workspaces) = self.auth_manager.effective_chatgpt_workspaces() && !expected_workspaces.contains(&chatgpt_account_id) { return Err(invalid_request(format!( diff --git a/codex-rs/app-server/tests/suite/strict_config.rs b/codex-rs/app-server/tests/suite/strict_config.rs index 93784c9752..d9fbdfd1d4 100644 --- a/codex-rs/app-server/tests/suite/strict_config.rs +++ b/codex-rs/app-server/tests/suite/strict_config.rs @@ -31,3 +31,36 @@ foo = "bar" Ok(()) } + +#[test] +fn managed_auth_requirements_fail_closed_for_standalone_app_server() -> Result<()> { + for requirements in [ + "allowed_login_methods = []\n", + "allowed_login_methods = [\"chatgpt\"]\nallowed_chatgpt_workspaces = []\n", + ] { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("requirements.toml"), requirements)?; + + let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?) + .env("CODEX_HOME", codex_home.path()) + .env( + "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", + codex_home.path().join("managed_config.toml"), + ) + .args(["--listen", "off"]) + .output()?; + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains("authentication requirements do not permit any usable login method"), + "expected managed authentication error in stderr, got: {stderr}" + ); + assert!( + !stderr.contains("using defaults"), + "managed authentication requirements must not fall back to defaults" + ); + } + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index e72461513f..e7a76dfb1c 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -356,6 +356,51 @@ async fn logout_account_succeeds_when_config_reload_fails() -> Result<()> { Ok(()) } +#[tokio::test] +async fn startup_enforces_local_auth_requirements_before_cloud_fetch() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), + ..Default::default() + }, + )?; + std::fs::write( + codex_home.path().join("requirements.toml"), + "allowed_login_methods = [\"api\"]\n", + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .plan_type("enterprise") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) + .await?; + + assert!( + mock_server + .received_requests() + .await + .expect("recorded requests") + .is_empty(), + "disallowed ChatGPT auth must not fetch cloud requirements" + ); + + assert_eq!(read_account(&mut mcp).await?.account, None); + + Ok(()) +} + #[tokio::test] async fn set_auth_token_updates_account_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/src/debug_sandbox/cloud_config.rs b/codex-rs/cli/src/debug_sandbox/cloud_config.rs index 5155088381..d7afa0aa10 100644 --- a/codex-rs/cli/src/debug_sandbox/cloud_config.rs +++ b/codex-rs/cli/src/debug_sandbox/cloud_config.rs @@ -1,9 +1,8 @@ use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; +use codex_core::config::bootstrap_auth_config; use codex_core::config::load_config_toml_with_layer_stack; -use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind; -use codex_core::config::resolve_bootstrap_auth_route_config; use codex_utils_absolute_path::AbsolutePathBuf; use toml::Value as TomlValue; @@ -41,28 +40,9 @@ pub(super) async fn bootstrap_cloud_config_bundle( }, ) .await?; - let bootstrap_config_toml = &bootstrap_config.config_toml; - let auth_route_config = resolve_bootstrap_auth_route_config( - bootstrap_config_toml, - bootstrap_config - .config_layer_stack - .requirements() - .feature_requirements - .as_ref(), - )?; - Ok(cloud_config_bundle_loader_for_storage( - codex_home.to_path_buf(), + bootstrap_auth_config(codex_home.as_path(), &bootstrap_config)?, /*enable_codex_api_key_env*/ false, - bootstrap_config_toml - .cli_auth_credentials_store - .unwrap_or_default(), - resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, - bootstrap_config_toml - .chatgpt_base_url - .clone() - .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()), - auth_route_config, ) .await) } diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index f02b419ba9..142b1c8a03 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -12,7 +12,6 @@ use codex_core::config::Config; use codex_login::AuthKeyringBackendKind; use codex_login::AuthRouteConfig; use codex_login::CLIENT_ID; -use codex_login::CodexAuth; use codex_login::ServerOptions; use codex_login::login_with_access_token; use codex_login::login_with_api_key; @@ -169,15 +168,18 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting browser login flow"); - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + if !config + .auth_config() + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } - let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces(); match login_with_chatgpt( config.codex_home.to_path_buf(), - forced_chatgpt_workspace_id, + effective_chatgpt_workspaces, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), config.auth_route_config(), @@ -203,7 +205,10 @@ pub async fn run_login_with_api_key( let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting api key login flow"); - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { + if !config + .auth_config() + .is_login_method_allowed(ForcedLoginMethod::Api) + { eprintln!("{API_KEY_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } @@ -233,17 +238,21 @@ pub async fn run_login_with_access_token( let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting access token login flow"); - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + if !config + .auth_config() + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { eprintln!("{ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } let auth_route_config = config.auth_route_config(); + let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces(); match login_with_access_token( &config.codex_home, &access_token, config.cli_auth_credentials_store_mode, - config.forced_chatgpt_workspace_id.as_deref(), + effective_chatgpt_workspaces.as_deref(), Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), &auth_route_config, @@ -311,7 +320,10 @@ pub async fn run_login_with_device_code( let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting device code login flow"); - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + if !config + .auth_config() + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } @@ -323,11 +335,11 @@ pub async fn run_login_with_device_code( &auth_route_config, ) .await; - let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces(); let mut opts = ServerOptions::new( config.codex_home.to_path_buf(), client_id.unwrap_or(CLIENT_ID.to_string()), - forced_chatgpt_workspace_id, + effective_chatgpt_workspaces, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), auth_route_config, @@ -359,7 +371,10 @@ pub async fn run_login_with_device_code_fallback_to_browser( let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting login flow with device code fallback"); - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + if !config + .auth_config() + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } @@ -372,11 +387,11 @@ pub async fn run_login_with_device_code_fallback_to_browser( ) .await; - let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces(); let mut opts = ServerOptions::new( config.codex_home.to_path_buf(), client_id.unwrap_or(CLIENT_ID.to_string()), - forced_chatgpt_workspace_id, + effective_chatgpt_workspaces, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), auth_route_config, @@ -423,16 +438,11 @@ pub async fn run_login_with_device_code_fallback_to_browser( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - let auth_route_config = config.auth_route_config(); - match CodexAuth::from_auth_storage( - &config.codex_home, - config.cli_auth_credentials_store_mode, - Some(&config.chatgpt_base_url), - config.auth_keyring_backend_kind(), - &auth_route_config, - ) - .await + match config + .auth_config() + .load_auth(/*enable_codex_api_key_env*/ false) + .await { Ok(Some(auth)) => match auth.auth_mode() { AuthMode::ApiKey => match auth.get_token() { @@ -469,8 +479,8 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Not logged in"); std::process::exit(1); } - Err(e) => { - eprintln!("Error checking login status: {e}"); + Err(err) => { + eprintln!("Error checking login status: {err}"); std::process::exit(1); } } @@ -513,7 +523,13 @@ async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config }; match Config::load_with_cli_overrides(cli_overrides).await { - Ok(config) => config, + Ok(config) => match config.auth_config().validate() { + Ok(()) => config, + Err(e) => { + eprintln!("Error loading configuration: {e}"); + std::process::exit(1); + } + }, Err(e) => { eprintln!("Error loading configuration: {e}"); std::process::exit(1); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2efa5dc927..4c88718a20 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1821,16 +1821,19 @@ async fn load_exec_server_remote_auth_provider( use_agent_identity_auth: bool, ) -> anyhow::Result { if use_agent_identity_auth { - let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { + read_codex_access_token_from_env().ok_or_else(|| { anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; - let auth_route_config = config.auth_route_config(); - let auth = CodexAuth::from_agent_identity_jwt( - &agent_identity_jwt, - Some(&config.chatgpt_base_url), - &auth_route_config, - ) - .await?; + let auth = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false) + .await + .auth() + .await + .ok_or_else(|| anyhow::anyhow!("Agent Identity authentication is unavailable"))?; + if !matches!(auth, CodexAuth::AgentIdentity(_)) { + anyhow::bail!( + "CODEX_ACCESS_TOKEN did not provide permitted Agent Identity authentication" + ); + } return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/cli/src/mcp_cmd/cloud_config.rs b/codex-rs/cli/src/mcp_cmd/cloud_config.rs index 3ab51a4549..ccaa028d25 100644 --- a/codex-rs/cli/src/mcp_cmd/cloud_config.rs +++ b/codex-rs/cli/src/mcp_cmd/cloud_config.rs @@ -6,10 +6,9 @@ use codex_config::ConfigLoadOptions; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::LoaderOverrides; +use codex_core::config::bootstrap_auth_config; use codex_core::config::find_codex_home; use codex_core::config::load_config_toml_with_layer_stack; -use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind; -use codex_core::config::resolve_bootstrap_auth_route_config; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; @@ -34,29 +33,10 @@ pub(super) async fn load_mcp_config( ) .await .context("failed to load bootstrap configuration")?; - let bootstrap_config_toml = &bootstrap_config.config_toml; - let auth_route_config = resolve_bootstrap_auth_route_config( - bootstrap_config_toml, - bootstrap_config - .config_layer_stack - .requirements() - .feature_requirements - .as_ref(), - ) - .context("failed to resolve cloud configuration authentication")?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - codex_home.to_path_buf(), + bootstrap_auth_config(codex_home.as_path(), &bootstrap_config) + .context("failed to resolve cloud configuration authentication")?, /*enable_codex_api_key_env*/ false, - bootstrap_config_toml - .cli_auth_credentials_store - .unwrap_or_default(), - resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config) - .context("failed to resolve cloud configuration credential storage")?, - bootstrap_config_toml - .chatgpt_base_url - .clone() - .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()), - auth_route_config, ) .await; diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index e8f95b2be6..73c65d48fc 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -18,8 +18,7 @@ use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy; use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; use codex_core_plugins::marketplace::MarketplacePluginSource; use codex_core_plugins::marketplace::find_marketplace_manifest_path; -use codex_login::CodexAuth; -use codex_login::auth::read_codex_api_key_from_env; +use codex_login::AuthManager; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_protocol::auth::AuthMode; @@ -600,22 +599,11 @@ async fn load_plugin_command_context( } pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option { - if let Some(api_key) = read_codex_api_key_from_env() { - return Some(CodexAuth::from_api_key(&api_key).api_auth_mode()); - } - - let auth_route_config = config.auth_route_config(); - CodexAuth::from_auth_storage( - &config.codex_home, - config.cli_auth_credentials_store_mode, - Some(&config.chatgpt_base_url), - config.auth_keyring_backend_kind(), - &auth_route_config, - ) - .await - .ok() - .flatten() - .map(|auth| auth.api_auth_mode()) + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true) + .await + .auth() + .await + .map(|auth| auth.api_auth_mode()) } struct PluginSelection { diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index 0f95bef5e0..ac9ea6de9b 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -66,6 +66,21 @@ fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { Ok(()) } +#[test] +fn login_status_reports_auth_storage_errors() -> Result<()> { + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + std::fs::write(codex_home.path().join("auth.json"), "{invalid json")?; + + codex_command(codex_home.path())? + .args(["login", "status"]) + .assert() + .failure() + .stderr(contains("Error checking login status:")); + + Ok(()) +} + #[test] fn login_with_access_token_rejects_invalid_jwt() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 1a0b400136..bc84f81f78 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -4,11 +4,9 @@ use crate::service::CloudConfigBundleService; use codex_config::CloudConfigBundleLoadError; use codex_config::CloudConfigBundleLoadErrorCode; use codex_config::CloudConfigBundleLoader; -use codex_config::types::AuthCredentialsStoreMode; use codex_http_client::HttpClientFactory; -use codex_login::AuthKeyringBackendKind; +use codex_login::AuthConfig; use codex_login::AuthManager; -use codex_login::AuthRouteConfig; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -59,24 +57,17 @@ pub fn cloud_config_bundle_loader( } pub async fn cloud_config_bundle_loader_for_storage( - codex_home: PathBuf, + auth_config: AuthConfig, enable_codex_api_key_env: bool, - credentials_store_mode: AuthCredentialsStoreMode, - keyring_backend_kind: AuthKeyringBackendKind, - chatgpt_base_url: String, - auth_route_config: AuthRouteConfig, ) -> CloudConfigBundleLoader { - let http_client_factory = auth_route_config.http_client_factory().clone(); - let auth_manager = AuthManager::shared( - codex_home.clone(), - enable_codex_api_key_env, - credentials_store_mode, - /*forced_chatgpt_workspace_id*/ None, - Some(chatgpt_base_url.clone()), - keyring_backend_kind, - auth_route_config, - ) - .await; + let codex_home = auth_config.codex_home.clone(); + let chatgpt_base_url = auth_config + .chatgpt_base_url + .clone() + .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let http_client_factory = auth_config.auth_route_config.http_client_factory().clone(); + let auth_manager = + AuthManager::shared_from_auth_config(auth_config, enable_codex_api_key_env).await; cloud_config_bundle_loader( auth_manager, chatgpt_base_url, diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index fb07513aa9..78187b042a 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -7,6 +7,7 @@ use codex_core::config::Config; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use codex_login::AuthManager; +use std::sync::Arc; pub fn set_user_agent_suffix(suffix: &str) { if let Ok(mut guard) = codex_login::default_client::USER_AGENT_SUFFIX.lock() { @@ -45,7 +46,7 @@ pub fn normalize_base_url(input: &str) -> String { pub async fn load_auth_manager( chatgpt_base_url: Option, -) -> (Option, HttpClientFactory) { +) -> (Option>, HttpClientFactory) { // TODO: pass in cli overrides once cloud tasks properly support them. let config = match Config::load_with_cli_overrides(Vec::new()).await { Ok(config) => config, @@ -58,16 +59,10 @@ pub async fn load_auth_manager( } }; let http_client_factory = config.http_client_factory(); - let auth_manager = AuthManager::new( - config.codex_home.to_path_buf(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - config.forced_chatgpt_workspace_id.clone(), - chatgpt_base_url.or(Some(config.chatgpt_base_url.clone())), - config.auth_keyring_backend_kind(), - config.auth_route_config(), - ) - .await; + let mut auth_config = config.auth_config(); + auth_config.chatgpt_base_url = chatgpt_base_url.or(Some(config.chatgpt_base_url.clone())); + let auth_manager = + AuthManager::shared_from_auth_config(auth_config, /*enable_codex_api_key_env*/ false).await; (Some(auth_manager), http_client_factory) } diff --git a/codex-rs/config/src/auth_policy.rs b/codex-rs/config/src/auth_policy.rs new file mode 100644 index 0000000000..a38765c3ed --- /dev/null +++ b/codex-rs/config/src/auth_policy.rs @@ -0,0 +1,61 @@ +use codex_protocol::config_types::ForcedLoginMethod; + +/// Authentication restrictions supplied by locally managed requirements. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ManagedAuthPolicy { + pub allowed_login_methods: Option>, + pub allowed_chatgpt_workspaces: Option>, +} + +impl ManagedAuthPolicy { + pub fn allows_login_method( + &self, + method: ForcedLoginMethod, + forced_login_method: Option, + forced_workspaces: Option<&[String]>, + ) -> bool { + forced_login_method.is_none_or(|forced| forced == method) + && self + .allowed_login_methods + .as_ref() + .is_none_or(|allowed| allowed.contains(&method)) + && (method != ForcedLoginMethod::Chatgpt + || self + .effective_chatgpt_workspaces(forced_workspaces) + .is_none_or(|workspaces| !workspaces.is_empty())) + } + + pub fn allowed_login_methods( + &self, + forced_login_method: Option, + forced_workspaces: Option<&[String]>, + ) -> Vec { + [ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt] + .into_iter() + .filter(|method| { + self.allows_login_method(*method, forced_login_method, forced_workspaces) + }) + .collect() + } + + pub fn effective_chatgpt_workspaces( + &self, + forced_workspaces: Option<&[String]>, + ) -> Option> { + match ( + forced_workspaces, + self.allowed_chatgpt_workspaces.as_deref(), + ) { + (Some(forced), Some(allowed)) => Some( + forced + .iter() + .filter(|workspace| allowed.contains(workspace)) + .cloned() + .collect(), + ), + (Some(forced), None) => Some(forced.to_vec()), + (None, Some(allowed)) => Some(allowed.to_vec()), + (None, None) => None, + } + } +} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 31ea1df209..b50936cbdd 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1,4 +1,5 @@ use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; @@ -19,6 +20,7 @@ use super::requirements_exec_policy::RequirementsExecPolicy; use super::requirements_exec_policy::RequirementsExecPolicyToml; use crate::Constrained; use crate::ConstraintError; +use crate::ManagedAuthPolicy; use crate::ManagedHooksRequirementsToml; use crate::config_toml::ConfigToml; use crate::mcp_requirements::McpServerRequirement; @@ -147,6 +149,8 @@ impl std::ops::DerefMut for ConstrainedWithSource { /// normalization. #[derive(Debug, Clone, PartialEq)] pub struct ConfigRequirements { + pub allowed_login_methods: Option>>, + pub allowed_chatgpt_workspaces: Option>>, pub sqlite_home: Option>, pub log_dir: Option>, pub model_catalog_json: Option>, @@ -181,6 +185,8 @@ pub struct ConfigRequirements { impl Default for ConfigRequirements { fn default() -> Self { Self { + allowed_login_methods: None, + allowed_chatgpt_workspaces: None, sqlite_home: None, log_dir: None, model_catalog_json: None, @@ -230,6 +236,24 @@ impl Default for ConfigRequirements { } impl ConfigRequirements { + pub fn managed_auth_policy(&self) -> ManagedAuthPolicy { + ManagedAuthPolicy { + allowed_login_methods: self + .allowed_login_methods + .as_ref() + .map(|allowed| allowed.value.clone()), + allowed_chatgpt_workspaces: self.allowed_chatgpt_workspaces.as_ref().map(|allowed| { + allowed + .value + .iter() + .map(|workspace| workspace.trim()) + .filter(|workspace| !workspace.is_empty()) + .map(str::to_string) + .collect() + }), + } + } + pub fn exec_policy_source(&self) -> Option<&RequirementSource> { self.exec_policy.as_ref().map(|policy| &policy.source) } @@ -873,6 +897,8 @@ pub(crate) fn merge_app_requirements_descending( /// Base config deserialized from system `requirements.toml` or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { + pub allowed_login_methods: Option>, + pub allowed_chatgpt_workspaces: Option>, pub sqlite_home: Option, pub log_dir: Option, pub model_catalog_json: Option, @@ -964,6 +990,8 @@ impl std::ops::Deref for Sourced { #[derive(Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsWithSources { + pub allowed_login_methods: Option>>, + pub allowed_chatgpt_workspaces: Option>>, pub sqlite_home: Option>, pub log_dir: Option>, pub model_catalog_json: Option>, @@ -1015,6 +1043,8 @@ impl ConfigRequirementsWithSources { // Destructure without `..` so adding fields to `ConfigRequirementsToml` // forces this merge logic to be updated. let ConfigRequirementsToml { + allowed_login_methods: _, + allowed_chatgpt_workspaces: _, sqlite_home: _, log_dir: _, model_catalog_json: _, @@ -1061,6 +1091,8 @@ impl ConfigRequirementsWithSources { other, source, { + allowed_login_methods, + allowed_chatgpt_workspaces, sqlite_home, log_dir, model_catalog_json, @@ -1104,6 +1136,8 @@ impl ConfigRequirementsWithSources { pub fn into_toml(self) -> ConfigRequirementsToml { let ConfigRequirementsWithSources { + allowed_login_methods, + allowed_chatgpt_workspaces, sqlite_home, log_dir, model_catalog_json, @@ -1136,6 +1170,8 @@ impl ConfigRequirementsWithSources { guardian_policy_config, } = self; ConfigRequirementsToml { + allowed_login_methods: allowed_login_methods.map(|sourced| sourced.value), + allowed_chatgpt_workspaces: allowed_chatgpt_workspaces.map(|sourced| sourced.value), sqlite_home: sqlite_home.map(|sourced| sourced.value), log_dir: log_dir.map(|sourced| sourced.value), model_catalog_json: model_catalog_json.map(|sourced| sourced.value), @@ -1235,7 +1271,9 @@ impl ConfigRequirementsToml { } pub fn is_empty(&self) -> bool { - self.sqlite_home.is_none() + self.allowed_login_methods.is_none() + && self.allowed_chatgpt_workspaces.is_none() + && self.sqlite_home.is_none() && self.log_dir.is_none() && self.model_catalog_json.is_none() && self.check_for_update_on_startup.is_none() @@ -1416,6 +1454,8 @@ impl TryFrom for ConfigRequirements { // defaults also remain there because they are initialization values, // not runtime constraints. let ConfigRequirementsWithSources { + allowed_login_methods, + allowed_chatgpt_workspaces, sqlite_home, log_dir, model_catalog_json, @@ -1744,6 +1784,8 @@ impl TryFrom for ConfigRequirements { }); let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); Ok(ConfigRequirements { + allowed_login_methods, + allowed_chatgpt_workspaces, sqlite_home, log_dir, model_catalog_json, @@ -1898,6 +1940,8 @@ mod tests { fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { + allowed_login_methods, + allowed_chatgpt_workspaces, sqlite_home, log_dir, model_catalog_json, @@ -1931,6 +1975,10 @@ mod tests { guardian_policy_config, } = toml; ConfigRequirementsWithSources { + allowed_login_methods: allowed_login_methods + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_chatgpt_workspaces: allowed_chatgpt_workspaces + .map(|value| Sourced::new(value, RequirementSource::Unknown)), sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)), log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)), model_catalog_json: model_catalog_json @@ -2201,6 +2249,8 @@ mod tests { // Intentionally constructed without `..Default::default()` so adding a new field to // `ConfigRequirementsToml` forces this test to be updated. let other = ConfigRequirementsToml { + allowed_login_methods: Some(vec![ForcedLoginMethod::Chatgpt]), + allowed_chatgpt_workspaces: Some(vec!["managed-workspace".to_string()]), sqlite_home: Some(sqlite_home.clone()), log_dir: Some(log_dir.clone()), model_catalog_json: Some(model_catalog_json.clone()), @@ -2239,6 +2289,14 @@ mod tests { assert_eq!( target, ConfigRequirementsWithSources { + allowed_login_methods: Some(Sourced::new( + vec![ForcedLoginMethod::Chatgpt], + source.clone(), + )), + allowed_chatgpt_workspaces: Some(Sourced::new( + vec!["managed-workspace".to_string()], + source.clone(), + )), sqlite_home: Some(Sourced::new(sqlite_home, source.clone())), log_dir: Some(Sourced::new(log_dir, source.clone())), model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())), diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index f45b346a0e..dec1829056 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,3 +1,4 @@ +mod auth_policy; mod cloud_config_bundle; mod cloud_config_layers; mod config_layer_source; @@ -34,6 +35,7 @@ pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; +pub use auth_policy::ManagedAuthPolicy; pub use cloud_config_bundle::CloudConfigBundle; pub use cloud_config_bundle::CloudConfigBundleLayers; pub use cloud_config_bundle::CloudConfigBundleLoadError; diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 7c2a703e92..63df1ec150 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -192,7 +192,14 @@ pub async fn load_config_layers_state( requirements_layers.extend(managed_preferences_requirements_layer); } - let config_requirements_toml = compose_requirements(requirements_layers)?.unwrap_or_default(); + let mut config_requirements_toml = + compose_requirements(requirements_layers)?.unwrap_or_default(); + // Remote app servers enforce auth policy for their workspaces; do not let local + // requirements reintroduce authentication restrictions for those workspaces. + if overrides.ignore_login_requirements { + config_requirements_toml.allowed_login_methods = None; + config_requirements_toml.allowed_chatgpt_workspaces = None; + } let thread_config_context = ThreadConfigContext { thread_id: None, diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs index 6a4f695397..c969305ebf 100644 --- a/codex-rs/config/src/requirements_layers/layer.rs +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -65,8 +65,18 @@ impl ComposableRequirementsLayer { let _guard = base_dir .as_ref() .map(|base_dir| AbsolutePathBufGuard::new(base_dir.as_path())); - let regular_toml = parse_layer_toml(&toml, &source)?; - let requirements = parse_layer_requirements(&toml, &source)?; + let mut regular_toml = parse_layer_toml(&toml, &source)?; + + // These fields can only be set locally; ignore them before validating cloud policy. + if matches!(source, RequirementSource::EnterpriseManaged { .. }) { + remove_top_level_field(&mut regular_toml, "allowed_login_methods"); + remove_top_level_field(&mut regular_toml, "allowed_chatgpt_workspaces"); + } + + let requirements = parse_layer_requirements( + &RequirementsLayerToml::Value(regular_toml.clone()), + &source, + )?; (regular_toml, requirements) }; diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 6272e63fca..002c6f1757 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -206,6 +206,8 @@ fn populate_merged_regular_fields_with_sources( // Destructure without `..` so every new requirements field must choose // whether it belongs in the regular TOML merge path or in a special merger. let ConfigRequirementsToml { + allowed_login_methods, + allowed_chatgpt_workspaces, sqlite_home, log_dir, model_catalog_json, @@ -239,6 +241,8 @@ fn populate_merged_regular_fields_with_sources( guardian_policy_config, } = requirements; + set_sourced!(allowed_login_methods, &["allowed_login_methods"]); + set_sourced!(allowed_chatgpt_workspaces, &["allowed_chatgpt_workspaces"]); set_sourced!(sqlite_home, &["sqlite_home"]); set_sourced!(log_dir, &["log_dir"]); set_sourced!(model_catalog_json, &["model_catalog_json"]); diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index f4935d2a1e..75a73a0888 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -57,6 +57,25 @@ fn empty_layers_compose_to_none() { assert_eq!(composed, None); } +#[test] +fn cloud_auth_requirements_do_not_override_local_or_discard_other_policy() { + let local = RequirementsLayerEntry::from_toml( + RequirementSource::Unknown, + "allowed_login_methods = [\"api\"]", + ); + let cloud = layer( + "req_cloud", + "Cloud policy", + "allowed_login_methods = [\"saml\"]\nallowed_chatgpt_workspaces = \"invalid\"\nallow_login_shell = false", + ); + assert_eq!( + compose(vec![local, cloud]).expect("cloud auth cannot invalidate enterprise policy"), + Some(expected_requirements( + "allowed_login_methods = [\"api\"]\nallow_login_shell = false" + )) + ); +} + #[test] fn top_level_values_use_toml_priority() { let composed = compose(vec![ diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 9c0c1d8b56..d967a8aed7 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -47,6 +47,8 @@ pub struct LoaderOverrides { pub system_config_path: Option, pub system_requirements_path: Option, pub ignore_managed_requirements: bool, + /// Remote app servers own their authentication policy independently. + pub ignore_login_requirements: bool, pub ignore_user_config: bool, pub ignore_user_and_project_exec_policy_rules: bool, //TODO(gt): Add a macos_ prefix to this field and remove the target_os check. @@ -68,6 +70,7 @@ impl LoaderOverrides { system_config_path: Some(base.join("config.toml")), system_requirements_path: Some(base.join("requirements.toml")), ignore_managed_requirements: false, + ignore_login_requirements: false, ignore_user_config: false, ignore_user_and_project_exec_policy_rules: false, #[cfg(target_os = "macos")] diff --git a/codex-rs/core/src/config/auth_keyring.rs b/codex-rs/core/src/config/auth_keyring.rs index e6a1a94f5c..c8709b66ce 100644 --- a/codex-rs/core/src/config/auth_keyring.rs +++ b/codex-rs/core/src/config/auth_keyring.rs @@ -1,11 +1,14 @@ use super::Config; use super::ConfigTomlLoadResult; use super::ManagedFeatures; +use super::resolve_bootstrap_auth_route_config; 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,66 @@ 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(), + managed_auth_policy: self.config_layer_stack.requirements().managed_auth_policy(), + auth_route_config: self.auth_route_config(), + } + } +} + +/// Builds authentication settings from the locally resolved bootstrap config. +/// +/// Use this before fetching cloud requirements, when a full [`Config`] is not +/// yet available. Preserves the configured credential store, keyring backend, +/// ChatGPT base URL, auth routing, and managed login/workspace restrictions. +pub fn bootstrap_auth_config( + codex_home: &Path, + bootstrap_config: &ConfigTomlLoadResult, +) -> std::io::Result { + let config = &bootstrap_config.config_toml; + // Empty legacy workspace settings mean unrestricted, not an empty allowlist. + let forced_chatgpt_workspace_id = config + .forced_chatgpt_workspace_id + .clone() + .map(|workspaces| { + workspaces + .into_vec() + .into_iter() + .map(|workspace| workspace.trim().to_string()) + .filter(|workspace| !workspace.is_empty()) + .collect::>() + }) + .filter(|workspaces| !workspaces.is_empty()); + let auth_config = 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, + managed_auth_policy: bootstrap_config + .config_layer_stack + .requirements() + .managed_auth_policy(), + auth_route_config: resolve_bootstrap_auth_route_config( + config, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?, + }; + auth_config.validate()?; + Ok(auth_config) } /// Resolve the auth keyring backend from a partially loaded bootstrap config. diff --git a/codex-rs/core/src/config/auth_keyring_tests.rs b/codex-rs/core/src/config/auth_keyring_tests.rs index 3ec78650b0..83a8f2923f 100644 --- a/codex-rs/core/src/config/auth_keyring_tests.rs +++ b/codex-rs/core/src/config/auth_keyring_tests.rs @@ -6,7 +6,9 @@ use codex_config::FeatureRequirementsToml; use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ForcedChatgptWorkspaceIds; use codex_features::FeaturesToml; +use codex_protocol::config_types::ForcedLoginMethod; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -60,6 +62,70 @@ fn resolve_bootstrap_auth_keyring_backend_kind_uses_secret_auth_storage_feature( Ok(()) } +#[test] +fn managed_auth_restrictions_intersect_workspaces_and_fail_closed() { + let config = ConfigToml { + forced_login_method: None, + forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + " denied ".to_string(), + " allowed ".to_string(), + ])), + ..Default::default() + }; + let mut requirements = ConfigRequirements { + allowed_login_methods: Some(Sourced::new( + vec![ForcedLoginMethod::Chatgpt], + RequirementSource::Unknown, + )), + allowed_chatgpt_workspaces: Some(Sourced::new( + vec!["allowed".to_string()], + RequirementSource::Unknown, + )), + ..Default::default() + }; + + let bootstrap_config = ConfigTomlLoadResult { + config_toml: config.clone(), + config_layer_stack: ConfigLayerStack::new( + Vec::new(), + requirements.clone(), + ConfigRequirementsToml::default(), + ) + .expect("requirements should stack"), + }; + let auth_config = bootstrap_auth_config(Path::new("codex-home"), &bootstrap_config) + .expect("policy should resolve"); + assert_eq!(auth_config.forced_login_method, None); + assert!(auth_config.is_login_method_allowed(ForcedLoginMethod::Chatgpt)); + assert!(!auth_config.is_login_method_allowed(ForcedLoginMethod::Api)); + assert_eq!( + auth_config.forced_chatgpt_workspace_id, + Some(vec!["denied".to_string(), "allowed".to_string()]) + ); + assert_eq!( + auth_config.effective_chatgpt_workspaces(), + Some(vec!["allowed".to_string()]) + ); + + requirements.allowed_chatgpt_workspaces = + Some(Sourced::new(Vec::new(), RequirementSource::Unknown)); + let bootstrap_config = ConfigTomlLoadResult { + config_toml: config, + config_layer_stack: ConfigLayerStack::new( + Vec::new(), + requirements, + ConfigRequirementsToml::default(), + ) + .expect("requirements should stack"), + }; + assert_eq!( + bootstrap_auth_config(Path::new("codex-home"), &bootstrap_config) + .expect_err("ChatGPT-only policy without an allowed workspace must fail") + .kind(), + std::io::ErrorKind::PermissionDenied + ); +} + fn config_toml_load_result( config_toml: ConfigToml, feature_requirements: Option>, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7db18e06cd..dcdb1d1a90 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9446,6 +9446,8 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() let fixture = create_test_fixture()?; let requirements_toml = codex_config::ConfigRequirementsToml { + allowed_login_methods: None, + allowed_chatgpt_workspaces: None, sqlite_home: None, log_dir: None, model_catalog_json: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a7784643ce..868713d4d1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -13,6 +13,7 @@ use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::ConstrainedWithSource; use codex_config::FeatureRequirementsToml; +use codex_config::ManagedAuthPolicy; use codex_config::McpServerRequirement; use codex_config::PluginRequirementsToml; use codex_config::ProfileV2Name; @@ -160,6 +161,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; @@ -1337,10 +1339,18 @@ impl AuthManagerConfig for Config { Config::auth_keyring_backend_kind(self) } + fn forced_login_method(&self) -> Option { + self.forced_login_method + } + fn forced_chatgpt_workspace_id(&self) -> Option> { self.forced_chatgpt_workspace_id.clone() } + fn managed_auth_policy(&self) -> ManagedAuthPolicy { + self.config_layer_stack.requirements().managed_auth_policy() + } + fn chatgpt_base_url(&self) -> String { self.chatgpt_base_url.clone() } @@ -3198,10 +3208,11 @@ impl Config { config_layer_stack.requirements(), &mut startup_warnings, ); - // Destructure every field to ensure ConfigRequirements additions are // either applied above or handled while constructing the final Config. let ConfigRequirements { + allowed_login_methods: _, + allowed_chatgpt_workspaces: _, sqlite_home: _, log_dir: _, model_catalog_json: _, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 57ea025f7f..7fd22ae917 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -63,10 +63,9 @@ 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_config_toml_with_layer_stack; -use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind; -use codex_core::config::resolve_bootstrap_auth_route_config; use codex_core::config::resolve_oss_provider; use codex_core::config::resolve_profile_v2_config_path; use codex_core::find_thread_meta_by_name_str; @@ -75,7 +74,6 @@ use codex_core::path_utils; use codex_core::read_session_meta_line; use codex_feedback::CodexFeedback; use codex_git_utils::get_git_repo_root; -use codex_login::AuthConfig; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::default_client::set_default_originator; use codex_login::enforce_login_restrictions; @@ -342,28 +340,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ) .await; let bootstrap_config_toml = &bootstrap_config.config_toml; - - let chatgpt_base_url = bootstrap_config_toml - .chatgpt_base_url - .clone() - .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let auth_route_config = resolve_bootstrap_auth_route_config( - bootstrap_config_toml, - bootstrap_config - .config_layer_stack - .requirements() - .feature_requirements - .as_ref(), - )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - codex_home.to_path_buf(), + bootstrap_auth_config(&codex_home, &bootstrap_config)?, /*enable_codex_api_key_env*/ false, - bootstrap_config_toml - .cli_auth_credentials_store - .unwrap_or_default(), - resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, - chatgpt_base_url, - auth_route_config, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); @@ -480,18 +459,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result set_default_client_residency_requirement(config.enforce_residency.value()); - let auth_route_config = config.auth_route_config(); - 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()), - auth_route_config, - }) - .await - { + if let Err(err) = enforce_login_restrictions(&config.auth_config()).await { eprintln!("{err}"); std::process::exit(1); } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index fa1120db5e..66b1b1f784 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -141,6 +141,33 @@ async fn login_with_access_token_writes_agent_identity_jwt() { server.verify().await; } +#[tokio::test] +async fn login_with_access_token_rejects_agent_identity_workspace_mismatch() { + let dir = tempdir().unwrap(); + let record = agent_identity_record(WORKSPACE_ID_DISALLOWED); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let server = MockServer::start().await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let allowed_workspaces = [WORKSPACE_ID_ALLOWED.to_string()]; + + let err = super::login_with_access_token( + dir.path(), + &agent_identity, + AuthCredentialsStoreMode::File, + Some(&allowed_workspaces), + Some(&chatgpt_base_url), + AuthKeyringBackendKind::default(), + &crate::test_support::transport_default_auth_route_config(), + ) + .await + .expect_err("agent identity workspace mismatch should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + assert!(!get_auth_file(dir.path()).exists()); + assert!(server.received_requests().await.unwrap().is_empty()); +} + #[tokio::test] #[serial(codex_auth_env)] async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result<()> { @@ -178,6 +205,7 @@ async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, @@ -366,6 +394,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -448,6 +477,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -490,6 +520,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -556,6 +587,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -617,6 +649,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -673,6 +706,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -816,6 +850,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -878,6 +913,7 @@ async fn loads_api_key_from_auth_json() { dir.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -974,6 +1010,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -1343,6 +1380,7 @@ async fn build_config( keyring_backend_kind: AuthKeyringBackendKind::Direct, forced_login_method, forced_chatgpt_workspace_id, + managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, auth_route_config: crate::test_support::transport_default_auth_route_config(), } @@ -1423,6 +1461,7 @@ async fn load_auth_reads_access_token_from_env() { codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, @@ -1471,6 +1510,7 @@ async fn load_auth_reads_personal_access_token_from_env() { codex_home.path(), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), @@ -1644,6 +1684,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { codex_home.path(), /*enable_codex_api_key_env*/ true, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -1687,6 +1728,162 @@ async fn enforce_login_restrictions_logs_out_for_method_mismatch() { ); } +#[tokio::test] +#[serial(codex_auth_env)] +async fn auth_manager_rejects_disallowed_stored_and_external_auth() { + 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, + AuthKeyringBackendKind::default(), + ) + .expect("seed api key"); + let mut config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + /*forced_chatgpt_workspace_id*/ None, + ) + .await; + config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Chatgpt]); + let manager = + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + + assert_eq!(manager.auth().await, None); + assert!( + manager + .set_external_auth(Arc::new(StaticExternalAuth(CodexAuth::from_api_key( + "sk-external", + )))) + .await + .is_err(), + "external auth cannot bypass managed login policy" + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn api_only_policy_rejects_access_tokens_before_hydration() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-rejected"); + let mut config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + /*forced_chatgpt_workspace_id*/ None, + ) + .await; + config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Api]); + let manager = + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + + assert_eq!(manager.auth().await, None); + assert!( + server + .received_requests() + .await + .expect("inspect auth requests") + .is_empty(), + "rejected access tokens must not call whoami or register Agent Identity" + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn workspace_policy_rejects_agent_identity_before_hydration() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + let record = agent_identity_record(WORKSPACE_ID_DISALLOWED); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_reset = remove_access_token_env_var(); + let access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); + let mut config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + /*forced_chatgpt_workspace_id*/ None, + ) + .await; + config.managed_auth_policy.allowed_chatgpt_workspaces = + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]); + let manager = + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + + assert_eq!(manager.auth().await, None); + drop(access_token_guard); + + for stored_agent_identity in [ + AgentIdentityStorage::Jwt(agent_identity), + AgentIdentityStorage::Record(record), + ] { + save_auth( + codex_home.path(), + &AuthDotJson { + auth_mode: Some(AuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(stored_agent_identity), + personal_access_token: None, + bedrock_api_key: None, + }, + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::Direct, + ) + .expect("store agent identity"); + let mut config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + /*forced_chatgpt_workspace_id*/ None, + ) + .await; + config.managed_auth_policy.allowed_chatgpt_workspaces = + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]); + let manager = + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + assert_eq!(manager.auth().await, None); + } + + assert!( + server.received_requests().await.unwrap().is_empty(), + "rejected agent identities must not fetch JWKS or register" + ); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn workspace_policy_checks_the_selected_request_account() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), + }, + codex_home.path(), + ) + .expect("seed ChatGPT credentials"); + let auth_path = codex_home.path().join("auth.json"); + let mut stored: serde_json::Value = + serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap(); + stored["tokens"]["account_id"] = json!(WORKSPACE_ID_DISALLOWED); + std::fs::write(&auth_path, serde_json::to_vec(&stored).unwrap()).unwrap(); + let config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), + ) + .await; + let manager = + AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await; + + assert_eq!(manager.auth().await, None); +} + #[tokio::test] #[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { @@ -1756,6 +1953,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace keyring_backend_kind: AuthKeyringBackendKind::default(), forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), + managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, auth_route_config: crate::test_support::transport_default_auth_route_config(), }; @@ -1880,6 +2078,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat keyring_backend_kind: AuthKeyringBackendKind::Direct, forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), + managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: Some(chatgpt_base_url), auth_route_config: crate::test_support::transport_default_auth_route_config(), }; @@ -2162,6 +2361,7 @@ async fn plan_type_maps_known_plan() { codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -2194,6 +2394,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -2229,6 +2430,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -2264,6 +2466,7 @@ async fn plan_type_maps_unknown_to_unknown() { codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, @@ -2296,6 +2499,7 @@ async fn missing_plan_type_maps_to_unknown() { codex_home.path(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 0b6e8c9e53..4441754138 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -56,6 +56,7 @@ use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use crate::token_data::parse_jwt_expiration; +use codex_config::ManagedAuthPolicy; use codex_config::types::AuthCredentialsStoreMode; use codex_http_client::HttpClient; use codex_http_client::HttpClientFactory; @@ -361,6 +362,7 @@ impl CodexAuth { codex_home, /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, keyring_backend_kind, @@ -940,7 +942,7 @@ pub async fn login_with_access_token( let auth_dot_json = match classify_codex_access_token(access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; - ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; + ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?; AuthDotJson { // Infer PAT auth from the credential field so older Codex builds can still // deserialize auth.json after a rollback. @@ -954,6 +956,8 @@ pub async fn login_with_access_token( } } CodexAccessToken::AgentIdentityJwt(jwt) => { + let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; + ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, &record.account_id)?; let base_url = chatgpt_base_url .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') @@ -978,14 +982,33 @@ pub async fn login_with_access_token( ) } -fn ensure_personal_access_token_workspace_allowed( +fn ensure_auth_workspace_allowed( expected_workspace_ids: Option<&[String]>, - auth: &PersonalAccessTokenAuth, + account_id: &str, ) -> std::io::Result<()> { - crate::server::ensure_workspace_account_allowed(expected_workspace_ids, auth.account_id()) + crate::server::ensure_workspace_account_allowed(expected_workspace_ids, account_id) .map_err(|message| std::io::Error::new(std::io::ErrorKind::PermissionDenied, message)) } +fn ensure_agent_identity_workspace_allowed( + expected_workspace_ids: Option<&[String]>, + agent_identity: &AgentIdentityStorage, +) -> std::io::Result<()> { + let Some(expected_workspace_ids) = expected_workspace_ids else { + return Ok(()); + }; + + match agent_identity { + AgentIdentityStorage::Jwt(jwt) => { + let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; + ensure_auth_workspace_allowed(Some(expected_workspace_ids), &record.account_id) + } + AgentIdentityStorage::Record(record) => { + ensure_auth_workspace_allowed(Some(expected_workspace_ids), &record.account_id) + } + } +} + /// Writes an in-memory auth payload for externally managed ChatGPT tokens. pub fn login_with_chatgpt_auth_tokens( codex_home: &Path, @@ -1047,9 +1070,131 @@ pub struct AuthConfig { pub forced_login_method: Option, pub chatgpt_base_url: Option, pub forced_chatgpt_workspace_id: Option>, + pub managed_auth_policy: ManagedAuthPolicy, pub auth_route_config: AuthRouteConfig, } +impl AuthConfig { + pub fn is_login_method_allowed(&self, method: ForcedLoginMethod) -> bool { + self.managed_auth_policy.allows_login_method( + method, + self.forced_login_method, + self.forced_chatgpt_workspace_id.as_deref(), + ) + } + + pub fn effective_chatgpt_workspaces(&self) -> Option> { + self.managed_auth_policy + .effective_chatgpt_workspaces(self.forced_chatgpt_workspace_id.as_deref()) + } + + pub fn validate(&self) -> std::io::Result<()> { + if self.is_login_method_allowed(ForcedLoginMethod::Api) + || self.is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "authentication requirements do not permit any usable login method", + )) + } + } + + pub fn allows_auth(&self, auth: &CodexAuth) -> bool { + let allowed_login_methods = self.allowed_login_methods(); + let workspaces = self.effective_chatgpt_workspaces(); + validate_auth_restrictions(Some(&allowed_login_methods), workspaces.as_deref(), auth) + .is_ok() + } + + pub async fn load_auth( + &self, + enable_codex_api_key_env: bool, + ) -> std::io::Result> { + let allowed_login_methods = self.allowed_login_methods(); + let workspaces = self.effective_chatgpt_workspaces(); + let agent_identity_authapi_base_url = + agent_identity_authapi_base_url(self.chatgpt_base_url.as_deref()).ok(); + let auth = load_auth( + &self.codex_home, + enable_codex_api_key_env, + self.auth_credentials_store_mode, + Some(&allowed_login_methods), + workspaces.as_deref(), + self.chatgpt_base_url.as_deref(), + self.keyring_backend_kind, + agent_identity_authapi_base_url.as_deref(), + &self.auth_route_config, + ) + .await?; + Ok(auth.filter(|auth| self.allows_auth(auth))) + } + + fn allowed_login_methods(&self) -> Vec { + self.managed_auth_policy.allowed_login_methods( + self.forced_login_method, + self.forced_chatgpt_workspace_id.as_deref(), + ) + } +} + +fn auth_mode_is_allowed( + allowed_login_methods: Option<&[ForcedLoginMethod]>, + mode: AuthMode, +) -> bool { + let method = if mode.uses_codex_backend() { + ForcedLoginMethod::Chatgpt + } else { + ForcedLoginMethod::Api + }; + allowed_login_methods.is_none_or(|allowed| allowed.contains(&method)) +} + +fn validate_auth_restrictions( + allowed_login_methods: Option<&[ForcedLoginMethod]>, + expected_workspaces: Option<&[String]>, + auth: &CodexAuth, +) -> Result<(), String> { + if !auth_mode_is_allowed(allowed_login_methods, auth.auth_mode()) { + return Err(match allowed_login_methods { + Some(methods) if methods.contains(&ForcedLoginMethod::Api) => { + "API key login is required".to_string() + } + Some(_) => "ChatGPT login is required".to_string(), + None => unreachable!("unrestricted login methods accept every auth mode"), + }); + } + + let Some(expected_workspaces) = expected_workspaces else { + return Ok(()); + }; + if matches!( + auth, + CodexAuth::ApiKey(_) | CodexAuth::Headers(_) | CodexAuth::BedrockApiKey(_) + ) { + return Ok(()); + } + + let actual_workspace = auth.get_account_id().or_else(|| { + auth.get_token_data() + .ok() + .and_then(|tokens| tokens.id_token.chatgpt_account_id) + }); + if actual_workspace + .as_ref() + .is_some_and(|workspace| expected_workspaces.contains(workspace)) + { + Ok(()) + } else { + let actual = actual_workspace.unwrap_or_else(|| "unknown".to_string()); + Err(format!( + "Login is restricted to workspace(s) {}, but current credentials belong to {actual}", + expected_workspaces.join(", ") + )) + } +} + /// Enforces configured login restrictions using auth-owned HTTP settings. pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { let agent_identity_authapi_base_url = @@ -1065,10 +1210,16 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( config: &AuthConfig, agent_identity_authapi_base_url: Option<&str>, ) -> std::io::Result<()> { + // Managed-only restrictions are enforced by AuthManager. + if config.forced_login_method.is_none() && config.forced_chatgpt_workspace_id.is_none() { + return Ok(()); + } + let Some(auth) = load_auth( &config.codex_home, /*enable_codex_api_key_env*/ true, config.auth_credentials_store_mode, + /*allowed_login_methods*/ None, /*forced_chatgpt_workspace_id*/ None, config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, @@ -1218,6 +1369,7 @@ async fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + allowed_login_methods: Option<&[ForcedLoginMethod]>, forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, @@ -1225,7 +1377,10 @@ async fn load_auth( auth_route_config: &AuthRouteConfig, ) -> 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() { + if enable_codex_api_key_env + && auth_mode_is_allowed(allowed_login_methods, AuthMode::ApiKey) + && let Some(api_key) = read_codex_api_key_from_env() + { return Ok(Some(CodexAuth::from_api_key(api_key.as_str()))); } @@ -1236,7 +1391,12 @@ async fn load_auth( AuthCredentialsStoreMode::Ephemeral, AuthKeyringBackendKind::default(), ); - if let Some(auth_dot_json) = ephemeral_storage.load()? { + if let Some(auth_dot_json) = ephemeral_storage.load()? + && auth_mode_is_allowed(allowed_login_methods, auth_dot_json.resolved_mode()) + { + if let Some(agent_identity) = auth_dot_json.agent_identity.as_ref() { + ensure_agent_identity_workspace_allowed(forced_chatgpt_workspace_id, agent_identity)?; + } let auth = CodexAuth::from_auth_dot_json( codex_home, auth_dot_json, @@ -1248,19 +1408,23 @@ async fn load_auth( ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { - ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, auth)?; + ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?; } return Ok(Some(auth)); } - if let Some(access_token) = read_codex_access_token_from_env() { + if auth_mode_is_allowed(allowed_login_methods, AuthMode::AgentIdentity) + && let Some(access_token) = read_codex_access_token_from_env() + { return match classify_codex_access_token(&access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; - ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; + ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?; Ok(Some(CodexAuth::PersonalAccessToken(auth))) } CodexAccessToken::AgentIdentityJwt(jwt) => { + let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; + ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, &record.account_id)?; CodexAuth::from_agent_identity_jwt_with_authapi_base_url( jwt, chatgpt_base_url, @@ -1288,6 +1452,12 @@ async fn load_auth( Some(auth) => auth, None => return Ok(None), }; + if !auth_mode_is_allowed(allowed_login_methods, auth_dot_json.resolved_mode()) { + return Ok(None); + } + if let Some(agent_identity) = auth_dot_json.agent_identity.as_ref() { + ensure_agent_identity_workspace_allowed(forced_chatgpt_workspace_id, agent_identity)?; + } let auth = CodexAuth::from_auth_dot_json( codex_home, @@ -1300,7 +1470,7 @@ async fn load_auth( ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { - ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, auth)?; + ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?; } Ok(Some(auth)) } @@ -1773,7 +1943,9 @@ pub struct AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + forced_login_method: Option, forced_chatgpt_workspace_id: RwLock>>, + managed_auth_policy: ManagedAuthPolicy, chatgpt_base_url: Option, agent_identity_authapi_base_url: Option, refresh_lock: Semaphore, @@ -1799,9 +1971,15 @@ pub trait AuthManagerConfig { /// Returns the backend to use when CLI auth keyring storage is selected. fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind; + /// Returns the resolved login-method restriction, if any. + fn forced_login_method(&self) -> Option; + /// Returns the workspace IDs that ChatGPT auth should be restricted to, if any. fn forced_chatgpt_workspace_id(&self) -> Option>; + /// Returns administrator-managed authentication restrictions. + fn managed_auth_policy(&self) -> ManagedAuthPolicy; + /// Returns the ChatGPT backend base URL used for first-party backend authorization. fn chatgpt_base_url(&self) -> String; @@ -1820,10 +1998,12 @@ impl Debug for AuthManager { &self.auth_credentials_store_mode, ) .field("keyring_backend_kind", &self.keyring_backend_kind) + .field("forced_login_method", &self.forced_login_method) .field( "forced_chatgpt_workspace_id", &self.forced_chatgpt_workspace_id, ) + .field("managed_auth_policy", &self.managed_auth_policy) .field("chatgpt_base_url", &self.chatgpt_base_url) .field("auth_route_config", &self.auth_route_config) .field("has_external_auth", &self.has_external_auth()) @@ -1849,21 +2029,40 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind, auth_route_config: AuthRouteConfig, ) -> Self { - let agent_identity_authapi_base_url = - agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok(); - let managed_auth = load_auth( - &codex_home, + Self::new_from_auth_config( + AuthConfig { + codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + forced_login_method: None, + chatgpt_base_url, + forced_chatgpt_workspace_id, + managed_auth_policy: ManagedAuthPolicy::default(), + auth_route_config, + }, enable_codex_api_key_env, - auth_credentials_store_mode, - forced_chatgpt_workspace_id.as_deref(), - chatgpt_base_url.as_deref(), - keyring_backend_kind, - agent_identity_authapi_base_url.as_deref(), - &auth_route_config, ) .await - .ok() - .flatten(); + } + + async fn new_from_auth_config(auth_config: AuthConfig, enable_codex_api_key_env: bool) -> Self { + let managed_auth = auth_config + .load_auth(enable_codex_api_key_env) + .await + .ok() + .flatten(); + let AuthConfig { + codex_home, + auth_credentials_store_mode, + keyring_backend_kind, + forced_login_method, + chatgpt_base_url, + forced_chatgpt_workspace_id, + managed_auth_policy, + auth_route_config, + } = auth_config; + let agent_identity_authapi_base_url = + agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok(); let (auth_change_tx, _auth_change_rx) = watch::channel(0); Self { codex_home, @@ -1875,7 +2074,9 @@ impl AuthManager { enable_codex_api_key_env, auth_credentials_store_mode, keyring_backend_kind, + forced_login_method, forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id), + managed_auth_policy, chatgpt_base_url, agent_identity_authapi_base_url, refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1901,7 +2102,9 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, keyring_backend_kind: AuthKeyringBackendKind::default(), + forced_login_method: None, forced_chatgpt_workspace_id: RwLock::new(None), + managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1926,7 +2129,9 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, keyring_backend_kind: AuthKeyringBackendKind::default(), + forced_login_method: None, forced_chatgpt_workspace_id: RwLock::new(None), + managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), @@ -1955,7 +2160,9 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, keyring_backend_kind: AuthKeyringBackendKind::default(), + forced_login_method: None, forced_chatgpt_workspace_id: RwLock::new(None), + managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, agent_identity_authapi_base_url: Some( agent_identity_authapi_base_url @@ -1982,7 +2189,9 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, keyring_backend_kind: AuthKeyringBackendKind::default(), + forced_login_method: None, forced_chatgpt_workspace_id: RwLock::new(None), + managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), refresh_lock: Semaphore::new(/*permits*/ 1), @@ -2056,10 +2265,10 @@ impl AuthManager { .acquire() .await .map_err(std::io::Error::other)?; - let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); + let effective_chatgpt_workspaces = self.effective_chatgpt_workspaces(); let cooldown_key = ManagedChatGptAgentIdentityBinding::from_auth( &auth, - forced_chatgpt_workspace_id.clone(), + effective_chatgpt_workspaces.clone(), ) .and_then(|binding| { self.agent_identity_authapi_base_url @@ -2079,7 +2288,7 @@ impl AuthManager { .agent_identity_auth( policy, self.agent_identity_authapi_base_url.as_deref(), - forced_chatgpt_workspace_id, + effective_chatgpt_workspaces, &self.auth_route_config, session_source, ) @@ -2098,7 +2307,7 @@ impl AuthManager { auth.agent_identity_auth( policy, self.agent_identity_authapi_base_url.as_deref(), - self.forced_chatgpt_workspace_id(), + self.effective_chatgpt_workspaces(), &self.auth_route_config, session_source, ) @@ -2209,12 +2418,14 @@ impl AuthManager { }; } - let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); + let allowed_login_methods = self.allowed_login_methods(); + let effective_chatgpt_workspaces = self.effective_chatgpt_workspaces(); load_auth( &self.codex_home, self.enable_codex_api_key_env, self.auth_credentials_store_mode, - forced_chatgpt_workspace_id.as_deref(), + Some(&allowed_login_methods), + effective_chatgpt_workspaces.as_deref(), self.chatgpt_base_url.as_deref(), self.keyring_backend_kind, self.agent_identity_authapi_base_url.as_deref(), @@ -2223,6 +2434,14 @@ impl AuthManager { .await .ok() .flatten() + .filter(|auth| { + validate_auth_restrictions( + Some(&allowed_login_methods), + effective_chatgpt_workspaces.as_deref(), + auth, + ) + .is_ok() + }) } fn set_cached_auth(&self, new_auth: Option) -> bool { @@ -2279,6 +2498,26 @@ impl AuthManager { .and_then(|guard| guard.clone()) } + pub fn effective_chatgpt_workspaces(&self) -> Option> { + self.managed_auth_policy + .effective_chatgpt_workspaces(self.forced_chatgpt_workspace_id().as_deref()) + } + + pub fn is_login_method_allowed(&self, method: ForcedLoginMethod) -> bool { + self.managed_auth_policy.allows_login_method( + method, + self.forced_login_method, + self.forced_chatgpt_workspace_id().as_deref(), + ) + } + + fn allowed_login_methods(&self) -> Vec { + self.managed_auth_policy.allowed_login_methods( + self.forced_login_method, + self.forced_chatgpt_workspace_id().as_deref(), + ) + } + pub fn has_external_auth(&self) -> bool { self.external_auth().is_some() } @@ -2322,18 +2561,30 @@ impl AuthManager { config: &impl AuthManagerConfig, enable_codex_api_key_env: bool, ) -> Arc { - Self::shared( - config.codex_home(), + Self::shared_from_auth_config( + AuthConfig { + codex_home: config.codex_home(), + 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(), + chatgpt_base_url: Some(config.chatgpt_base_url()), + forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id(), + managed_auth_policy: config.managed_auth_policy(), + auth_route_config: config.auth_route_config(), + }, enable_codex_api_key_env, - config.cli_auth_credentials_store_mode(), - config.forced_chatgpt_workspace_id(), - Some(config.chatgpt_base_url()), - config.auth_keyring_backend_kind(), - config.auth_route_config(), ) .await } + /// Builds a shared manager using restrictions resolved before authentication. + pub async fn shared_from_auth_config( + auth_config: AuthConfig, + enable_codex_api_key_env: bool, + ) -> Arc { + Arc::new(Self::new_from_auth_config(auth_config, enable_codex_api_key_env).await) + } + pub fn unauthorized_recovery(self: &Arc) -> UnauthorizedRecovery { UnauthorizedRecovery::new(Arc::clone(self)) } @@ -2582,16 +2833,13 @@ impl AuthManager { } fn validate_external_auth(&self, auth: &CodexAuth) -> Result<(), RefreshTokenError> { - if let Some(account_id) = auth.get_account_id() - && let Some(expected_workspace_ids) = self.forced_chatgpt_workspace_id() - && !expected_workspace_ids.contains(&account_id) - { - return Err(RefreshTokenError::Transient(std::io::Error::other( - format!( - "external auth returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}" - ), - ))); - } + let allowed_login_methods = self.allowed_login_methods(); + validate_auth_restrictions( + Some(&allowed_login_methods), + self.effective_chatgpt_workspaces().as_deref(), + auth, + ) + .map_err(std::io::Error::other)?; Ok(()) } diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 998fe2a33a..8baf188a32 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -80,6 +80,7 @@ pub async fn run_main( .map_err(|e| { std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; + config.auth_config().validate()?; set_default_client_residency_requirement(config.enforce_residency.value()); let otel = codex_core::otel_init::build_provider( &config, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 8eba68d792..127e3606d4 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -929,6 +929,8 @@ interrupt_message = false }; let requirements_toml = ConfigRequirementsToml { + allowed_login_methods: None, + allowed_chatgpt_workspaces: None, sqlite_home: Some(sqlite_home), log_dir: Some(log_dir), model_catalog_json: Some(model_catalog_json), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0b255736ae..b1062103fa 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -7,8 +7,9 @@ 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_config_toml_with_layer_stack; -use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +#[cfg(test)] use crate::legacy_core::config::resolve_bootstrap_http_client_factory; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; @@ -47,7 +48,6 @@ use codex_config::types::ResumeCwdMode; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_login::AuthConfig; -use codex_login::AuthRouteConfig; use codex_login::default_client::originator; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::enforce_login_restrictions; @@ -274,6 +274,17 @@ impl AppServerTarget { matches!(self, Self::Remote { .. }) } + fn auth_config_for_cloud_loader(&self, mut auth_config: AuthConfig) -> AuthConfig { + if self.uses_remote_workspace() { + // Remove local auth restrictions before loading credentials for a remote + // workspace; the remote app server enforces its own authentication policy. + auth_config.forced_login_method = None; + auth_config.forced_chatgpt_workspace_id = None; + auth_config.managed_auth_policy = Default::default(); + } + auth_config + } + fn thread_params_mode(&self) -> ThreadParamsMode { if self.uses_remote_workspace() { ThreadParamsMode::Remote @@ -1007,6 +1018,7 @@ pub async fn run_main( loader_overrides.user_config_path = Some(user_config_path); loader_overrides.user_config_profile = Some(profile_v2.clone()); } + loader_overrides.ignore_login_requirements = app_server_target.uses_remote_workspace(); let bootstrap_config = load_bootstrap_config_or_exit( &codex_home, @@ -1018,30 +1030,10 @@ pub async fn run_main( ) .await; let bootstrap_config_toml = &bootstrap_config.config_toml; - - let chatgpt_base_url = bootstrap_config_toml - .chatgpt_base_url - .clone() - .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let bootstrap_http_client_factory = resolve_bootstrap_http_client_factory( - bootstrap_config_toml, - bootstrap_config - .config_layer_stack - .requirements() - .feature_requirements - .as_ref(), - )?; - let auth_route_config = - AuthRouteConfig::from_http_client_factory(bootstrap_http_client_factory); let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - codex_home.to_path_buf(), + app_server_target + .auth_config_for_cloud_loader(bootstrap_auth_config(&codex_home, &bootstrap_config)?), /*enable_codex_api_key_env*/ false, - bootstrap_config_toml - .cli_auth_credentials_store - .unwrap_or_default(), - resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, - chatgpt_base_url, - auth_route_config, ) .await; @@ -1135,12 +1127,8 @@ pub async fn run_main( .await; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - config.codex_home.to_path_buf(), + app_server_target.auth_config_for_cloud_loader(config.auth_config()), /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - config.auth_keyring_backend_kind(), - config.chatgpt_base_url.clone(), - config.auth_route_config(), ) .await; let environment_manager = Arc::new( @@ -1209,19 +1197,8 @@ pub async fn run_main( } if !app_server_target.uses_remote_workspace() { - let auth_route_config = config.auth_route_config(); #[allow(clippy::print_stderr)] - 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()), - auth_route_config, - }) - .await - { + if let Err(err) = enforce_login_restrictions(&config.auth_config()).await { eprintln!("{err}"); std::process::exit(1); } @@ -1491,12 +1468,8 @@ async fn run_ratatui_app( // status detection edge cases. if show_login_screen && !uses_remote_workspace { cloud_config_bundle = cloud_config_bundle_loader_for_storage( - initial_config.codex_home.to_path_buf(), + initial_config.auth_config(), /*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(), - initial_config.auth_route_config(), ) .await; } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 73a0769ab3..730172c5a2 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginAccountResponse; +use codex_login::AuthConfig; use codex_login::read_openai_api_key_from_env; use codex_protocol::auth::AuthMode; use crossterm::event::KeyCode; @@ -234,7 +235,7 @@ pub(crate) struct AuthModeWidget { pub sign_in_state: Arc>, pub login_status: LoginStatus, pub app_server_request_handle: AppServerRequestHandle, - pub forced_login_method: Option, + pub auth_config: AuthConfig, pub animations_enabled: bool, pub animations_suppressed: Cell, } @@ -308,11 +309,13 @@ impl AuthModeWidget { } fn is_api_login_allowed(&self) -> bool { - !matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) + self.auth_config + .is_login_method_allowed(ForcedLoginMethod::Api) } fn is_chatgpt_login_allowed(&self) -> bool { - !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) + self.auth_config + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) } fn displayed_sign_in_options(&self) -> Vec { @@ -1029,9 +1032,6 @@ mod tests { use codex_app_server_client::InProcessClientStartArgs; 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; use tempfile::TempDir; @@ -1059,7 +1059,7 @@ mod tests { .build() .await .unwrap(); - let auth_route_config = config.auth_route_config(); + let mut auth_config = config.auth_config(); let client = InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), config: Arc::new(config), @@ -1067,12 +1067,8 @@ mod tests { loader_overrides: Default::default(), strict_config: false, cloud_config_bundle: cloud_config_bundle_loader_for_storage( - codex_home_path.clone(), + auth_config.clone(), /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - AuthKeyringBackendKind::default(), - "https://chatgpt.com/backend-api/".to_string(), - auth_route_config, ) .await, feedback: codex_feedback::CodexFeedback::new(), @@ -1094,6 +1090,7 @@ mod tests { }) .await .unwrap(); + auth_config.forced_login_method = Some(ForcedLoginMethod::Chatgpt); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), highlighted_mode: SignInOption::ChatGpt, @@ -1101,7 +1098,7 @@ mod tests { sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), login_status: LoginStatus::NotAuthenticated, app_server_request_handle: AppServerRequestHandle::InProcess(client.request_handle()), - forced_login_method: Some(ForcedLoginMethod::Chatgpt), + auth_config, animations_enabled: true, animations_suppressed: std::cell::Cell::new(false), }; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index af151409b6..2f654533ec 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -111,7 +111,7 @@ impl OnboardingScreen { config, } = args; let cwd = config.cwd.to_path_buf(); - let forced_login_method = config.forced_login_method; + let auth_config = config.auth_config(); let mut steps: Vec = Vec::new(); steps.push(Step::Welcome(WelcomeWidget::new( !matches!(login_status, LoginStatus::NotAuthenticated), @@ -119,10 +119,12 @@ impl OnboardingScreen { config.animations, ))); if show_login_screen { - let highlighted_mode = match forced_login_method { - Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, - _ => SignInOption::ChatGpt, - }; + let highlighted_mode = + if auth_config.is_login_method_allowed(ForcedLoginMethod::Chatgpt) { + SignInOption::ChatGpt + } else { + SignInOption::ApiKey + }; if let Some(app_server_request_handle) = app_server_request_handle { steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), @@ -131,7 +133,7 @@ impl OnboardingScreen { sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), login_status, app_server_request_handle, - forced_login_method, + auth_config, animations_enabled: config.animations, animations_suppressed: std::cell::Cell::new(false), })); diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 3568109765..faa4e78e76 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -13,9 +13,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::bootstrap_auth_config; 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_bootstrap_http_client_factory; 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; @@ -28,7 +27,6 @@ use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; -use codex_login::AuthRouteConfig; use codex_protocol::ThreadId; use codex_utils_cli::CliConfigOverrides; use codex_utils_home_dir::find_codex_home; @@ -380,6 +378,7 @@ async fn start_app_server_for_archive_command( )); loader_overrides.user_config_profile = Some(profile_v2.clone()); } + loader_overrides.ignore_login_requirements = app_server_target.uses_remote_workspace(); let bootstrap_config = load_config_toml_with_layer_stack( codex_home.as_path(), @@ -394,26 +393,12 @@ 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() - .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let http_client_factory = resolve_bootstrap_http_client_factory( - config_toml, - bootstrap_config - .config_layer_stack - .requirements() - .feature_requirements - .as_ref(), - )?; - let auth_route_config = AuthRouteConfig::from_http_client_factory(http_client_factory); let cloud_config_bundle = cloud_config_bundle_loader_for_storage( - codex_home.to_path_buf(), + app_server_target.auth_config_for_cloud_loader(bootstrap_auth_config( + codex_home.as_path(), + &bootstrap_config, + )?), /*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, - auth_route_config, ) .await;