mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
[codex] harden workload identity bootstrap and isolation [ci changed_files]
This commit is contained in:
@@ -234,6 +234,7 @@ async fn plugin_list_local_succeeds_when_workload_identity_becomes_unavailable()
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert!(response.marketplace_load_errors.is_empty());
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::AuthManagerConfig;
|
||||
use codex_login::WorkloadIdentityConfig;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
@@ -18,6 +20,40 @@ fn refresher_task_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
|
||||
REFRESHER_TASK.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
struct StorageAuthManagerConfig {
|
||||
codex_home: PathBuf,
|
||||
credentials_store_mode: AuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
chatgpt_base_url: String,
|
||||
workload_identity: Option<WorkloadIdentityConfig>,
|
||||
}
|
||||
|
||||
impl AuthManagerConfig for StorageAuthManagerConfig {
|
||||
fn codex_home(&self) -> PathBuf {
|
||||
self.codex_home.clone()
|
||||
}
|
||||
|
||||
fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode {
|
||||
self.credentials_store_mode
|
||||
}
|
||||
|
||||
fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind {
|
||||
self.keyring_backend_kind
|
||||
}
|
||||
|
||||
fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn chatgpt_base_url(&self) -> String {
|
||||
self.chatgpt_base_url.clone()
|
||||
}
|
||||
|
||||
fn workload_identity(&self) -> Option<WorkloadIdentityConfig> {
|
||||
self.workload_identity.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cloud_config_bundle_loader(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
@@ -58,14 +94,20 @@ pub async fn cloud_config_bundle_loader_for_storage(
|
||||
credentials_store_mode: AuthCredentialsStoreMode,
|
||||
keyring_backend_kind: AuthKeyringBackendKind,
|
||||
chatgpt_base_url: String,
|
||||
workload_identity: Option<WorkloadIdentityConfig>,
|
||||
) -> CloudConfigBundleLoader {
|
||||
let auth_manager = AuthManager::shared(
|
||||
codex_home.clone(),
|
||||
enable_codex_api_key_env,
|
||||
let auth_config = StorageAuthManagerConfig {
|
||||
codex_home: codex_home.clone(),
|
||||
credentials_store_mode,
|
||||
Some(chatgpt_base_url.clone()),
|
||||
keyring_backend_kind,
|
||||
)
|
||||
.await;
|
||||
chatgpt_base_url: chatgpt_base_url.clone(),
|
||||
workload_identity,
|
||||
};
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(&auth_config, enable_codex_api_key_env).await;
|
||||
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bundle_loader_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::*;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn storage_loader_consults_workload_identity_without_stored_auth() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let missing_token_path = codex_home.path().join("missing-subject-token");
|
||||
let workload_identity = serde_json::from_value(json!({
|
||||
"identity_provider_id": "idp_test",
|
||||
"identity_provider_mapping_id": "idpm_test",
|
||||
"audience": "api://codex-test",
|
||||
"token_url": "http://127.0.0.1:1/oauth/token",
|
||||
"credential_source": {
|
||||
"type": "file",
|
||||
"path": missing_token_path,
|
||||
},
|
||||
}))
|
||||
.expect("valid workload identity config");
|
||||
|
||||
let loader = cloud_config_bundle_loader_for_storage(
|
||||
codex_home.path().to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthKeyringBackendKind::default(),
|
||||
"http://127.0.0.1:1/backend-api".to_string(),
|
||||
Some(workload_identity),
|
||||
)
|
||||
.await;
|
||||
|
||||
let error = loader
|
||||
.get()
|
||||
.await
|
||||
.expect_err("missing WIF source should fail managed config auth");
|
||||
assert_eq!(error.code(), CloudConfigBundleLoadErrorCode::Auth);
|
||||
}
|
||||
@@ -244,7 +244,8 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
|
||||
fn parent_turn_denied_reads_context(turn: &TurnContext) -> Option<String> {
|
||||
#[allow(deprecated)]
|
||||
let cwd = &turn.cwd;
|
||||
let file_system_policy = turn.permission_profile.file_system_sandbox_policy();
|
||||
let permission_profile = turn.model_visible_permission_profile();
|
||||
let file_system_policy = permission_profile.file_system_sandbox_policy();
|
||||
let mut entries = file_system_policy
|
||||
.get_unreadable_roots_with_cwd(cwd)
|
||||
.into_iter()
|
||||
|
||||
@@ -463,6 +463,7 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re
|
||||
session.thread_id = fixed_guardian_parent_session_id();
|
||||
let denied_root = test_path_buf("/repo/private").abs();
|
||||
let denied_glob = test_path_buf("/repo/private/**").display().to_string();
|
||||
let credential_path = test_path_buf("/run/secrets/codex-wif/token").abs();
|
||||
turn.permission_profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
@@ -483,9 +484,17 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: credential_path.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
);
|
||||
Arc::make_mut(&mut turn.config).workload_identity_credential_deny_paths =
|
||||
vec![credential_path.clone()];
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
@@ -511,6 +520,7 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re
|
||||
assert!(text.contains("do not approve escalation whose purpose is to read them"));
|
||||
assert!(text.contains(denied_root.to_string_lossy().as_ref()));
|
||||
assert!(text.contains(&format!("glob `{denied_glob}`")));
|
||||
assert!(!text.contains(credential_path.to_string_lossy().as_ref()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ use codex_login::CodexAuth;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
const OPENAI_FILE_AUTH_RESOLUTION_FAILED_MESSAGE: &str =
|
||||
"failed to resolve ChatGPT auth for OpenAI file upload";
|
||||
|
||||
fn model_visible_auth_resolution_error(error: std::io::Error) -> String {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
"failed to resolve auth for OpenAI file upload"
|
||||
);
|
||||
OPENAI_FILE_AUTH_RESOLUTION_FAILED_MESSAGE.to_string()
|
||||
}
|
||||
|
||||
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -40,7 +51,7 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
.auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.map_err(|err| format!("failed to resolve auth for OpenAI file upload: {err}"))?;
|
||||
.map_err(model_visible_auth_resolution_error)?;
|
||||
let mut rewritten_arguments = arguments.clone();
|
||||
|
||||
for field_name in openai_file_input_params {
|
||||
@@ -195,6 +206,17 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn auth_resolution_errors_hide_credential_source_details() {
|
||||
let credential_path = "/run/secrets/codex-wif/subject-token";
|
||||
let message = model_visible_auth_resolution_error(std::io::Error::other(format!(
|
||||
"file credential source could not read {credential_path}"
|
||||
)));
|
||||
|
||||
assert_eq!(message, OPENAI_FILE_AUTH_RESOLUTION_FAILED_MESSAGE);
|
||||
assert!(!message.contains(credential_path));
|
||||
}
|
||||
|
||||
fn set_primary_environment_cwd(turn_context: &mut TurnContext, cwd: &Path) {
|
||||
let cwd = AbsolutePathBuf::try_from(cwd).expect("absolute path");
|
||||
turn_context.permission_profile = codex_protocol::models::PermissionProfile::Disabled;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use base64::Engine;
|
||||
use codex_config::types::ShellEnvironmentPolicyToml;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_workload_identity::CredentialSourceConfig;
|
||||
@@ -26,17 +28,32 @@ const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
|
||||
#[derive(Serialize)]
|
||||
struct TestConfigToml {
|
||||
workload_identity: WorkloadIdentityConfig,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
shell_environment_policy: Option<ShellEnvironmentPolicyToml>,
|
||||
}
|
||||
|
||||
fn workload_identity_config_toml(server: &MockServer, token_path: PathBuf) -> Result<String> {
|
||||
workload_identity_config_toml_with_source(
|
||||
server,
|
||||
CredentialSourceConfig::File { path: token_path },
|
||||
/*shell_environment_policy*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
fn workload_identity_config_toml_with_source(
|
||||
server: &MockServer,
|
||||
credential_source: CredentialSourceConfig,
|
||||
shell_environment_policy: Option<ShellEnvironmentPolicyToml>,
|
||||
) -> Result<String> {
|
||||
Ok(toml::to_string(&TestConfigToml {
|
||||
workload_identity: WorkloadIdentityConfig {
|
||||
identity_provider_id: "idp_test".to_string(),
|
||||
identity_provider_mapping_id: "idpm_test".to_string(),
|
||||
audience: "api://codex-test".to_string(),
|
||||
token_url: format!("{}/oauth/token", server.uri()),
|
||||
credential_source: CredentialSourceConfig::File { path: token_path },
|
||||
credential_source,
|
||||
},
|
||||
shell_environment_policy,
|
||||
})?)
|
||||
}
|
||||
|
||||
@@ -137,6 +154,88 @@ async fn configured_workload_identity_authenticates_responses_turn() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workload_identity_credentials_are_unavailable_to_shell_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let credential_dir = TempDir::new()?;
|
||||
let token_path = credential_dir.path().join("azure-subject-token");
|
||||
std::fs::write(&token_path, "external-subject-token\n")?;
|
||||
let access_token = test_access_token();
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(exchange_response(&access_token))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let shell_call_id = "wif-isolation-shell";
|
||||
let token_path_string = token_path.to_string_lossy();
|
||||
assert!(!token_path_string.contains('\''));
|
||||
let command = format!(
|
||||
"if [ -z \"${{AZURE_FEDERATED_TOKEN_FILE+x}}\" ]; then printf 'env-hidden\\n'; else printf 'env-visible:%s\\n' \"$AZURE_FEDERATED_TOKEN_FILE\"; fi; if cat '{token_path_string}' >/dev/null 2>&1; then printf 'file-readable\\n'; else printf 'file-denied\\n'; fi"
|
||||
);
|
||||
let shell_arguments = serde_json::to_string(&json!({
|
||||
"command": command,
|
||||
"login": false,
|
||||
"timeout_ms": 5_000,
|
||||
}))?;
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(shell_call_id, "shell_command", &shell_arguments),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "isolated"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let config_toml = workload_identity_config_toml_with_source(
|
||||
&server,
|
||||
CredentialSourceConfig::Azure {
|
||||
token_file: Some(token_path.clone()),
|
||||
},
|
||||
Some(ShellEnvironmentPolicyToml {
|
||||
r#set: Some(HashMap::from([(
|
||||
"AZURE_FEDERATED_TOKEN_FILE".to_string(),
|
||||
"credential-env-must-not-leak".to_string(),
|
||||
)])),
|
||||
..Default::default()
|
||||
}),
|
||||
)?;
|
||||
let mut builder = test_codex_with_workload_identity(config_toml);
|
||||
let test = builder.build(&server).await?;
|
||||
test.submit_turn_with_permission_profile(
|
||||
"verify workload credentials are isolated",
|
||||
test.config.permissions.permission_profile().clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let output = response_mock
|
||||
.function_call_output_text(shell_call_id)
|
||||
.expect("shell output present");
|
||||
assert!(
|
||||
output.contains("env-hidden"),
|
||||
"unexpected shell output: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("file-denied"),
|
||||
"unexpected shell output: {output}"
|
||||
);
|
||||
assert!(!output.contains("credential-env-must-not-leak"));
|
||||
assert!(!output.contains("external-subject-token"));
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn required_workload_identity_failure_blocks_session_startup() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -357,6 +357,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
.unwrap_or_default(),
|
||||
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
|
||||
chatgpt_base_url,
|
||||
bootstrap_config_toml.workload_identity.clone(),
|
||||
)
|
||||
.await;
|
||||
let run_cli_overrides = cli_kv_overrides.clone();
|
||||
|
||||
@@ -138,42 +138,5 @@ fn default_shell_command() -> Command {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn excluded_environment_variable_overrides_handler_env() {
|
||||
let variable = "CODEX_WIF_HOOK_ASSERTION";
|
||||
let shell = CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
excluded_environment_variables: vec![variable.to_string()],
|
||||
};
|
||||
let handler = ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: None,
|
||||
command: "true".to_string(),
|
||||
timeout_sec: 1,
|
||||
status_message: None,
|
||||
source_path: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
source: HookSource::Unknown,
|
||||
display_order: 0,
|
||||
env: HashMap::from([(variable.to_string(), "secret.assertion".to_string())]),
|
||||
};
|
||||
|
||||
let command = build_command(&shell, &handler);
|
||||
assert!(
|
||||
command
|
||||
.as_std()
|
||||
.get_envs()
|
||||
.any(|(name, value)| { name == OsStr::new(variable) && value.is_none() })
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "command_runner_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn excluded_environment_variable_overrides_handler_env() {
|
||||
let variable = "CODEX_WIF_HOOK_ASSERTION";
|
||||
let shell = CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
excluded_environment_variables: vec![variable.to_string()],
|
||||
};
|
||||
let handler = ConfiguredHandler {
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: None,
|
||||
command: "true".to_string(),
|
||||
timeout_sec: 1,
|
||||
status_message: None,
|
||||
source_path: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
source: HookSource::Unknown,
|
||||
display_order: 0,
|
||||
env: HashMap::from([(variable.to_string(), "secret.assertion".to_string())]),
|
||||
};
|
||||
|
||||
let command = build_command(&shell, &handler);
|
||||
assert!(
|
||||
command
|
||||
.as_std()
|
||||
.get_envs()
|
||||
.any(|(name, value)| { name == OsStr::new(variable) && value.is_none() })
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ use super::access_token::CodexAccessToken;
|
||||
use super::access_token::classify_codex_access_token;
|
||||
use super::external_bearer::BearerTokenRefresher;
|
||||
use super::revoke::revoke_auth_tokens;
|
||||
use super::workload_identity::WorkloadIdentityExternalAuth;
|
||||
use super::workload_identity::shared_workload_identity_external_auth;
|
||||
pub use crate::auth::agent_identity::AgentIdentityAuth;
|
||||
pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth;
|
||||
pub use crate::auth::personal_access_token::PersonalAccessTokenAuth;
|
||||
@@ -51,7 +51,6 @@ use codex_protocol::auth::PlanType as InternalPlanType;
|
||||
use codex_protocol::auth::RefreshTokenFailedError;
|
||||
use codex_protocol::auth::RefreshTokenFailedReason;
|
||||
use codex_workload_identity_providers::WorkloadIdentityConfig;
|
||||
use codex_workload_identity_providers::build_client as build_workload_identity_client;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -1961,12 +1960,12 @@ impl AuthManager {
|
||||
.await,
|
||||
);
|
||||
if let Some(workload_identity) = config.workload_identity() {
|
||||
let client = build_workload_identity_client(
|
||||
let external_auth = shared_workload_identity_external_auth(
|
||||
workload_identity,
|
||||
oauth_client_id(),
|
||||
build_reqwest_client(),
|
||||
);
|
||||
auth_manager.set_external_auth(Arc::new(WorkloadIdentityExternalAuth::new(client)));
|
||||
auth_manager.set_external_auth(external_auth);
|
||||
}
|
||||
auth_manager
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Weak;
|
||||
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_workload_identity_providers::ConfiguredWorkloadIdentityClient;
|
||||
use codex_workload_identity_providers::WorkloadIdentityConfig;
|
||||
use codex_workload_identity_providers::build_client as build_workload_identity_client;
|
||||
|
||||
use super::ExternalAuth;
|
||||
use super::ExternalAuthFuture;
|
||||
@@ -11,6 +18,45 @@ pub(super) struct WorkloadIdentityExternalAuth {
|
||||
process_isolation_error: Option<String>,
|
||||
}
|
||||
|
||||
struct SharedWorkloadIdentityExternalAuth {
|
||||
config: WorkloadIdentityConfig,
|
||||
client_id: String,
|
||||
auth: Weak<WorkloadIdentityExternalAuth>,
|
||||
}
|
||||
|
||||
fn shared_workload_identity_auths() -> &'static Mutex<Vec<SharedWorkloadIdentityExternalAuth>> {
|
||||
static SHARED_AUTHS: OnceLock<Mutex<Vec<SharedWorkloadIdentityExternalAuth>>> = OnceLock::new();
|
||||
SHARED_AUTHS.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
pub(super) fn shared_workload_identity_external_auth(
|
||||
config: WorkloadIdentityConfig,
|
||||
client_id: String,
|
||||
http: reqwest::Client,
|
||||
) -> Arc<dyn ExternalAuth> {
|
||||
let mut shared_auths = shared_workload_identity_auths()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
shared_auths.retain(|entry| entry.auth.strong_count() > 0);
|
||||
if let Some(auth) = shared_auths.iter().find_map(|entry| {
|
||||
(entry.config == config && entry.client_id == client_id)
|
||||
.then(|| entry.auth.upgrade())
|
||||
.flatten()
|
||||
}) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
let auth = Arc::new(WorkloadIdentityExternalAuth::new(
|
||||
build_workload_identity_client(config.clone(), client_id.clone(), http),
|
||||
));
|
||||
shared_auths.push(SharedWorkloadIdentityExternalAuth {
|
||||
config,
|
||||
client_id,
|
||||
auth: Arc::downgrade(&auth),
|
||||
});
|
||||
auth
|
||||
}
|
||||
|
||||
impl WorkloadIdentityExternalAuth {
|
||||
pub(super) fn new(client: ConfiguredWorkloadIdentityClient) -> Self {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -56,13 +102,21 @@ impl ExternalAuth for WorkloadIdentityExternalAuth {
|
||||
}
|
||||
|
||||
fn resolve(&self) -> ExternalAuthFuture<'_, Option<ExternalAuthTokens>> {
|
||||
Box::pin(async move { self.tokens(false).await.map(Some) })
|
||||
Box::pin(async move {
|
||||
self.tokens(/*force_refresh*/ false).await.map(Some)
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh(
|
||||
&self,
|
||||
_context: ExternalAuthRefreshContext,
|
||||
) -> ExternalAuthFuture<'_, ExternalAuthTokens> {
|
||||
Box::pin(async move { self.tokens(true).await })
|
||||
Box::pin(async move {
|
||||
self.tokens(/*force_refresh*/ true).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workload_identity_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config(mapping_id: &str) -> WorkloadIdentityConfig {
|
||||
serde_json::from_value(json!({
|
||||
"identity_provider_id": "idp_test",
|
||||
"identity_provider_mapping_id": mapping_id,
|
||||
"audience": "api://codex-test",
|
||||
"token_url": "http://127.0.0.1:1/oauth/token",
|
||||
"credential_source": {
|
||||
"type": "file",
|
||||
"path": "/run/secrets/codex-wif/subject-token",
|
||||
},
|
||||
}))
|
||||
.expect("valid workload identity config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_config_reuses_process_scoped_external_auth() {
|
||||
let config = test_config("idpm_shared");
|
||||
let first = shared_workload_identity_external_auth(
|
||||
config.clone(),
|
||||
"client-test".to_string(),
|
||||
reqwest::Client::new(),
|
||||
);
|
||||
let second = shared_workload_identity_external_auth(
|
||||
config,
|
||||
"client-test".to_string(),
|
||||
reqwest::Client::new(),
|
||||
);
|
||||
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_config_uses_distinct_external_auth() {
|
||||
let first = shared_workload_identity_external_auth(
|
||||
test_config("idpm_first"),
|
||||
"client-test".to_string(),
|
||||
reqwest::Client::new(),
|
||||
);
|
||||
let second = shared_workload_identity_external_auth(
|
||||
test_config("idpm_second"),
|
||||
"client-test".to_string(),
|
||||
reqwest::Client::new(),
|
||||
);
|
||||
|
||||
assert!(!Arc::ptr_eq(&first, &second));
|
||||
}
|
||||
@@ -52,4 +52,5 @@ pub use auth::read_openai_api_key_from_env;
|
||||
pub use auth::save_auth;
|
||||
pub use auth_env_telemetry::AuthEnvTelemetry;
|
||||
pub use auth_env_telemetry::collect_auth_env_telemetry;
|
||||
pub use codex_workload_identity_providers::WorkloadIdentityConfig;
|
||||
pub use token_data::TokenData;
|
||||
|
||||
@@ -964,6 +964,7 @@ pub async fn run_main(
|
||||
.unwrap_or_default(),
|
||||
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
|
||||
chatgpt_base_url,
|
||||
bootstrap_config_toml.workload_identity.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1428,6 +1429,7 @@ async fn run_ratatui_app(
|
||||
initial_config.cli_auth_credentials_store_mode,
|
||||
initial_config.auth_keyring_backend_kind(),
|
||||
initial_config.chatgpt_base_url.clone(),
|
||||
initial_config.workload_identity.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,7 @@ mod tests {
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthKeyringBackendKind::default(),
|
||||
"https://chatgpt.com/backend-api/".to_string(),
|
||||
/*workload_identity*/ None,
|
||||
)
|
||||
.await,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
|
||||
@@ -335,6 +335,7 @@ async fn start_app_server_for_archive_command(
|
||||
config_toml.cli_auth_credentials_store.unwrap_or_default(),
|
||||
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
|
||||
chatgpt_base_url,
|
||||
config_toml.workload_identity.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user