Unverified Commit f9771d51 authored by Russell Cohen's avatar Russell Cohen Committed by GitHub
Browse files

Extract JSON credentials (#734)

* Extract JSON credentials

* CR feedback
parent 5b2f7c67
Loading
Loading
Loading
Loading
+14 −299
Original line number Diff line number Diff line
@@ -10,19 +10,13 @@

use crate::imds;
use crate::imds::client::{ImdsError, LazyClient};
use crate::json_credentials::{parse_json_credentials, JsonCredentials};
use crate::provider_config::ProviderConfig;
use aws_types::credentials::{future, CredentialsError, ProvideCredentials};
use aws_types::os_shim_internal::Env;
use aws_types::{credentials, Credentials};
use smithy_client::SdkError;
use smithy_json::deserialize::token::skip_value;
use smithy_json::deserialize::{json_token_iter, EscapeError, Token};
use smithy_types::instant::Format;
use smithy_types::Instant;
use std::borrow::Cow;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::time::SystemTime;

use tokio::sync::OnceCell;

/// IMDSv2 Credentials Provider
@@ -171,8 +165,8 @@ impl ImdsCredentialsProvider {
            ))
            .await
            .map_err(|e| CredentialsError::ProviderError(e.into()))?;
        match parse_imds_credentials(&credentials) {
            Ok(ImdsCredentialsResponse::Success {
        match parse_json_credentials(&credentials) {
            Ok(JsonCredentials::RefreshableCredentials {
                access_key_id,
                secret_access_key,
                session_token,
@@ -181,11 +175,11 @@ impl ImdsCredentialsProvider {
            }) => Ok(Credentials::new(
                access_key_id,
                secret_access_key,
                session_token.map(|tok| tok.to_string()),
                Some(expiration),
                Some(session_token.to_string()),
                expiration.into(),
                "IMDSv2",
            )),
            Ok(ImdsCredentialsResponse::Error { code, message })
            Ok(JsonCredentials::Error { code, message })
                if code == codes::ASSUME_ROLE_UNAUTHORIZED_ACCESS =>
            {
                Err(CredentialsError::InvalidConfiguration(
@@ -197,294 +191,15 @@ impl ImdsCredentialsProvider {
                    .into(),
                ))
            }
            Ok(ImdsCredentialsResponse::Error { code, message }) => {
                Err(CredentialsError::ProviderError(
            Ok(JsonCredentials::Error { code, message }) => Err(CredentialsError::ProviderError(
                format!(
                    "Error retrieving credentials from IMDS: {} {}",
                    code, message
                )
                .into(),
                ))
            }
            )),
            // got bad data from IMDS, should not occur during normal operation:
            Err(invalid) => Err(CredentialsError::Unhandled(invalid.into())),
        }
    }
}

/// Internal data mapping for IMDS response
#[derive(PartialEq, Eq, Debug)]
enum ImdsCredentialsResponse<'a> {
    Success {
        access_key_id: Cow<'a, str>,
        secret_access_key: Cow<'a, str>,
        session_token: Option<Cow<'a, str>>,
        expiration: SystemTime,
        r#type: Cow<'a, str>,
    },
    Error {
        code: Cow<'a, str>,
        message: Cow<'a, str>,
    },
}

impl From<EscapeError> for InvalidImdsResponse {
    fn from(err: EscapeError) -> Self {
        InvalidImdsResponse::JsonError(err.into())
    }
}

impl From<smithy_json::deserialize::Error> for InvalidImdsResponse {
    fn from(err: smithy_json::deserialize::Error) -> Self {
        InvalidImdsResponse::JsonError(err.into())
    }
}

#[derive(Debug)]
enum InvalidImdsResponse {
    JsonError(Box<dyn Error + Send + Sync>),
    Custom(Cow<'static, str>),
    MissingField(&'static str),
}

impl Display for InvalidImdsResponse {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            InvalidImdsResponse::Custom(msg) => write!(f, "{}", msg),
            InvalidImdsResponse::MissingField(field) => write!(
                f,
                "Expected field `{}` in IMDS response but it was missing",
                field
            ),
            InvalidImdsResponse::JsonError(json) => {
                write!(f, "invalid JSON in IMDS response: {}", json)
            }
        }
    }
}

impl Error for InvalidImdsResponse {}

/// Deserialize an IMDS response from a string
///
/// There are two levels of error here: the top level distinguishes between a successfully parsed
/// response from IMDS vs. something invalid / unexpected. The inner error distinguishes between
/// a successful request to IMDS that includes credentials vs. an error with a code.
fn parse_imds_credentials(
    credentials_response: &str,
) -> Result<ImdsCredentialsResponse, InvalidImdsResponse> {
    let mut tokens = json_token_iter(credentials_response.as_bytes()).peekable();
    let mut code = None;
    let mut tpe = None;
    let mut access_key_id = None;
    let mut secret_access_key = None;
    let mut session_token = None;
    let mut expiration = None;
    let mut message = None;
    if !matches!(tokens.next().transpose()?, Some(Token::StartObject { .. })) {
        return Err(InvalidImdsResponse::JsonError(
            "expected a JSON document starting with `{`".into(),
        ));
    }
    loop {
        match tokens.next().transpose()? {
            Some(Token::EndObject { .. }) => break,
            Some(Token::ObjectKey { key, .. }) => {
                if let Some(Ok(Token::ValueString { value, .. })) = tokens.peek() {
                    match key.as_escaped_str() {
                        /*
                         "Code": "Success",
                         "Type": "AWS-HMAC",
                         "AccessKeyId" : "accessKey",
                         "SecretAccessKey" : "secret",
                         "Token" : "token",
                         "Expiration" : "....",
                         "LastUpdated" : "2009-11-23T0:00:00Z"
                        */
                        "Code" => code = Some(value.to_unescaped()?),
                        "Type" => tpe = Some(value.to_unescaped()?),
                        "AccessKeyId" => access_key_id = Some(value.to_unescaped()?),
                        "SecretAccessKey" => secret_access_key = Some(value.to_unescaped()?),
                        "Token" => session_token = Some(value.to_unescaped()?),
                        "Expiration" => expiration = Some(value.to_unescaped()?),

                        // Error case handling: message will be set
                        "Message" => message = Some(value.to_unescaped()?),
                        _ => {}
                    }
                }
                skip_value(&mut tokens)?;
            }
            other => {
                return Err(InvalidImdsResponse::Custom(
                    format!("expected object key, found: {:?}", other,).into(),
                ));
            }
        }
    }
    if tokens.next().is_some() {
        return Err(InvalidImdsResponse::Custom(
            "found more JSON tokens after completing parsing".into(),
        ));
    }
    match code {
        // IMDS does not appear to reply with an `Code` missing, but documentation indicates it
        // may be possible
        None | Some(Cow::Borrowed("Success")) => {
            let tpe = tpe.ok_or(InvalidImdsResponse::MissingField("Type"))?;
            let access_key_id =
                access_key_id.ok_or(InvalidImdsResponse::MissingField("AccessKeyId"))?;
            let secret_access_key =
                secret_access_key.ok_or(InvalidImdsResponse::MissingField("SecretAccessKey"))?;
            let expiration = expiration.ok_or(InvalidImdsResponse::MissingField("Expiration"))?;
            let expiration = Instant::from_str(expiration.as_ref(), Format::DateTime)
                .map_err(|err| {
                    InvalidImdsResponse::Custom(format!("invalid date: {}", err).into())
                })?
                .to_system_time()
                .ok_or_else(|| {
                    InvalidImdsResponse::Custom("invalid expiration (prior to unix epoch)".into())
                })?;
            Ok(ImdsCredentialsResponse::Success {
                access_key_id,
                secret_access_key,
                r#type: tpe,
                session_token,
                expiration,
            })
        }
        Some(other) => Ok(ImdsCredentialsResponse::Error {
            code: other,
            message: message.unwrap_or_else(|| "no message".into()),
        }),
    }
}

#[cfg(test)]
mod test {
    use crate::imds::credentials::{
        parse_imds_credentials, ImdsCredentialsResponse, InvalidImdsResponse,
    };
    use std::time::{Duration, UNIX_EPOCH};

    #[test]
    fn imds_success_response() {
        let response = r#"
        {
          "Code" : "Success",
          "LastUpdated" : "2021-09-17T20:57:08Z",
          "Type" : "AWS-HMAC",
          "AccessKeyId" : "ASIARTEST",
          "SecretAccessKey" : "xjtest",
          "Token" : "IQote///test",
          "Expiration" : "2021-09-18T03:31:56Z"
        }"#;
        let parsed = parse_imds_credentials(response).expect("valid JSON");
        assert_eq!(
            parsed,
            ImdsCredentialsResponse::Success {
                r#type: "AWS-HMAC".into(),
                access_key_id: "ASIARTEST".into(),
                secret_access_key: "xjtest".into(),
                session_token: Some("IQote///test".into()),
                expiration: UNIX_EPOCH + Duration::from_secs(1631935916),
            }
        )
    }

    #[test]
    fn imds_invalid_json() {
        let error = parse_imds_credentials("404: not found").expect_err("no json");
        match error {
            InvalidImdsResponse::JsonError(_) => {} // ok.
            err => panic!("incorrect error: {:?}", err),
        }
    }

    #[test]
    fn imds_not_json_object() {
        let error = parse_imds_credentials("[1,2,3]").expect_err("no json");
        match error {
            InvalidImdsResponse::JsonError(_) => {} // ok.
            _ => panic!("incorrect error"),
        }
    }

    #[test]
    fn imds_missing_code() {
        let resp = r#"{
            "LastUpdated" : "2021-09-17T20:57:08Z",
            "Type" : "AWS-HMAC",
            "AccessKeyId" : "ASIARTEST",
            "SecretAccessKey" : "xjtest",
            "Token" : "IQote///test",
            "Expiration" : "2021-09-18T03:31:56Z"
        }"#;
        let parsed = parse_imds_credentials(resp).expect("code not required");
        assert_eq!(
            parsed,
            ImdsCredentialsResponse::Success {
                r#type: "AWS-HMAC".into(),
                access_key_id: "ASIARTEST".into(),
                secret_access_key: "xjtest".into(),
                session_token: Some("IQote///test".into()),
                expiration: UNIX_EPOCH + Duration::from_secs(1631935916),
            }
        )
    }

    #[test]
    fn imds_optional_session_token() {
        let resp = r#"{
            "LastUpdated" : "2021-09-17T20:57:08Z",
            "Type" : "AWS-HMAC",
            "AccessKeyId" : "ASIARTEST",
            "SecretAccessKey" : "xjtest",
            "Expiration" : "2021-09-18T03:31:56Z"
        }"#;
        let parsed = parse_imds_credentials(resp).expect("code not required");
        assert_eq!(
            parsed,
            ImdsCredentialsResponse::Success {
                r#type: "AWS-HMAC".into(),
                access_key_id: "ASIARTEST".into(),
                secret_access_key: "xjtest".into(),
                session_token: None,
                expiration: UNIX_EPOCH + Duration::from_secs(1631935916),
            }
        )
    }

    #[test]
    fn imds_missing_akid() {
        let resp = r#"{
            "Code": "Success",
            "LastUpdated" : "2021-09-17T20:57:08Z",
            "Type" : "AWS-HMAC",
            "SecretAccessKey" : "xjtest",
            "Token" : "IQote///test",
            "Expiration" : "2021-09-18T03:31:56Z"
        }"#;
        match parse_imds_credentials(resp).expect_err("no code") {
            InvalidImdsResponse::MissingField("AccessKeyId") => {} // ok
            resp => panic!("incorrect imds response: {:?}", resp),
        }
    }

    #[test]
    fn imds_error_response() {
        let response = r#"{
          "Code" : "AssumeRoleUnauthorizedAccess",
          "Message" : "EC2 cannot assume the role integration-test.",
          "LastUpdated" : "2021-09-17T20:46:56Z"
        }"#;
        let parsed = parse_imds_credentials(response).expect("valid JSON");
        assert_eq!(
            parsed,
            ImdsCredentialsResponse::Error {
                code: "AssumeRoleUnauthorizedAccess".into(),
                message: "EC2 cannot assume the role integration-test.".into(),
            }
        );
    }
}
+299 −0
Original line number Diff line number Diff line
use smithy_json::deserialize::token::skip_value;
use smithy_json::deserialize::{json_token_iter, EscapeError, Token};
use smithy_types::instant::Format;
use smithy_types::Instant;
use std::borrow::Cow;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::time::SystemTime;

