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        let command = if cfg!(windows) {
89            let mut command = Command::new("cmd.exe");
90            command.args(["/C", self.command.unredacted()]);
91            command
92        } else {
93            let mut command = Command::new("sh");
94            command.args(["-c", self.command.unredacted()]);
95            command
96        };
97        let output = tokio::process::Command::from(command)
98            .output()
99            .await
100            .map_err(|e| {
101                CredentialsError::provider_error(format!(
102                    "Error retrieving credentials from external process: {}",
103                    e
104                ))
105            })?;
106
107        // Security: command arguments can be logged at trace level
108        tracing::trace!(command = ?self.command, status = ?output.status, "executed command (unredacted)");
109
110        if !output.status.success() {
111            let reason =
112                std::str::from_utf8(&output.stderr).unwrap_or("could not decode stderr as UTF-8");
113            return Err(CredentialsError::provider_error(format!(
114                "Error retrieving credentials: external process exited with code {}. Stderr: {}",
115                output.status, reason
116            )));
117        }
118
119        let output = std::str::from_utf8(&output.stdout).map_err(|e| {
120            CredentialsError::provider_error(format!(
121                "Error retrieving credentials from external process: could not decode output as UTF-8: {}",
122                e
123            ))
124        })?;
125
126        parse_credential_process_json_credentials(output, self.profile_account_id.as_ref())
127            .map(|mut creds| {
128                creds
129                    .get_property_mut_or_default::<Vec<AwsCredentialFeature>>()
130                    .push(AwsCredentialFeature::CredentialsProcess);
131                creds
132            })
133            .map_err(|invalid| {
134                CredentialsError::provider_error(format!(
135                "Error retrieving credentials from external process, could not parse response: {}",
136                invalid
137            ))
138            })
139    }
140}
141
142#[derive(Debug, Default)]
143pub(crate) struct Builder {
144    command: Option<CommandWithSensitiveArgs<String>>,
145    profile_account_id: Option<AccountId>,
146}
147
148impl Builder {
149    pub(crate) fn command(mut self, command: CommandWithSensitiveArgs<String>) -> Self {
150        self.command = Some(command);
151        self
152    }
153
154    #[allow(dead_code)] // only used in unit tests
155    pub(crate) fn account_id(mut self, account_id: impl Into<AccountId>) -> Self {
156        self.set_account_id(Some(account_id.into()));
157        self
158    }
159
160    pub(crate) fn set_account_id(&mut self, account_id: Option<AccountId>) {
161        self.profile_account_id = account_id;
162    }
163
164    pub(crate) fn build(self) -> CredentialProcessProvider {
165        CredentialProcessProvider {
166            command: self.command.expect("should be set"),
167            profile_account_id: self.profile_account_id,
168        }
169    }
170}
171
172/// Deserialize a credential_process response from a string
173///
174/// Returns an error if the response cannot be successfully parsed or is missing keys.
175///
176/// Keys are case insensitive.
177/// The function optionally takes `profile_account_id` that originates from the profile section.
178/// If process execution result does not contain an account ID, the function uses it as a fallback.
179pub(crate) fn parse_credential_process_json_credentials(
180    credentials_response: &str,
181    profile_account_id: Option<&AccountId>,
182) -> Result<Credentials, InvalidJsonCredentials> {
183    let mut version = None;
184    let mut access_key_id = None;
185    let mut secret_access_key = None;
186    let mut session_token = None;
187    let mut expiration = None;
188    let mut account_id = profile_account_id
189        .as_ref()
190        .map(|id| Cow::Borrowed(id.as_str()));
191    json_parse_loop(credentials_response.as_bytes(), |key, value| {
192        match (key, value) {
193            /*
194             "Version": 1,
195             "AccessKeyId": "ASIARTESTID",
196             "SecretAccessKey": "TESTSECRETKEY",
197             "SessionToken": "TESTSESSIONTOKEN",
198             "Expiration": "2022-05-02T18:36:00+00:00",
199             "AccountId": "111122223333"
200            */
201            (key, Token::ValueNumber { value, .. }) if key.eq_ignore_ascii_case("Version") => {
202                version = Some(i32::try_from(*value).map_err(|err| {
203                    InvalidJsonCredentials::InvalidField {
204                        field: "Version",
205                        err: err.into(),
206                    }
207                })?);
208            }
209            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccessKeyId") => {
210                access_key_id = Some(value.to_unescaped()?)
211            }
212            (key, Token::ValueString { value, .. })
213                if key.eq_ignore_ascii_case("SecretAccessKey") =>
214            {
215                secret_access_key = Some(value.to_unescaped()?)
216            }
217            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("SessionToken") => {
218                session_token = Some(value.to_unescaped()?)
219            }
220            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Expiration") => {
221                expiration = Some(value.to_unescaped()?)
222            }
223            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccountId") => {
224                account_id = Some(value.to_unescaped()?)
225            }
226
227            _ => {}
228        };
229        Ok(())
230    })?;
231
232    match version {
233        Some(1) => { /* continue */ }
234        None => return Err(InvalidJsonCredentials::MissingField("Version")),
235        Some(version) => {
236            return Err(InvalidJsonCredentials::InvalidField {
237                field: "version",
238                err: format!("unknown version number: {}", version).into(),
239            })
240        }
241    }
242
243    let access_key_id = access_key_id.ok_or(InvalidJsonCredentials::MissingField("AccessKeyId"))?;
244    let secret_access_key =
245        secret_access_key.ok_or(InvalidJsonCredentials::MissingField("SecretAccessKey"))?;
246    let expiration = expiration.map(parse_expiration).transpose()?;
247    if expiration.is_none() {
248        tracing::debug!("no expiration provided for credentials provider credentials. these credentials will never be refreshed.")
249    }
250    let mut builder = Credentials::builder()
251        .access_key_id(access_key_id)
252        .secret_access_key(secret_access_key)
253        .provider_name("CredentialProcess");
254    builder.set_session_token(session_token.map(String::from));
255    builder.set_expiry(expiration);
256    builder.set_account_id(account_id.map(AccountId::from));
257    Ok(builder.build())
258}
259
260fn parse_expiration(expiration: impl AsRef<str>) -> Result<SystemTime, InvalidJsonCredentials> {
261    OffsetDateTime::parse(expiration.as_ref(), &Rfc3339)
262        .map(SystemTime::from)
263        .map_err(|err| InvalidJsonCredentials::InvalidField {
264            field: "Expiration",
265            err: err.into(),
266        })
267}
268
269#[cfg(test)]
270mod test {
271    use crate::credential_process::CredentialProcessProvider;
272    use crate::sensitive_command::CommandWithSensitiveArgs;
273    use aws_credential_types::credential_feature::AwsCredentialFeature;
274    use aws_credential_types::provider::ProvideCredentials;
275    use std::time::{Duration, SystemTime};
276    use time::format_description::well_known::Rfc3339;
277    use time::OffsetDateTime;
278    use tokio::time::timeout;
279
280    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
281    #[tokio::test]
282    #[cfg_attr(windows, ignore)]
283    async fn test_credential_process() {
284        let provider = CredentialProcessProvider::new(String::from(
285            r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "SessionToken": "TESTSESSIONTOKEN", "AccountId": "123456789001", "Expiration": "2022-05-02T18:36:00+00:00" }'"#,
286        ));
287        let creds = provider.provide_credentials().await.expect("valid creds");
288        assert_eq!(creds.access_key_id(), "ASIARTESTID");
289        assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
290        assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN"));
291        assert_eq!(creds.account_id().unwrap().as_str(), "123456789001");
292        assert_eq!(
293            creds.expiry(),
294            Some(
295                SystemTime::try_from(
296                    OffsetDateTime::parse("2022-05-02T18:36:00+00:00", &Rfc3339)
297                        .expect("static datetime")
298                )
299                .expect("static datetime")
300            )
301        );
302    }
303
304    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
305    #[tokio::test]
306    #[cfg_attr(windows, ignore)]
307    async fn test_credential_process_no_expiry() {
308        let provider = CredentialProcessProvider::new(String::from(
309            r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY" }'"#,
310        ));
311        let creds = provider.provide_credentials().await.expect("valid creds");
312        assert_eq!(creds.access_key_id(), "ASIARTESTID");
313        assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
314        assert_eq!(creds.session_token(), None);
315        assert_eq!(creds.expiry(), None);
316    }
317
318    #[tokio::test]
319    async fn credentials_process_timeouts() {
320        let provider = CredentialProcessProvider::new(String::from("sleep 1000"));
321        let _creds = timeout(Duration::from_millis(1), provider.provide_credentials())
322            .await
323            .expect_err("timeout forced");
324    }
325
326    #[tokio::test]
327    async fn credentials_with_fallback_account_id() {
328        let provider = CredentialProcessProvider::builder()
329            .command(CommandWithSensitiveArgs::new(String::from(
330                r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY" }'"#,
331            )))
332            .account_id("012345678901")
333            .build();
334        let creds = provider.provide_credentials().await.unwrap();
335        assert_eq!("012345678901", creds.account_id().unwrap().as_str());
336    }
337
338    #[tokio::test]
339    async fn fallback_account_id_shadowed_by_account_id_in_process_output() {
340        let provider = CredentialProcessProvider::builder()
341            .command(CommandWithSensitiveArgs::new(String::from(
342                r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }'"#,
343            )))
344            .account_id("012345678901")
345            .build();
346        let creds = provider.provide_credentials().await.unwrap();
347        assert_eq!("111122223333", creds.account_id().unwrap().as_str());
348    }
349
350    #[tokio::test]
351    async fn credential_feature() {
352        let provider = CredentialProcessProvider::builder()
353            .command(CommandWithSensitiveArgs::new(String::from(
354                r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }'"#,
355            )))
356            .account_id("012345678901")
357            .build();
358        let creds = provider.provide_credentials().await.unwrap();
359        assert_eq!(
360            &vec![AwsCredentialFeature::CredentialsProcess],
361            creds.get_property::<Vec<AwsCredentialFeature>>().unwrap()
362        );
363    }
364}