[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,6 +23,9 @@ use core_test_support::skip_if_no_network;
// ---------- Small helpers ----------
const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000";
const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002";
fn make_jwt(payload: serde_json::Value) -> String {
let header = json!({ "alg": "none", "typ": "JWT" });
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
@@ -131,7 +134,7 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> {
let jwt = make_jwt(json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acct_321"
"chatgpt_account_id": WORKSPACE_ID_ALLOWED
}
}));
@@ -152,7 +155,7 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> {
assert_eq!(tokens.access_token, "access-token-123");
assert_eq!(tokens.refresh_token, "refresh-token-123");
assert_eq!(tokens.id_token.raw_jwt, jwt);
assert_eq!(tokens.account_id.as_deref(), Some("acct_321"));
assert_eq!(tokens.account_id.as_deref(), Some(WORKSPACE_ID_ALLOWED));
Ok(())
}
@@ -174,8 +177,8 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> {
let jwt = make_jwt(json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acct_321",
"organization_id": "org-actual"
"chatgpt_account_id": WORKSPACE_ID_DISALLOWED,
"organization_id": WORKSPACE_ID_DISALLOWED
}
}));
@@ -183,7 +186,7 @@ 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(vec![WORKSPACE_ID_ALLOWED.to_string()]);
let err = run_device_code_login(opts)
.await
+58 -10
View File
@@ -12,10 +12,15 @@ use codex_config::types::AuthCredentialsStoreMode;
use codex_login::ServerOptions;
use codex_login::run_login_server;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use url::Url;
const DEFAULT_LOGIN_PORT: u16 = 1455;
const FALLBACK_LOGIN_PORT: u16 = 1457;
const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000";
const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001";
const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002";
// See spawn.rs for details
@@ -121,7 +126,7 @@ 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(vec![chatgpt_account_id.to_string()]),
codex_streamlined_login: false,
};
let server = run_login_server(opts)?;
@@ -165,7 +170,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
async fn creates_missing_codex_home_dir() -> Result<()> {
skip_if_no_network!(Ok(()));
let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123");
let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED);
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let tmp = tempdir()?;
@@ -204,11 +209,52 @@ async fn creates_missing_codex_home_dir() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result<()> {
skip_if_no_network!(Ok(()));
let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED);
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let tmp = tempdir()?;
let codex_home = tmp.path().to_path_buf();
let state = "state-multi".to_string();
let opts = ServerOptions {
codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
open_browser: false,
force_state: Some(state),
forced_chatgpt_workspace_id: Some(vec![
WORKSPACE_ID_ALLOWED.to_string(),
WORKSPACE_ID_SECOND_ALLOWED.to_string(),
]),
codex_streamlined_login: false,
};
let server = run_login_server(opts)?;
let auth_url = Url::parse(&server.auth_url)?;
let allowed_workspace_ids = auth_url
.query_pairs()
.filter_map(|(key, value)| (key == "allowed_workspace_id").then(|| value.into_owned()))
.collect::<Vec<_>>();
assert_eq!(
allowed_workspace_ids,
vec![format!(
"{WORKSPACE_ID_ALLOWED},{WORKSPACE_ID_SECOND_ALLOWED}"
)]
);
Ok(())
}
#[tokio::test]
async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
skip_if_no_network!(Ok(()));
let (issuer_addr, _issuer_handle) = start_mock_issuer("org-actual");
let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_DISALLOWED);
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let tmp = tempdir()?;
@@ -223,14 +269,14 @@ 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(vec![WORKSPACE_ID_ALLOWED.to_string()]),
codex_streamlined_login: false,
};
let server = run_login_server(opts)?;
assert!(
server
.auth_url
.contains("allowed_workspace_id=org-required"),
.contains(&format!("allowed_workspace_id={WORKSPACE_ID_ALLOWED}")),
"auth URL should include forced workspace parameter"
);
let login_port = server.actual_port;
@@ -241,7 +287,9 @@ 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(&format!(
"Login is restricted to workspace id(s) {WORKSPACE_ID_ALLOWED}"
)),
"error body should mention workspace restriction"
);
@@ -266,7 +314,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() -> Result<()> {
skip_if_no_network!(Ok(()));
let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123");
let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED);
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let tmp = tempdir()?;
@@ -334,7 +382,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error()
async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<()> {
skip_if_no_network!(Ok(()));
let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123");
let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED);
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let tmp = tempdir()?;
@@ -442,7 +490,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() ->
})
};
let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123");
let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED);
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let tmp = tempdir()?;
@@ -480,7 +528,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() ->
async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
skip_if_no_network!(Ok(()));
let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123");
let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED);
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let first_tmp = tempdir()?;