#[derive(Debug)]
pub(crate) enum InvalidJsonCredentials {
    /// The response did not contain valid JSON
    JsonError(Box<dyn Error + Send + Sync>),
    /// The response was missing a required field
    MissingField(&'static str),

    /// Another unhandled error occured
    Other(Cow<'static, str>),
}

impl From<EscapeError> for InvalidJsonCredentials {
    fn from(err: EscapeError) -> Self {
        InvalidJsonCredentials::JsonError(err.into())
    }
}

impl From<smithy_json::deserialize::Error> for InvalidJsonCredentials {
    fn from(err: smithy_json::deserialize::Error) -> Self {
        InvalidJsonCredentials::JsonError(err.into())
    }
}

impl Display for InvalidJsonCredentials {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            InvalidJsonCredentials::JsonError(json) => {
                write!(f, "invalid JSON in response: {}", json)
            }
            InvalidJsonCredentials::MissingField(field) => write!(
                f,
                "Expected field `{}` in response but it was missing",
                field
            ),
            InvalidJsonCredentials::Other(msg) => write!(f, "{}", msg),
        }
    }
}

impl Error for InvalidJsonCredentials {}

#[non_exhaustive]
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum JsonCredentials<'a> {
    RefreshableCredentials {
        access_key_id: Cow<'a, str>,
        secret_access_key: Cow<'a, str>,
        session_token: Cow<'a, str>,
        expiration: SystemTime,
    },
    Error {
        code: Cow<'a, str>,
        message: Cow<'a, str>,
    }, // TODO(GeneralizedHttpCredentials): Add support for static credentials:
       //  {
       //    "AccessKeyId" : "MUA...",
       //    "SecretAccessKey" : "/7PC5om...."
       //  }

       // TODO(GeneralizedHttpCredentials): Add support for Assume role credentials:
       //   {
       //     // fields to construct STS client:
       //     "Region": "sts-region-name",
       //     "AccessKeyId" : "MUA...",
       //     "Expiration" : "2016-02-25T06:03:31Z", // optional
       //     "SecretAccessKey" : "/7PC5om....",
       //     "Token" : "AQoDY....=", // optional
       //     // fields controlling the STS role:
       //     "RoleArn": "...", // required
       //     "RoleSessionName": "...", // required
       //     // and also: DurationSeconds, ExternalId, SerialNumber, TokenCode, Policy
       //     ...
       //   }
}

