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