diff --git a/.github/workflows/pull-request-bot.yml b/.github/workflows/pull-request-bot.yml index b3785c3da1..2bc040a5e7 100644 --- a/.github/workflows/pull-request-bot.yml +++ b/.github/workflows/pull-request-bot.yml @@ -104,11 +104,11 @@ jobs: toolchain: ${{ env.rust_version }} default: true - name: Generate doc preview - # Only generate three of the smallest services since the doc build can be very large. One of these must be - # STS since aws-config depends on it. STS and Transcribe Streaming and DynamoDB (paginators/waiters) were chosen + # Only generate three of the smallest services since the doc build can be very large. STS and SSO must be + # included since aws-config depends on them. Transcribe Streaming and DynamoDB (paginators/waiters) were chosen # below to stay small while still representing most features. Combined, they are about ~20MB at time of writing. run: | - ./gradlew -Paws.services=+sts,+transcribestreaming,+dynamodb :aws:sdk:assemble + ./gradlew -Paws.services=+sts,+sso,+transcribestreaming,+dynamodb :aws:sdk:assemble # Copy the Server runtime crate(s) in cp -r rust-runtime/aws-smithy-http-server aws/sdk/build/aws-sdk/sdk diff --git a/CHANGELOG.next.toml b/CHANGELOG.next.toml index bac8042dd4..bb0502deba 100644 --- a/CHANGELOG.next.toml +++ b/CHANGELOG.next.toml @@ -16,3 +16,9 @@ message = "The docs for fluent builders now have easy links to their correspondi references = ["aws-sdk-rust#348"] meta = { "breaking" = false, "tada" = true, "bug" = false } author = "Velfi" + +[[aws-sdk-rust]] +message = "Add support for SSO credentials" +references = ["smithy-rs#1051", "aws-sdk-rust#4"] +meta = { "breaking" = false, "tada" = true, "bug" = false } +author = "rcoh" diff --git a/aws/rust-runtime/aws-config/Cargo.toml b/aws/rust-runtime/aws-config/Cargo.toml index cc1530cf47..e3854c3197 100644 --- a/aws/rust-runtime/aws-config/Cargo.toml +++ b/aws/rust-runtime/aws-config/Cargo.toml @@ -17,6 +17,7 @@ default = ["rustls", "rt-tokio"] [dependencies] aws-sdk-sts = { path = "../../sdk/build/aws-sdk/sdk/sts", default-features = false } +aws-sdk-sso = { path = "../../sdk/build/aws-sdk/sdk/sso", default-features = false } aws-smithy-async = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-async" } aws-smithy-client = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-client" } aws-smithy-types = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-types" } @@ -29,6 +30,12 @@ aws-http = { path = "../../sdk/build/aws-sdk/sdk/aws-http" } aws-smithy-http = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-http" } aws-smithy-http-tower = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-http-tower" } aws-smithy-json = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-json" } + +# implementation detail of SSO credential caching +ring = "0.16" +hex = "0.4.3" +zeroize = "1" + bytes = "1.1.0" http = "0.2.4" tower = { version = "0.4.8" } diff --git a/aws/rust-runtime/aws-config/src/default_provider.rs b/aws/rust-runtime/aws-config/src/default_provider.rs index dae6da5ca6..af3a0946fd 100644 --- a/aws/rust-runtime/aws-config/src/default_provider.rs +++ b/aws/rust-runtime/aws-config/src/default_provider.rs @@ -676,6 +676,9 @@ pub mod credentials { make_test!(ecs_assume_role); make_test!(ecs_credentials); + make_test!(sso_assume_role); + make_test!(sso_no_token_file); + #[tokio::test] async fn profile_name_override() { let (_, conf) = diff --git a/aws/rust-runtime/aws-config/src/fs_util.rs b/aws/rust-runtime/aws-config/src/fs_util.rs new file mode 100644 index 0000000000..22b6cebad7 --- /dev/null +++ b/aws/rust-runtime/aws-config/src/fs_util.rs @@ -0,0 +1,62 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +use aws_types::os_shim_internal; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) enum Os { + Windows, + NotWindows, +} + +impl Os { + pub fn real() -> Self { + match std::env::consts::OS { + "windows" => Os::Windows, + _ => Os::NotWindows, + } + } +} + +/// Resolve a home directory given a set of environment variables +pub(crate) fn home_dir(env_var: &os_shim_internal::Env, os: Os) -> Option { + if let Ok(home) = env_var.get("HOME") { + tracing::debug!(src = "HOME", "loaded home directory"); + return Some(home); + } + + if os == Os::Windows { + if let Ok(home) = env_var.get("USERPROFILE") { + tracing::debug!(src = "USERPROFILE", "loaded home directory"); + return Some(home); + } + + let home_drive = env_var.get("HOMEDRIVE"); + let home_path = env_var.get("HOMEPATH"); + tracing::debug!(src = "HOMEDRIVE/HOMEPATH", "loaded home directory"); + if let (Ok(mut drive), Ok(path)) = (home_drive, home_path) { + drive.push_str(&path); + return Some(drive); + } + } + None +} + +#[cfg(test)] +mod test { + use super::*; + use aws_types::os_shim_internal::Env; + + #[test] + fn homedir_profile_only_windows() { + // windows specific variables should only be considered when the platform is windows + let env = Env::from_slice(&[("USERPROFILE", "C:\\Users\\name")]); + assert_eq!( + home_dir(&env, Os::Windows), + Some("C:\\Users\\name".to_string()) + ); + assert_eq!(home_dir(&env, Os::NotWindows), None); + } +} diff --git a/aws/rust-runtime/aws-config/src/json_credentials.rs b/aws/rust-runtime/aws-config/src/json_credentials.rs index 679b6c12a8..22a5f00c8b 100644 --- a/aws/rust-runtime/aws-config/src/json_credentials.rs +++ b/aws/rust-runtime/aws-config/src/json_credentials.rs @@ -20,6 +20,12 @@ pub(crate) enum InvalidJsonCredentials { /// The response was missing a required field MissingField(&'static str), + /// A field was invalid + InvalidField { + field: &'static str, + err: Box, + }, + /// Another unhandled error occurred Other(Cow<'static, str>), } @@ -48,6 +54,9 @@ impl Display for InvalidJsonCredentials { field ), InvalidJsonCredentials::Other(msg) => write!(f, "{}", msg), + InvalidJsonCredentials::InvalidField { field, err } => { + write!(f, "Invalid field in response: `{}`. {}", field, err) + } } } } @@ -99,68 +108,34 @@ pub(crate) enum JsonCredentials<'a> { 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.to_unescaped()? { - /* - "Code": "Success", - "Type": "AWS-HMAC", - "AccessKeyId" : "accessKey", - "SecretAccessKey" : "secret", - "Token" : "token", - "Expiration" : "....", - "LastUpdated" : "2009-11-23T0:00:00Z" - */ - c if c.eq_ignore_ascii_case("Code") => code = Some(value.to_unescaped()?), - c if c.eq_ignore_ascii_case("AccessKeyId") => { - access_key_id = Some(value.to_unescaped()?) - } - c if c.eq_ignore_ascii_case("SecretAccessKey") => { - secret_access_key = Some(value.to_unescaped()?) - } - c if c.eq_ignore_ascii_case("Token") => { - session_token = Some(value.to_unescaped()?) - } - c if c.eq_ignore_ascii_case("Expiration") => { - expiration = Some(value.to_unescaped()?) - } + json_parse_loop(credentials_response.as_bytes(), |key, value| { + match key { + /* + "Code": "Success", + "Type": "AWS-HMAC", + "AccessKeyId" : "accessKey", + "SecretAccessKey" : "secret", + "Token" : "token", + "Expiration" : "....", + "LastUpdated" : "2009-11-23T0:00:00Z" + */ + c if c.eq_ignore_ascii_case("Code") => code = Some(value), + c if c.eq_ignore_ascii_case("AccessKeyId") => access_key_id = Some(value), + c if c.eq_ignore_ascii_case("SecretAccessKey") => secret_access_key = Some(value), + c if c.eq_ignore_ascii_case("Token") => session_token = Some(value), + c if c.eq_ignore_ascii_case("Expiration") => expiration = Some(value), - // Error case handling: message will be set - c if c.eq_ignore_ascii_case("Message") => { - message = Some(value.to_unescaped()?) - } - _ => {} - } - } - skip_value(&mut tokens)?; - } - other => { - return Err(InvalidJsonCredentials::Other( - format!("expected object key, found: {:?}", other,).into(), - )); - } + // Error case handling: message will be set + c if c.eq_ignore_ascii_case("Message") => message = Some(value), + _ => {} } - } - 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 a `Code` missing, but documentation indicates it // may be possible @@ -175,7 +150,10 @@ pub(crate) fn parse_json_credentials( expiration.ok_or(InvalidJsonCredentials::MissingField("Expiration"))?; let expiration = SystemTime::try_from( DateTime::from_str(expiration.as_ref(), Format::DateTime).map_err(|err| { - InvalidJsonCredentials::Other(format!("invalid date: {}", err).into()) + InvalidJsonCredentials::InvalidField { + field: "Expiration", + err: err.into(), + } })?, ) .map_err(|_| { @@ -197,6 +175,42 @@ pub(crate) fn parse_json_credentials( } } +pub(crate) fn json_parse_loop<'a>( + input: &'a [u8], + mut f: impl FnMut(Cow<'a, str>, Cow<'a, str>), +) -> Result<(), InvalidJsonCredentials> { + let mut tokens = json_token_iter(input).peekable(); + 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() { + let key = key.to_unescaped()?; + let value = value.to_unescaped()?; + f(key, value) + } + 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(), + )); + } + Ok(()) +} + #[cfg(test)] mod test { use crate::json_credentials::{ diff --git a/aws/rust-runtime/aws-config/src/lib.rs b/aws/rust-runtime/aws-config/src/lib.rs index 2bf8301eac..d9833bd3d0 100644 --- a/aws/rust-runtime/aws-config/src/lib.rs +++ b/aws/rust-runtime/aws-config/src/lib.rs @@ -76,7 +76,9 @@ pub mod imds; mod json_credentials; +mod fs_util; mod http_provider; +pub mod sso; // Re-export types from smithy-types pub use aws_smithy_types::retry::RetryConfig; diff --git a/aws/rust-runtime/aws-config/src/profile/credentials.rs b/aws/rust-runtime/aws-config/src/profile/credentials.rs index ec4b9d0f14..439b1ee08d 100644 --- a/aws/rust-runtime/aws-config/src/profile/credentials.rs +++ b/aws/rust-runtime/aws-config/src/profile/credentials.rs @@ -29,17 +29,14 @@ use std::fmt::{Display, Formatter}; use std::sync::Arc; use aws_types::credentials::{self, future, CredentialsError, ProvideCredentials}; -use aws_types::os_shim_internal::{Env, Fs}; -use aws_types::region::Region; + use tracing::Instrument; -use crate::connector::expect_connector; -use crate::meta::region::ProvideRegion; use crate::profile::credentials::exec::named::NamedProviderFactory; use crate::profile::credentials::exec::{ClientConfiguration, ProviderChain}; use crate::profile::parser::ProfileParseError; +use crate::profile::Profile; use crate::provider_config::ProviderConfig; -use aws_smithy_client::erase::DynConnector; mod exec; mod repr; @@ -125,6 +122,18 @@ impl ProvideCredentials for ProfileFileCredentialsProvider { /// /// Other more complex configurations are possible, consult `test-data/assume-role-tests.json`. /// +/// ### Loading Credentials from SSO +/// ```ini +/// [default] +/// sso_start_url = https://example.com/start +/// sso_region = us-east-2 +/// sso_account_id = 123456789011 +/// sso_role_name = readOnly +/// region = us-west-2 +/// ``` +/// +/// SSO can also be used as a source profile for assume role chains. +/// /// ## Location of Profile Files /// * The location of the config file will be loaded from the `AWS_CONFIG_FILE` environment variable /// with a fallback to `~/.aws/config` @@ -146,10 +155,7 @@ impl ProvideCredentials for ProfileFileCredentialsProvider { pub struct ProfileFileCredentialsProvider { factory: NamedProviderFactory, client_config: ClientConfiguration, - fs: Fs, - env: Env, - region: Option, - connector: DynConnector, + provider_config: ProviderConfig, profile_override: Option, } @@ -160,16 +166,8 @@ impl ProfileFileCredentialsProvider { } async fn load_credentials(&self) -> credentials::Result { - // 1. grab a read lock, use it to see if the base profile has already been loaded - // 2. If it's loaded, great, lets use it. - // If not, upgrade to a write lock and use that to load the profile file. - // 3. Finally, downgrade to ensure no one swapped in the intervening time, then use try_load() - // to pull the new state. let profile = build_provider_chain( - &self.fs, - &self.env, - &self.region, - &self.connector, + &self.provider_config, &self.factory, self.profile_override.as_deref(), ) @@ -279,6 +277,15 @@ pub enum ProfileFileError { }, } +impl ProfileFileError { + fn missing_field(profile: &Profile, field: &'static str) -> Self { + ProfileFileError::MissingProfile { + profile: profile.name().to_string(), + message: format!("`{}` was missing", field).into(), + } + } +} + impl Display for ProfileFileError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -428,39 +435,34 @@ impl Builder { ) }); let factory = exec::named::NamedProviderFactory::new(named_providers); - let connector = expect_connector(conf.default_connector()); - let core_client = conf.sdk_client(); + let core_client = conf.sts_client(); ProfileFileCredentialsProvider { factory, client_config: ClientConfiguration { - core_client, + sts_client: core_client, region: conf.region(), }, - fs: conf.fs(), - env: conf.env(), - region: conf.region(), - connector, + provider_config: conf, profile_override: self.profile_override, } } } async fn build_provider_chain( - fs: &Fs, - env: &Env, - region: &dyn ProvideRegion, - connector: &DynConnector, + provider_config: &ProviderConfig, factory: &NamedProviderFactory, profile_override: Option<&str>, ) -> Result { - let profile_set = super::parser::load(fs, env).await.map_err(|err| { - tracing::warn!(err = %err, "failed to parse profile"); - ProfileFileError::CouldNotParseProfile(err) - })?; + let profile_set = super::parser::load(&provider_config.fs(), &provider_config.env()) + .await + .map_err(|err| { + tracing::warn!(err = %err, "failed to parse profile"); + ProfileFileError::CouldNotParseProfile(err) + })?; let repr = repr::resolve_chain(&profile_set, profile_override)?; tracing::info!(chain = ?repr, "constructed abstract provider from config file"); - exec::ProviderChain::from_repr(fs.clone(), connector, region.region().await, repr, factory) + exec::ProviderChain::from_repr(provider_config, repr, factory) } #[cfg(test)] diff --git a/aws/rust-runtime/aws-config/src/profile/credentials/exec.rs b/aws/rust-runtime/aws-config/src/profile/credentials/exec.rs index b65b11c3e7..d56e34ed6c 100644 --- a/aws/rust-runtime/aws-config/src/profile/credentials/exec.rs +++ b/aws/rust-runtime/aws-config/src/profile/credentials/exec.rs @@ -13,12 +13,13 @@ use super::repr::{self, BaseProvider}; use crate::profile::credentials::ProfileFileError; use crate::provider_config::ProviderConfig; +use crate::sso::{SsoConfig, SsoCredentialsProvider}; use crate::sts; use crate::web_identity_token::{StaticConfiguration, WebIdentityTokenCredentialsProvider}; use aws_sdk_sts::middleware::DefaultMiddleware; use aws_smithy_client::erase::DynConnector; use aws_types::credentials::{self, CredentialsError, ProvideCredentials}; -use aws_types::os_shim_internal::Fs; + use std::fmt::Debug; #[derive(Debug)] @@ -30,7 +31,7 @@ pub struct AssumeRoleProvider { #[derive(Debug)] pub struct ClientConfiguration { - pub(crate) core_client: aws_smithy_client::Client, + pub(crate) sts_client: aws_smithy_client::Client, pub(crate) region: Option, } @@ -59,7 +60,7 @@ impl AssumeRoleProvider { .await .expect("valid operation"); let assume_role_creds = client_config - .core_client + .sts_client .call(operation) .await .map_err(CredentialsError::provider_error)? @@ -86,9 +87,7 @@ impl ProviderChain { impl ProviderChain { pub fn from_repr( - fs: Fs, - connector: &DynConnector, - region: Option, + provider_config: &ProviderConfig, repr: repr::ProfileChain, factory: &named::NamedProviderFactory, ) -> Result { @@ -106,10 +105,6 @@ impl ProviderChain { web_identity_token_file, session_name, } => { - let conf = ProviderConfig::empty() - .with_http_connector(connector.clone()) - .with_fs(fs) - .with_region(region); let provider = WebIdentityTokenCredentialsProvider::builder() .static_configuration(StaticConfiguration { web_identity_token_file: web_identity_token_file.into(), @@ -118,10 +113,24 @@ impl ProviderChain { || sts::util::default_session_name("web-identity-token-profile"), ), }) - .configure(&conf) + .configure(provider_config) .build(); Arc::new(provider) } + BaseProvider::Sso { + sso_account_id, + sso_region, + sso_role_name, + sso_start_url, + } => { + let sso_config = SsoConfig { + account_id: sso_account_id.to_string(), + role_name: sso_role_name.to_string(), + start_url: sso_start_url.to_string(), + region: Region::new(sso_region.to_string()), + }; + Arc::new(SsoCredentialsProvider::new(provider_config, sso_config)) + } }; tracing::info!(base = ?repr.base(), "first credentials will be loaded from {:?}", repr.base()); let chain = repr @@ -179,8 +188,9 @@ mod test { use crate::profile::credentials::exec::named::NamedProviderFactory; use crate::profile::credentials::exec::ProviderChain; use crate::profile::credentials::repr::{BaseProvider, ProfileChain}; + use crate::provider_config::ProviderConfig; use crate::test_case::no_traffic_connector; - use aws_sdk_sts::Region; + use aws_types::Credentials; use std::collections::HashMap; use std::sync::Arc; @@ -203,9 +213,7 @@ mod test { fn error_on_unknown_provider() { let factory = NamedProviderFactory::new(HashMap::new()); let chain = ProviderChain::from_repr( - Default::default(), - &no_traffic_connector(), - Some(Region::new("us-east-1")), + &ProviderConfig::empty().with_http_connector(no_traffic_connector()), ProfileChain { base: BaseProvider::NamedSource("floozle"), chain: vec![], diff --git a/aws/rust-runtime/aws-config/src/profile/credentials/repr.rs b/aws/rust-runtime/aws-config/src/profile/credentials/repr.rs index 4c8e3d2c58..5e7ab250b1 100644 --- a/aws/rust-runtime/aws-config/src/profile/credentials/repr.rs +++ b/aws/rust-runtime/aws-config/src/profile/credentials/repr.rs @@ -73,16 +73,15 @@ pub enum BaseProvider<'a> { role_arn: &'a str, web_identity_token_file: &'a str, session_name: Option<&'a str>, - }, // TODO(https://github.com/awslabs/aws-sdk-rust/issues/4): add SSO support - /* - /// An SSO Provider - Sso { - sso_account_id: &'a str, - sso_region: &'a str, - sso_role_name: &'a str, - sso_start_url: &'a str, - }, - */ + }, + + /// An SSO Provider + Sso { + sso_account_id: &'a str, + sso_region: &'a str, + sso_role_name: &'a str, + sso_start_url: &'a str, + }, } /// A profile that specifies a role to assume @@ -194,6 +193,13 @@ mod role { pub const SOURCE_PROFILE: &str = "source_profile"; } +mod sso { + pub const ACCOUNT_ID: &str = "sso_account_id"; + pub const REGION: &str = "sso_region"; + pub const ROLE_NAME: &str = "sso_role_name"; + pub const START_URL: &str = "sso_start_url"; +} + mod web_identity_token { pub const TOKEN_FILE: &str = "web_identity_token_file"; } @@ -210,6 +216,7 @@ fn base_provider(profile: &Profile) -> Result { match profile.get(role::CREDENTIAL_SOURCE) { Some(source) => Ok(BaseProvider::NamedSource(source)), None => web_identity_token_from_profile(profile) + .or_else(|| sso_from_profile(profile)) .unwrap_or_else(|| Ok(BaseProvider::AccessKey(static_creds_from_profile(profile)?))), } } @@ -261,6 +268,41 @@ fn role_arn_from_profile(profile: &Profile) -> Option { }) } +fn sso_from_profile(profile: &Profile) -> Option> { + /* + Sample: + [profile sample-profile] + sso_account_id = 012345678901 + sso_region = us-east-1 + sso_role_name = SampleRole + sso_start_url = https://d-abc123.awsapps.com/start-beta + */ + let account_id = profile.get(sso::ACCOUNT_ID); + let region = profile.get(sso::REGION); + let role_name = profile.get(sso::ROLE_NAME); + let start_url = profile.get(sso::START_URL); + if [account_id, region, role_name, start_url] + .iter() + .all(|field| field.is_none()) + { + return None; + } + let missing_field = |s| move || ProfileFileError::missing_field(profile, s); + let parse_profile = || { + let sso_account_id = account_id.ok_or_else(missing_field(sso::ACCOUNT_ID))?; + let sso_region = region.ok_or_else(missing_field(sso::REGION))?; + let sso_role_name = role_name.ok_or_else(missing_field(sso::ROLE_NAME))?; + let sso_start_url = start_url.ok_or_else(missing_field(sso::START_URL))?; + Ok(BaseProvider::Sso { + sso_account_id, + sso_region, + sso_role_name, + sso_start_url, + }) + }; + Some(parse_profile()) +} + fn web_identity_token_from_profile( profile: &Profile, ) -> Option> { @@ -394,6 +436,17 @@ mod tests { web_identity_token_file: web_identity_token_file.into(), role_session_name: session_name.map(|sess| sess.to_string()), }), + BaseProvider::Sso { + sso_account_id, + sso_region, + sso_role_name, + sso_start_url, + } => output.push(Provider::Sso { + sso_account_id: sso_account_id.into(), + sso_region: sso_region.into(), + sso_role_name: sso_role_name.into(), + sso_start_url: sso_start_url.into(), + }), }; for role in profile_chain.chain { output.push(Provider::AssumeRole { @@ -429,5 +482,11 @@ mod tests { web_identity_token_file: String, role_session_name: Option, }, + Sso { + sso_account_id: String, + sso_region: String, + sso_role_name: String, + sso_start_url: String, + }, } } diff --git a/aws/rust-runtime/aws-config/src/profile/parser/source.rs b/aws/rust-runtime/aws-config/src/profile/parser/source.rs index 4c056b47a1..5b51d99665 100644 --- a/aws/rust-runtime/aws-config/src/profile/parser/source.rs +++ b/aws/rust-runtime/aws-config/src/profile/parser/source.rs @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0. */ +use crate::fs_util::{home_dir, Os}; use aws_types::os_shim_internal; use std::borrow::Cow; use std::io::ErrorKind; @@ -186,50 +187,9 @@ fn check_is_likely_running_on_a_lambda(environment: &os_shim_internal::Env) -> b environment.get("LAMBDA_TASK_ROOT").is_ok() } -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -enum Os { - Windows, - NotWindows, -} - -impl Os { - pub fn real() -> Self { - match std::env::consts::OS { - "windows" => Os::Windows, - _ => Os::NotWindows, - } - } -} - -/// Resolve a home directory given a set of environment variables -fn home_dir(env_var: &os_shim_internal::Env, os: Os) -> Option { - if let Ok(home) = env_var.get("HOME") { - tracing::debug!(src = "HOME", "loaded home directory"); - return Some(home); - } - - if os == Os::Windows { - if let Ok(home) = env_var.get("USERPROFILE") { - tracing::debug!(src = "USERPROFILE", "loaded home directory"); - return Some(home); - } - - let home_drive = env_var.get("HOMEDRIVE"); - let home_path = env_var.get("HOMEPATH"); - tracing::debug!(src = "HOMEDRIVE/HOMEPATH", "loaded home directory"); - if let (Ok(mut drive), Ok(path)) = (home_drive, home_path) { - drive.push_str(&path); - return Some(drive); - } - } - None -} - #[cfg(test)] mod tests { - use crate::profile::parser::source::{ - expand_home, home_dir, load, load_config_file, FileKind, Os, - }; + use crate::profile::parser::source::{expand_home, load, load_config_file, FileKind}; use aws_types::os_shim_internal::{Env, Fs}; use serde::Deserialize; use std::collections::HashMap; @@ -351,17 +311,6 @@ mod tests { ); } - #[test] - fn homedir_profile_only_windows() { - // windows specific variables should only be considered when the platform is windows - let env = Env::from_slice(&[("USERPROFILE", "C:\\Users\\name")]); - assert_eq!( - home_dir(&env, Os::Windows), - Some("C:\\Users\\name".to_string()) - ); - assert_eq!(home_dir(&env, Os::NotWindows), None); - } - #[test] fn expand_home_no_home() { let environment = Env::from_slice(&[]); diff --git a/aws/rust-runtime/aws-config/src/sso.rs b/aws/rust-runtime/aws-config/src/sso.rs new file mode 100644 index 0000000000..1ca99c554c --- /dev/null +++ b/aws/rust-runtime/aws-config/src/sso.rs @@ -0,0 +1,442 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +//! SSO Credentials Provider +//! +//! This credentials provider enables loading credentials from `~/.aws/sso/cache`. For more information, +//! see [Using AWS SSO Credentials](https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/sso-credentials.html) +//! +//! This provider is included automatically when profiles are loaded. + +use crate::fs_util::{home_dir, Os}; +use crate::json_credentials::{json_parse_loop, InvalidJsonCredentials}; +use crate::provider_config::ProviderConfig; +use aws_sdk_sso::middleware::DefaultMiddleware as SsoMiddleware; +use aws_sdk_sso::model::RoleCredentials; +use aws_smithy_client::erase::DynConnector; +use aws_smithy_types::date_time::Format; +use aws_smithy_types::DateTime; + +use aws_types::credentials::{CredentialsError, ProvideCredentials}; +use aws_types::os_shim_internal::{Env, Fs}; +use aws_types::region::Region; +use aws_types::{credentials, Credentials}; +use ring::digest; +use std::convert::TryInto; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io; +use std::path::PathBuf; +use zeroize::Zeroizing; + +impl crate::provider_config::ProviderConfig { + pub(crate) fn sso_client( + &self, + ) -> aws_smithy_client::Client { + use crate::connector::expect_connector; + use crate::provider_config::HttpSettings; + + aws_smithy_client::Builder::<(), SsoMiddleware>::new() + .connector(expect_connector(self.connector(&HttpSettings::default()))) + .sleep_impl(self.sleep()) + .build() + } +} + +/// SSO Credentials Provider +/// +/// _Note: This provider is part of the default credentials chain and is integrated with the profile-file provider._ +/// +/// This credentials provider will use cached SSO tokens stored in `~/.aws/sso/cache/.json`. +/// `` is computed based on the configured [`start_url`](Builder::start_url). +#[derive(Debug)] +pub struct SsoCredentialsProvider { + fs: Fs, + env: Env, + sso_config: SsoConfig, + client: aws_smithy_client::Client, +} + +impl SsoCredentialsProvider { + /// Creates a builder for [`SsoCredentialsProvider`] + pub fn builder() -> Builder { + Builder::new() + } + + pub(crate) fn new(provider_config: &ProviderConfig, sso_config: SsoConfig) -> Self { + let fs = provider_config.fs(); + let env = provider_config.env(); + + SsoCredentialsProvider { + fs, + env, + client: provider_config.sso_client(), + sso_config, + } + } + + async fn credentials(&self) -> credentials::Result { + load_sso_credentials(&self.sso_config, &self.client, &self.env, &self.fs).await + } +} + +impl ProvideCredentials for SsoCredentialsProvider { + fn provide_credentials<'a>(&'a self) -> credentials::future::ProvideCredentials<'a> + where + Self: 'a, + { + credentials::future::ProvideCredentials::new(self.credentials()) + } +} + +/// Builder for [`SsoCredentialsProvider`] +#[derive(Default, Debug, Clone)] +pub struct Builder { + provider_config: Option, + account_id: Option, + role_name: Option, + start_url: Option, + region: Option, +} + +impl Builder { + /// Create a new builder for [`SsoCredentialsProvider`] + pub fn new() -> Self { + Self::default() + } + + /// Override the configuration used for this provider + pub fn configure(mut self, provider_config: &ProviderConfig) -> Self { + self.provider_config = Some(provider_config.clone()); + self + } + + /// Set the account id used for SSO + pub fn account_id(mut self, account_id: impl Into) -> Self { + self.account_id = Some(account_id.into()); + self + } + + /// Set the role name used for SSO + pub fn role_name(mut self, role_name: impl Into) -> Self { + self.role_name = Some(role_name.into()); + self + } + + /// Set the start URL used for SSO + pub fn start_url(mut self, start_url: impl Into) -> Self { + self.start_url = Some(start_url.into()); + self + } + + /// Set the region used for SSO + pub fn region(mut self, region: Region) -> Self { + self.region = Some(region); + self + } + + /// Construct an SsoCredentialsProvider from the builder + /// + /// # Panics + /// This method will panic if the any of the following required fields are unset: + /// - [`start_url`](Self::start_url) + /// - [`role_name`](Self::role_name) + /// - [`account_id`](Self::account_id) + /// - [`region`](Self::region) + pub fn build(self) -> SsoCredentialsProvider { + let provider_config = self.provider_config.unwrap_or_default(); + let sso_config = SsoConfig { + account_id: self.account_id.expect("account_id must be set"), + role_name: self.role_name.expect("role_name must be set"), + start_url: self.start_url.expect("start_url must be set"), + region: self.region.expect("region must be set"), + }; + SsoCredentialsProvider::new(&provider_config, sso_config) + } +} + +#[derive(Debug)] +pub(crate) enum LoadTokenError { + InvalidCredentials(InvalidJsonCredentials), + NoHomeDirectory, + IoError { err: io::Error, path: PathBuf }, +} + +impl Display for LoadTokenError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + LoadTokenError::InvalidCredentials(err) => { + write!(f, "SSO Token was invalid (expected JSON): {}", err) + } + LoadTokenError::NoHomeDirectory => write!(f, "Could not resolve a home directory"), + LoadTokenError::IoError { err, path } => { + write!(f, "failed to read `{}`: {}", path.display(), err) + } + } + } +} + +impl Error for LoadTokenError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + LoadTokenError::InvalidCredentials(err) => Some(err as _), + LoadTokenError::NoHomeDirectory => None, + LoadTokenError::IoError { err, .. } => Some(err as _), + } + } +} + +#[derive(Debug)] +pub(crate) struct SsoConfig { + pub(crate) account_id: String, + pub(crate) role_name: String, + pub(crate) start_url: String, + pub(crate) region: Region, +} + +async fn load_sso_credentials( + sso_config: &SsoConfig, + sso: &aws_smithy_client::Client, + env: &Env, + fs: &Fs, +) -> credentials::Result { + let token = load_token(&sso_config.start_url, env, fs) + .await + .map_err(CredentialsError::provider_error)?; + let config = aws_sdk_sso::Config::builder() + .region(sso_config.region.clone()) + .build(); + let operation = aws_sdk_sso::operation::GetRoleCredentials::builder() + .role_name(&sso_config.role_name) + .access_token(&*token.access_token) + .account_id(&sso_config.account_id) + .build() + .map_err(|err| { + CredentialsError::unhandled(format!("could not construct SSO token input: {}", err)) + })? + .make_operation(&config) + .await + .map_err(CredentialsError::unhandled)?; + let resp = sso + .call(operation) + .await + .map_err(CredentialsError::provider_error)?; + let credentials: RoleCredentials = resp + .role_credentials + .ok_or_else(|| CredentialsError::unhandled("SSO did not return credentials"))?; + let akid = credentials + .access_key_id + .ok_or_else(|| CredentialsError::unhandled("no access key id in response"))?; + let secret_key = credentials + .secret_access_key + .ok_or_else(|| CredentialsError::unhandled("no secret key in response"))?; + let expiration = DateTime::from_millis(credentials.expiration) + .try_into() + .map_err(|err| { + CredentialsError::unhandled(format!( + "expiration could not be converted into a system time: {}", + err + )) + })?; + Ok(Credentials::new( + akid, + secret_key, + credentials.session_token, + Some(expiration), + "SSO", + )) +} + +/// Load the token for `start_url` from `~/.aws/sso/cache/.json` +async fn load_token(start_url: &str, env: &Env, fs: &Fs) -> Result { + let home = home_dir(env, Os::real()).ok_or(LoadTokenError::NoHomeDirectory)?; + let path = sso_token_path(start_url, &home); + let data = + Zeroizing::new( + fs.read_to_end(&path) + .await + .map_err(|err| LoadTokenError::IoError { + err, + path: path.to_path_buf(), + })?, + ); + let token = parse_token_json(&data).map_err(LoadTokenError::InvalidCredentials)?; + Ok(token) +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SsoToken { + access_token: Zeroizing, + expires_at: DateTime, + region: Option, +} + +/// Parse SSO token JSON from input +fn parse_token_json(input: &[u8]) -> Result { + /* + Example: + { + "accessToken": "base64string", + "expiresAt": "2019-11-14T04:05:45Z", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + }*/ + let mut acccess_token = None; + let mut expires_at = None; + let mut region = None; + let mut start_url = None; + json_parse_loop(input, |key, value| match key { + key if key.eq_ignore_ascii_case("accessToken") => acccess_token = Some(value.to_string()), + key if key.eq_ignore_ascii_case("expiresAt") => expires_at = Some(value), + key if key.eq_ignore_ascii_case("region") => region = Some(value.to_string()), + key if key.eq_ignore_ascii_case("startUrl") => start_url = Some(value.to_string()), + _other => {} // ignored + })?; + let access_token = + Zeroizing::new(acccess_token.ok_or(InvalidJsonCredentials::MissingField("accessToken"))?); + let expires_at = expires_at.ok_or(InvalidJsonCredentials::MissingField("expiresAt"))?; + let expires_at = DateTime::from_str(expires_at.as_ref(), Format::DateTime).map_err(|e| { + InvalidJsonCredentials::InvalidField { + field: "expiresAt", + err: e.into(), + } + })?; + let region = region.map(Region::new); + Ok(SsoToken { + access_token, + expires_at, + region, + }) +} + +/// Determine the SSO token path for a given start_url +fn sso_token_path(start_url: &str, home: &str) -> PathBuf { + // hex::encode returns a lowercase string + let mut out = PathBuf::with_capacity(home.len() + "/.aws/sso/cache".len() + ".json".len() + 40); + out.push(home); + out.push(".aws/sso/cache"); + out.push(&hex::encode(digest::digest( + &digest::SHA1_FOR_LEGACY_USE_ONLY, + start_url.as_bytes(), + ))); + out.set_extension("json"); + out +} + +#[cfg(test)] +mod test { + use crate::json_credentials::InvalidJsonCredentials; + use crate::sso::{load_token, parse_token_json, sso_token_path, LoadTokenError, SsoToken}; + use aws_smithy_types::DateTime; + use aws_types::os_shim_internal::{Env, Fs}; + use aws_types::region::Region; + use zeroize::Zeroizing; + + #[test] + fn deserialize_valid_tokens() { + let token = br#" + { + "accessToken": "base64string", + "expiresAt": "2009-02-13T23:31:30Z", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + }"#; + assert_eq!( + parse_token_json(token).expect("valid"), + SsoToken { + access_token: Zeroizing::new("base64string".into()), + expires_at: DateTime::from_secs(1234567890), + region: Some(Region::from_static("us-west-2")) + } + ); + + let no_region = br#"{ + "accessToken": "base64string", + "expiresAt": "2009-02-13T23:31:30Z" + }"#; + assert_eq!( + parse_token_json(no_region).expect("valid"), + SsoToken { + access_token: Zeroizing::new("base64string".into()), + expires_at: DateTime::from_secs(1234567890), + region: None + } + ); + } + + #[test] + fn invalid_timestamp() { + let token = br#" + { + "accessToken": "base64string", + "expiresAt": "notatimestamp", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + }"#; + let err = parse_token_json(token).expect_err("invalid timestamp"); + assert!( + format!("{}", err).contains("Invalid field in response: `expiresAt`."), + "{}", + err + ); + } + + #[test] + fn missing_fields() { + let token = br#" + { + "expiresAt": "notatimestamp", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + }"#; + let err = parse_token_json(token).expect_err("missing akid"); + assert!( + matches!(err, InvalidJsonCredentials::MissingField("accessToken")), + "incorrect error: {:?}", + err + ); + + let token = br#" + { + "accessToken": "akid", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + }"#; + let err = parse_token_json(token).expect_err("missing expiry"); + assert!( + matches!(err, InvalidJsonCredentials::MissingField("expiresAt")), + "incorrect error: {:?}", + err + ); + } + + #[test] + fn determine_correct_cache_filenames() { + assert_eq!( + sso_token_path("https://d-92671207e4.awsapps.com/start", "/home/me").as_os_str(), + "/home/me/.aws/sso/cache/13f9d35043871d073ab260e020f0ffde092cb14b.json" + ); + assert_eq!( + sso_token_path("https://d-92671207e4.awsapps.com/start", "/home/me/").as_os_str(), + "/home/me/.aws/sso/cache/13f9d35043871d073ab260e020f0ffde092cb14b.json" + ); + } + + #[tokio::test] + async fn gracefully_handle_missing_files() { + let err = load_token( + "asdf", + &Env::from_slice(&[("HOME", "/home")]), + &Fs::from_slice(&[]), + ) + .await + .expect_err("should fail, file is missing"); + assert!( + matches!(err, LoadTokenError::IoError { .. }), + "should be io error, got {}", + err + ); + } +} diff --git a/aws/rust-runtime/aws-config/src/sts.rs b/aws/rust-runtime/aws-config/src/sts.rs index 0352af8c54..5f973d23ab 100644 --- a/aws/rust-runtime/aws-config/src/sts.rs +++ b/aws/rust-runtime/aws-config/src/sts.rs @@ -12,7 +12,7 @@ pub use assume_role::{AssumeRoleProvider, AssumeRoleProviderBuilder}; use aws_sdk_sts::middleware::DefaultMiddleware; impl crate::provider_config::ProviderConfig { - pub(crate) fn sdk_client( + pub(crate) fn sts_client( &self, ) -> aws_smithy_client::Client { use crate::connector::expect_connector; diff --git a/aws/rust-runtime/aws-config/src/test_case.rs b/aws/rust-runtime/aws-config/src/test_case.rs index f4b84caf91..a68bb8e764 100644 --- a/aws/rust-runtime/aws-config/src/test_case.rs +++ b/aws/rust-runtime/aws-config/src/test_case.rs @@ -227,6 +227,7 @@ impl TestEnvironment { let (connector, conf) = self.provider_config().await; let provider = make_provider(conf).await; let result = provider.provide_credentials().await; + tokio::time::pause(); self.log_info(); self.check_results(result); // todo: validate bodies diff --git a/aws/rust-runtime/aws-config/src/web_identity_token.rs b/aws/rust-runtime/aws-config/src/web_identity_token.rs index 5b2a6edecf..9a1bd79b8f 100644 --- a/aws/rust-runtime/aws-config/src/web_identity_token.rs +++ b/aws/rust-runtime/aws-config/src/web_identity_token.rs @@ -211,7 +211,7 @@ impl Builder { /// builder, this function will panic. pub fn build(self) -> WebIdentityTokenCredentialsProvider { let conf = self.config.unwrap_or_default(); - let client = conf.sdk_client(); + let client = conf.sts_client(); let source = self.source.unwrap_or_else(|| Source::Env(conf.env())); WebIdentityTokenCredentialsProvider { source, diff --git a/aws/rust-runtime/aws-config/test-data/assume-role-tests.json b/aws/rust-runtime/aws-config/test-data/assume-role-tests.json index 81d08f7fd2..6d58d060c3 100644 --- a/aws/rust-runtime/aws-config/test-data/assume-role-tests.json +++ b/aws/rust-runtime/aws-config/test-data/assume-role-tests.json @@ -519,5 +519,47 @@ } ] } + }, + { + "docs": "SSO profile selected", + "input": { + "selected_profile": "A", + "profile": { + "A": { + "sso_account_id": "0123", + "sso_region": "us-east-7", + "sso_role_name": "testrole", + "sso_start_url": "https://foo.bar" + } + } + }, + "output": { + "ProfileChain": [ + { + "Sso": { + "sso_account_id": "0123", + "sso_region": "us-east-7", + "sso_role_name": "testrole", + "sso_start_url": "https://foo.bar" + } + } + ] + } + }, + { + "docs": "invalid SSO configuration", + "input": { + "selected_profile": "A", + "profile": { + "A": { + "sso_region": "us-east-7", + "sso_role_name": "testrole", + "sso_start_url": "https://foo.bar" + } + } + }, + "output": { + "Error": "`sso_account_id` was missing" + } } ] diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/env.json b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/env.json new file mode 100644 index 0000000000..5b93b6dd7f --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/env.json @@ -0,0 +1,5 @@ +{ + "HOME": "/home", + "AWS_REGION": "us-west-2", + "AWS_PROFILE": "sso-test" +} diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/fs/home/.aws/config b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/fs/home/.aws/config new file mode 100644 index 0000000000..7967221048 --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/fs/home/.aws/config @@ -0,0 +1,6 @@ +[profile sso-test] +sso_start_url = https://ssotest.awsapps.com/start +sso_region = us-east-2 +sso_account_id = 123456789 +sso_role_name = MySsoRole +region = us-east-2 diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/fs/home/.aws/sso/cache/dace00cba5f8355ec9d274ceb2bcebdfbeed0e12.json b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/fs/home/.aws/sso/cache/dace00cba5f8355ec9d274ceb2bcebdfbeed0e12.json new file mode 100644 index 0000000000..c4cca143fb --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/fs/home/.aws/sso/cache/dace00cba5f8355ec9d274ceb2bcebdfbeed0e12.json @@ -0,0 +1,5 @@ +{ + "accessToken": "a-token", + "expiresAt": "2080-10-16T03:56:45Z", + "startUrl": "https://ssotest.awsapps.com/start" +} diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/http-traffic.json b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/http-traffic.json new file mode 100644 index 0000000000..ea606c0a8f --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/http-traffic.json @@ -0,0 +1,93 @@ +{ + "events": [ + { + "connection_id": 0, + "action": { + "Request": { + "request": { + "uri": "https://portal.sso.us-east-2.amazonaws.com/federation/credentials?account_id=123456789&role_name=MySsoRole", + "headers": { + "x-amz-sso_bearer_token": [ + "a-token" + ], + "Host": [ + "portal.sso.us-east-2.amazonaws.com" + ] + }, + "method": "GET" + } + } + } + }, + { + "connection_id": 0, + "action": { + "Eof": { + "ok": true, + "direction": "Request" + } + } + }, + { + "connection_id": 0, + "action": { + "Response": { + "response": { + "Ok": { + "status": 200, + "version": "HTTP/1.1", + "headers": { + "Date": [ + "Mon, 03 Jan 2022 19:13:54 GMT" + ], + "Content-Type": [ + "application/json" + ], + "Content-Length": [ + "144" + ], + "Connection": [ + "keep-alive" + ], + "Access-Control-Expose-Headers": [ + "RequestId" + ], + "Cache-Control": [ + "no-cache" + ], + "RequestId": [ + "b339b807-25d1-474c-a476-b070e9f350e4" + ], + "Server": [ + "AWS SSO" + ] + } + } + } + } + } + }, + { + "connection_id": 0, + "action": { + "Data": { + "data": { + "Utf8": "{\"roleCredentials\":{\"accessKeyId\":\"ASIARCORRECT\",\"secretAccessKey\":\"secretkeycorrect\",\"sessionToken\":\"tokencorrect\",\"expiration\":1234567890000}}" + }, + "direction": "Response" + } + } + }, + { + "connection_id": 0, + "action": { + "Eof": { + "ok": true, + "direction": "Response" + } + } + } + ], + "docs": "Load SSO credentials", + "version": "V0" +} diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/test-case.json b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/test-case.json new file mode 100644 index 0000000000..e1259ceb60 --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_assume_role/test-case.json @@ -0,0 +1,12 @@ +{ + "name": "sso-role", + "docs": "load creds from SSO role", + "result": { + "Ok": { + "access_key_id": "ASIARCORRECT", + "secret_access_key": "secretkeycorrect", + "session_token": "tokencorrect", + "expiry": 1234567890 + } + } +} diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/env.json b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/env.json new file mode 100644 index 0000000000..5b93b6dd7f --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/env.json @@ -0,0 +1,5 @@ +{ + "HOME": "/home", + "AWS_REGION": "us-west-2", + "AWS_PROFILE": "sso-test" +} diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/fs/home/.aws/config b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/fs/home/.aws/config new file mode 100644 index 0000000000..7967221048 --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/fs/home/.aws/config @@ -0,0 +1,6 @@ +[profile sso-test] +sso_start_url = https://ssotest.awsapps.com/start +sso_region = us-east-2 +sso_account_id = 123456789 +sso_role_name = MySsoRole +region = us-east-2 diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/fs/home/.aws/sso/cache/differenthashthatdoesntmatch.json b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/fs/home/.aws/sso/cache/differenthashthatdoesntmatch.json new file mode 100644 index 0000000000..c4cca143fb --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/fs/home/.aws/sso/cache/differenthashthatdoesntmatch.json @@ -0,0 +1,5 @@ +{ + "accessToken": "a-token", + "expiresAt": "2080-10-16T03:56:45Z", + "startUrl": "https://ssotest.awsapps.com/start" +} diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/http-traffic.json b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/http-traffic.json new file mode 100644 index 0000000000..a0965529c7 --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/http-traffic.json @@ -0,0 +1,5 @@ +{ + "events": [], + "docs": "missing token file, no traffic", + "version": "V0" +} diff --git a/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/test-case.json b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/test-case.json new file mode 100644 index 0000000000..9617044ce5 --- /dev/null +++ b/aws/rust-runtime/aws-config/test-data/default-provider-chain/sso_no_token_file/test-case.json @@ -0,0 +1,7 @@ +{ + "name": "sso-role", + "docs": "load creds from SSO role", + "result": { + "ErrorContains": "failed to read `/home/.aws/sso/cache/dace00cba5f8355ec9d274ceb2bcebdfbeed0e12.json`: No such file or directory (os error 2)" + } +} diff --git a/aws/sdk/gradle.properties b/aws/sdk/gradle.properties index 522d9f9b9e..63d7c3b64c 100644 --- a/aws/sdk/gradle.properties +++ b/aws/sdk/gradle.properties @@ -32,6 +32,7 @@ aws.services.smoketest=\ +s3,\ +s3control,\ +sts,\ + +sso,\ +transcribestreaming,\ +route53 diff --git a/rust-runtime/aws-smithy-client/src/dvr/replay.rs b/rust-runtime/aws-smithy-client/src/dvr/replay.rs index e6c3b583b8..37c3470a04 100644 --- a/rust-runtime/aws-smithy-client/src/dvr/replay.rs +++ b/rust-runtime/aws-smithy-client/src/dvr/replay.rs @@ -73,14 +73,7 @@ impl ReplayingConnection { ))? .take() .await; - if actual.uri() != expected.uri() { - return Err(format!( - "URI did not match. Expected: {}. Found: {}", - expected.uri(), - actual.uri() - ) - .into()); - } + aws_smithy_protocol_test::assert_uris_match(actual.uri(), expected.uri()); body_comparer(expected.body().as_ref(), actual.body().as_ref())?; let expected_headers = checked_headers .iter() diff --git a/rust-runtime/aws-smithy-protocol-test/src/lib.rs b/rust-runtime/aws-smithy-protocol-test/src/lib.rs index ac90195f70..e142116e89 100644 --- a/rust-runtime/aws-smithy-protocol-test/src/lib.rs +++ b/rust-runtime/aws-smithy-protocol-test/src/lib.rs @@ -119,6 +119,23 @@ fn extract_params(uri: &Uri) -> HashSet<&str> { uri.query().unwrap_or_default().split('&').collect() } +#[track_caller] +pub fn assert_uris_match(left: &Uri, right: &Uri) { + if left == right { + return; + } + assert_eq!(left.authority(), right.authority()); + assert_eq!(left.scheme(), right.scheme()); + assert_eq!(left.path(), right.path()); + assert_eq!( + extract_params(left), + extract_params(right), + "Query parameters did not match. left: {}, right: {}", + left, + right + ); +} + pub fn validate_query_string( request: &Request, expected_params: &[&str],