aws_config/
json_credentials.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use aws_smithy_json::deserialize::token::skip_value;
7use aws_smithy_json::deserialize::{json_token_iter, EscapeError, Token};
8use aws_smithy_types::date_time::Format;
9use aws_smithy_types::DateTime;
10use std::borrow::Cow;
11use std::error::Error;
12use std::fmt::{self, Display, Formatter};
13use std::time::SystemTime;
14
15#[derive(Debug)]
16pub(crate) enum InvalidJsonCredentials {
17    /// The response did not contain valid JSON
18    JsonError(Box<dyn Error + Send + Sync>),
19    /// The response was missing a required field
20    MissingField(&'static str),
21
22    /// A field was invalid
23    InvalidField {
24        field: &'static str,
25        err: Box<dyn Error + Send + Sync>,
26    },
27
28    /// Another unhandled error occurred
29    Other(Cow<'static, str>),
30}
31
32impl From<EscapeError> for InvalidJsonCredentials {
33    fn from(err: EscapeError) -> Self {
34        InvalidJsonCredentials::JsonError(err.into())
35    }
36}
37
38impl From<aws_smithy_json::deserialize::error::DeserializeError> for InvalidJsonCredentials {
39    fn from(err: aws_smithy_json::deserialize::error::DeserializeError) -> Self {
40        InvalidJsonCredentials::JsonError(err.into())
41    }
42}
43
44impl Display for InvalidJsonCredentials {
45    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
46        match self {
47            InvalidJsonCredentials::JsonError(json) => {
48                write!(f, "invalid JSON in response: {}", json)
49            }
50            InvalidJsonCredentials::MissingField(field) => write!(
51                f,
52                "Expected field `{}` in response but it was missing",
53                field
54            ),
55            InvalidJsonCredentials::Other(msg) => write!(f, "{}", msg),
56            InvalidJsonCredentials::InvalidField { field, err } => {
57                write!(f, "Invalid field in response: `{}`. {}", field, err)
58            }
59        }
60    }
61}
62
63impl Error for InvalidJsonCredentials {}
64
65#[derive(PartialEq, Eq)]
66pub(crate) struct RefreshableCredentials<'a> {
67    pub(crate) access_key_id: Cow<'a, str>,
68    pub(crate) secret_access_key: Cow<'a, str>,
69    pub(crate) session_token: Cow<'a, str>,
70    pub(crate) expiration: SystemTime,
71}
72
73impl<'a> fmt::Debug for RefreshableCredentials<'a> {
74    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
75        f.debug_struct("RefreshableCredentials")
76            .field("access_key_id", &self.access_key_id)
77            .field("secret_access_key", &"** redacted **")
78            .field("session_token", &"** redacted **")
79            .field("expiration", &self.expiration)
80            .finish()
81    }
82}
83
84#[non_exhaustive]
85#[derive(Debug, PartialEq, Eq)]
86pub(crate) enum JsonCredentials<'a> {
87    RefreshableCredentials(RefreshableCredentials<'a>),
88    Error {
89        code: Cow<'a, str>,
90        message: Cow<'a, str>,
91    }, // TODO(https://github.com/awslabs/aws-sdk-rust/issues/340): Add support for static credentials:
92       //  {
93       //    "AccessKeyId" : "MUA...",
94       //    "SecretAccessKey" : "/7PC5om...."
95       //  }
96
97       // TODO(https://github.com/awslabs/aws-sdk-rust/issues/340): Add support for Assume role credentials:
98       //   {
99       //     // fields to construct STS client:
100       //     "Region": "sts-region-name",
101       //     "AccessKeyId" : "MUA...",
102       //     "Expiration" : "2016-02-25T06:03:31Z", // optional
103       //     "SecretAccessKey" : "/7PC5om....",
104       //     "Token" : "AQoDY....=", // optional
105       //     // fields controlling the STS role:
106       //     "RoleArn": "...", // required
107       //     "RoleSessionName": "...", // required
108       //     // and also: DurationSeconds, ExternalId, SerialNumber, TokenCode, Policy
109       //     ...
110       //   }
111}
112
113/// Deserialize an IMDS response from a string
114///
115/// There are two levels of error here: the top level distinguishes between a successfully parsed
116/// response from the credential provider vs. something invalid / unexpected. The inner error
117/// distinguishes between a successful response that contains credentials vs. an error with a code and
118/// error message.
119///
120/// Keys are case insensitive.
121pub(crate) fn parse_json_credentials(
122    credentials_response: &str,
123) -> Result<JsonCredentials<'_>, InvalidJsonCredentials> {
124    let mut code = None;
125    let mut access_key_id = None;
126    let mut secret_access_key = None;
127    let mut session_token = None;
128    let mut expiration = None;
129    let mut message = None;
130    json_parse_loop(credentials_response.as_bytes(), |key, value| {
131        match (key, value) {
132            /*
133             "Code": "Success",
134             "Type": "AWS-HMAC",
135             "AccessKeyId" : "accessKey",
136             "SecretAccessKey" : "secret",
137             "Token" : "token",
138             "Expiration" : "....",
139             "LastUpdated" : "2009-11-23T00:00:00Z"
140            */
141            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Code") => {
142                code = Some(value.to_unescaped()?);
143            }
144            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("AccessKeyId") => {
145                access_key_id = Some(value.to_unescaped()?);
146            }
147            (key, Token::ValueString { value, .. })
148                if key.eq_ignore_ascii_case("SecretAccessKey") =>
149            {
150                secret_access_key = Some(value.to_unescaped()?);
151            }
152            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Token") => {
153                session_token = Some(value.to_unescaped()?);
154            }
155            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Expiration") => {
156                expiration = Some(value.to_unescaped()?);
157            }
158
159            // Error case handling: message will be set
160            (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("Message") => {
161                message = Some(value.to_unescaped()?);
162            }
163            _ => {}
164        };
165        Ok(())
166    })?;
167    match code {
168        // IMDS does not appear to reply with a `Code` missing, but documentation indicates it
169        // may be possible
170        None | Some(Cow::Borrowed("Success")) => {
171            let access_key_id =
172                access_key_id.ok_or(InvalidJsonCredentials::MissingField("AccessKeyId"))?;
173            let secret_access_key =
174                secret_access_key.ok_or(InvalidJsonCredentials::MissingField("SecretAccessKey"))?;
175            let session_token =
176                session_token.ok_or(InvalidJsonCredentials::MissingField("Token"))?;
177            let expiration =
178                expiration.ok_or(InvalidJsonCredentials::MissingField("Expiration"))?;
179            let expiration = SystemTime::try_from(
180                DateTime::from_str(expiration.as_ref(), Format::DateTime).map_err(|err| {
181                    InvalidJsonCredentials::InvalidField {
182                        field: "Expiration",
183                        err: err.into(),
184                    }
185                })?,
186            )
187            .map_err(|_| {
188                InvalidJsonCredentials::Other(
189                    "credential expiration time cannot be represented by a SystemTime".into(),
190                )
191            })?;
192            Ok(JsonCredentials::RefreshableCredentials(
193                RefreshableCredentials {
194                    access_key_id,
195                    secret_access_key,
196                    session_token,
197                    expiration,
198                },
199            ))
200        }
201        Some(other) => Ok(JsonCredentials::Error {
202            code: other,
203            message: message.unwrap_or_else(|| "no message".into()),
204        }),
205    }
206}
207
208pub(crate) fn json_parse_loop<'a>(
209    input: &'a [u8],
210    mut f: impl FnMut(Cow<'a, str>, &Token<'a>) -> Result<(), InvalidJsonCredentials>,
211) -> Result<(), InvalidJsonCredentials> {
212    let mut tokens = json_token_iter(input).peekable();
213    if !matches!(tokens.next().transpose()?, Some(Token::StartObject { .. })) {
214        return Err(InvalidJsonCredentials::JsonError(
215            "expected a JSON document starting with `{`".into(),
216        ));
217    }
218    loop {
219        match tokens.next().transpose()? {
220            Some(Token::EndObject { .. }) => break,
221            Some(Token::ObjectKey { key, .. }) => {
222                if let Some(Ok(token)) = tokens.peek() {
223                    let key = key.to_unescaped()?;
224                    f(key, token)?
225                }
226                skip_value(&mut tokens)?;
227            }
228            other => {
229                return Err(InvalidJsonCredentials::Other(
230                    format!("expected object key, found: {:?}", other).into(),
231                ));
232            }
233        }
234    }
235    if tokens.next().is_some() {
236        return Err(InvalidJsonCredentials::Other(
237            "found more JSON tokens after completing parsing".into(),
238        ));
239    }
240    Ok(())
241}
242
243#[cfg(test)]
244mod test {
245    use crate::json_credentials::{
246        parse_json_credentials, InvalidJsonCredentials, JsonCredentials, RefreshableCredentials,
247    };
248    use std::time::{Duration, UNIX_EPOCH};
249
250    #[test]
251    fn json_credentials_success_response() {
252        let response = r#"
253        {
254          "Code" : "Success",
255          "LastUpdated" : "2021-09-17T20:57:08Z",
256          "Type" : "AWS-HMAC",
257          "AccessKeyId" : "ASIARTEST",
258          "SecretAccessKey" : "xjtest",
259          "Token" : "IQote///test",
260          "Expiration" : "2021-09-18T03:31:56Z"
261        }"#;
262        let parsed = parse_json_credentials(response).expect("valid JSON");
263        assert_eq!(
264            parsed,
265            JsonCredentials::RefreshableCredentials(RefreshableCredentials {
266                access_key_id: "ASIARTEST".into(),
267                secret_access_key: "xjtest".into(),
268                session_token: "IQote///test".into(),
269                expiration: UNIX_EPOCH + Duration::from_secs(1631935916),
270            })
271        )
272    }
273
274    #[test]
275    fn json_credentials_invalid_json() {
276        let error = parse_json_credentials("404: not found").expect_err("no json");
277        match error {
278            InvalidJsonCredentials::JsonError(_) => {} // ok.
279            err => panic!("incorrect error: {:?}", err),
280        }
281    }
282
283    #[test]
284    fn json_credentials_not_json_object() {
285        let error = parse_json_credentials("[1,2,3]").expect_err("no json");
286        match error {
287            InvalidJsonCredentials::JsonError(_) => {} // ok.
288            _ => panic!("incorrect error"),
289        }
290    }
291
292    #[test]
293    fn json_credentials_missing_code() {
294        let resp = r#"{
295            "LastUpdated" : "2021-09-17T20:57:08Z",
296            "Type" : "AWS-HMAC",
297            "AccessKeyId" : "ASIARTEST",
298            "SecretAccessKey" : "xjtest",
299            "Token" : "IQote///test",
300            "Expiration" : "2021-09-18T03:31:56Z"
301        }"#;
302        let parsed = parse_json_credentials(resp).expect("code not required");
303        assert_eq!(
304            parsed,
305            JsonCredentials::RefreshableCredentials(RefreshableCredentials {
306                access_key_id: "ASIARTEST".into(),
307                secret_access_key: "xjtest".into(),
308                session_token: "IQote///test".into(),
309                expiration: UNIX_EPOCH + Duration::from_secs(1631935916),
310            })
311        )
312    }
313
314    #[test]
315    fn json_credentials_required_session_token() {
316        let resp = r#"{
317            "LastUpdated" : "2021-09-17T20:57:08Z",
318            "Type" : "AWS-HMAC",
319            "AccessKeyId" : "ASIARTEST",
320            "SecretAccessKey" : "xjtest",
321            "Expiration" : "2021-09-18T03:31:56Z"
322        }"#;
323        let parsed = parse_json_credentials(resp).expect_err("token missing");
324        assert_eq!(
325            format!("{}", parsed),
326            "Expected field `Token` in response but it was missing"
327        );
328    }
329
330    #[test]
331    fn json_credentials_missing_akid() {
332        let resp = r#"{
333            "Code": "Success",
334            "LastUpdated" : "2021-09-17T20:57:08Z",
335            "Type" : "AWS-HMAC",
336            "SecretAccessKey" : "xjtest",
337            "Token" : "IQote///test",
338            "Expiration" : "2021-09-18T03:31:56Z"
339        }"#;
340        match parse_json_credentials(resp).expect_err("no code") {
341            InvalidJsonCredentials::MissingField("AccessKeyId") => {} // ok
342            resp => panic!("incorrect json_credentials response: {:?}", resp),
343        }
344    }
345
346    #[test]
347    fn json_credentials_error_response() {
348        let response = r#"{
349          "Code" : "AssumeRoleUnauthorizedAccess",
350          "Message" : "EC2 cannot assume the role integration-test.",
351          "LastUpdated" : "2021-09-17T20:46:56Z"
352        }"#;
353        let parsed = parse_json_credentials(response).expect("valid JSON");
354        assert_eq!(
355            parsed,
356            JsonCredentials::Error {
357                code: "AssumeRoleUnauthorizedAccess".into(),
358                message: "EC2 cannot assume the role integration-test.".into(),
359            }
360        );
361    }
362
363    /// Validate the specific JSON response format sent by ECS
364    #[test]
365    fn json_credentials_ecs() {
366        // identical, but extra `RoleArn` field is present
367        let response = r#"{
368            "RoleArn":"arn:aws:iam::123456789:role/ecs-task-role",
369            "AccessKeyId":"ASIARTEST",
370            "SecretAccessKey":"SECRETTEST",
371            "Token":"tokenEaCXVzLXdlc3QtMiJGMEQCIHt47W18eF4dYfSlmKGiwuJnqmIS3LMXNYfODBCEhcnaAiAnuhGOpcdIDxin4QFzhtgaCR2MpcVqR8NFJdMgOt0/xyrnAwhhEAEaDDEzNDA5NTA2NTg1NiIM9M9GT+c5UfV/8r7PKsQDUa9xE9Eprz5N+jgxbFSD2aJR2iyXCcP9Q1cOh4fdZhyw2WNmq9XnIa2tkzrreiQ5R2t+kzergJHO1KRZPfesarfJ879aWJCSocsEKh7xXwwzTsVXrNo5eWkpwTh64q+Ksz15eoaBhtrvnGvPx6SmXv7SToi/DTHFafJlT/T9jITACZvZXSE9zfLka26Rna3rI4g0ugowha//j1f/c1XuKloqshpZvMKc561om9Y5fqBv1fRiS2KhetGTcmz3wUqNQAk8Dq9oINS7cCtdIO0atqCK69UaKeJ9uKY8mzY9dFWw2IrkpOoXmA9r955iU0NOz/95jVJiPZ/8aE8vb0t67gQfzBUCfky+mGSGWAfPRXQlFa5AEulCTHPd7IcTVCtasG033oKEKgB8QnTxvM2LaPlwaaHo7MHGYXeUKbn9NRKd8m1ShwmAlr4oKp1vQp6cPHDTsdTfPTzh/ZAjUPs+ljQbAwqXbPQdUUPpOk0vltY8k6Im9EA0pf80iUNoqrixpmPsR2hzI/ybUwdh+QhvCSBx+J8KHqF6X92u4qAVYIxLy/LGZKT9YC6Kr9Gywn+Ro+EK/xl3axHPzNpbjRDJnbW3HrMw5LmmiwY6pgGWgmD6IOq4QYUtu1uhaLQZyoI5o5PWn+d3kqqxifu8D0ykldB3lQGdlJ2rjKJjCdx8fce1SoXao9cc4hiwn39hUPuTqzVwv2zbzCKmNggIpXP6gqyRtUCakf6tI7ZwqTb2S8KF3t4ElIP8i4cPdNoI0JHSC+sT4LDPpUcX1CjGxfvo55mBHJedW3LXve8TRj4UckFXT1gLuTnzqPMrC5AHz4TAt+uv",
372            "Expiration" : "2009-02-13T23:31:30Z"
373        }"#;
374        let parsed = parse_json_credentials(response).expect("valid JSON");
375        use std::borrow::Cow;
376        assert!(
377            matches!(
378                &parsed,
379                JsonCredentials::RefreshableCredentials(RefreshableCredentials{
380                    access_key_id: Cow::Borrowed("ASIARTEST"),
381                    secret_access_key: Cow::Borrowed("SECRETTEST"),
382                    session_token,
383                    expiration
384                }) if session_token.starts_with("token") && *expiration == UNIX_EPOCH + Duration::from_secs(1234567890)
385            ),
386            "{:?}",
387            parsed
388        );
389    }
390
391    #[test]
392    fn case_insensitive_code_parsing() {
393        let response = r#"{
394          "code" : "AssumeRoleUnauthorizedAccess",
395          "message" : "EC2 cannot assume the role integration-test."
396        }"#;
397        let parsed = parse_json_credentials(response).expect("valid JSON");
398        assert_eq!(
399            parsed,
400            JsonCredentials::Error {
401                code: "AssumeRoleUnauthorizedAccess".into(),
402                message: "EC2 cannot assume the role integration-test.".into(),
403            }
404        );
405    }
406}