From f9771d517bb5978c33ea0c738f4fcdd178d16823 Mon Sep 17 00:00:00 2001 From: Russell Cohen Date: Mon, 4 Oct 2021 17:46:17 -0400 Subject: [PATCH] Extract JSON credentials (#734) * Extract JSON credentials * CR feedback --- .../aws-config/src/imds/credentials.rs | 313 +----------------- .../aws-config/src/json_credentials.rs | 299 +++++++++++++++++ aws/rust-runtime/aws-config/src/lib.rs | 1 + 3 files changed, 314 insertions(+), 299 deletions(-) create mode 100644 aws/rust-runtime/aws-config/src/json_credentials.rs diff --git a/aws/rust-runtime/aws-config/src/imds/credentials.rs b/aws/rust-runtime/aws-config/src/imds/credentials.rs index b084f5713..dbecca671 100644 --- a/aws/rust-runtime/aws-config/src/imds/credentials.rs +++ b/aws/rust-runtime/aws-config/src/imds/credentials.rs @@ -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( - format!( - "Error retrieving credentials from IMDS: {} {}", - code, message - ) - .into(), - )) - } + 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>, - expiration: SystemTime, - r#type: Cow<'a, str>, - }, - Error { - code: Cow<'a, str>, - message: Cow<'a, str>, - }, -} - -impl From for InvalidImdsResponse { - fn from(err: EscapeError) -> Self { - InvalidImdsResponse::JsonError(err.into()) - } -} - -impl From for InvalidImdsResponse { - fn from(err: smithy_json::deserialize::Error) -> Self { - InvalidImdsResponse::JsonError(err.into()) - } -} - -#[derive(Debug)] -enum InvalidImdsResponse { - JsonError(Box), - 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 { - 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(), - } - ); - } -} diff --git a/aws/rust-runtime/aws-config/src/json_credentials.rs b/aws/rust-runtime/aws-config/src/json_credentials.rs new file mode 100644 index 000000000..170b62b79 --- /dev/null +++ b/aws/rust-runtime/aws-config/src/json_credentials.rs @@ -0,0 +1,299 @@ +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), + /// The response was missing a required field + MissingField(&'static str), + + /// Another unhandled error occured + Other(Cow<'static, str>), +} + +impl From for InvalidJsonCredentials { + fn from(err: EscapeError) -> Self { + InvalidJsonCredentials::JsonError(err.into()) + } +} + +impl From 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 { + 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(), + } + ); + } +} diff --git a/aws/rust-runtime/aws-config/src/lib.rs b/aws/rust-runtime/aws-config/src/lib.rs index 70bd58feb..73502d6ce 100644 --- a/aws/rust-runtime/aws-config/src/lib.rs +++ b/aws/rust-runtime/aws-config/src/lib.rs @@ -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 /// -- GitLab