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