This is an automated email from the ASF dual-hosted git repository. xuanwo pushed a commit to branch xuanwo/google-external-account-executable in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
commit 9b2b9584f5d09920dbf8a5e23e427ecb380e6d84 Author: Xuanwo <[email protected]> AuthorDate: Thu Mar 19 05:10:25 2026 +0800 feat(google): support executable external account sources --- services/google/Cargo.toml | 1 + services/google/src/credential.rs | 54 ++ .../src/provide_credential/external_account.rs | 773 ++++++++++++++++++++- 3 files changed, 827 insertions(+), 1 deletion(-) diff --git a/services/google/Cargo.toml b/services/google/Cargo.toml index 81c5646..918abcd 100644 --- a/services/google/Cargo.toml +++ b/services/google/Cargo.toml @@ -37,6 +37,7 @@ rsa = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +tokio = { workspace = true, features = ["time"] } [dev-dependencies] bytes = { workspace = true } diff --git a/services/google/src/credential.rs b/services/google/src/credential.rs index 81291ca..d67c8e3 100644 --- a/services/google/src/credential.rs +++ b/services/google/src/credential.rs @@ -106,6 +106,9 @@ pub mod external_account { /// File-based credential source. #[serde(rename_all = "snake_case")] File(FileSource), + /// Executable-based credential source. + #[serde(rename_all = "snake_case")] + Executable(ExecutableSource), } /// Configuration for fetching credentials from a URL. @@ -130,6 +133,26 @@ pub mod external_account { pub format: Format, } + /// Configuration for executing a command to load credentials. + #[derive(Clone, Deserialize, Debug)] + #[serde(rename_all = "snake_case")] + pub struct ExecutableSource { + /// The executable configuration. + pub executable: ExecutableConfig, + } + + /// Executable-based credential configuration. + #[derive(Clone, Deserialize, Debug)] + #[serde(rename_all = "snake_case")] + pub struct ExecutableConfig { + /// The full command to run. + pub command: String, + /// Optional timeout in milliseconds. + pub timeout_millis: Option<u64>, + /// Optional output file used to cache the executable response. + pub output_file: Option<String>, + } + /// Format for parsing credentials. #[derive(Clone, Deserialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] @@ -384,6 +407,37 @@ mod tests { let cred = CredentialFile::from_slice(ea_json.as_bytes()).unwrap(); assert!(matches!(cred, CredentialFile::ExternalAccount(_))); + let exec_ea_json = r#"{ + "type": "external_account", + "audience": "test_audience", + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", + "token_url": "https://example.com/token", + "credential_source": { + "executable": { + "command": "/usr/bin/fetch-token --flag", + "timeout_millis": 5000, + "output_file": "/tmp/token-cache.json" + } + } + }"#; + let cred = CredentialFile::from_slice(exec_ea_json.as_bytes()).unwrap(); + match cred { + CredentialFile::ExternalAccount(external_account) => { + match external_account.credential_source { + external_account::Source::Executable(source) => { + assert_eq!(source.executable.command, "/usr/bin/fetch-token --flag"); + assert_eq!(source.executable.timeout_millis, Some(5000)); + assert_eq!( + source.executable.output_file.as_deref(), + Some("/tmp/token-cache.json") + ); + } + _ => panic!("Expected Executable source"), + } + } + _ => panic!("Expected ExternalAccount"), + } + // Test authorized user let au_json = r#"{ "type": "authorized_user", diff --git a/services/google/src/provide_credential/external_account.rs b/services/google/src/provide_credential/external_account.rs index dee05c6..bac0eaa 100644 --- a/services/google/src/provide_credential/external_account.rs +++ b/services/google/src/provide_credential/external_account.rs @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +use std::collections::BTreeMap; use std::time::Duration; use form_urlencoded::Serializer; @@ -28,6 +29,19 @@ use reqsign_core::{Context, ProvideCredential, Result}; /// The maximum impersonated token lifetime allowed, 1 hour. const MAX_LIFETIME: Duration = Duration::from_secs(3600); +/// Default timeout declared by AIP-4117 for executable sources. +const DEFAULT_EXECUTABLE_TIMEOUT: Duration = Duration::from_secs(30); +/// Gate required by AIP-4117 before executable sources may be used. +const GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES: &str = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"; +const GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE: &str = "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"; +const GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE: &str = "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"; +const GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL: &str = + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"; +const GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE: &str = "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"; +const EXECUTABLE_RESPONSE_VERSION: u64 = 1; +const TOKEN_TYPE_JWT: &str = "urn:ietf:params:oauth:token-type:jwt"; +const TOKEN_TYPE_ID_TOKEN: &str = "urn:ietf:params:oauth:token-type:id_token"; +const TOKEN_TYPE_SAML2: &str = "urn:ietf:params:oauth:token-type:saml2"; /// STS token response. #[derive(Deserialize)] @@ -51,6 +65,29 @@ struct ImpersonationRequest { lifetime: String, } +#[derive(Deserialize)] +struct ExecutableResponse { + version: u64, + success: bool, + #[serde(default)] + token_type: Option<String>, + #[serde(default)] + id_token: Option<String>, + #[serde(default)] + saml_response: Option<String>, + #[serde(default)] + expiration_time: Option<i64>, + #[serde(default)] + code: Option<String>, + #[serde(default)] + message: Option<String>, +} + +struct ExecutableSubjectToken { + token: String, + expires_at: Option<Timestamp>, +} + /// ExternalAccountCredentialProvider exchanges external account credentials for access tokens. #[derive(Debug, Clone)] pub struct ExternalAccountCredentialProvider { @@ -86,6 +123,9 @@ impl ExternalAccountCredentialProvider { self.load_file_sourced_token(ctx, source).await } external_account::Source::Url(source) => self.load_url_sourced_token(ctx, source).await, + external_account::Source::Executable(source) => { + self.load_executable_sourced_token(ctx, source).await + } } } @@ -152,6 +192,246 @@ impl ExternalAccountCredentialProvider { Ok(token) } + fn resolved_subject_token_type(&self, ctx: &Context) -> Result<String> { + resolve_template(ctx, &self.external_account.subject_token_type) + } + + fn build_executable_env( + &self, + ctx: &Context, + output_file: Option<&str>, + ) -> Result<BTreeMap<String, String>> { + let mut envs = BTreeMap::new(); + envs.insert( + GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE.to_string(), + resolve_template(ctx, &self.external_account.audience)?, + ); + envs.insert( + GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE.to_string(), + self.resolved_subject_token_type(ctx)?, + ); + + if let Some(url) = &self.external_account.service_account_impersonation_url { + let url = resolve_template(ctx, url)?; + let email = parse_impersonated_service_account_email(&url)?; + envs.insert( + GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL.to_string(), + email, + ); + } + + if let Some(path) = output_file { + envs.insert( + GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE.to_string(), + path.to_string(), + ); + } + + Ok(envs) + } + + fn validate_executable_usage( + &self, + ctx: &Context, + source: &external_account::ExecutableSource, + ) -> Result<(String, Duration, Option<String>)> { + if ctx + .env_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) + .as_deref() + != Some("1") + { + return Err(reqsign_core::Error::config_invalid( + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES must be set to 1 to use executable-sourced external accounts", + )); + } + + let command = resolve_template(ctx, &source.executable.command)?; + let command = command.trim().to_string(); + if command.is_empty() { + return Err(reqsign_core::Error::config_invalid( + "credential_source.executable.command must not be empty", + )); + } + + let timeout = match source.executable.timeout_millis { + Some(0) => { + return Err(reqsign_core::Error::config_invalid( + "credential_source.executable.timeout_millis must be positive", + )); + } + Some(v) => Duration::from_millis(v), + None => DEFAULT_EXECUTABLE_TIMEOUT, + }; + + let output_file = source + .executable + .output_file + .as_deref() + .map(|v| resolve_template(ctx, v)) + .transpose()?; + + Ok((command, timeout, output_file)) + } + + fn parse_executable_response( + &self, + ctx: &Context, + body: &[u8], + require_expiration: bool, + require_unexpired: bool, + ) -> Result<ExecutableSubjectToken> { + let response: ExecutableResponse = serde_json::from_slice(body).map_err(|e| { + reqsign_core::Error::unexpected("failed to parse executable response").with_source(e) + })?; + + if response.version != EXECUTABLE_RESPONSE_VERSION { + return Err(reqsign_core::Error::credential_invalid(format!( + "unsupported executable response version: {}", + response.version + ))); + } + + if !response.success { + let message = match (response.code.as_deref(), response.message.as_deref()) { + (Some(code), Some(message)) => { + format!("executable credential source failed with code {code}: {message}") + } + (None, Some(message)) => { + format!("executable credential source failed: {message}") + } + (Some(code), None) => { + format!("executable credential source failed with code {code}") + } + (None, None) => "executable credential source failed".to_string(), + }; + return Err(reqsign_core::Error::credential_invalid(message)); + } + + let token_type = response.token_type.as_deref().ok_or_else(|| { + reqsign_core::Error::credential_invalid( + "successful executable response missing token_type", + ) + })?; + if !matches!( + token_type, + TOKEN_TYPE_JWT | TOKEN_TYPE_ID_TOKEN | TOKEN_TYPE_SAML2 + ) { + return Err(reqsign_core::Error::credential_invalid(format!( + "unsupported executable response token_type: {token_type}" + ))); + } + + let expected = self.resolved_subject_token_type(ctx)?; + if token_type != expected { + return Err(reqsign_core::Error::credential_invalid(format!( + "executable response token_type {token_type} does not match configured subject_token_type {expected}" + ))); + } + + let token = if token_type == TOKEN_TYPE_SAML2 { + response.saml_response.as_deref().ok_or_else(|| { + reqsign_core::Error::credential_invalid( + "successful SAML executable response missing saml_response", + ) + })? + } else { + response.id_token.as_deref().ok_or_else(|| { + reqsign_core::Error::credential_invalid( + "successful executable response missing id_token", + ) + })? + }; + let token = token.trim().to_string(); + if token.is_empty() { + return Err(reqsign_core::Error::credential_invalid( + "executable response subject token is empty", + )); + } + + let expires_at = response + .expiration_time + .map(Timestamp::from_second) + .transpose()?; + + if require_expiration && expires_at.is_none() { + return Err(reqsign_core::Error::credential_invalid( + "executable response missing expiration_time required by output_file", + )); + } + + if let Some(expires_at) = expires_at { + if require_unexpired && Timestamp::now() >= expires_at { + return Err(reqsign_core::Error::credential_invalid( + "executable response is expired", + )); + } + } + + Ok(ExecutableSubjectToken { token, expires_at }) + } + + async fn load_executable_sourced_token( + &self, + ctx: &Context, + source: &external_account::ExecutableSource, + ) -> Result<String> { + let (command, timeout, output_file) = self.validate_executable_usage(ctx, source)?; + + if let Some(path) = output_file.as_deref() { + if let Ok(content) = ctx.file_read(path).await { + debug!("loading executable credential response from output file: {path}"); + let subject = self.parse_executable_response(ctx, &content, true, false)?; + if subject + .expires_at + .is_some_and(|expires_at| Timestamp::now() < expires_at) + { + return Ok(subject.token); + } + } + } + + let envs = self.build_executable_env(ctx, output_file.as_deref())?; + debug!( + "executing external account credential command with declared timeout {:?}", + timeout + ); + let output = execute_command_with_env(ctx, &command, &envs, timeout).await?; + let parsed = + self.parse_executable_response(ctx, &output.stdout, output_file.is_some(), true); + let subject = if output.success() { + parsed? + } else { + match parsed { + Err(err) if err.kind() == reqsign_core::ErrorKind::CredentialInvalid => { + return Err(err); + } + Ok(_) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let detail = if stderr.is_empty() { + format!("command exited with status {}", output.status) + } else { + format!("command exited with status {}: {}", output.status, stderr) + }; + return Err(reqsign_core::Error::credential_invalid(format!( + "executable credential source failed: {detail}" + ))); + } + Err(_) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let detail = if stderr.is_empty() { + format!("command exited with status {}", output.status) + } else { + format!("command exited with status {}: {}", output.status, stderr) + }; + return Err(reqsign_core::Error::credential_invalid(format!( + "executable credential source failed: {detail}" + ))); + } + } + }; + Ok(subject.token) + } + async fn exchange_sts_token(&self, ctx: &Context, oidc_token: &str) -> Result<Token> { debug!("exchanging OIDC token for STS access token"); @@ -350,14 +630,112 @@ fn resolve_template(ctx: &Context, input: &str) -> Result<String> { } } +fn parse_impersonated_service_account_email(url: &str) -> Result<String> { + let marker = "/serviceAccounts/"; + let start = url.find(marker).ok_or_else(|| { + reqsign_core::Error::config_invalid(format!( + "service_account_impersonation_url missing {marker}: {url}" + )) + })?; + let rest = &url[start + marker.len()..]; + let end = rest.find(':').ok_or_else(|| { + reqsign_core::Error::config_invalid(format!( + "service_account_impersonation_url missing action separator: {url}" + )) + })?; + + let email = percent_encoding::percent_decode_str(&rest[..end]) + .decode_utf8() + .map_err(|e| { + reqsign_core::Error::config_invalid( + "service_account_impersonation_url contains invalid UTF-8 email", + ) + .with_source(e) + })?; + if email.is_empty() { + return Err(reqsign_core::Error::config_invalid( + "service_account_impersonation_url resolved empty service account email", + )); + } + + Ok(email.into_owned()) +} + +async fn execute_command_with_env( + ctx: &Context, + command: &str, + envs: &BTreeMap<String, String>, + timeout: Duration, +) -> Result<reqsign_core::CommandOutput> { + #[cfg(windows)] + { + let mut script = String::new(); + for (k, v) in envs { + script.push_str("set \""); + script.push_str(k); + script.push('='); + script.push_str("e_for_cmd_set(v)); + script.push_str("\" && "); + } + script.push_str(command); + + let args = ["/C", script.as_str()]; + tokio::time::timeout(timeout, ctx.command_execute("cmd", &args)) + .await + .map_err(|_| { + reqsign_core::Error::credential_invalid(format!( + "executable credential source timed out after {}ms", + timeout.as_millis() + )) + })? + } + + #[cfg(not(windows))] + { + let mut script = String::new(); + for (k, v) in envs { + script.push_str(k); + script.push('='); + script.push_str("e_for_sh(v)); + script.push(' '); + } + script.push_str("exec "); + script.push_str(command); + + let args = ["-c", script.as_str()]; + tokio::time::timeout(timeout, ctx.command_execute("sh", &args)) + .await + .map_err(|_| { + reqsign_core::Error::credential_invalid(format!( + "executable credential source timed out after {}ms", + timeout.as_millis() + )) + })? + } +} + +#[cfg(windows)] +fn quote_for_cmd_set(value: &str) -> String { + value.replace('^', "^^").replace('"', "^\"") +} + +#[cfg(not(windows))] +fn quote_for_sh(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + #[cfg(test)] mod tests { use super::*; use bytes::Bytes; use http::header::{AUTHORIZATION, CONTENT_TYPE}; - use reqsign_core::{Env, FileRead, HttpSend}; + use reqsign_core::{CommandExecute, CommandOutput, Env, FileRead, HttpSend}; use std::collections::HashMap; use std::path::PathBuf; + use std::sync::{Arc, Mutex}; #[derive(Debug, Default)] struct MockEnv { @@ -404,6 +782,66 @@ mod tests { } } + #[derive(Debug, Default)] + struct RecordedCommand { + program: Option<String>, + args: Vec<String>, + } + + #[derive(Clone, Debug)] + struct MockCommandExecute { + recorded: Arc<Mutex<RecordedCommand>>, + output: CommandOutput, + } + + impl MockCommandExecute { + fn success(stdout: impl Into<Vec<u8>>) -> Self { + Self { + recorded: Arc::new(Mutex::new(RecordedCommand::default())), + output: CommandOutput { + status: 0, + stdout: stdout.into(), + stderr: Vec::new(), + }, + } + } + + fn failure(stderr: impl Into<Vec<u8>>) -> Self { + Self { + recorded: Arc::new(Mutex::new(RecordedCommand::default())), + output: CommandOutput { + status: 1, + stdout: Vec::new(), + stderr: stderr.into(), + }, + } + } + + fn with_status( + status: i32, + stdout: impl Into<Vec<u8>>, + stderr: impl Into<Vec<u8>>, + ) -> Self { + Self { + recorded: Arc::new(Mutex::new(RecordedCommand::default())), + output: CommandOutput { + status, + stdout: stdout.into(), + stderr: stderr.into(), + }, + } + } + } + + impl CommandExecute for MockCommandExecute { + async fn command_execute(&self, program: &str, args: &[&str]) -> Result<CommandOutput> { + let mut recorded = self.recorded.lock().expect("lock must succeed"); + recorded.program = Some(program.to_string()); + recorded.args = args.iter().map(|v| (*v).to_string()).collect(); + Ok(self.output.clone()) + } + } + #[derive(Debug)] struct CaptureStsHttpSend { expected_url: String, @@ -596,4 +1034,337 @@ mod tests { assert!(cred.has_valid_token()); Ok(()) } + + fn executable_source( + command: &str, + output_file: Option<&str>, + ) -> external_account::ExecutableSource { + external_account::ExecutableSource { + executable: external_account::ExecutableConfig { + command: command.to_string(), + timeout_millis: Some(5000), + output_file: output_file.map(|v| v.to_string()), + }, + } + } + + fn executable_account( + source: external_account::ExecutableSource, + subject_token_type: &str, + ) -> ExternalAccount { + ExternalAccount { + audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider".to_string(), + subject_token_type: subject_token_type.to_string(), + token_url: "https://sts.googleapis.com/v1/token".to_string(), + credential_source: external_account::Source::Executable(source), + service_account_impersonation_url: Some( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test%40example.com:generateAccessToken" + .to_string(), + ), + service_account_impersonation: None, + } + } + + #[tokio::test] + async fn test_executable_source_uses_cached_output_file() -> Result<()> { + let external_account = executable_account( + executable_source("/bin/example --flag", Some("/tmp/exec-cache.json")), + TOKEN_TYPE_ID_TOKEN, + ); + let cache = serde_json::json!({ + "version": 1, + "success": true, + "token_type": TOKEN_TYPE_ID_TOKEN, + "id_token": "cached-token", + "expiration_time": Timestamp::now().as_second() + 3600, + }); + let fs = MockFileRead::default().with_file( + "/tmp/exec-cache.json", + serde_json::to_vec(&cache).expect("json"), + ); + let ctx = Context::new() + .with_file_read(fs) + .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")) + .with_command_execute(MockCommandExecute::success(br#"{"unexpected":true}"#)); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let token = provider.load_oidc_token(&ctx).await?; + assert_eq!(token, "cached-token"); + Ok(()) + } + + #[tokio::test] + async fn test_executable_source_runs_command_with_required_env() -> Result<()> { + let external_account = executable_account( + executable_source( + "/bin/example --arg=value", + Some("/tmp/cache-${SUFFIX}.json"), + ), + TOKEN_TYPE_ID_TOKEN, + ); + let command = MockCommandExecute::success( + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "success": true, + "token_type": TOKEN_TYPE_ID_TOKEN, + "id_token": "exec-token", + "expiration_time": Timestamp::now().as_second() + 3600, + })) + .expect("json"), + ); + let recorded = command.recorded.clone(); + let env = MockEnv::default() + .with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1") + .with_var("SUFFIX", "value"); + let ctx = Context::new().with_env(env).with_command_execute(command); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let token = provider.load_oidc_token(&ctx).await?; + assert_eq!(token, "exec-token"); + + let recorded = recorded.lock().expect("lock must succeed"); + #[cfg(windows)] + { + assert_eq!(recorded.program.as_deref(), Some("cmd")); + let script = recorded.args.get(1).expect("cmd script must exist"); + assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=")); + assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=")); + assert!(script.contains("[email protected]")); + assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=/tmp/cache-value.json")); + assert!(script.contains("/bin/example --arg=value")); + } + #[cfg(not(windows))] + { + assert_eq!(recorded.program.as_deref(), Some("sh")); + let script = recorded.args.get(1).expect("sh script must exist"); + assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE='//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider'")); + assert!(script.contains( + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE='urn:ietf:params:oauth:token-type:id_token'" + )); + assert!( + script.contains("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL='[email protected]'") + ); + assert!(script.contains("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE='/tmp/cache-value.json'")); + assert!(script.ends_with("exec /bin/example --arg=value")); + } + Ok(()) + } + + #[tokio::test] + async fn test_executable_source_requires_opt_in() { + let external_account = + executable_account(executable_source("/bin/example", None), TOKEN_TYPE_ID_TOKEN); + let ctx = Context::new().with_command_execute(MockCommandExecute::success(Vec::new())); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let err = provider + .load_oidc_token(&ctx) + .await + .expect_err("missing opt-in must fail"); + assert!( + err.to_string() + .contains("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES") + ); + } + + #[tokio::test] + async fn test_executable_source_rejects_error_response() { + let external_account = + executable_account(executable_source("/bin/example", None), TOKEN_TYPE_ID_TOKEN); + let command = MockCommandExecute::with_status( + 1, + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "success": false, + "code": "401", + "message": "Caller not authorized.", + })) + .expect("json"), + b"permission denied".as_slice(), + ); + let ctx = Context::new() + .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")) + .with_command_execute(command); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let err = provider + .load_oidc_token(&ctx) + .await + .expect_err("error response must fail"); + assert!(err.to_string().contains("Caller not authorized")); + } + + #[derive(Clone, Debug)] + struct SlowCommandExecute; + + impl CommandExecute for SlowCommandExecute { + async fn command_execute(&self, _program: &str, _args: &[&str]) -> Result<CommandOutput> { + tokio::time::sleep(Duration::from_millis(20)).await; + Ok(CommandOutput { + status: 0, + stdout: br#"{"version":1,"success":true,"token_type":"urn:ietf:params:oauth:token-type:id_token","id_token":"slow-token"}"#.to_vec(), + stderr: Vec::new(), + }) + } + } + + #[tokio::test] + async fn test_executable_source_honors_timeout() { + let source = external_account::ExecutableSource { + executable: external_account::ExecutableConfig { + command: "/bin/example".to_string(), + timeout_millis: Some(1), + output_file: None, + }, + }; + let external_account = executable_account(source, TOKEN_TYPE_ID_TOKEN); + let ctx = Context::new() + .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")) + .with_command_execute(SlowCommandExecute); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let err = provider + .load_oidc_token(&ctx) + .await + .expect_err("slow executable must time out"); + assert!(err.to_string().contains("timed out")); + } + + #[tokio::test] + async fn test_executable_source_rejects_non_zero_exit() { + let external_account = + executable_account(executable_source("/bin/example", None), TOKEN_TYPE_ID_TOKEN); + let command = MockCommandExecute::failure("permission denied"); + let ctx = Context::new() + .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")) + .with_command_execute(command); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let err = provider + .load_oidc_token(&ctx) + .await + .expect_err("non-zero exit must fail"); + assert!(!err.to_string().is_empty()); + } + + #[tokio::test] + async fn test_executable_source_rejects_token_type_mismatch() { + let external_account = + executable_account(executable_source("/bin/example", None), TOKEN_TYPE_ID_TOKEN); + let command = MockCommandExecute::success( + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "success": true, + "token_type": TOKEN_TYPE_SAML2, + "saml_response": "response", + })) + .expect("json"), + ); + let ctx = Context::new() + .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")) + .with_command_execute(command); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let err = provider + .load_oidc_token(&ctx) + .await + .expect_err("mismatched token type must fail"); + assert!(err.to_string().contains("does not match")); + } + + #[tokio::test] + async fn test_executable_source_requires_expiration_for_output_file() { + let external_account = executable_account( + executable_source("/bin/example", Some("/tmp/cache.json")), + TOKEN_TYPE_ID_TOKEN, + ); + let command = MockCommandExecute::success( + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "success": true, + "token_type": TOKEN_TYPE_ID_TOKEN, + "id_token": "token", + })) + .expect("json"), + ); + let ctx = Context::new() + .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")) + .with_command_execute(command); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let err = provider + .load_oidc_token(&ctx) + .await + .expect_err("missing expiration must fail"); + assert!(err.to_string().contains("expiration_time")); + } + + #[tokio::test] + async fn test_executable_source_rejects_expired_cached_output() -> Result<()> { + let external_account = executable_account( + executable_source("/bin/example", Some("/tmp/cache.json")), + TOKEN_TYPE_ID_TOKEN, + ); + let cache = serde_json::json!({ + "version": 1, + "success": true, + "token_type": TOKEN_TYPE_ID_TOKEN, + "id_token": "cached-token", + "expiration_time": Timestamp::now().as_second() - 1, + }); + let fs = MockFileRead::default() + .with_file("/tmp/cache.json", serde_json::to_vec(&cache).expect("json")); + let command = MockCommandExecute::success( + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "success": true, + "token_type": TOKEN_TYPE_ID_TOKEN, + "id_token": "fresh-token", + "expiration_time": Timestamp::now().as_second() + 3600, + })) + .expect("json"), + ); + let ctx = Context::new() + .with_file_read(fs) + .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")) + .with_command_execute(command); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let token = provider.load_oidc_token(&ctx).await?; + assert_eq!(token, "fresh-token"); + Ok(()) + } + + #[tokio::test] + async fn test_executable_source_rejects_invalid_cached_output() { + let external_account = executable_account( + executable_source("/bin/example", Some("/tmp/cache.json")), + TOKEN_TYPE_ID_TOKEN, + ); + let fs = MockFileRead::default().with_file("/tmp/cache.json", b"{invalid json"); + let command = MockCommandExecute::success( + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "success": true, + "token_type": TOKEN_TYPE_ID_TOKEN, + "id_token": "fresh-token", + "expiration_time": Timestamp::now().as_second() + 3600, + })) + .expect("json"), + ); + let ctx = Context::new() + .with_file_read(fs) + .with_env(MockEnv::default().with_var(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES, "1")) + .with_command_execute(command); + + let provider = ExternalAccountCredentialProvider::new(external_account); + let err = provider + .load_oidc_token(&ctx) + .await + .expect_err("invalid cache must fail"); + assert!( + err.to_string() + .contains("failed to parse executable response") + ); + } }
