mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
[codex] harden workload identity auth boundaries [ci changed_files]
This commit is contained in:
Generated
+1
-14
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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" })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user