[codex] Support multiple forced ChatGPT workspaces (#18161)

## Summary

This change lets `forced_chatgpt_workspace_id` accept multiple workspace
IDs instead of a single value.

It keeps the existing config key name, adds backward-compatible parsing
for a single string in `config.toml`, and normalizes the setting into an
allowed workspace list across login enforcement, app-server config
surfaces, and local ChatGPT auth helpers.

## Why

Workspace-restricted deployments may need to allow more than one ChatGPT
workspace without dropping the guardrail entirely.

## Server-side impact

Codex's local server and app-server protocol needed changes because they
previously assumed a single workspace ID. The local login flow now
matches the auth backend interface by sending the allowed workspace list
as a single comma-separated `allowed_workspace_id` query parameter.

## Validation

This was tested with:

- A single workspace config
- With multi-workspace configs
- With multiple workspaces in the config
- The user only being a part of a subset of them

All were successful.

Automated coverage:

- `cargo test -p codex-login`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-tui local_chatgpt_auth`
- `cargo test --locked -p codex-app-server
login_account_chatgpt_includes_forced_workspace_allowlist_query_param`
This commit is contained in:
rreichel3-oai authored and GitHub committed 2026-05-14 17:11:36 -04:00
1 parent 32b45a43e2
commit 02a7205250
22 files changed
+673 -117

No files matched your search

+23 -16
View File
@@ -610,8 +610,8 @@ 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 chatgpt_base_url: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
}
pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
@@ -653,9 +653,8 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
}
}
if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() {
// workspace is the external identifier for account id.
let chatgpt_account_id = match auth {
if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() {
let chatgpt_account_id = match &auth {
CodexAuth::ApiKey(_) => return Ok(()),
CodexAuth::AgentIdentity(_) => auth.get_account_id(),
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
@@ -674,13 +673,21 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<
token_data.id_token.chatgpt_account_id
}
};
if chatgpt_account_id.as_deref() != Some(expected_account_id) {
// workspace is the external identifier for account id.
let chatgpt_account_id = chatgpt_account_id.as_deref();
if !chatgpt_account_id.is_some_and(|actual| {
expected_account_ids
.iter()
.any(|expected| expected == actual)
}) {
let expected_workspaces = expected_account_ids.join(", ");
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 workspace(s) {expected_workspaces}, 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 workspace(s) {expected_workspaces}, but current credentials lack a workspace identifier. Logging out."
),
};
return logout_with_message(
@@ -1247,7 +1254,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<Vec<String>>>,
chatgpt_base_url: Option<String>,
refresh_lock: Semaphore,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
@@ -1266,8 +1273,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<Vec<String>>;
/// Returns the ChatGPT backend base URL used for first-party backend authorization.
fn chatgpt_base_url(&self) -> String;
@@ -1548,7 +1555,7 @@ 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<Vec<String>>) {
if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write()
&& *guard != workspace_id
{
@@ -1556,7 +1563,7 @@ impl AuthManager {
}
}
pub fn forced_chatgpt_workspace_id(&self) -> Option<String> {
pub fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>> {
self.forced_chatgpt_workspace_id
.read()
.ok()
@@ -1836,13 +1843,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(expected_workspace_ids) = forced_chatgpt_workspace_id.as_deref()
&& !expected_workspace_ids.contains(&chatgpt_metadata.account_id)
{
return Err(RefreshTokenError::Transient(std::io::Error::other(
format!(
"external auth refresh returned workspace {:?}, expected {expected_workspace_id:?}",
chatgpt_metadata.account_id,
"external auth refresh returned workspace {:?}, expected one of {:?}",
chatgpt_metadata.account_id, expected_workspace_ids,
),
)));
}