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