Support multiple forced ChatGPT workspace ids

Add a shared ForcedChatgptWorkspaceIds config type that accepts the existing single string form or an array of workspace IDs.

Update ChatGPT login, device-code, external auth, app-server, CLI, and TUI enforcement paths to allow any configured workspace.

Regenerate config/app-server schemas and document the single-value and array config forms.
This commit is contained in:
rreichel3-oai
2026-04-15 16:54:04 -04:00
parent ba36415a30
commit cad30d2689
23 changed files with 421 additions and 70 deletions
@@ -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",
@@ -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",
@@ -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",
@@ -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<string>;
@@ -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";
@@ -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<JsonValue> | { [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<JsonValue> | { [key in string]?: JsonValue } | null });
@@ -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<AskForApproval>,
pub sandbox_mode: Option<SandboxMode>,
pub sandbox_settings: Option<SandboxSettings>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub model: Option<String>,
pub model_reasoning_effort: Option<ReasoningEffort>,
@@ -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<ApprovalsReviewer>,
pub sandbox_mode: Option<SandboxMode>,
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub web_search: Option<WebSearchMode>,
pub tools: Option<ToolsV2>,
@@ -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,
};
+2 -1
View File
@@ -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<String>,
forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> std::io::Result<()> {
let opts = ServerOptions::new(
+3 -2
View File
@@ -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<String>,
/// 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<String>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
/// When set, restricts the login mechanism users may use.
#[serde(default)]
+20 -2
View File
@@ -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": [
+53
View File
@@ -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<PrecedenceTestFixture> {
let toml = r#"
model = "o3"
+7 -12
View File
@@ -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<String>,
/// When set, restricts ChatGPT login to a specific workspace identifier.
pub forced_chatgpt_workspace_id: Option<String>,
/// When set, restricts ChatGPT login to one or more workspace identifiers.
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
/// When set, restricts the login mechanism users may use.
pub forced_login_method: Option<ForcedLoginMethod>,
@@ -623,7 +624,7 @@ impl AuthManagerConfig for Config {
self.cli_auth_credentials_store_mode
}
fn forced_chatgpt_workspace_id(&self) -> Option<String> {
fn forced_chatgpt_workspace_id(&self) -> Option<ForcedChatgptWorkspaceIds> {
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;
+34 -1
View File
@@ -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()
{
+19 -13
View File
@@ -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<ForcedLoginMethod>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
}
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<CachedAuth>,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: RwLock<Option<String>>,
forced_chatgpt_workspace_id: RwLock<Option<ForcedChatgptWorkspaceIds>>,
refresh_lock: AsyncMutex<()>,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
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<String>;
/// Returns the workspace IDs that ChatGPT auth should be restricted to, if any.
fn forced_chatgpt_workspace_id(&self) -> Option<ForcedChatgptWorkspaceIds>;
}
impl Debug for AuthManager {
@@ -1445,7 +1447,10 @@ impl AuthManager {
}
}
pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option<String>) {
pub fn set_forced_chatgpt_workspace_id(
&self,
workspace_id: Option<ForcedChatgptWorkspaceIds>,
) {
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<String> {
pub fn forced_chatgpt_workspace_id(&self) -> Option<ForcedChatgptWorkspaceIds> {
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,
),
)));
+1 -1
View File
@@ -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));
+85 -12
View File
@@ -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<String>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
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<String>,
forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> Self {
Self {
@@ -153,7 +154,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
&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<String, serde_json::Value> {
/// 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() {
@@ -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
@@ -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"
);
+63
View File
@@ -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<String>),
}
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<Self> {
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::<Vec<_>>();
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;
+16 -7
View File
@@ -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<LocalChatgptAuth, String> {
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");
+21
View File
@@ -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