1#![cfg(feature = "credentials-process")]
7
8use crate::json_credentials::{json_parse_loop, InvalidJsonCredentials};
11use crate::sensitive_command::CommandWithSensitiveArgs;
12use aws_credential_types::attributes::AccountId;
13use aws_credential_types::credential_feature::AwsCredentialFeature;
14use aws_credential_types::provider::{self, error::CredentialsError, future, ProvideCredentials};
15use aws_credential_types::Credentials;
16use aws_smithy_json::deserialize::Token;
17use std::borrow::Cow;
18use std::process::Command;
19use std::time::SystemTime;
20use time::format_description::well_known::Rfc3339;
21use time::OffsetDateTime;
22
23#[derive(Debug)]
57pub struct CredentialProcessProvider {
58 command: CommandWithSensitiveArgs<String>,
59 profile_account_id: Option<AccountId>,
60}
61
62impl ProvideCredentials for CredentialProcessProvider {
63 fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
64 where
65 Self: 'a,
66 {
67 future::ProvideCredentials::new(self.credentials())
68 }
69}
70
71impl CredentialProcessProvider {
72 pub fn new(command: String) -> Self {
74 Self {
75 command: CommandWithSensitiveArgs::new(command),
76 profile_account_id: None,
77 }
78 }
79
80 pub(crate) fn builder() -> Builder {
81 Builder::default()
82 }
83
84 async fn credentials(&self) -> provider::Result {
85 tracing::debug!(command = %self.command, "loading credentials from external process");
87
88 #[cfg(windows)]
99 let command = {
100 use std::os::windows::process::CommandExt;
101 let mut command = Command::new("cmd.exe");
102 command.arg("/C");
103 command.raw_arg(format!("\"{}\"", self.command.unredacted()));
104 command
105 };
106 #[cfg(not(windows))]
107 let command = {
108 let mut command = Command::new("sh");
109 command.args(["-c", self.command.unredacted()]);
110 command
111 };
112 let output = tokio::process::Command::from(command)
113 .output()
114 .await
115 .map_err(|e| {
116 CredentialsError::provider_error(format!(
117 "Error retrieving credentials from external process: {e}",
118 ))
119 })?;
120
121 tracing::trace!(command = ?self.command, status = ?output.status, "executed command (unredacted)");
123
124 if !output.status.success() {
125 let reason =
126 std::str::from_utf8(&output.stderr).unwrap_or("could not decode stderr as UTF-8");
127 return Err(CredentialsError::provider_error(format!(
128 "Error retrieving credentials: external process exited with code {}. Stderr: {}",
129 output.status, reason
130 )));
131 }
132
133 let output = std::str::from_utf8(&output.stdout).map_err(|e| {
134 CredentialsError::provider_error(format!(
135 "Error retrieving credentials from external process: could not decode output as UTF-8: {e}",
136 ))
137 })?;
138
139 parse_credential_process_json_credentials(output, self.profile_account_id.as_ref())
140 .map(|mut creds| {
141 creds
142 .get_property_mut_or_default::<Vec<AwsCredentialFeature>>()
143 .push(AwsCredentialFeature::CredentialsProcess);
144 creds
145 })
146 .map_err(|invalid| {
147 CredentialsError::provider_error(format!(
148 "Error retrieving credentials from external process, could not parse response: {invalid}",
149 ))
150 })
151 }
152}
153
154#[derive(Debug, Default)]
155pub(crate) struct Builder {
156 command: Option<CommandWithSensitiveArgs<String>>,
157 profile_account_id: Option<AccountId>,
158}
159
160impl Builder {
161 pub(crate) fn command(mut self, command: CommandWithSensitiveArgs<String>) -> Self {
162 self.command = Some(command);
163 self
164 }
165
166 #[allow(dead_code)] pub(crate) fn account_id(mut self, account_id: impl Into<AccountId>) -> Self {
168 self.set_account_id(Some(account_id.into()));
169 self
170 }
171
172 pub(crate) fn set_account_id(&mut self, account_id: Option<AccountId>) {
173 self.profile_account_id = account_id;
174 }
175
176 pub(crate) fn build(self) -> CredentialProcessProvider {
177 CredentialProcessProvider {
178 command: self.command.expect("should be set"),
179 profile_account_id: self.profile_account_id,
180 }
181 }
182}
183
184pub(crate) fn parse_credential_process_json_credentials(
192 credentials_response: &str,
193 profile_account_id: Option<&AccountId>,
194) -> Result<Credentials, InvalidJsonCredentials> {
195 let mut version = None;
196 let mut access_key_id = None;
197 let mut secret_access_key = None;
198 let mut session_token = None;
199 let mut expiration = None;
200 let mut account_id = profile_account_id
201 .as_ref()
202 .map(|id| Cow::Borrowed(id.as_str()));
203 json_parse_loop(credentials_response.as_bytes(), |key, value| {
204 match (key, value) {
205 (key, Token::ValueNumber { value, .. }) if key.eq_ignore_ascii_case("Version") => {
214 version = Some(i32::try_from(*value).map_err(|err| {
215 InvalidJsonCredentials::InvalidField {
216 field: "Version",
217 err: err.into(),
218 }
219 })?);
220 }
221 (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccessKeyId") => {
222 access_key_id = Some(value.to_unescaped()?)
223 }
224 (key, Token::ValueString { value, .. })
225 if key.eq_ignore_ascii_case("SecretAccessKey") =>
226 {
227 secret_access_key = Some(value.to_unescaped()?)
228 }
229 (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("SessionToken") => {
230 session_token = Some(value.to_unescaped()?)
231 }
232 (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Expiration") => {
233 expiration = Some(value.to_unescaped()?)
234 }
235 (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccountId") => {
236 account_id = Some(value.to_unescaped()?)
237 }
238
239 _ => {}
240 };
241 Ok(())
242 })?;
243
244 match version {
245 Some(1) => { }
246 None => return Err(InvalidJsonCredentials::MissingField("Version")),
247 Some(version) => {
248 return Err(InvalidJsonCredentials::InvalidField {
249 field: "version",
250 err: format!("unknown version number: {version}").into(),
251 })
252 }
253 }
254
255 let access_key_id = access_key_id.ok_or(InvalidJsonCredentials::MissingField("AccessKeyId"))?;
256 let secret_access_key =
257 secret_access_key.ok_or(InvalidJsonCredentials::MissingField("SecretAccessKey"))?;
258 let expiration = expiration.map(parse_expiration).transpose()?;
259 if expiration.is_none() {
260 tracing::debug!("no expiration provided for credentials provider credentials. these credentials will never be refreshed.")
261 }
262 let mut builder = Credentials::builder()
263 .access_key_id(access_key_id)
264 .secret_access_key(secret_access_key)
265 .provider_name("CredentialProcess");
266 builder.set_session_token(session_token.map(String::from));
267 builder.set_expiry(expiration);
268 builder.set_account_id(account_id.map(AccountId::from));
269 Ok(builder.build())
270}
271
272fn parse_expiration(expiration: impl AsRef<str>) -> Result<SystemTime, InvalidJsonCredentials> {
273 OffsetDateTime::parse(expiration.as_ref(), &Rfc3339)
274 .map(SystemTime::from)
275 .map_err(|err| InvalidJsonCredentials::InvalidField {
276 field: "Expiration",
277 err: err.into(),
278 })
279}
280
281#[cfg(test)]
282mod test {
283 use crate::credential_process::CredentialProcessProvider;
284 use crate::sensitive_command::CommandWithSensitiveArgs;
285 use aws_credential_types::credential_feature::AwsCredentialFeature;
286 use aws_credential_types::provider::ProvideCredentials;
287 use std::time::{Duration, SystemTime};
288 use time::format_description::well_known::Rfc3339;
289 use time::OffsetDateTime;
290 use tokio::time::timeout;
291
292 #[tokio::test]
294 #[cfg_attr(windows, ignore)]
295 async fn test_credential_process() {
296 let provider = CredentialProcessProvider::new(String::from(
297 r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "SessionToken": "TESTSESSIONTOKEN", "AccountId": "123456789001", "Expiration": "2022-05-02T18:36:00+00:00" }'"#,
298 ));
299 let creds = provider.provide_credentials().await.expect("valid creds");
300 assert_eq!(creds.access_key_id(), "ASIARTESTID");
301 assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
302 assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN"));
303 assert_eq!(creds.account_id().unwrap().as_str(), "123456789001");
304 assert_eq!(
305 creds.expiry(),
306 Some(SystemTime::from(
307 OffsetDateTime::parse("2022-05-02T18:36:00+00:00", &Rfc3339)
308 .expect("static datetime"),
309 ))
310 );
311 }
312
313 #[tokio::test]
315 #[cfg_attr(windows, ignore)]
316 async fn test_credential_process_no_expiry() {
317 let provider = CredentialProcessProvider::new(String::from(
318 r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY" }'"#,
319 ));
320 let creds = provider.provide_credentials().await.expect("valid creds");
321 assert_eq!(creds.access_key_id(), "ASIARTESTID");
322 assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
323 assert_eq!(creds.session_token(), None);
324 assert_eq!(creds.expiry(), None);
325 }
326
327 #[tokio::test]
328 async fn credentials_process_timeouts() {
329 let provider = CredentialProcessProvider::new(String::from("sleep 1000"));
330 let _creds = timeout(Duration::from_millis(1), provider.provide_credentials())
331 .await
332 .expect_err("timeout forced");
333 }
334
335 #[tokio::test]
336 async fn credentials_with_fallback_account_id() {
337 let provider = CredentialProcessProvider::builder()
338 .command(CommandWithSensitiveArgs::new(String::from(
339 r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY" }'"#,
340 )))
341 .account_id("012345678901")
342 .build();
343 let creds = provider.provide_credentials().await.unwrap();
344 assert_eq!("012345678901", creds.account_id().unwrap().as_str());
345 }
346
347 #[tokio::test]
348 async fn fallback_account_id_shadowed_by_account_id_in_process_output() {
349 let provider = CredentialProcessProvider::builder()
350 .command(CommandWithSensitiveArgs::new(String::from(
351 r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }'"#,
352 )))
353 .account_id("012345678901")
354 .build();
355 let creds = provider.provide_credentials().await.unwrap();
356 assert_eq!("111122223333", creds.account_id().unwrap().as_str());
357 }
358
359 #[tokio::test]
360 async fn credential_feature() {
361 let provider = CredentialProcessProvider::builder()
362 .command(CommandWithSensitiveArgs::new(String::from(
363 r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }'"#,
364 )))
365 .account_id("012345678901")
366 .build();
367 let creds = provider.provide_credentials().await.unwrap();
368 assert_eq!(
369 &vec![AwsCredentialFeature::CredentialsProcess],
370 creds.get_property::<Vec<AwsCredentialFeature>>().unwrap()
371 );
372 }
373}
374
375#[cfg(all(test, windows))]
381mod windows_tests {
382 use crate::credential_process::CredentialProcessProvider;
383 use aws_credential_types::provider::ProvideCredentials;
384 use std::path::{Path, PathBuf};
385
386 const CREDS_JSON: &str = "{\"Version\":1,\"AccessKeyId\":\"ASIARTESTID\",\"SecretAccessKey\":\"TESTSECRETKEY\",\"SessionToken\":\"TESTSESSIONTOKEN\",\"Expiration\":\"2035-01-01T00:00:00Z\"}";
387
388 fn write_provider(dir: &Path) -> PathBuf {
392 std::fs::create_dir_all(dir).unwrap();
393 let script = dir.join("provider.cmd");
394 std::fs::write(&script, format!("@echo off\r\necho {CREDS_JSON}\r\n")).unwrap();
395 script
396 }
397
398 #[tokio::test]
399 async fn spaced_path_with_argument_resolves() {
400 let tmp = tempfile::TempDir::new().unwrap();
401 let script = write_provider(&tmp.path().join("Program Space"));
402 assert!(
403 script.to_string_lossy().contains(' '),
404 "test fixture path must contain a space: {}",
405 script.display()
406 );
407
408 let command = format!("\"{}\" --role=Machine", script.display());
411 let provider = CredentialProcessProvider::new(command);
412
413 let creds = provider
414 .provide_credentials()
415 .await
416 .expect("credentials should resolve from a quoted spaced path with an argument");
417 assert_eq!(creds.access_key_id(), "ASIARTESTID");
418 assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
419 assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN"));
420 }
421
422 #[tokio::test]
423 async fn spaced_path_without_argument_resolves() {
424 let tmp = tempfile::TempDir::new().unwrap();
425 let script = write_provider(&tmp.path().join("Program Space"));
426
427 let command = format!("\"{}\"", script.display());
428 let provider = CredentialProcessProvider::new(command);
429
430 let creds = provider
431 .provide_credentials()
432 .await
433 .expect("credentials should resolve from a quoted spaced path with no argument");
434 assert_eq!(creds.access_key_id(), "ASIARTESTID");
435 }
436
437 #[tokio::test]
438 async fn unquoted_unspaced_path_still_resolves() {
439 let tmp = tempfile::TempDir::new().unwrap();
441 let script = write_provider(&tmp.path().join("nospace"));
442 if script.to_string_lossy().contains(' ') {
445 return;
446 }
447
448 let command = script.display().to_string();
449 let provider = CredentialProcessProvider::new(command);
450
451 let creds = provider
452 .provide_credentials()
453 .await
454 .expect("control (unquoted, no spaces) should resolve");
455 assert_eq!(creds.access_key_id(), "ASIARTESTID");
456 }
457}