/// Deserialize an IMDS response from a string
///
/// There are two levels of error here: the top level distinguishes between a successfully parsed
/// response from the credential provider vs. something invalid / unexpected. The inner error
/// distinguishes between a successful response that contains credentials vs. an error with a code and
/// error message.
pub(crate) fn parse_json_credentials(
    credentials_response: &str,
) -> Result<JsonCredentials, InvalidJsonCredentials> {
    let mut tokens = json_token_iter(credentials_response.as_bytes()).peekable();
    let mut code = None;
    let mut access_key_id = None;
    let mut secret_access_key = None;
    let mut session_token = None;
    let mut expiration = None;
    let mut message = None;
    if !matches!(tokens.next().transpose()?, Some(Token::StartObject { .. })) {
        return Err(InvalidJsonCredentials::JsonError(
            "expected a JSON document starting with `{`".into(),
        ));
    }
    loop {
        match tokens.next().transpose()? {
            Some(Token::EndObject { .. }) => break,
            Some(Token::ObjectKey { key, .. }) => {
                if let Some(Ok(Token::ValueString { value, .. })) = tokens.peek() {
                    match key.as_escaped_str() {
                        /*
                         "Code": "Success",
                         "Type": "AWS-HMAC",
                         "AccessKeyId" : "accessKey",
                         "SecretAccessKey" : "secret",
                         "Token" : "token",
                         "Expiration" : "....",
                         "LastUpdated" : "2009-11-23T0:00:00Z"
                        */
                        "Code" => code = Some(value.to_unescaped()?),
                        "AccessKeyId" => access_key_id = Some(value.to_unescaped()?),
                        "SecretAccessKey" => secret_access_key = Some(value.to_unescaped()?),
                        "Token" => session_token = Some(value.to_unescaped()?),
                        "Expiration" => expiration = Some(value.to_unescaped()?),

                        // Error case handling: message will be set
                        "Message" => message = Some(value.to_unescaped()?),
                        _ => {}
                    }
                }
                skip_value(&mut tokens)?;
            }
            other => {
                return Err(InvalidJsonCredentials::Other(
                    format!("expected object key, found: {:?}", other,).into(),
                ));
            }
        }
    }
    if tokens.next().is_some() {
        return Err(InvalidJsonCredentials::Other(
            "found more JSON tokens after completing parsing".into(),
        ));
    }
    match code {
        // IMDS does not appear to reply with an `Code` missing, but documentation indicates it
        // may be possible
        None | Some(Cow::Borrowed("Success")) => {
            let access_key_id =
                access_key_id.ok_or(InvalidJsonCredentials::MissingField("AccessKeyId"))?;
            let secret_access_key =
                secret_access_key.ok_or(InvalidJsonCredentials::MissingField("SecretAccessKey"))?;
            let session_token =
                session_token.ok_or(InvalidJsonCredentials::MissingField("Token"))?;
            let expiration =
                expiration.ok_or(InvalidJsonCredentials::MissingField("Expiration"))?;
            let expiration = Instant::from_str(expiration.as_ref(), Format::DateTime)
                .map_err(|err| {
                    InvalidJsonCredentials::Other(format!("invalid date: {}", err).into())
                })?
                .to_system_time()
                .ok_or_else(|| {
                    InvalidJsonCredentials::Other("invalid expiration (prior to unix epoch)".into())
                })?;
            Ok(JsonCredentials::RefreshableCredentials {
                access_key_id,
                secret_access_key,
                session_token,
                expiration,
            })
        }
        Some(other) => Ok(JsonCredentials::Error {
            code: other,
            message: message.unwrap_or_else(|| "no message".into()),
        }),
    }
}

