mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
[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:
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
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in new issue
Block a user