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::provider::{self, error::CredentialsError, future, ProvideCredentials};
14use aws_credential_types::Credentials;
15use aws_smithy_json::deserialize::Token;
16use std::borrow::Cow;
17use std::process::Command;
18use std::time::SystemTime;
19use time::format_description::well_known::Rfc3339;
20use time::OffsetDateTime;
21
22/// External process credentials provider
23///
24/// This credentials provider runs a configured external process and parses
25/// its output to retrieve credentials.
26///
27/// The external process must exit with status 0 and output the following
28/// JSON format to `stdout` to provide credentials:
29///
30/// ```json
31/// {
32///     "Version:" 1,
33///     "AccessKeyId": "access key id",
34///     "SecretAccessKey": "secret access key",
35///     "SessionToken": "session token",
36///     "Expiration": "time that the expiration will expire"
37/// }
38/// ```
39///
40/// The `Version` must be set to 1. `AccessKeyId` and `SecretAccessKey` are always required.
41/// `SessionToken` must be set if a session token is associated with the `AccessKeyId`.
42/// The `Expiration` is optional, and must be given in the RFC 3339 date time format (e.g.,
43/// `2022-05-26T12:34:56.789Z`).
44///
45/// If the external process exits with a non-zero status, then the contents of `stderr`
46/// will be output as part of the credentials provider error message.
47///
48/// This credentials provider is included in the profile credentials provider, and can be
49/// configured using the `credential_process` attribute. For example:
50///
51/// ```plain
52/// [profile example]
53/// credential_process = /path/to/my/process --some --arguments
54/// ```
55#[derive(Debug)]
56pub struct CredentialProcessProvider {
57    command: CommandWithSensitiveArgs<String>,
58    profile_account_id: Option<AccountId>,
59}
60
61impl ProvideCredentials for CredentialProcessProvider {
62    fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
63    where
64        Self: 'a,
65    {
66        future::ProvideCredentials::new(self.credentials())
67    }
68}
69
70impl CredentialProcessProvider {
71    /// Create new [`CredentialProcessProvider`] with the `command` needed to execute the external process.
72    pub fn new(command: String) -> Self {
73        Self {
74            command: CommandWithSensitiveArgs::new(command),
75            profile_account_id: None,
76        }
77    }
78
79    pub(crate) fn builder() -> Builder {
80        Builder::default()
81    }
82
83    async fn credentials(&self) -> provider::Result {
84        // Security: command arguments must be redacted at debug level
85        tracing::debug!(command = %self.command, "loading credentials from external process");
86
87        let command = if cfg!(windows) {
88            let mut command = Command::new("cmd.exe");
89            command.args(["/C", self.command.unredacted()]);
90            command
91        } else {
92            let mut command = Command::new("sh");
93            command.args(["-c", self.command.unredacted()]);
94            command
95        };
96        let output = tokio::process::Command::from(command)
97            .output()
98            .await
99            .map_err(|e| {
100                CredentialsError::provider_error(format!(
101                    "Error retrieving credentials from external process: {}",
102                    e
103                ))
104            })?;
105
106        // Security: command arguments can be logged at trace level
107        tracing::trace!(command = ?self.command, status = ?output.status, "executed command (unredacted)");
108
109        if !output.status.success() {
110            let reason =
111                std::str::from_utf8(&output.stderr).unwrap_or("could not decode stderr as UTF-8");
112            return Err(CredentialsError::provider_error(format!(
113                "Error retrieving credentials: external process exited with code {}. Stderr: {}",
114                output.status, reason
115            )));
116        }
117
118        let output = std::str::from_utf8(&output.stdout).map_err(|e| {
119            CredentialsError::provider_error(format!(
120                "Error retrieving credentials from external process: could not decode output as UTF-8: {}",
121                e
122            ))
123        })?;
124
125        parse_credential_process_json_credentials(output, self.profile_account_id.as_ref()).map_err(
126            |invalid| {
127                CredentialsError::provider_error(format!(
128                "Error retrieving credentials from external process, could not parse response: {}",
129                invalid
130            ))
131            },
132        )
133    }
134}
135
136#[derive(Debug, Default)]
137pub(crate) struct Builder {
138    command: Option<CommandWithSensitiveArgs<String>>,
139    profile_account_id: Option<AccountId>,
140}
141
142impl Builder {
143    pub(crate) fn command(mut self, command: CommandWithSensitiveArgs<String>) -> Self {
144        self.command = Some(command);
145        self
146    }
147
148    #[allow(dead_code)] // only used in unit tests
149    pub(crate) fn account_id(mut self, account_id: impl Into<AccountId>) -> Self {
150        self.set_account_id(Some(account_id.into()));
151        self
152    }
153
154    pub(crate) fn set_account_id(&mut self, account_id: Option<AccountId>) {
155        self.profile_account_id = account_id;
156    }
157
158    pub(crate) fn build(self) -> CredentialProcessProvider {
159        CredentialProcessProvider {
160            command: self.command.expect("should be set"),
161            profile_account_id: self.profile_account_id,
162        }
163    }
164}
165
166/// Deserialize a credential_process response from a string
167///
168/// Returns an error if the response cannot be successfully parsed or is missing keys.
169///
170/// Keys are case insensitive.
171/// The function optionally takes `profile_account_id` that originates from the profile section.
172/// If process execution result does not contain an account ID, the function uses it as a fallback.
173pub(crate) fn parse_credential_process_json_credentials(
174    credentials_response: &str,
175    profile_account_id: Option<&AccountId>,
176) -> Result<Credentials, InvalidJsonCredentials> {
177    let mut version = None;
178    let mut access_key_id = None;
179    let mut secret_access_key = None;
180    let mut session_token = None;
181    let mut expiration = None;
182    let mut account_id = profile_account_id
183        .as_ref()
184        .map(|id| Cow::Borrowed(id.as_str()));
185    json_parse_loop(credentials_response.as_bytes(), |key, value| {
186        match (key, value) {
187            /*
188             "Version": 1,
189             "AccessKeyId": "ASIARTESTID",
190             "SecretAccessKey": "TESTSECRETKEY",
191             "SessionToken": "TESTSESSIONTOKEN",
192             "Expiration": "2022-05-02T18:36:00+00:00",
193             "AccountId": "111122223333"
194            */
195            (key, Token::ValueNumber { value, .. }) if key.eq_ignore_ascii_case("Version") => {
196                version = Some(i32::try_from(*value).map_err(|err| {
197                    InvalidJsonCredentials::InvalidField {
198                        field: "Version",
199                        err: err.into(),
200                    }
201                })?);
202            }
203            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccessKeyId") => {
204                access_key_id = Some(value.to_unescaped()?)
205            }
206            (key, Token::ValueString { value, .. })
207                if key.eq_ignore_ascii_case("SecretAccessKey") =>
208            {
209                secret_access_key = Some(value.to_unescaped()?)
210            }
211            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("SessionToken") => {
212                session_token = Some(value.to_unescaped()?)
213            }
214            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Expiration") => {
215                expiration = Some(value.to_unescaped()?)
216            }
217            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccountId") => {
218                account_id = Some(value.to_unescaped()?)
219            }
220
221            _ => {}
222        };
223        Ok(())
224    })?;
225
226    match version {
227        Some(1) => { /* continue */ }
228        None => return Err(InvalidJsonCredentials::MissingField("Version")),
229        Some(version) => {
230            return Err(InvalidJsonCredentials::InvalidField {
231                field: "version",
232                err: format!("unknown version number: {}", version).into(),
233            })
234        }
235    }
236
237    let access_key_id = access_key_id.ok_or(InvalidJsonCredentials::MissingField("AccessKeyId"))?;
238    let secret_access_key =
239        secret_access_key.ok_or(InvalidJsonCredentials::MissingField("SecretAccessKey"))?;
240    let expiration = expiration.map(parse_expiration).transpose()?;
241    if expiration.is_none() {
242        tracing::debug!("no expiration provided for credentials provider credentials. these credentials will never be refreshed.")
243    }
244    let mut builder = Credentials::builder()
245        .access_key_id(access_key_id)
246        .secret_access_key(secret_access_key)
247        .provider_name("CredentialProcess");
248    builder.set_session_token(session_token.map(String::from));
249    builder.set_expiry(expiration);
250    builder.set_account_id(account_id.map(AccountId::from));
251    Ok(builder.build())
252}
253
254fn parse_expiration(expiration: impl AsRef<str>) -> Result<SystemTime, InvalidJsonCredentials> {
255    OffsetDateTime::parse(expiration.as_ref(), &Rfc3339)
256        .map(SystemTime::from)
257        .map_err(|err| InvalidJsonCredentials::InvalidField {
258            field: "Expiration",
259            err: err.into(),
260        })
261}
262
263#[cfg(test)]
264mod test {
265    use crate::credential_process::CredentialProcessProvider;
266    use crate::sensitive_command::CommandWithSensitiveArgs;
267    use aws_credential_types::provider::ProvideCredentials;
268    use std::time::{Duration, SystemTime};
269    use time::format_description::well_known::Rfc3339;
270    use time::OffsetDateTime;
271    use tokio::time::timeout;
272
273    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
274    #[tokio::test]
275    #[cfg_attr(windows, ignore)]
276    async fn test_credential_process() {
277        let provider = CredentialProcessProvider::new(String::from(
278            r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "SessionToken": "TESTSESSIONTOKEN", "AccountId": "123456789001", "Expiration": "2022-05-02T18:36:00+00:00" }'"#,
279        ));
280        let creds = provider.provide_credentials().await.expect("valid creds");
281        assert_eq!(creds.access_key_id(), "ASIARTESTID");
282        assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
283        assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN"));
284        assert_eq!(creds.account_id().unwrap().as_str(), "123456789001");
285        assert_eq!(
286            creds.expiry(),
287            Some(
288                SystemTime::try_from(
289                    OffsetDateTime::parse("2022-05-02T18:36:00+00:00", &Rfc3339)
290                        .expect("static datetime")
291                )
292                .expect("static datetime")
293            )
294        );
295    }
296
297    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
298    #[tokio::test]
299    #[cfg_attr(windows, ignore)]
300    async fn test_credential_process_no_expiry() {
301        let provider = CredentialProcessProvider::new(String::from(
302            r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY" }'"#,
303        ));
304        let creds = provider.provide_credentials().await.expect("valid creds");
305        assert_eq!(creds.access_key_id(), "ASIARTESTID");
306        assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
307        assert_eq!(creds.session_token(), None);
308        assert_eq!(creds.expiry(), None);
309    }
310
311    #[tokio::test]
312    async fn credentials_process_timeouts() {
313        let provider = CredentialProcessProvider::new(String::from("sleep 1000"));
314        let _creds = timeout(Duration::from_millis(1), provider.provide_credentials())
315            .await
316            .expect_err("timeout forced");
317    }
318
319    #[tokio::test]
320    async fn credentials_with_fallback_account_id() {
321        let provider = CredentialProcessProvider::builder()
322            .command(CommandWithSensitiveArgs::new(String::from(
323                r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY" }'"#,
324            )))
325            .account_id("012345678901")
326            .build();
327        let creds = provider.provide_credentials().await.unwrap();
328        assert_eq!("012345678901", creds.account_id().unwrap().as_str());
329    }
330
331    #[tokio::test]
332    async fn fallback_account_id_shadowed_by_account_id_in_process_output() {
333        let provider = CredentialProcessProvider::builder()
334            .command(CommandWithSensitiveArgs::new(String::from(
335                r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }'"#,
336            )))
337            .account_id("012345678901")
338            .build();
339        let creds = provider.provide_credentials().await.unwrap();
340        assert_eq!("111122223333", creds.account_id().unwrap().as_str());
341    }
342}