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    /// Builds a shell command that prints `json` to stdout, quoted correctly for
293    /// the shell the provider will use on this platform.
294    ///
295    /// The provider runs the command through `sh -c` on Unix and `cmd.exe /C` on
296    /// Windows, and the two disagree about quoting:
297    ///
298    /// * `sh` needs the JSON wrapped in single quotes so the double quotes inside
299    ///   it survive word splitting.
300    /// * `cmd.exe` has no notion of single quotes. It would pass them through
301    ///   literally, yielding output like `'{"Version":1}'`, which is not valid
302    ///   JSON. Its `echo` emits the remainder of the line verbatim, so the double
303    ///   quotes survive with no quoting at all.
304    ///
305    /// A runtime `cfg!` is fine here because both branches compile everywhere;
306    /// contrast with `credentials()` above, which needs `#[cfg(windows)]` because
307    /// `raw_arg` only exists on Windows.
308    fn echo_json(json: &str) -> String {
309        if cfg!(windows) {
310            format!("echo {json}")
311        } else {
312            format!("echo '{json}'")
313        }
314    }
315
316    #[tokio::test]
317    async fn test_credential_process() {
318        let provider = CredentialProcessProvider::new(echo_json(
319            r#"{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "SessionToken": "TESTSESSIONTOKEN", "AccountId": "123456789001", "Expiration": "2022-05-02T18:36:00+00:00" }"#,
320        ));
321        let creds = provider.provide_credentials().await.expect("valid creds");
322        assert_eq!(creds.access_key_id(), "ASIARTESTID");
323        assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
324        assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN"));
325        assert_eq!(creds.account_id().unwrap().as_str(), "123456789001");
326        assert_eq!(
327            creds.expiry(),
328            Some(SystemTime::from(
329                OffsetDateTime::parse("2022-05-02T18:36:00+00:00", &Rfc3339)
330                    .expect("static datetime"),
331            ))
332        );
333    }
334
335    #[tokio::test]
336    async fn test_credential_process_no_expiry() {
337        let provider = CredentialProcessProvider::new(echo_json(
338            r#"{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY" }"#,
339        ));
340        let creds = provider.provide_credentials().await.expect("valid creds");
341        assert_eq!(creds.access_key_id(), "ASIARTESTID");
342        assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
343        assert_eq!(creds.session_token(), None);
344        assert_eq!(creds.expiry(), None);
345    }
346
347    #[tokio::test]
348    async fn credentials_process_timeouts() {
349        // Keep this sleep short. The 1ms timeout below fires long before it
350        // elapses, but the spawned process is not killed when the timed-out
351        // future is dropped, and on Windows the test is not reported as finished
352        // until that child exits, stalling the whole test binary for the
353        // duration. `sleep` still has to outlast the 1ms timeout by a wide
354        // margin for the assertion to hold.
355        let provider = CredentialProcessProvider::new(String::from("sleep 1"));
356        let _creds = timeout(Duration::from_millis(1), provider.provide_credentials())
357            .await
358            .expect_err("timeout forced");
359    }
360
361    #[tokio::test]
362    async fn credentials_with_fallback_account_id() {
363        let provider = CredentialProcessProvider::builder()
364            .command(CommandWithSensitiveArgs::new(echo_json(
365                r#"{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY" }"#,
366            )))
367            .account_id("012345678901")
368            .build();
369        let creds = provider.provide_credentials().await.unwrap();
370        assert_eq!("012345678901", creds.account_id().unwrap().as_str());
371    }
372
373    #[tokio::test]
374    async fn fallback_account_id_shadowed_by_account_id_in_process_output() {
375        let provider = CredentialProcessProvider::builder()
376            .command(CommandWithSensitiveArgs::new(echo_json(
377                r#"{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }"#,
378            )))
379            .account_id("012345678901")
380            .build();
381        let creds = provider.provide_credentials().await.unwrap();
382        assert_eq!("111122223333", creds.account_id().unwrap().as_str());
383    }
384
385    #[tokio::test]
386    async fn credential_feature() {
387        let provider = CredentialProcessProvider::builder()
388            .command(CommandWithSensitiveArgs::new(echo_json(
389                r#"{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }"#,
390            )))
391            .account_id("012345678901")
392            .build();
393        let creds = provider.provide_credentials().await.unwrap();
394        assert_eq!(
395            &vec![AwsCredentialFeature::CredentialsProcess],
396            creds.get_property::<Vec<AwsCredentialFeature>>().unwrap()
397        );
398    }
399}
400
401// Integration tests that actually spawn a process from a path containing a
402// space. These run only on Windows: they are the regression tests for the
403// `credential_process` quoting bug (internal: P491659165). The pre-existing
404// `credential_process` tests above use the Unix `echo` builtin and are
405// skipped on Windows.
406#[cfg(all(test, windows))]
407mod windows_tests {
408    use crate::credential_process::CredentialProcessProvider;
409    use aws_credential_types::provider::ProvideCredentials;
410    use std::path::{Path, PathBuf};
411
412    const CREDS_JSON: &str = "{\"Version\":1,\"AccessKeyId\":\"ASIARTESTID\",\"SecretAccessKey\":\"TESTSECRETKEY\",\"SessionToken\":\"TESTSESSIONTOKEN\",\"Expiration\":\"2035-01-01T00:00:00Z\"}";
413
414    // Write a `.cmd` provider that prints valid credential JSON to stdout into
415    // `dir`, returning the path to the script. `@echo off` keeps stdout clean so
416    // the only thing emitted is the JSON document.
417    fn write_provider(dir: &Path) -> PathBuf {
418        std::fs::create_dir_all(dir).unwrap();
419        let script = dir.join("provider.cmd");
420        std::fs::write(&script, format!("@echo off\r\necho {CREDS_JSON}\r\n")).unwrap();
421        script
422    }
423
424    #[tokio::test]
425    async fn spaced_path_with_argument_resolves() {
426        let tmp = tempfile::TempDir::new().unwrap();
427        let script = write_provider(&tmp.path().join("Program Space"));
428        assert!(
429            script.to_string_lossy().contains(' '),
430            "test fixture path must contain a space: {}",
431            script.display()
432        );
433
434        // Quote the path as a real config would, and pass an argument — exactly
435        // the AppStream shape: `"...PhotonRoleCredentialProvider.exe" --role=Machine`.
436        let command = format!("\"{}\" --role=Machine", script.display());
437        let provider = CredentialProcessProvider::new(command);
438
439        let creds = provider
440            .provide_credentials()
441            .await
442            .expect("credentials should resolve from a quoted spaced path with an argument");
443        assert_eq!(creds.access_key_id(), "ASIARTESTID");
444        assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
445        assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN"));
446    }
447
448    #[tokio::test]
449    async fn spaced_path_without_argument_resolves() {
450        let tmp = tempfile::TempDir::new().unwrap();
451        let script = write_provider(&tmp.path().join("Program Space"));
452
453        let command = format!("\"{}\"", script.display());
454        let provider = CredentialProcessProvider::new(command);
455
456        let creds = provider
457            .provide_credentials()
458            .await
459            .expect("credentials should resolve from a quoted spaced path with no argument");
460        assert_eq!(creds.access_key_id(), "ASIARTESTID");
461    }
462
463    #[tokio::test]
464    async fn unquoted_unspaced_path_still_resolves() {
465        // Control: an unquoted path with no spaces continues to work.
466        let tmp = tempfile::TempDir::new().unwrap();
467        let script = write_provider(&tmp.path().join("nospace"));
468        // Only meaningful if no path component (including the temp root) has a
469        // space; otherwise the unquoted form is not a valid no-space control.
470        if script.to_string_lossy().contains(' ') {
471            return;
472        }
473
474        let command = script.display().to_string();
475        let provider = CredentialProcessProvider::new(command);
476
477        let creds = provider
478            .provide_credentials()
479            .await
480            .expect("control (unquoted, no spaces) should resolve");
481        assert_eq!(creds.access_key_id(), "ASIARTESTID");
482    }
483}