#[cfg(test)]
mod test {
    use crate::json_credentials::{
        parse_json_credentials, InvalidJsonCredentials, JsonCredentials,
    };
    use std::time::{Duration, UNIX_EPOCH};

    #[test]
    fn json_credentials_success_response() {
        let response = r#"
        {
          "Code" : "Success",
          "LastUpdated" : "2021-09-17T20:57:08Z",
          "Type" : "AWS-HMAC",
          "AccessKeyId" : "ASIARTEST",
          "SecretAccessKey" : "xjtest",
          "Token" : "IQote///test",
          "Expiration" : "2021-09-18T03:31:56Z"
        }"#;
        let parsed = parse_json_credentials(response).expect("valid JSON");
        assert_eq!(
            parsed,
            JsonCredentials::RefreshableCredentials {
                access_key_id: "ASIARTEST".into(),
                secret_access_key: "xjtest".into(),
                session_token: "IQote///test".into(),
                expiration: UNIX_EPOCH + Duration::from_secs(1631935916),
            }
        )
    }

    #[test]
    fn json_credentials_invalid_json() {
        let error = parse_json_credentials("404: not found").expect_err("no json");
        match error {
            InvalidJsonCredentials::JsonError(_) => {} // ok.
            err => panic!("incorrect error: {:?}", err),
        }
    }

