Skip to main content

aws_config/
credential_process.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6#![cfg(feature = "credentials-process")]
7
8//! Credentials Provider for external process
9
10use 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/// External process credentials provider
24///
25/// This credentials provider runs a configured external process and parses
26/// its output to retrieve credentials.
27///
28/// The external process must exit with status 0 and output the following
29/// JSON format to `stdout` to provide credentials:
30///
31/// ```json
32/// {
33///     "Version:" 1,
34///     "AccessKeyId": "access key id",
35///     "SecretAccessKey": "secret access key",
36///     "SessionToken": "session token",
37///     "Expiration": "time that the expiration will expire"
38/// }
39/// ```
40///
41/// The `Version` must be set to 1. `AccessKeyId` and `SecretAccessKey` are always required.
42/// `SessionToken` must be set if a session token is associated with the `AccessKeyId`.
43/// The `Expiration` is optional, and must be given in the RFC 3339 date time format (e.g.,
44/// `2022-05-26T12:34:56.789Z`).
45///
46/// If the external process exits with a non-zero status, then the contents of `stderr`
47/// will be output as part of the credentials provider error message.
48///
49/// This credentials provider is included in the profile credentials provider, and can be
50/// configured using the `credential_process` attribute. For example:
51///
52/// ```plain
53/// [profile example]
54/// credential_process = /path/to/my/process --some --arguments
55/// ```
56#[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    /// Create new [`CredentialProcessProvider`] with the `command` needed to execute the external process.
73    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        // Security: command arguments must be redacted at debug level
86        tracing::debug!(command = %self.command, "loading credentials from external process");
87
88        // On Windows, the command runs through `cmd.exe /C`. The command string
89        // is appended with `raw_arg` rather than as a normal argument so that
90        // Rust does not apply its own C runtime style escaping (which `cmd.exe`
91        // does not understand), and the whole command is wrapped in an extra
92        // pair of quotes as `cmd.exe` requires. This preserves a quoted first
93        // token containing spaces. Ex: for an executable installed under
94        // `C:\Program Files\...`, such as AppStream 2.0's machine-role provider.
95        // Previously the entire string was passed as a single normal argument,
96        // whose escaping combined with `cmd.exe`'s quote-stripping to mangle
97        // such paths.
98        #[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        // Security: command arguments can be logged at trace level
122        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)] // only used in unit tests
167    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
184/// Deserialize a credential_process response from a string
185///
186/// Returns an error if the response cannot be successfully parsed or is missing keys.
187///
188/// Keys are case insensitive.
189/// The function optionally takes `profile_account_id` that originates from the profile section.
190/// If process execution result does not contain an account ID, the function uses it as a fallback.
191pub(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            /*
206             "Version": 1,
207             "AccessKeyId": "ASIARTESTID",
208             "SecretAccessKey": "TESTSECRETKEY",
209             "SessionToken": "TESTSESSIONTOKEN",
210             "Expiration": "2022-05-02T18:36:00+00:00",
211             "AccountId": "111122223333"
212            */
213            (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) => { /* continue */ }
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    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
293    #[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    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
314    #[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// Integration tests that actually spawn a process from a path containing a
376// space. These run only on Windows: they are the regression tests for the
377// `credential_process` quoting bug (internal: P491659165). The pre-existing
378// `credential_process` tests above use the Unix `echo` builtin and are
379// skipped on Windows.
380#[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    // Write a `.cmd` provider that prints valid credential JSON to stdout into
389    // `dir`, returning the path to the script. `@echo off` keeps stdout clean so
390    // the only thing emitted is the JSON document.
391    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        // Quote the path as a real config would, and pass an argument — exactly
409        // the AppStream shape: `"...PhotonRoleCredentialProvider.exe" --role=Machine`.
410        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        // Control: an unquoted path with no spaces continues to work.
440        let tmp = tempfile::TempDir::new().unwrap();
441        let script = write_provider(&tmp.path().join("nospace"));
442        // Only meaningful if no path component (including the temp root) has a
443        // space; otherwise the unquoted form is not a valid no-space control.
444        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}