diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d0ce368b78..dd92d485d0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6431,9 +6431,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -7643,6 +7647,20 @@ ], "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index aca9b23880..0651001e7b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3048,9 +3048,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -4260,6 +4264,20 @@ ], "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 58f8186266..cc5e33320a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -234,9 +234,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -577,6 +581,20 @@ } ] }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/typescript/ForcedChatgptWorkspaceIds.ts b/codex-rs/app-server-protocol/schema/typescript/ForcedChatgptWorkspaceIds.ts new file mode 100644 index 0000000000..a8b5b682cf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ForcedChatgptWorkspaceIds.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Workspace IDs that ChatGPT auth is allowed to use. + * + * The config parser accepts the historical single-string form and the newer + * list form for deployments that allow multiple ChatGPT workspaces. + */ +export type ForcedChatgptWorkspaceIds = string | Array; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7bbb417fdc..344be13768 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -16,6 +16,7 @@ export type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams"; export type { ExecCommandApprovalResponse } from "./ExecCommandApprovalResponse"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { FileChange } from "./FileChange"; +export type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; export type { ForcedLoginMethod } from "./ForcedLoginMethod"; export type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; export type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentItem"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 508fe84e92..2198004d90 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ForcedChatgptWorkspaceIds } from "../ForcedChatgptWorkspaceIds"; import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; @@ -20,4 +21,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 6aa2e9fa30..49595789cb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use codex_git_utils::GitSha; use codex_protocol::ThreadId; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -198,7 +199,7 @@ pub struct UserSavedConfig { pub approval_policy: Option, pub sandbox_mode: Option, pub sandbox_settings: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub model: Option, pub model_reasoning_effort: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 5897625de1..f220c6d5b6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -18,6 +18,7 @@ use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleA use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; @@ -744,7 +745,7 @@ pub struct Config { pub approvals_reviewer: Option, pub sandbox_mode: Option, pub sandbox_workspace_write: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub web_search: Option, pub tools: Option, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 42cb8b2d1a..d6f48f22ff 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1627,13 +1627,14 @@ impl CodexMessageProcessor { } } - if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() - && chatgpt_account_id != expected_workspace + if let Some(allowed_workspace_ids) = self.config.forced_chatgpt_workspace_id.as_ref() + && !allowed_workspace_ids.contains(&chatgpt_account_id) { + let allowed_description = allowed_workspace_ids.description(); let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!( - "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." + "External auth must use {allowed_description}, but received {chatgpt_account_id:?}." ), data: None, }; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index bd17a546a1..2d7dff983a 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -17,6 +17,7 @@ use codex_login::login_with_api_key; use codex_login::logout; use codex_login::run_device_code_login; use codex_login::run_login_server; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_utils_cli::CliConfigOverrides; use std::fs::OpenOptions; @@ -112,7 +113,7 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { pub async fn login_with_chatgpt( codex_home: PathBuf, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { let opts = ServerOptions::new( diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 92e5304fe5..9b3fd6baea 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -36,6 +36,7 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::OPENAI_PROVIDER_ID; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -149,9 +150,9 @@ pub struct ConfigToml { /// Set to an empty string to disable automatic commit attribution. pub commit_attribution: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. + /// When set, restricts ChatGPT login to one or more workspace identifiers. #[serde(default)] - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, /// When set, restricts the login mechanism users may use. #[serde(default)] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9280895b5e..0615d61c0a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -672,6 +672,20 @@ "FilesystemPermissionsToml": { "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Workspace IDs that ChatGPT auth is allowed to use.\n\nThe config parser accepts the historical single-string form and the newer list form for deployments that allow multiple ChatGPT workspaces." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", @@ -2407,9 +2421,13 @@ "description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme." }, "forced_chatgpt_workspace_id": { + "allOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + } + ], "default": null, - "description": "When set, restricts ChatGPT login to a specific workspace identifier.", - "type": "string" + "description": "When set, restricts ChatGPT login to one or more workspace identifiers." }, "forced_login_method": { "allOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b2931e1055..2723dc9ece 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -48,6 +48,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -4424,6 +4425,58 @@ fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { Ok(()) } +#[test] +fn forced_chatgpt_workspace_id_accepts_string_and_list() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let single_cfg: ConfigToml = toml::from_str(r#"forced_chatgpt_workspace_id = " org_single ""#) + .expect("single workspace id should parse"); + + let single_config = Config::load_from_base_config_with_overrides( + single_cfg, + ConfigOverrides::default(), + codex_home.abs(), + )?; + + assert_eq!( + single_config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Single("org_single".to_string())) + ); + + let list_cfg: ConfigToml = + toml::from_str(r#"forced_chatgpt_workspace_id = [" org_a ", "", "org_b"]"#) + .expect("workspace id list should parse"); + + let list_config = Config::load_from_base_config_with_overrides( + list_cfg, + ConfigOverrides::default(), + codex_home.abs(), + )?; + + assert_eq!( + list_config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + "org_a".to_string(), + "org_b".to_string() + ])) + ); + + let empty_list_cfg: ConfigToml = toml::from_str(r#"forced_chatgpt_workspace_id = []"#) + .expect("empty workspace id list should parse"); + + let empty_list_config = Config::load_from_base_config_with_overrides( + empty_list_cfg, + ConfigOverrides::default(), + codex_home.abs(), + )?; + + assert_eq!( + empty_list_config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Multiple(Vec::new())) + ); + + Ok(()) +} + fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b58acbf651..b8ebf0f738 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -63,6 +63,7 @@ use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::ModelsManagerConfig; use codex_protocol::config_types::AltScreenMode; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -523,8 +524,8 @@ pub struct Config { /// instructions inserted into developer messages when realtime becomes /// active. pub experimental_realtime_start_instructions: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. - pub forced_chatgpt_workspace_id: Option, + /// When set, restricts ChatGPT login to one or more workspace identifiers. + pub forced_chatgpt_workspace_id: Option, /// When set, restricts the login mechanism users may use. pub forced_login_method: Option, @@ -623,7 +624,7 @@ impl AuthManagerConfig for Config { self.cli_auth_credentials_store_mode } - fn forced_chatgpt_workspace_id(&self) -> Option { + fn forced_chatgpt_workspace_id(&self) -> Option { self.forced_chatgpt_workspace_id.clone() } } @@ -1816,15 +1817,9 @@ impl Config { let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); - let forced_chatgpt_workspace_id = - cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); + let forced_chatgpt_workspace_id = cfg + .forced_chatgpt_workspace_id + .and_then(ForcedChatgptWorkspaceIds::normalized); let forced_login_method = cfg.forced_login_method; diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 8a16f5939c..ff4af8a2a4 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -8,6 +8,7 @@ use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; use base64::Engine; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; use pretty_assertions::assert_eq; @@ -650,7 +651,8 @@ async fn build_config( codex_home: codex_home.to_path_buf(), auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_login_method, - forced_chatgpt_workspace_id, + forced_chatgpt_workspace_id: forced_chatgpt_workspace_id + .map(ForcedChatgptWorkspaceIds::Single), } } @@ -765,6 +767,37 @@ async fn enforce_login_restrictions_allows_matching_workspace() { ); } +#[tokio::test] +#[serial(codex_api_key)] +async fn enforce_login_restrictions_allows_workspace_from_list() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("org_mine".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = AuthConfig { + codex_home: codex_home.path().to_path_buf(), + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_login_method: None, + forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + "org_allowed".to_string(), + "org_mine".to_string(), + ])), + }; + + super::enforce_login_restrictions(&config).expect("listed workspace should succeed"); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should remain when restrictions pass" + ); +} + #[tokio::test] async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 8768171d95..d31c28721d 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -37,6 +37,7 @@ use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; use codex_protocol::auth::RefreshTokenFailedError; use codex_protocol::auth::RefreshTokenFailedReason; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use serde_json::Value; use thiserror::Error; @@ -540,7 +541,7 @@ pub struct AuthConfig { pub codex_home: PathBuf, pub auth_credentials_store_mode: AuthCredentialsStoreMode, pub forced_login_method: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, } pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { @@ -578,7 +579,7 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { } } - if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { + if let Some(allowed_workspace_ids) = config.forced_chatgpt_workspace_id.as_ref() { if !auth.is_chatgpt_auth() { return Ok(()); } @@ -598,13 +599,14 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { // workspace is the external identifier for account id. let chatgpt_account_id = token_data.id_token.chatgpt_account_id.as_deref(); - if chatgpt_account_id != Some(expected_account_id) { + if chatgpt_account_id.is_none_or(|actual| !allowed_workspace_ids.contains(actual)) { + let allowed_description = allowed_workspace_ids.description(); let message = match chatgpt_account_id { Some(actual) => format!( - "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." + "Login is restricted to {allowed_description}, but current credentials belong to {actual}. Logging out." ), None => format!( - "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." + "Login is restricted to {allowed_description}, but current credentials lack a workspace identifier. Logging out." ), }; return logout_with_message( @@ -1155,7 +1157,7 @@ pub struct AuthManager { inner: RwLock, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: RwLock>, + forced_chatgpt_workspace_id: RwLock>, refresh_lock: AsyncMutex<()>, external_auth: RwLock>>, auth_state_tx: watch::Sender<()>, @@ -1174,8 +1176,8 @@ pub trait AuthManagerConfig { /// Returns the CLI auth credential storage mode for auth loading. fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode; - /// Returns the workspace ID that ChatGPT auth should be restricted to, if any. - fn forced_chatgpt_workspace_id(&self) -> Option; + /// Returns the workspace IDs that ChatGPT auth should be restricted to, if any. + fn forced_chatgpt_workspace_id(&self) -> Option; } impl Debug for AuthManager { @@ -1445,7 +1447,10 @@ impl AuthManager { } } - pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { + pub fn set_forced_chatgpt_workspace_id( + &self, + workspace_id: Option, + ) { if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() && *guard != workspace_id { @@ -1454,7 +1459,7 @@ impl AuthManager { } } - pub fn forced_chatgpt_workspace_id(&self) -> Option { + pub fn forced_chatgpt_workspace_id(&self) -> Option { self.forced_chatgpt_workspace_id .read() .ok() @@ -1698,12 +1703,13 @@ impl AuthManager { "external auth refresh did not return ChatGPT metadata", ))); }; - if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref() - && chatgpt_metadata.account_id != expected_workspace_id + if let Some(allowed_workspace_ids) = forced_chatgpt_workspace_id.as_ref() + && !allowed_workspace_ids.contains(&chatgpt_metadata.account_id) { + let allowed_description = allowed_workspace_ids.description(); return Err(RefreshTokenError::Transient(std::io::Error::other( format!( - "external auth refresh returned workspace {:?}, expected {expected_workspace_id:?}", + "external auth refresh returned workspace {:?}, expected {allowed_description}", chatgpt_metadata.account_id, ), ))); diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 4b9cb7c321..c33f73dd4f 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -204,7 +204,7 @@ pub async fn complete_device_code_login( .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; if let Err(message) = crate::server::ensure_workspace_allowed( - opts.forced_chatgpt_workspace_id.as_deref(), + opts.forced_chatgpt_workspace_id.as_ref(), &tokens.id_token, ) { return Err(io::Error::new(io::ErrorKind::PermissionDenied, message)); diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 0c7b810184..b8ce5c0f15 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -36,6 +36,7 @@ use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_client::build_reqwest_client_with_custom_ca; use codex_config::types::AuthCredentialsStoreMode; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use codex_utils_template::Template; use rand::RngCore; use serde_json::Value as JsonValue; @@ -64,7 +65,7 @@ pub struct ServerOptions { pub port: u16, pub open_browser: bool, pub force_state: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, } @@ -73,7 +74,7 @@ impl ServerOptions { pub fn new( codex_home: PathBuf, client_id: String, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> Self { Self { @@ -153,7 +154,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { &redirect_uri, &pkce, &state, - opts.forced_chatgpt_workspace_id.as_deref(), + opts.forced_chatgpt_workspace_id.as_ref(), ); if opts.open_browser { @@ -333,7 +334,7 @@ async fn process_request( { Ok(tokens) => { if let Err(message) = ensure_workspace_allowed( - opts.forced_chatgpt_workspace_id.as_deref(), + opts.forced_chatgpt_workspace_id.as_ref(), &tokens.id_token, ) { eprintln!("Workspace restriction error: {message}"); @@ -471,7 +472,7 @@ fn build_authorize_url( redirect_uri: &str, pkce: &PkceCodes, state: &str, - forced_chatgpt_workspace_id: Option<&str>, + forced_chatgpt_workspace_id: Option<&ForcedChatgptWorkspaceIds>, ) -> String { let mut query = vec![ ("response_type".to_string(), "code".to_string()), @@ -492,8 +493,13 @@ fn build_authorize_url( ("state".to_string(), state.to_string()), ("originator".to_string(), originator().value), ]; - if let Some(workspace_id) = forced_chatgpt_workspace_id { - query.push(("allowed_workspace_id".to_string(), workspace_id.to_string())); + if let Some(workspace_ids) = forced_chatgpt_workspace_id { + query.extend( + workspace_ids + .ids() + .iter() + .map(|workspace_id| ("allowed_workspace_id".to_string(), workspace_id.to_string())), + ); } let qs = query .into_iter() @@ -870,22 +876,25 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { /// Validates the ID token against an optional workspace restriction. pub(crate) fn ensure_workspace_allowed( - expected: Option<&str>, + allowed_workspace_ids: Option<&ForcedChatgptWorkspaceIds>, id_token: &str, ) -> Result<(), String> { - let Some(expected) = expected else { + let Some(allowed_workspace_ids) = allowed_workspace_ids else { return Ok(()); }; let claims = jwt_auth_claims(id_token); let Some(actual) = claims.get("chatgpt_account_id").and_then(JsonValue::as_str) else { - return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); + return Err("Login is restricted to a specific workspace, but the token did not include a chatgpt_account_id claim.".to_string()); }; - if actual == expected { + if allowed_workspace_ids.contains(actual) { Ok(()) } else { - Err(format!("Login is restricted to workspace id {expected}.")) + Err(format!( + "Login is restricted to {}.", + allowed_workspace_ids.description() + )) } } @@ -1093,9 +1102,14 @@ pub(crate) async fn obtain_api_key( } #[cfg(test)] mod tests { + use base64::Engine as _; + use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use pretty_assertions::assert_eq; + use serde_json::json; use super::TokenEndpointErrorDetail; + use super::build_authorize_url; + use super::ensure_workspace_allowed; use super::html_escape; use super::is_missing_codex_entitlement_error; use super::parse_token_endpoint_error; @@ -1103,6 +1117,65 @@ mod tests { use super::redact_sensitive_url_parts; use super::render_login_error_page; use super::sanitize_url_for_logging; + use crate::pkce::PkceCodes; + + fn workspace_jwt(workspace_id: &str) -> String { + let header = json!({"alg": "none", "typ": "JWT"}); + let payload = json!({ + "https://api.openai.com/auth": { + "chatgpt_account_id": workspace_id, + } + }); + let encode = |value: serde_json::Value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&value).expect("jwt part should serialize")) + }; + format!("{}.{}.sig", encode(header), encode(payload)) + } + + #[test] + fn build_authorize_url_repeats_allowed_workspace_id_for_list() { + let pkce = PkceCodes { + code_verifier: "verifier".to_string(), + code_challenge: "challenge".to_string(), + }; + let workspace_ids = + ForcedChatgptWorkspaceIds::Multiple(vec!["org_a".to_string(), "org b".to_string()]); + + let url = build_authorize_url( + "https://auth.example", + "client", + "http://localhost/callback", + &pkce, + "state", + Some(&workspace_ids), + ); + + assert!(url.contains("allowed_workspace_id=org_a")); + assert!(url.contains("allowed_workspace_id=org%20b")); + assert_eq!(url.matches("allowed_workspace_id=").count(), 2); + } + + #[test] + fn ensure_workspace_allowed_accepts_any_workspace_from_list() { + let workspace_ids = ForcedChatgptWorkspaceIds::Multiple(vec![ + "org_allowed".to_string(), + "org_actual".to_string(), + ]); + + ensure_workspace_allowed(Some(&workspace_ids), &workspace_jwt("org_actual")) + .expect("listed workspace should be allowed"); + } + + #[test] + fn ensure_workspace_allowed_rejects_empty_workspace_list() { + let workspace_ids = ForcedChatgptWorkspaceIds::Multiple(Vec::new()); + + let err = ensure_workspace_allowed(Some(&workspace_ids), &workspace_jwt("org_actual")) + .expect_err("empty workspace list should reject ChatGPT auth"); + + assert!(err.contains("Login is restricted to a configured workspace")); + } #[test] fn parse_token_endpoint_error_prefers_error_description() { diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index bed94c7005..baf8ffa45a 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -7,6 +7,7 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::ServerOptions; use codex_login::auth::load_auth_dot_json; use codex_login::run_device_code_login; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use serde_json::json; use std::sync::Arc; use std::sync::atomic::AtomicUsize; @@ -183,7 +184,9 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { let issuer = mock_server.uri(); let mut opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); - opts.forced_chatgpt_workspace_id = Some("org-required".to_string()); + opts.forced_chatgpt_workspace_id = Some(ForcedChatgptWorkspaceIds::Single( + "org-required".to_string(), + )); let err = run_device_code_login(opts) .await diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 9522f5b0b0..bfffd46f37 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -10,6 +10,7 @@ use base64::Engine; use codex_config::types::AuthCredentialsStoreMode; use codex_login::ServerOptions; use codex_login::run_login_server; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; use core_test_support::skip_if_no_network; use tempfile::tempdir; @@ -117,7 +118,9 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { port: 0, open_browser: false, force_state: Some(state), - forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), + forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Single( + chatgpt_account_id.to_string(), + )), }; let server = run_login_server(opts)?; assert!( @@ -217,7 +220,9 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { port: 0, open_browser: false, force_state: Some(state.clone()), - forced_chatgpt_workspace_id: Some("org-required".to_string()), + forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Single( + "org-required".to_string(), + )), }; let server = run_login_server(opts)?; assert!( @@ -234,7 +239,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { assert!(resp.status().is_success()); let body = resp.text().await?; assert!( - body.contains("Login is restricted to workspace id org-required"), + body.contains("Login is restricted to workspace org-required"), "error body should mention workspace restriction" ); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 5fd09c24b3..9811c68797 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -264,6 +264,69 @@ pub enum ForcedLoginMethod { Api, } +/// Workspace IDs that ChatGPT auth is allowed to use. +/// +/// The config parser accepts the historical single-string form and the newer +/// list form for deployments that allow multiple ChatGPT workspaces. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(untagged)] +pub enum ForcedChatgptWorkspaceIds { + Single(String), + Multiple(Vec), +} + +impl ForcedChatgptWorkspaceIds { + /// Trims configured IDs and removes empty entries. + /// + /// A legacy empty string normalizes to `None` for compatibility. An + /// explicitly configured list remains active even if all entries are empty, + /// which avoids accidentally disabling a managed workspace restriction. + pub fn normalized(self) -> Option { + match self { + Self::Single(id) => { + let trimmed = id.trim(); + (!trimmed.is_empty()).then(|| Self::Single(trimmed.to_string())) + } + Self::Multiple(ids) => { + let ids = ids + .into_iter() + .filter_map(|id| { + let trimmed = id.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + .collect::>(); + + match ids.as_slice() { + [id] => Some(Self::Single(id.clone())), + _ => Some(Self::Multiple(ids)), + } + } + } + } + + /// Returns the allowed workspace IDs in config order. + pub fn ids(&self) -> &[String] { + match self { + Self::Single(id) => std::slice::from_ref(id), + Self::Multiple(ids) => ids.as_slice(), + } + } + + /// Returns true when `workspace_id` is one of the configured IDs. + pub fn contains(&self, workspace_id: &str) -> bool { + self.ids().iter().any(|id| id == workspace_id) + } + + /// Human-readable description for login and auth error messages. + pub fn description(&self) -> String { + match self.ids() { + [] => "a configured workspace".to_string(), + [id] => format!("workspace {id}"), + ids => format!("one of workspaces {}", ids.join(", ")), + } + } +} + const DEFAULT_PROVIDER_AUTH_TIMEOUT_MS: u64 = 5_000; const DEFAULT_PROVIDER_AUTH_REFRESH_INTERVAL_MS: u64 = 300_000; diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index 1f84b289a7..262b8a9f90 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -5,6 +5,7 @@ use std::path::Path; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_login::load_auth_dot_json; +use codex_protocol::config_types::ForcedChatgptWorkspaceIds; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LocalChatgptAuth { @@ -16,7 +17,7 @@ pub(crate) struct LocalChatgptAuth { pub(crate) fn load_local_chatgpt_auth( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: Option<&str>, + forced_chatgpt_workspace_id: Option<&ForcedChatgptWorkspaceIds>, ) -> Result { let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode) .map_err(|err| format!("failed to load local auth: {err}"))? @@ -33,11 +34,12 @@ pub(crate) fn load_local_chatgpt_auth( .account_id .or(tokens.id_token.chatgpt_account_id.clone()) .ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?; - if let Some(expected_workspace) = forced_chatgpt_workspace_id - && chatgpt_account_id != expected_workspace + if let Some(allowed_workspace_ids) = forced_chatgpt_workspace_id + && !allowed_workspace_ids.contains(&chatgpt_account_id) { + let allowed_description = allowed_workspace_ids.description(); return Err(format!( - "local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}" + "local ChatGPT auth must use {allowed_description}, but found {chatgpt_account_id:?}" )); } @@ -114,15 +116,20 @@ mod tests { .expect("chatgpt auth should save"); } + fn workspace_ids(id: &str) -> ForcedChatgptWorkspaceIds { + ForcedChatgptWorkspaceIds::Single(id.to_string()) + } + #[test] fn loads_local_chatgpt_auth_from_managed_auth() { let codex_home = TempDir::new().expect("tempdir"); write_chatgpt_auth(codex_home.path(), "business"); + let forced_workspace_ids = workspace_ids("workspace-1"); let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&forced_workspace_ids), ) .expect("chatgpt auth should load"); @@ -175,6 +182,7 @@ mod tests { fn prefers_managed_auth_over_external_ephemeral_tokens() { let codex_home = TempDir::new().expect("tempdir"); write_chatgpt_auth(codex_home.path(), "business"); + let forced_workspace_ids = workspace_ids("workspace-1"); login_with_chatgpt_auth_tokens( codex_home.path(), &fake_jwt("user@example.com", "workspace-2", "enterprise"), @@ -186,7 +194,7 @@ mod tests { let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&forced_workspace_ids), ) .expect("managed auth should win"); @@ -198,11 +206,12 @@ mod tests { fn preserves_usage_based_plan_type_wire_name() { let codex_home = TempDir::new().expect("tempdir"); write_chatgpt_auth(codex_home.path(), "self_serve_business_usage_based"); + let forced_workspace_ids = workspace_ids("workspace-1"); let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&forced_workspace_ids), ) .expect("chatgpt auth should load"); diff --git a/docs/config.md b/docs/config.md index c314ce2283..ab5637c27e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -54,6 +54,27 @@ When Codex knows which client started the turn, the legacy notify JSON payload a The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`. +## Managed ChatGPT Workspace Restrictions + +Managed deployments can restrict ChatGPT login to one or more workspace IDs with +`forced_chatgpt_workspace_id`. Existing single-workspace configurations remain +valid: + +```toml +forced_chatgpt_workspace_id = "workspace_id" +``` + +To allow any of several workspaces, set the same key to a list: + +```toml +forced_chatgpt_workspace_id = ["workspace_id_a", "workspace_id_b"] +``` + +Codex trims whitespace from configured workspace IDs and ignores empty entries. +An empty list remains an active allowlist and allows no ChatGPT workspace. If +this setting is present, ChatGPT credentials whose token workspace ID is not in +the configured allowlist are rejected and logged out. + ## SQLite State DB Codex stores the SQLite-backed state DB under `sqlite_home` (config key) or the