    #[test]
    fn json_credentials_not_json_object() {
        let error = parse_json_credentials("[1,2,3]").expect_err("no json");
        match error {
            InvalidJsonCredentials::JsonError(_) => {} // ok.
            _ => panic!("incorrect error"),
        }
    }

    #[test]
    fn json_credentials_missing_code() {
        let resp = r#"{
            "LastUpdated" : "2021-09-17T20:57:08Z",
            "Type" : "AWS-HMAC",
            "AccessKeyId" : "ASIARTEST",
            "SecretAccessKey" : "xjtest",
            "Token" : "IQote///test",
            "Expiration" : "2021-09-18T03:31:56Z"
        }"#;
        let parsed = parse_json_credentials(resp).expect("code not required");
        assert_eq!(
            parsed,
            JsonCredentials::RefreshableCredentials {
                access_key_id: "ASIARTEST".into(),
                secret_access_key: "xjtest".into(),
                session_token: "IQote///test".into(),
                expiration: UNIX_EPOCH + Duration::from_secs(1631935916),
            }
        )
    }

    #[test]
    fn json_credentials_required_session_token() {
        let resp = r#"{
            "LastUpdated" : "2021-09-17T20:57:08Z",
            "Type" : "AWS-HMAC",
            "AccessKeyId" : "ASIARTEST",
            "SecretAccessKey" : "xjtest",
            "Expiration" : "2021-09-18T03:31:56Z"
        }"#;
        let parsed = parse_json_credentials(resp).expect_err("token missing");
        assert_eq!(
            format!("{}", parsed),
            "Expected field `Token` in response but it was missing"
        );
    }

    #[test]
    fn json_credentials_missing_akid() {
        let resp = r#"{
            "Code": "Success",
            "LastUpdated" : "2021-09-17T20:57:08Z",
            "Type" : "AWS-HMAC",
            "SecretAccessKey" : "xjtest",
            "Token" : "IQote///test",
            "Expiration" : "2021-09-18T03:31:56Z"
        }"#;
        match parse_json_credentials(resp).expect_err("no code") {
            InvalidJsonCredentials::MissingField("AccessKeyId") => {} // ok
            resp => panic!("incorrect json_credentials response: {:?}", resp),
        }
    }

    #[test]
    fn json_credentials_error_response() {
        let response = r#"{
          "Code" : "AssumeRoleUnauthorizedAccess",
          "Message" : "EC2 cannot assume the role integration-test.",
          "LastUpdated" : "2021-09-17T20:46:56Z"
        }"#;
        let parsed = parse_json_credentials(response).expect("valid JSON");
        assert_eq!(
            parsed,
            JsonCredentials::Error {
                code: "AssumeRoleUnauthorizedAccess".into(),
                message: "EC2 cannot assume the role integration-test.".into(),
            }
        );
    }
}
+1 −0
Original line number Diff line number Diff line
@@ -72,6 +72,7 @@ pub mod provider_config;
mod cache;
#[cfg(feature = "imds")]
pub mod imds;
mod json_credentials;

/// Create an environment loader for AWS Configuration
///