[codex] harden workload identity auth boundaries [ci changed_files]

This commit is contained in:
Cooper Gamble
2026-06-17 17:00:14 +00:00
parent d239918e76
commit be36e82566
53 changed files with 495 additions and 417 deletions
+1 -14
View File
@@ -3453,6 +3453,7 @@ version = "0.0.0"
dependencies = [
"codex-agent-identity",
"codex-api",
"codex-app-server-protocol",
"codex-aws-auth",
"codex-client",
"codex-feedback",
@@ -4456,19 +4457,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-workload-identity-gcp"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-workload-identity",
"pretty_assertions",
"reqwest 0.12.28",
"tokio",
"url",
"wiremock",
]
[[package]]
name = "codex-workload-identity-github-actions"
version = "0.0.0"
@@ -4491,7 +4479,6 @@ dependencies = [
"codex-workload-identity",
"codex-workload-identity-aws",
"codex-workload-identity-azure",
"codex-workload-identity-gcp",
"codex-workload-identity-github-actions",
"codex-workload-identity-spiffe",
"reqwest 0.12.28",
-2
View File
@@ -35,7 +35,6 @@ members = [
"workload-identity",
"workload-identity-aws",
"workload-identity-azure",
"workload-identity-gcp",
"workload-identity-github-actions",
"workload-identity-providers",
"workload-identity-spiffe",
@@ -173,7 +172,6 @@ codex-config = { path = "config" }
codex-workload-identity = { path = "workload-identity" }
codex-workload-identity-aws = { path = "workload-identity-aws" }
codex-workload-identity-azure = { path = "workload-identity-azure" }
codex-workload-identity-gcp = { path = "workload-identity-gcp" }
codex-workload-identity-github-actions = { path = "workload-identity-github-actions" }
codex-workload-identity-providers = { path = "workload-identity-providers" }
codex-workload-identity-spiffe = { path = "workload-identity-spiffe" }
+1 -1
View File
@@ -474,7 +474,7 @@ async fn send_track_events(
return;
}
let Some(auth) = auth_manager.auth().await else {
let Ok(Some(auth)) = auth_manager.auth().await else {
return;
};
if !auth.uses_codex_backend() {
@@ -18,7 +18,7 @@ pub(super) async fn load_remote_control_auth(
) -> io::Result<RemoteControlConnectionAuth> {
let mut reloaded = false;
let auth = loop {
let Some(auth) = auth_manager.auth().await else {
let Some(auth) = auth_manager.auth().await? else {
if reloaded {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
@@ -2265,6 +2265,7 @@ mod tests {
auth_manager
.auth()
.await
.expect("auth resolution should succeed")
.expect("auth should remain available")
.get_token()
.expect("token should be readable"),
@@ -2368,6 +2369,7 @@ mod tests {
auth_manager
.auth()
.await
.expect("auth resolution should succeed")
.expect("auth should remain available")
.get_token()
.expect("token should be readable"),
@@ -781,10 +781,11 @@ impl AccountRequestProcessor {
}
} else {
let auth = if do_refresh {
self.auth_manager.auth_cached()
Ok(self.auth_manager.auth_cached())
} else {
self.auth_manager.auth().await
};
}
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
match auth {
Some(auth) => {
let permanent_refresh_failure =
@@ -856,7 +857,12 @@ impl AccountRequestProcessor {
async fn get_account_rate_limits_response(
&self,
) -> Result<GetAccountRateLimitsResponse, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
let Some(auth) = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?
else {
return Err(invalid_request(
"codex account authentication required to read rate limits",
));
@@ -919,7 +925,12 @@ impl AccountRequestProcessor {
async fn get_account_token_usage_response(
&self,
) -> Result<GetAccountTokenUsageResponse, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
let Some(auth) = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?
else {
return Err(invalid_request(
"codex account authentication required to read token usage",
));
@@ -978,7 +989,12 @@ impl AccountRequestProcessor {
&self,
params: SendAddCreditsNudgeEmailParams,
) -> Result<AddCreditsNudgeEmailStatus, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
let Some(auth) = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?
else {
return Err(invalid_request(
"codex account authentication required to notify workspace owner",
));
@@ -49,7 +49,12 @@ impl AccountRequestProcessor {
}
async fn rate_limit_reset_backend_client(&self) -> Result<BackendClient, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
let Some(auth) = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?
else {
return Err(invalid_request(
"codex account authentication required for rate limit reset credits",
));
@@ -64,7 +64,11 @@ impl AppsRequestProcessor {
.set_enabled(Feature::Apps, thread.enabled(Feature::Apps));
}
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
if !config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
@@ -332,7 +332,11 @@ impl CatalogRequestProcessor {
}
None => self.load_latest_config(/*fallback_cwd*/ None).await?,
};
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
@@ -507,7 +511,11 @@ impl CatalogRequestProcessor {
};
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
@@ -613,7 +621,11 @@ impl CatalogRequestProcessor {
cwds
};
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let plugins_manager = self.thread_manager.plugins_manager();
let mut data = Vec::new();
for cwd in cwds {
@@ -120,7 +120,11 @@ impl McpRequestProcessor {
timeout_secs,
} = params;
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let effective_servers = self
.thread_manager
.mcp_manager()
@@ -226,7 +230,11 @@ impl McpRequestProcessor {
.await
}
};
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let environment_manager = self.thread_manager.environment_manager();
// This status path has no turn-selected environment. Use config cwd
// as the local stdio fallback; named environment stdio MCPs must
@@ -379,7 +387,11 @@ impl McpRequestProcessor {
.mcp_manager()
.runtime_config(&config)
.await;
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let environment_manager = self.thread_manager.environment_manager();
// This threadless resource-read path has no turn cwd or turn-selected
// environment. Use config cwd only as the local stdio fallback; named
@@ -544,7 +544,11 @@ impl PluginRequestProcessor {
if !config.features.enabled(Feature::Plugins) {
return Ok(empty_response());
}
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
@@ -795,7 +799,11 @@ impl PluginRequestProcessor {
if !config.features.enabled(Feature::Plugins) {
return Ok(empty_response());
}
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
@@ -992,7 +1000,11 @@ impl PluginRequestProcessor {
let config = self.load_latest_config(config_cwd).await?;
let plugins_input = config.plugins_config_input();
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
let plugin = match read_source {
@@ -1176,7 +1188,11 @@ impl PluginRequestProcessor {
));
}
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
@@ -1397,7 +1413,11 @@ impl PluginRequestProcessor {
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request("plugin sharing is not enabled"));
}
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
Ok((config, auth))
}
@@ -1425,7 +1445,11 @@ impl PluginRequestProcessor {
};
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
let config = self.load_latest_config(config_cwd.clone()).await?;
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
@@ -1499,7 +1523,11 @@ impl PluginRequestProcessor {
}
validate_remote_plugin_id(&remote_plugin_id)?;
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
@@ -1896,7 +1924,11 @@ impl PluginRequestProcessor {
}
validate_remote_plugin_id(&plugin_id)?;
let auth = self.auth_manager.auth().await;
let auth = self
.auth_manager
.auth()
.await
.map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
+1 -1
View File
@@ -27,7 +27,7 @@ pub(crate) async fn chatgpt_get_request_with_timeout<T: DeserializeOwned>(
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
let auth = auth_manager
.auth()
.await
.await?
.ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?;
anyhow::ensure!(
auth.uses_codex_backend(),
+2 -2
View File
@@ -29,7 +29,7 @@ const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
async fn apps_enabled(config: &Config) -> bool {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
let auth = auth_manager.auth().await;
let auth = auth_manager.auth().await.ok().flatten();
config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
@@ -40,7 +40,7 @@ async fn connector_auth(config: &Config) -> anyhow::Result<CodexAuth> {
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
let auth = auth_manager
.auth()
.await
.await?
.ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?;
anyhow::ensure!(
auth.uses_codex_backend(),
+10 -1
View File
@@ -2301,7 +2301,16 @@ async fn websocket_reachability_check(
));
let runtime_provider = create_model_provider(provider.clone(), auth_manager);
let auth = runtime_provider.auth().await;
let auth = match runtime_provider.auth().await {
Ok(auth) => auth,
Err(err) => {
return websocket_probe_warning(
"Responses WebSocket auth could not be resolved",
details,
format!("auth resolution failed: {err}"),
);
}
};
details.push(format!(
"auth mode: {}",
auth.as_ref().map(auth_mode_name).unwrap_or("none")
+2 -2
View File
@@ -1815,13 +1815,13 @@ async fn load_exec_server_remote_auth(
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
let auth = match auth_manager.auth().await {
let auth = match auth_manager.auth().await? {
Some(auth) => auth,
None => {
auth_manager.reload().await;
auth_manager
.auth()
.await
.await?
.ok_or_else(|| anyhow::anyhow!(missing_auth_error))?
}
};
+29 -3
View File
@@ -53,6 +53,15 @@ fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
|| matches!(plan_type, PlanType::Enterprise | PlanType::Edu))
}
fn auth_resolution_error(error: std::io::Error) -> CloudConfigBundleLoadError {
tracing::error!(error = %error, "Failed to resolve auth for cloud config bundle");
CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::Auth,
/*status_code*/ None,
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
)
}
fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
if bundle.is_empty() {
None
@@ -171,7 +180,12 @@ where
async fn load_startup_bundle(
&self,
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
let Some(auth) = self.auth_manager.auth().await else {
let Some(auth) = self
.auth_manager
.auth()
.await
.map_err(auth_resolution_error)?
else {
return Ok(None);
};
if !cloud_config_eligible_auth(&auth) {
@@ -380,7 +394,12 @@ where
);
match auth_recovery.next().await {
Ok(_) => {
let Some(refreshed_auth) = self.auth_manager.auth().await else {
let Some(refreshed_auth) = self
.auth_manager
.auth()
.await
.map_err(auth_resolution_error)?
else {
tracing::error!(
"Auth recovery succeeded but no auth is available for cloud config bundle"
);
@@ -471,7 +490,14 @@ where
}
async fn refresh_cache_once(&self) -> bool {
let Some(auth) = self.auth_manager.auth().await else {
let auth = match self.auth_manager.auth().await {
Ok(auth) => auth,
Err(error) => {
tracing::error!(error = %error, "Failed to resolve auth while refreshing cloud config bundle");
return false;
}
};
let Some(auth) = auth else {
return false;
};
if !cloud_config_eligible_auth(&auth) {
+1 -1
View File
@@ -70,7 +70,7 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
let auth_manager = util::load_auth_manager(Some(base_url.clone())).await;
let auth = match auth_manager.as_ref() {
Some(manager) => manager.auth().await,
Some(manager) => manager.auth().await?,
None => None,
};
let auth = match auth {
+1 -1
View File
@@ -70,7 +70,7 @@ pub async fn build_chatgpt_headers() -> HeaderMap {
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Some(am) = load_auth_manager(/*chatgpt_base_url*/ None).await
&& let Some(auth) = am.auth().await
&& let Ok(Some(auth)) = am.auth().await
&& auth.uses_codex_backend()
{
headers.extend(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers());
+14 -2
View File
@@ -1606,7 +1606,13 @@ impl PluginsManager {
let auth_manager_for_remote_sync = auth_manager.clone();
let on_effective_plugins_changed = on_effective_plugins_changed.clone();
tokio::spawn(async move {
let auth = auth_manager_for_remote_sync.auth().await;
let auth = match auth_manager_for_remote_sync.auth().await {
Ok(auth) => auth,
Err(err) => {
warn!(error = %err, "failed to resolve auth for remote plugin cache refresh");
return;
}
};
manager.maybe_start_remote_plugin_caches_refresh(
&config_for_remote_sync,
auth.clone(),
@@ -1643,7 +1649,13 @@ impl PluginsManager {
let config_for_featured_plugins = config.clone();
let manager = Arc::clone(self);
tokio::spawn(async move {
let auth = auth_manager.auth().await;
let auth = match auth_manager.auth().await {
Ok(auth) => auth,
Err(err) => {
warn!(error = %err, "failed to resolve auth for featured plugin cache warmup");
return;
}
};
if let Err(err) = manager
.featured_plugin_ids_for_config(&config_for_featured_plugins, auth.as_ref())
.await
-21
View File
@@ -855,27 +855,6 @@
],
"type": "object"
},
{
"additionalProperties": false,
"description": "A GCE or GKE service-account identity token from the metadata service.",
"properties": {
"service_account": {
"default": null,
"description": "Service account email or `default` when omitted.",
"type": "string"
},
"type": {
"enum": [
"gcp"
],
"type": "string"
}
},
"required": [
"type"
],
"type": "object"
},
{
"additionalProperties": false,
"description": "A GitHub Actions OIDC token requested from the runner token service.",
+1 -1
View File
@@ -848,7 +848,7 @@ impl ModelClient {
/// This centralizes setup used by both prewarm and normal request paths so they stay in
/// lockstep when auth/provider resolution changes.
async fn current_client_setup(&self) -> Result<CurrentClientSetup> {
let auth = self.state.provider.auth().await;
let auth = self.state.provider.auth().await?;
let api_provider = self.state.provider.api_provider().await?;
let api_auth = self.state.provider.api_auth().await?;
Ok(CurrentClientSetup {
+15 -3
View File
@@ -134,7 +134,13 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
) -> Option<Vec<AppInfo>> {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
let auth = auth_manager.auth().await;
let auth = match auth_manager.auth().await {
Ok(auth) => auth,
Err(err) => {
tracing::warn!(error = %err, "failed to resolve auth for cached connectors");
return None;
}
};
if !config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
@@ -224,7 +230,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
) -> anyhow::Result<AccessibleConnectorsStatus> {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
let auth = auth_manager.auth().await;
let auth = auth_manager.auth().await?;
if !config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
@@ -458,7 +464,13 @@ async fn cached_directory_connectors_for_tool_suggest_with_auth(
} else {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
loaded_auth = auth_manager.auth().await;
loaded_auth = match auth_manager.auth().await {
Ok(auth) => auth,
Err(err) => {
tracing::warn!(error = %err, "failed to resolve auth for connector directory cache");
return Vec::new();
}
};
loaded_auth.as_ref()
};
let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {
+6 -1
View File
@@ -35,7 +35,12 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
let Some(arguments) = arguments_value.as_object() else {
return Ok(Some(arguments_value));
};
let auth = sess.services.auth_manager.auth().await;
let auth = sess
.services
.auth_manager
.auth()
.await
.map_err(|err| format!("failed to resolve auth for OpenAI file upload: {err}"))?;
let mut rewritten_arguments = arguments.clone();
for field_name in openai_file_input_params {
+7 -1
View File
@@ -694,7 +694,13 @@ async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &
match mcp_tools_result {
Ok(mcp_tools) => {
let auth = sess.services.auth_manager.auth().await;
let auth = match sess.services.auth_manager.auth().await {
Ok(auth) => auth,
Err(err) => {
tracing::warn!(error = %err, "failed to resolve auth after connector authorization");
return;
}
};
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
&turn_context.config,
auth.as_ref(),
+6 -1
View File
@@ -1256,7 +1256,12 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
}
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
let auth = session.services.auth_manager.auth().await;
let auth = session
.services
.auth_manager
.auth()
.await
.expect("auth resolution should succeed");
let manager = codex_mcp::McpConnectionManager::new(
&HashMap::new(),
turn_context.config.mcp_oauth_credentials_store_mode,
+1 -1
View File
@@ -694,7 +694,7 @@ async fn prepare_realtime_start(
.model_client
.auth_manager()
.unwrap_or_else(|| Arc::clone(&sess.services.auth_manager));
let auth = auth_manager.auth().await;
let auth = auth_manager.auth().await?;
let config = sess.get_config().await;
let transport = params
.transport
+7 -1
View File
@@ -302,7 +302,13 @@ impl Session {
keyring_backend_kind: AuthKeyringBackendKind,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
) {
let auth = self.services.auth_manager.auth().await;
let auth = match self.services.auth_manager.auth().await {
Ok(auth) => auth,
Err(err) => {
tracing::warn!(error = %err, "failed to resolve auth while refreshing MCP servers");
None
}
};
let config = self.get_config().await;
let mcp_config = self.runtime_mcp_config(config.as_ref()).await;
let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config);
+32 -17
View File
@@ -2995,23 +2995,35 @@ impl Session {
.plugins_manager
.plugins_for_config(&turn_context.config.plugins_config_input())
.await;
let recommended_plugin_candidates =
if crate::tools::spec_plan::tool_suggest_enabled(turn_context) {
let auth = self.services.auth_manager.auth().await;
let plugins_config = turn_context.config.plugins_config_input();
self.services
.plugins_manager
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
plugins_config: &plugins_config,
loaded_plugins: &loaded_plugins,
auth: auth.as_ref(),
disabled_tools: &turn_context.config.tool_suggest.disabled_tools,
app_server_client_name: turn_context.app_server_client_name.as_deref(),
})
.await
} else {
None
};
let recommended_plugin_candidates = if crate::tools::spec_plan::tool_suggest_enabled(
turn_context,
) {
match self.services.auth_manager.auth().await {
Ok(auth) => {
let plugins_config = turn_context.config.plugins_config_input();
self.services
.plugins_manager
.recommended_plugin_candidates_for_config(
RecommendedPluginCandidatesInput {
plugins_config: &plugins_config,
loaded_plugins: &loaded_plugins,
auth: auth.as_ref(),
disabled_tools: &turn_context.config.tool_suggest.disabled_tools,
app_server_client_name: turn_context
.app_server_client_name
.as_deref(),
},
)
.await
}
Err(err) => {
tracing::warn!(error = %err, "failed to resolve auth for plugin recommendations");
None
}
}
} else {
None
};
if let Some(recommended_plugins) = recommended_plugin_candidates
.as_deref()
.and_then(RecommendedPluginsInstructions::from_plugins)
@@ -3625,6 +3637,9 @@ async fn build_hooks_for_config(
plugin_hook_load_warnings,
shell_program: hook_shell_program,
shell_args: hook_shell_argv,
// Hooks are explicitly trusted user/admin code, not model sandbox processes. Removing
// credential-bearing environment variables avoids accidental propagation without
// pretending to isolate same-user hook code from Codex-owned files or process memory.
excluded_environment_variables: config
.workload_identity
.as_ref()
+1 -1
View File
@@ -1192,7 +1192,7 @@ pub(crate) async fn built_tools(
};
let tool_suggest_is_enabled = tool_suggest_enabled(turn_context);
let auth = if tool_suggest_is_enabled {
sess.services.auth_manager.auth().await
sess.services.auth_manager.auth().await?
} else {
None
};
@@ -191,7 +191,11 @@ impl RequestPluginInstallHandler {
.as_ref()
.is_some_and(|response| response.action == ElicitationAction::Accept);
let auth = session.services.auth_manager.auth().await;
let auth = session.services.auth_manager.auth().await.map_err(|err| {
FunctionCallError::Fatal(format!(
"failed to resolve auth after plugin installation: {err}"
))
})?;
let completed = if user_confirmed {
verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await
} else {
-2
View File
@@ -9,13 +9,11 @@ default = ["workload-identity-azure"]
workload-identity-all-providers = [
"workload-identity-aws",
"workload-identity-azure",
"workload-identity-gcp",
"workload-identity-github-actions",
"workload-identity-spiffe",
]
workload-identity-aws = ["codex-workload-identity-providers/aws"]
workload-identity-azure = ["codex-workload-identity-providers/azure"]
workload-identity-gcp = ["codex-workload-identity-providers/gcp"]
workload-identity-github-actions = ["codex-workload-identity-providers/github-actions"]
workload-identity-spiffe = ["codex-workload-identity-providers/spiffe"]
+20 -6
View File
@@ -649,7 +649,11 @@ async fn external_chatgpt_auth_resolves_and_refreshes_without_writing_auth_file(
manager.get_api_auth_mode(),
Some(AuthMode::ChatgptAuthTokens)
);
let auth = manager.auth().await.expect("external auth should resolve");
let auth = manager
.auth()
.await
.expect("external auth resolution should succeed")
.expect("external auth should resolve");
assert!(manager.is_external_chatgpt_auth_active());
assert_eq!(auth.get_token().unwrap(), token);
assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED));
@@ -698,7 +702,11 @@ async fn external_chatgpt_auth_allows_host_to_change_account_during_refresh() {
)
.await;
manager.set_external_auth(external);
manager.auth().await.expect("initial auth should resolve");
manager
.auth()
.await
.expect("initial auth resolution should succeed")
.expect("initial auth should resolve");
manager
.refresh_token()
@@ -729,7 +737,7 @@ async fn auth_result_preserves_external_auth_resolution_errors() {
.expect_err("external auth failure should be preserved");
assert_eq!(error.to_string(), "external token exchange failed");
assert_eq!(manager.auth().await, None);
assert!(manager.auth().await.is_err());
}
#[tokio::test]
@@ -740,10 +748,12 @@ async fn external_bearer_only_auth_manager_uses_cached_provider_token() {
let first = manager
.auth()
.await
.expect("provider auth resolution should succeed")
.and_then(|auth| auth.api_key().map(str::to_string));
let second = manager
.auth()
.await
.expect("provider auth resolution should succeed")
.and_then(|auth| auth.api_key().map(str::to_string));
assert_eq!(first.as_deref(), Some("provider-token"));
@@ -762,10 +772,12 @@ async fn external_bearer_only_auth_manager_disables_auto_refresh_when_interval_i
let first = manager
.auth()
.await
.expect("provider auth resolution should succeed")
.and_then(|auth| auth.api_key().map(str::to_string));
let second = manager
.auth()
.await
.expect("provider auth resolution should succeed")
.and_then(|auth| auth.api_key().map(str::to_string));
assert_eq!(first.as_deref(), Some("provider-token"));
@@ -778,7 +790,7 @@ async fn external_bearer_only_auth_manager_returns_none_when_command_fails() {
let manager = AuthManager::external_bearer_only(script.auth_config());
assert_eq!(manager.auth_result().await.unwrap(), None);
assert_eq!(manager.auth().await, None);
assert_eq!(manager.auth().await.unwrap(), None);
}
#[tokio::test]
@@ -790,6 +802,7 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() {
let initial_token = manager
.auth()
.await
.expect("provider auth resolution should succeed")
.and_then(|auth| auth.api_key().map(str::to_string));
let mut recovery = manager.unauthorized_recovery();
@@ -806,6 +819,7 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() {
let refreshed_token = manager
.auth()
.await
.expect("provider auth resolution should succeed")
.and_then(|auth| auth.api_key().map(str::to_string));
assert_eq!(initial_token.as_deref(), Some("provider-token"));
assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token"));
@@ -1194,7 +1208,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() {
)
.await;
assert_eq!(manager.auth().await, None);
assert_eq!(manager.auth().await.unwrap(), None);
server.verify().await;
}
@@ -1245,7 +1259,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch()
)
.await;
assert_eq!(manager.auth().await, None);
assert_eq!(manager.auth().await.unwrap(), None);
}
server.verify().await;
}
+12 -15
View File
@@ -1700,21 +1700,13 @@ impl AuthManager {
})
}
/// Current cached auth (clone). May be `None` if not logged in or load failed.
/// For managed ChatGPT auth that needs a proactive refresh, first performs
/// a guarded reload and then refreshes only if the on-disk auth is unchanged.
pub async fn auth(&self) -> Option<CodexAuth> {
match self.auth_result().await {
Ok(auth) => auth,
Err(error) => {
tracing::error!("Failed to resolve external auth: {error}");
None
}
}
}
/// Resolves current auth while preserving errors from explicitly configured external auth.
pub async fn auth_result(&self) -> std::io::Result<Option<CodexAuth>> {
/// Resolves current auth. May return `None` if not logged in or load failed.
///
/// Errors from required external auth are preserved so callers cannot accidentally fall back
/// to another credential when workload identity resolution fails. For managed ChatGPT auth
/// that needs a proactive refresh, this first performs a guarded reload and then refreshes
/// only if the on-disk auth is unchanged.
pub async fn auth(&self) -> std::io::Result<Option<CodexAuth>> {
if let Some(external_auth) = self.external_auth() {
match self.resolve_external_auth(&external_auth).await {
Ok(Some(auth)) => return Ok(Some(auth)),
@@ -1740,6 +1732,11 @@ impl AuthManager {
Ok(self.auth_cached())
}
/// Backwards-compatible explicit name for callers that already handle auth resolution errors.
pub async fn auth_result(&self) -> std::io::Result<Option<CodexAuth>> {
self.auth().await
}
/// Force a reload of the auth information from auth.json. Returns
/// whether the auth value changed.
pub async fn reload(&self) -> bool {
@@ -99,6 +99,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> {
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should be cached")?;
let cached = cached_auth
.get_token_data()
@@ -165,6 +166,7 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> {
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should be cached")?;
let cached = cached_auth
.get_token_data()
@@ -210,6 +212,7 @@ async fn auth_refreshes_when_access_token_is_near_expiry() -> Result<()> {
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should be cached")?;
let refreshed_tokens = TokenData {
@@ -262,6 +265,7 @@ async fn auth_skips_access_token_outside_refresh_window() -> Result<()> {
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should be cached")?;
let cached = cached_auth
@@ -445,6 +449,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> {
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should be cached")?;
let cached = cached_auth
.get_token_data()
@@ -495,6 +500,7 @@ async fn refreshes_token_when_access_token_is_expired() -> Result<()> {
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should be cached")?;
let refreshed_tokens = TokenData {
access_token: "new-access-token".to_string(),
@@ -565,6 +571,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> {
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should reload from disk")?;
let cached = cached_auth
.get_token_data()
@@ -633,6 +640,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should reload from disk")?;
let cached = cached_auth
.get_token_data()
@@ -691,6 +699,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should remain cached")?;
let cached = cached_auth
.get_token_data()
@@ -760,6 +769,7 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> {
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should remain cached")?;
let cached = cached_auth
.get_token_data()
@@ -829,6 +839,7 @@ async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Resu
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should remain cached")?;
let cached = cached_auth
.get_token_data()
@@ -971,6 +982,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()>
.auth_manager
.auth()
.await
.context("auth should resolve")?
.context("auth should remain cached")?;
let cached = cached_auth
.get_token_data()
@@ -1069,6 +1081,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> {
.auth_manager
.auth()
.await
.expect("auth should resolve")
.expect("auth should be cached");
let cached_tokens = cached_auth
.get_token_data()
+1 -1
View File
@@ -13,7 +13,7 @@ pub(crate) async fn rate_limits_ok(auth_manager: &AuthManager, config: &Config)
}
async fn rate_limits_check(auth_manager: &AuthManager, config: &Config) -> Option<bool> {
let auth = auth_manager.auth().await?;
let auth = auth_manager.auth().await.ok()??;
if !auth.uses_codex_backend() {
return None;
}
+1 -1
View File
@@ -615,7 +615,7 @@ impl ModelProvider for MockMemoryModelProvider {
self.delegate.auth_manager()
}
fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>> {
fn auth(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result<Option<CodexAuth>>> {
let delegate = Arc::clone(&self.delegate);
Box::pin(async move { delegate.auth().await })
}
+1
View File
@@ -29,6 +29,7 @@ tokio = { workspace = true, features = ["sync", "time"] }
tracing = { workspace = true, features = ["log"] }
[dev-dependencies]
codex-app-server-protocol = { workspace = true }
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
@@ -126,8 +126,8 @@ impl ModelProvider for AmazonBedrockModelProvider {
.and_then(|_| self.auth_manager.as_ref().cloned())
}
fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>> {
Box::pin(AmazonBedrockModelProvider::auth(self))
fn auth(&self) -> ModelProviderFuture<'_, Result<Option<CodexAuth>>> {
Box::pin(async { Ok(AmazonBedrockModelProvider::auth(self).await) })
}
fn account_state(&self) -> ProviderAccountResult {
@@ -49,16 +49,18 @@ impl OpenAiModelsEndpoint {
}
}
async fn auth(&self) -> Option<CodexAuth> {
async fn auth(&self) -> CoreResult<Option<CodexAuth>> {
match self.auth_manager.as_ref() {
Some(auth_manager) => auth_manager.auth().await,
None => None,
Some(auth_manager) => Ok(auth_manager.auth().await?),
None => Ok(None),
}
}
async fn uses_codex_backend(&self) -> bool {
self.auth()
.await
.ok()
.flatten()
.as_ref()
.is_some_and(CodexAuth::uses_codex_backend)
}
@@ -69,7 +71,7 @@ impl OpenAiModelsEndpoint {
) -> CoreResult<(Vec<ModelInfo>, Option<String>)> {
let _timer =
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
let auth = self.auth().await;
let auth = self.auth().await?;
let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
let api_provider = self.provider_info.to_api_provider(auth_mode)?;
let api_auth = resolve_provider_auth(auth.as_ref(), &self.provider_info)?;
+48 -7
View File
@@ -140,7 +140,7 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
fn auth_manager(&self) -> Option<Arc<AuthManager>>;
/// Returns the current provider-scoped auth value, if one is configured.
fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>>;
fn auth(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result<Option<CodexAuth>>>;
/// Returns the current app-visible account state for this provider.
fn account_state(&self) -> ProviderAccountResult;
@@ -148,7 +148,7 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
/// Returns provider configuration adapted for the API client.
fn api_provider(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result<Provider>> {
Box::pin(async move {
let auth = self.auth().await;
let auth = self.auth().await?;
self.info()
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))
})
@@ -166,7 +166,7 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<SharedAuthProvider>> {
Box::pin(async move {
let auth = self.auth().await;
let auth = self.auth().await?;
resolve_provider_auth(auth.as_ref(), self.info())
})
}
@@ -229,11 +229,11 @@ impl ModelProvider for ConfiguredModelProvider {
.is_some_and(|auth| auth.is_chatgpt_auth())
}
fn auth(&self) -> ModelProviderFuture<'_, Option<CodexAuth>> {
fn auth(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result<Option<CodexAuth>>> {
Box::pin(async move {
match self.auth_manager.as_ref() {
Some(auth_manager) => auth_manager.auth().await,
None => None,
Some(auth_manager) => Ok(auth_manager.auth().await?),
None => Ok(None),
}
})
}
@@ -307,6 +307,10 @@ impl ModelProvider for ConfiguredModelProvider {
#[cfg(test)]
mod tests {
use codex_app_server_protocol::AuthMode;
use codex_login::ExternalAuth;
use codex_login::ExternalAuthFuture;
use codex_login::ExternalAuthRefreshContext;
use std::num::NonZeroU64;
use codex_login::auth::BedrockApiKeyAuth;
@@ -327,6 +331,29 @@ mod tests {
use super::*;
struct FailingRequiredExternalAuth;
impl ExternalAuth for FailingRequiredExternalAuth {
fn auth_mode(&self) -> AuthMode {
AuthMode::Chatgpt
}
fn requires_successful_resolution(&self) -> bool {
true
}
fn resolve(&self) -> ExternalAuthFuture<'_, Option<codex_login::ExternalAuthTokens>> {
Box::pin(async { Err(std::io::Error::other("required external auth failed")) })
}
fn refresh(
&self,
_context: ExternalAuthRefreshContext,
) -> ExternalAuthFuture<'_, codex_login::ExternalAuthTokens> {
Box::pin(async { Err(std::io::Error::other("required external auth failed")) })
}
}
fn provider_info_with_command_auth() -> ModelProviderInfo {
ModelProviderInfo {
auth: Some(ModelProviderAuthInfo {
@@ -370,6 +397,20 @@ mod tests {
}
}
#[tokio::test]
async fn required_external_auth_failure_does_not_fall_back_to_provider_token() {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("cached-api-key"));
auth_manager.set_external_auth(Arc::new(FailingRequiredExternalAuth));
let mut provider_info = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
provider_info.experimental_bearer_token = Some("provider-fallback-token".to_string());
let provider = create_model_provider(provider_info, Some(auth_manager));
assert!(provider.auth().await.is_err());
assert!(provider.api_provider().await.is_err());
assert!(provider.api_auth().await.is_err());
}
fn remote_model(slug: &str) -> ModelInfo {
serde_json::from_value(json!({
"slug": slug,
@@ -480,7 +521,7 @@ mod tests {
Some(AuthManager::from_auth_for_testing(auth.clone())),
);
assert_eq!(provider.auth().await, Some(auth));
assert_eq!(provider.auth().await.unwrap(), Some(auth));
}
#[test]
@@ -726,6 +726,7 @@ impl TestAuthAwareModelsEndpoint {
Some(auth_manager) => auth_manager
.auth()
.await
.expect("auth resolution should succeed")
.as_ref()
.is_some_and(CodexAuth::uses_codex_backend),
None => false,
@@ -269,6 +269,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
experimental_thread_store: ThreadStoreConfig::Local,
forced_chatgpt_workspace_id: None,
forced_login_method: None,
workload_identity: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Disabled),
web_search_config: None,
experimental_request_user_input_enabled: true,
+4 -10
View File
@@ -1,7 +1,6 @@
use std::env;
use aws_config::ecs::EcsCredentialsProvider;
use aws_config::imds::credentials::ImdsCredentialsProvider;
use aws_config::meta::credentials::CredentialsProviderChain;
use aws_config::provider_config::ProviderConfig;
use aws_config::web_identity_token::WebIdentityTokenCredentialsProvider;
@@ -31,8 +30,9 @@ const STS_ACTION_QUERY: &str = "Action=GetCallerIdentity&Version=2011-06-15";
/// Creates a SigV4-signed STS `GetCallerIdentity` proof from AWS workload credentials.
///
/// The credential chain intentionally excludes environment access keys, shared profiles, SSO,
/// credential processes, and AWS login credentials. It accepts only web identity, ECS/EKS
/// container credentials, and EC2 IMDSv2 credentials.
/// credential processes, AWS login credentials, and EC2 instance metadata. It accepts only web
/// identity and ECS/EKS container credentials whose acquisition inputs can be removed from model
/// subprocesses.
#[derive(Clone, Debug)]
pub struct AwsSubjectTokenProvider {
identity_provider_id: String,
@@ -68,12 +68,6 @@ impl AwsSubjectTokenProvider {
EcsCredentialsProvider::builder()
.configure(&provider_config)
.build(),
)
.or_else(
"Ec2InstanceMetadata",
ImdsCredentialsProvider::builder()
.configure(&provider_config)
.build(),
);
let context = AwsAuthContext::from_provider(
SharedCredentialsProvider::new(credentials),
@@ -219,7 +213,7 @@ fn invalid_configuration() -> SubjectTokenError {
fn missing_workload_credentials() -> SubjectTokenError {
SubjectTokenError::MissingPrerequisite {
provider: "aws",
prerequisite: "AWS web identity, container, or IMDSv2 workload credentials".to_string(),
prerequisite: "AWS web identity or container workload credentials".to_string(),
}
}
@@ -136,7 +136,7 @@ async fn rejects_static_credentials_without_a_session_token() {
assert_eq!(provider, "aws");
assert_eq!(
prerequisite,
"AWS web identity, container, or IMDSv2 workload credentials"
"AWS web identity or container workload credentials"
);
}
other => panic!("unexpected error: {other}"),
@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "workload-identity-gcp",
crate_name = "codex_workload_identity_gcp",
)
-22
View File
@@ -1,22 +0,0 @@
[package]
name = "codex-workload-identity-gcp"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
codex-workload-identity = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
wiremock = { workspace = true }
[lib]
doctest = false
-158
View File
@@ -1,158 +0,0 @@
use std::time::Duration;
use codex_workload_identity::BoundedResponseBodyError;
use codex_workload_identity::MAX_SUBJECT_TOKEN_BYTES;
use codex_workload_identity::SubjectToken;
use codex_workload_identity::SubjectTokenError;
use codex_workload_identity::SubjectTokenProvider;
use codex_workload_identity::read_bounded_response_body;
use reqwest::header::HeaderValue;
use url::Url;
const GCP_METADATA_ORIGIN: &str = "http://metadata.google.internal";
const GCP_METADATA_HOST_ENV: &str = "GCE_METADATA_HOST";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Debug)]
pub struct GcpSubjectTokenProvider {
metadata_origin: String,
service_account: String,
audience: String,
http: reqwest::Client,
}
impl GcpSubjectTokenProvider {
pub fn new(service_account: Option<String>, audience: String, http: reqwest::Client) -> Self {
let metadata_origin = std::env::var(GCP_METADATA_HOST_ENV)
.ok()
.filter(|host| !host.is_empty())
.map(|host| format!("http://{host}"))
.unwrap_or_else(|| GCP_METADATA_ORIGIN.to_string());
Self {
metadata_origin,
service_account: service_account.unwrap_or_else(|| "default".to_string()),
audience,
http,
}
}
fn request_url(&self) -> Result<Url, SubjectTokenError> {
let mut url = Url::parse(&self.metadata_origin)
.map_err(|_| SubjectTokenError::InvalidConfiguration { provider: "gcp" })?;
let allowed_host = url
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case("metadata.google.internal"))
|| url.host().is_some_and(|host| match host {
url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
url::Host::Ipv4(address) => address.is_loopback(),
url::Host::Ipv6(address) => address.is_loopback(),
});
if url.scheme() != "http"
|| !allowed_host
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(SubjectTokenError::InvalidConfiguration { provider: "gcp" });
}
url.path_segments_mut()
.map_err(|()| SubjectTokenError::InvalidConfiguration { provider: "gcp" })?
.clear()
.extend([
"computeMetadata",
"v1",
"instance",
"service-accounts",
self.service_account.as_str(),
"identity",
]);
url.query_pairs_mut()
.append_pair("audience", &self.audience)
.append_pair("format", "full");
Ok(url)
}
}
impl SubjectTokenProvider for GcpSubjectTokenProvider {
async fn subject_token(&self) -> Result<SubjectToken, SubjectTokenError> {
let response = self
.http
.get(self.request_url()?)
.header("Metadata-Flavor", HeaderValue::from_static("Google"))
.timeout(REQUEST_TIMEOUT)
.send()
.await
.map_err(|_| SubjectTokenError::Unavailable { provider: "gcp" })?;
if !response.status().is_success() {
return Err(SubjectTokenError::InvalidResponse { provider: "gcp" });
}
let body = read_bounded_response_body(response, MAX_SUBJECT_TOKEN_BYTES)
.await
.map_err(|error| match error {
BoundedResponseBodyError::Request(_) => {
SubjectTokenError::InvalidResponse { provider: "gcp" }
}
BoundedResponseBodyError::TooLarge => {
SubjectTokenError::TooLarge { provider: "gcp" }
}
})?;
let body = String::from_utf8(body)
.map_err(|_| SubjectTokenError::InvalidResponse { provider: "gcp" })?;
SubjectToken::jwt(body, "gcp")
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
#[tokio::test]
async fn requests_identity_token_with_exact_audience() -> anyhow::Result<()> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/computeMetadata/v1/instance/service-accounts/default/identity",
))
.and(header("Metadata-Flavor", "Google"))
.and(query_param("audience", "openai-audience"))
.and(query_param("format", "full"))
.respond_with(ResponseTemplate::new(200).set_body_string("gcp.jwt.token\n"))
.expect(1)
.mount(&server)
.await;
let source = GcpSubjectTokenProvider {
metadata_origin: server.uri(),
service_account: "default".to_string(),
audience: "openai-audience".to_string(),
http: reqwest::Client::new(),
};
assert_eq!(
source.subject_token().await?,
SubjectToken::jwt("gcp.jwt.token", "gcp")?
);
Ok(())
}
#[tokio::test]
async fn rejects_non_metadata_non_loopback_host() {
let source = GcpSubjectTokenProvider {
metadata_origin: "http://attacker.example".to_string(),
service_account: "default".to_string(),
audience: "openai-audience".to_string(),
http: reqwest::Client::new(),
};
assert!(matches!(
source.subject_token().await,
Err(SubjectTokenError::InvalidConfiguration { provider: "gcp" })
));
}
}
@@ -8,7 +8,6 @@ license.workspace = true
default = []
azure = ["dep:codex-workload-identity-azure"]
aws = ["dep:codex-workload-identity-aws"]
gcp = ["dep:codex-workload-identity-gcp"]
github-actions = ["dep:codex-workload-identity-github-actions"]
spiffe = ["dep:codex-workload-identity-spiffe"]
@@ -19,7 +18,6 @@ workspace = true
codex-workload-identity = { workspace = true }
codex-workload-identity-aws = { workspace = true, optional = true }
codex-workload-identity-azure = { workspace = true, optional = true }
codex-workload-identity-gcp = { workspace = true, optional = true }
codex-workload-identity-github-actions = { workspace = true, optional = true }
codex-workload-identity-spiffe = { workspace = true, optional = true }
reqwest = { workspace = true }
@@ -9,8 +9,6 @@ use codex_workload_identity::UnavailableSubjectTokenSource;
use codex_workload_identity_aws::AwsSubjectTokenProvider;
#[cfg(feature = "azure")]
use codex_workload_identity_azure::AzureSubjectTokenProvider;
#[cfg(feature = "gcp")]
use codex_workload_identity_gcp::GcpSubjectTokenProvider;
#[cfg(feature = "github-actions")]
use codex_workload_identity_github_actions::GithubActionsSubjectTokenProvider;
#[cfg(feature = "spiffe")]
@@ -28,8 +26,6 @@ pub enum ConfiguredSubjectTokenProvider {
Aws(AwsSubjectTokenProvider),
#[cfg(feature = "azure")]
Azure(AzureSubjectTokenProvider),
#[cfg(feature = "gcp")]
Gcp(GcpSubjectTokenProvider),
#[cfg(feature = "github-actions")]
GithubActions(GithubActionsSubjectTokenProvider),
#[cfg(feature = "spiffe")]
@@ -62,21 +58,6 @@ impl ConfiguredSubjectTokenProvider {
Self::Unavailable(UnavailableSubjectTokenSource::new("azure"))
}
}
CredentialSourceConfig::Gcp { service_account } => {
#[cfg(feature = "gcp")]
{
Self::Gcp(GcpSubjectTokenProvider::new(
service_account.clone(),
audience.to_string(),
http,
))
}
#[cfg(not(feature = "gcp"))]
{
let _ = (service_account, http);
Self::Unavailable(UnavailableSubjectTokenSource::new("gcp"))
}
}
CredentialSourceConfig::GithubActions {} => {
#[cfg(feature = "github-actions")]
{
@@ -139,8 +120,6 @@ impl SubjectTokenProvider for ConfiguredSubjectTokenProvider {
Self::Aws(source) => source.subject_token().await,
#[cfg(feature = "azure")]
Self::Azure(source) => source.subject_token().await,
#[cfg(feature = "gcp")]
Self::Gcp(source) => source.subject_token().await,
#[cfg(feature = "github-actions")]
Self::GithubActions(source) => source.subject_token().await,
#[cfg(feature = "spiffe")]
@@ -163,25 +142,3 @@ pub fn build_client(
);
ConfiguredWorkloadIdentityClient::new(config, client_id, http, source)
}
#[cfg(all(test, not(feature = "gcp")))]
mod tests {
use super::*;
#[tokio::test]
async fn omitted_provider_has_stable_runtime_error() {
let provider = ConfiguredSubjectTokenProvider::from_config(
&CredentialSourceConfig::Gcp {
service_account: None,
},
"idp_example",
"openai-audience",
reqwest::Client::new(),
);
assert!(matches!(
provider.subject_token().await,
Err(SubjectTokenError::ProviderNotIncluded { provider: "gcp" })
));
}
}
+49 -8
View File
@@ -73,6 +73,7 @@ pub enum WorkloadIdentityError {
struct CachedAccessToken {
token: WorkloadIdentityAccessToken,
refresh_at: Instant,
expires_at: Instant,
}
struct CachedExchangeFailure {
@@ -121,7 +122,7 @@ where
}
pub async fn resolve(&self) -> Result<WorkloadIdentityAccessToken, WorkloadIdentityError> {
if let Some(cached) = self.cached_result() {
if let Some(cached) = self.cached_resolve_result() {
return cached;
}
let _permit = self
@@ -129,10 +130,13 @@ where
.acquire()
.await
.map_err(|_| WorkloadIdentityError::ExchangeUnavailable)?;
if let Some(cached) = self.cached_result() {
if let Some(cached) = self.cached_resolve_result() {
return cached;
}
self.exchange_and_record().await
match self.exchange_and_record().await {
Ok(token) => Ok(token),
Err(error) => self.valid_cached_token().ok_or(error),
}
}
pub async fn refresh(&self) -> Result<WorkloadIdentityAccessToken, WorkloadIdentityError> {
@@ -141,27 +145,59 @@ where
.acquire()
.await
.map_err(|_| WorkloadIdentityError::ExchangeUnavailable)?;
self.exchange_and_record().await
let result = self.exchange_and_record().await;
if result.is_err() {
self.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.token = None;
}
result
}
fn cached_result(&self) -> Option<Result<WorkloadIdentityAccessToken, WorkloadIdentityError>> {
fn cached_resolve_result(
&self,
) -> Option<Result<WorkloadIdentityAccessToken, WorkloadIdentityError>> {
let cache = self
.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = Instant::now();
if let Some(token) = cache
.token
.as_ref()
.filter(|cached| now < cached.refresh_at)
{
return Some(Ok(token.token.clone()));
}
if let Some(failure) = cache.failure.as_ref()
&& now < failure.retry_at
{
if let Some(token) = cache
.token
.as_ref()
.filter(|cached| now < cached.expires_at)
{
return Some(Ok(token.token.clone()));
}
return Some(Err(WorkloadIdentityError::RecentFailure(
failure.message.clone(),
)));
}
None
}
fn valid_cached_token(&self) -> Option<WorkloadIdentityAccessToken> {
let cache = self
.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = Instant::now();
cache
.token
.as_ref()
.filter(|cached| now < cached.refresh_at)
.map(|cached| Ok(cached.token.clone()))
.filter(|cached| now < cached.expires_at)
.map(|cached| cached.token.clone())
}
async fn exchange_and_record(
@@ -244,9 +280,13 @@ where
let refresh_lead = MAX_REFRESH_LEAD.min(Duration::from_secs(
response.expires_in.saturating_div(10).max(1),
));
let refresh_at = Instant::now()
let now = Instant::now();
let refresh_at = now
.checked_add(lifetime.saturating_sub(refresh_lead))
.ok_or(WorkloadIdentityError::InvalidLifetime)?;
let expires_at = now
.checked_add(lifetime)
.ok_or(WorkloadIdentityError::InvalidLifetime)?;
let mut cache = self
.cache
.lock()
@@ -264,6 +304,7 @@ where
cache.token = Some(CachedAccessToken {
token: token.clone(),
refresh_at,
expires_at,
});
Ok(token)
}
@@ -147,6 +147,92 @@ async fn forced_refresh_performs_a_new_exchange() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test]
async fn proactive_refresh_failure_uses_token_until_expiry() -> anyhow::Result<()> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(exchange_response("still.valid.access.token"))
.expect(1)
.mount(&server)
.await;
let client = test_client(&server).await?;
let token = client.resolve().await?;
{
let mut cache = client.cache.lock().expect("cache lock");
cache.token.as_mut().expect("cached token").refresh_at = Instant::now();
}
server.reset().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
assert_eq!(client.resolve().await?, token);
assert_eq!(client.resolve().await?, token);
Ok(())
}
#[tokio::test]
async fn proactive_refresh_failure_fails_after_cached_token_expires() -> anyhow::Result<()> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(exchange_response("expiring.access.token"))
.expect(1)
.mount(&server)
.await;
let client = test_client(&server).await?;
client.resolve().await?;
{
let mut cache = client.cache.lock().expect("cache lock");
let cached = cache.token.as_mut().expect("cached token");
cached.refresh_at = Instant::now();
cached.expires_at = Instant::now();
}
server.reset().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
assert!(client.resolve().await.is_err());
assert!(client.resolve().await.is_err());
Ok(())
}
#[tokio::test]
async fn forced_refresh_does_not_reuse_a_rejected_token() -> anyhow::Result<()> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(exchange_response("rejected.access.token"))
.expect(1)
.mount(&server)
.await;
let client = test_client(&server).await?;
client.resolve().await?;
server.reset().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
assert!(client.refresh().await.is_err());
assert!(client.cache.lock().expect("cache lock").token.is_none());
assert!(client.resolve().await.is_err());
Ok(())
}
#[tokio::test]
async fn refresh_rejects_a_changed_principal() -> anyhow::Result<()> {
let server = MockServer::start().await;
+1 -17
View File
@@ -14,7 +14,6 @@ const AWS_CONTAINER_CREDENTIALS_FULL_URI_ENV: &str = "AWS_CONTAINER_CREDENTIALS_
const AWS_CONTAINER_CREDENTIALS_RELATIVE_URI_ENV: &str = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI";
const AWS_WEB_IDENTITY_TOKEN_FILE_ENV: &str = "AWS_WEB_IDENTITY_TOKEN_FILE";
const AZURE_FEDERATED_TOKEN_FILE_ENV: &str = "AZURE_FEDERATED_TOKEN_FILE";
const GCP_METADATA_HOST_ENV: &str = "GCE_METADATA_HOST";
const GITHUB_ACTIONS_REQUEST_URL_ENV: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
const GITHUB_ACTIONS_REQUEST_TOKEN_ENV: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
const SPIFFE_ENDPOINT_SOCKET_ENV: &str = "SPIFFE_ENDPOINT_SOCKET";
@@ -63,13 +62,6 @@ pub enum CredentialSourceConfig {
token_file: Option<PathBuf>,
},
/// A GCE or GKE service-account identity token from the metadata service.
Gcp {
/// Service account email or `default` when omitted.
#[serde(default)]
service_account: Option<String>,
},
/// A GitHub Actions OIDC token requested from the runner token service.
#[serde(rename = "github_actions")]
GithubActions {},
@@ -180,12 +172,6 @@ impl WorkloadIdentityConfig {
token_file: Some(token_file),
} if !token_file.is_absolute() => Err(WorkloadIdentityConfigError::RelativeTokenFile),
CredentialSourceConfig::Azure { .. } => Ok(()),
CredentialSourceConfig::Gcp {
service_account: Some(service_account),
} if service_account.trim().is_empty() => Err(
WorkloadIdentityConfigError::EmptySourceField("service_account"),
),
CredentialSourceConfig::Gcp { .. } => Ok(()),
CredentialSourceConfig::GithubActions {} => Ok(()),
CredentialSourceConfig::Spiffe {
endpoint_socket: Some(endpoint_socket),
@@ -216,7 +202,6 @@ impl CredentialSourceConfig {
Self::Environment { .. } => "environment",
Self::File { .. } => "file",
Self::Azure { .. } => "azure",
Self::Gcp { .. } => "gcp",
Self::GithubActions {} => "github_actions",
Self::Spiffe { .. } => "spiffe",
Self::Aws { .. } => "aws",
@@ -227,7 +212,6 @@ impl CredentialSourceConfig {
match self {
Self::Environment { variable } => vec![variable.as_str()],
Self::Azure { .. } => vec![AZURE_FEDERATED_TOKEN_FILE_ENV],
Self::Gcp { .. } => vec![GCP_METADATA_HOST_ENV],
Self::GithubActions {} => vec![
GITHUB_ACTIONS_REQUEST_URL_ENV,
GITHUB_ACTIONS_REQUEST_TOKEN_ENV,
@@ -269,7 +253,7 @@ impl CredentialSourceConfig {
.filter_map(std::env::var_os)
.map(PathBuf::from)
.collect(),
Self::Environment { .. } | Self::Gcp { .. } | Self::GithubActions {} => Vec::new(),
Self::Environment { .. } | Self::GithubActions {} => Vec::new(),
}
}
}
@@ -101,9 +101,6 @@ fn every_source_variant_has_a_stable_name() {
path: PathBuf::from("/var/run/openai/token"),
},
CredentialSourceConfig::Azure { token_file: None },
CredentialSourceConfig::Gcp {
service_account: None,
},
CredentialSourceConfig::GithubActions {},
CredentialSourceConfig::Spiffe {
endpoint_socket: None,
@@ -118,7 +115,6 @@ fn every_source_variant_has_a_stable_name() {
"environment",
"file",
"azure",
"gcp",
"github_actions",
"spiffe",
"aws",
@@ -139,13 +135,6 @@ fn reports_credential_bearing_environment_inputs() {
CredentialSourceConfig::Azure { token_file: None }.sensitive_environment_variables(),
vec!["AZURE_FEDERATED_TOKEN_FILE"]
);
assert_eq!(
CredentialSourceConfig::Gcp {
service_account: None
}
.sensitive_environment_variables(),
vec!["GCE_METADATA_HOST"]
);
assert_eq!(
CredentialSourceConfig::GithubActions {}.sensitive_environment_variables(),
vec![