diff --git a/.github/workflows/ci-tls.yml b/.github/workflows/ci-tls.yml index 8c70d2b2e8755f3f9c17743201d29c0fde85e40c..68a88674051b13686d88cfd2ce1d1bbf5d68ea42 100644 --- a/.github/workflows/ci-tls.yml +++ b/.github/workflows/ci-tls.yml @@ -59,7 +59,7 @@ jobs: run: ../smithy-rs/tools/ci-scripts/configure-tls/configure-badssl - name: Build SDK working-directory: smithy-rs - run: ./gradlew :aws:sdk:assemble -Paws.services=+sts,+sso + run: ./gradlew :aws:sdk:assemble -Paws.services=+sts,+sso,+ssooidc - name: Build trytls shell: bash working-directory: trytls diff --git a/.github/workflows/pull-request-bot.yml b/.github/workflows/pull-request-bot.yml index 1997859064418a6101b967c536005a925598a3c1..2f0bf5c33c913695f7dd8c57d5ada0140a564e77 100644 --- a/.github/workflows/pull-request-bot.yml +++ b/.github/workflows/pull-request-bot.yml @@ -106,7 +106,7 @@ jobs: # 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,+sso,+transcribestreaming,+dynamodb :aws:sdk:assemble + ./gradlew -Paws.services=+sts,+sso,+ssooidc,+transcribestreaming,+dynamodb :aws:sdk:assemble # Copy the Server runtime crate(s) in cp -r rust-runtime/aws-smithy-http-server rust-runtime/aws-smithy-http-server-python rust-runtime/aws-smithy-http-server-typescript aws/sdk/build/aws-sdk/sdk diff --git a/CHANGELOG.next.toml b/CHANGELOG.next.toml index 120add9bb8899954655be40b42c715ee0dd5e62d..b1a59c806c2cf1119ac6e41bdaa81589f4772ee1 100644 --- a/CHANGELOG.next.toml +++ b/CHANGELOG.next.toml @@ -11,6 +11,18 @@ # meta = { "breaking" = false, "tada" = false, "bug" = false, "target" = "client | server | all"} # author = "rcoh" +[[aws-sdk-rust]] +message = "(Behavior Break!) The SSO credentials provider is no longer enabled by default in `aws-config`, and so SSO profile config will no longer work out of box. The `credentials-sso` feature in `aws-config` was removed from the default features, and renamed to `sso`. If you need credentials from SSO, then enable the `sso` feature in `aws-config`." +references = ["smithy-rs#2917"] +meta = { "breaking" = true, "tada" = false, "bug" = false } +author = "jdisanti" + +[[aws-sdk-rust]] +message = "The `SsoCredentialsProvider` now supports token refresh and is compatible with the token cache file paths the latest AWS CLI uses." +references = ["smithy-rs#2917", "aws-sdk-rust#703", "aws-sdk-rust#699"] +meta = { "breaking" = false, "tada" = true, "bug" = false } +author = "jdisanti" + [[smithy-rs]] message = "HTTP connector configuration has changed significantly. See the [upgrade guidance](https://github.com/awslabs/smithy-rs/discussions/3022) for details." references = ["smithy-rs#3011"] @@ -390,3 +402,9 @@ message = "[`PresignedRequest`](https://docs.rs/aws-sdk-s3/latest/aws_sdk_s3/pre references = ["smithy-rs#3059"] meta = { "breaking" = true, "tada" = false, "bug" = false } author = "rcoh" + +[[smithy-rs]] +message = "`RuntimeComponents` have been added as an argument to the `IdentityResolver::resolve_identity` trait function." +references = ["smithy-rs#2917"] +meta = { "breaking" = true, "tada" = false, "bug" = false, "target" = "client"} +author = "jdisanti" diff --git a/aws/rust-runtime/aws-config/Cargo.toml b/aws/rust-runtime/aws-config/Cargo.toml index 3c43472b1d3513ee6c59b90a365f31bd54dc2d4c..8e40611d28980ec7dfb992068118209f2929c108 100644 --- a/aws/rust-runtime/aws-config/Cargo.toml +++ b/aws/rust-runtime/aws-config/Cargo.toml @@ -13,9 +13,9 @@ client-hyper = ["aws-smithy-runtime/connector-hyper-0-14-x"] rustls = ["aws-smithy-runtime/tls-rustls", "client-hyper"] allow-compilation = [] # our tests use `cargo test --all-features` and native-tls breaks CI rt-tokio = ["aws-smithy-async/rt-tokio", "aws-smithy-runtime/rt-tokio", "tokio/rt"] -credentials-sso = ["dep:aws-sdk-sso", "dep:ring", "dep:hex", "dep:zeroize"] +sso = ["dep:aws-sdk-sso", "dep:aws-sdk-ssooidc", "dep:ring", "dep:hex", "dep:zeroize", "aws-smithy-runtime-api/http-auth"] -default = ["client-hyper", "rustls", "rt-tokio", "credentials-sso"] +default = ["client-hyper", "rustls", "rt-tokio"] [dependencies] aws-credential-types = { path = "../../sdk/build/aws-sdk/sdk/aws-credential-types" } @@ -46,8 +46,13 @@ ring = { version = "0.16", optional = true } hex = { version = "0.4.3", optional = true } zeroize = { version = "1", optional = true } +# implementation detail of SSO OIDC `CreateToken` for SSO token providers +aws-sdk-ssooidc = { path = "../../sdk/build/aws-sdk/sdk/ssooidc", default-features = false, optional = true } + [dev-dependencies] +aws-credential-types = { path = "../../sdk/build/aws-sdk/sdk/aws-credential-types", features = ["test-util"] } aws-smithy-runtime = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-runtime", features = ["client", "connector-hyper-0-14-x", "test-util"] } +aws-smithy-runtime-api = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-runtime-api", features = ["test-util"] } futures-util = { version = "0.3.16", default-features = false } tracing-test = "0.2.1" tracing-subscriber = { version = "0.3.16", features = ["fmt", "json"] } @@ -61,8 +66,6 @@ arbitrary = "1.3" serde = { version = "1", features = ["derive"] } serde_json = "1" -aws-credential-types = { path = "../../sdk/build/aws-sdk/sdk/aws-credential-types", features = ["test-util"] } - # used for a usage example hyper-rustls = { version = "0.24", features = ["webpki-tokio", "http2", "http1"] } aws-smithy-async = { path = "../../sdk/build/aws-sdk/sdk/aws-smithy-async", features = ["rt-tokio", "test-util"] } diff --git a/aws/rust-runtime/aws-config/external-types.toml b/aws/rust-runtime/aws-config/external-types.toml index 95ccecc43ec285b235aaedc0910b5e2bc2f77b38..94002d550d600cb422f65395c0d3f15dbac1f20c 100644 --- a/aws/rust-runtime/aws-config/external-types.toml +++ b/aws/rust-runtime/aws-config/external-types.toml @@ -20,6 +20,7 @@ allowed_external_types = [ "aws_smithy_runtime_api::client::dns::SharedDnsResolver", "aws_smithy_runtime_api::client::http::HttpClient", "aws_smithy_runtime_api::client::http::SharedHttpClient", + "aws_smithy_runtime_api::client::identity::ResolveIdentity", "aws_smithy_types::retry", "aws_smithy_types::retry::*", "aws_smithy_types::timeout", diff --git a/aws/rust-runtime/aws-config/src/default_provider/credentials.rs b/aws/rust-runtime/aws-config/src/default_provider/credentials.rs index 13636e7d14e9a9fb9a5585ea06e3516ea6c2ee2f..7c9aaa06e718e43d3e43e44466a3072e462dfe29 100644 --- a/aws/rust-runtime/aws-config/src/default_provider/credentials.rs +++ b/aws/rust-runtime/aws-config/src/default_provider/credentials.rs @@ -295,15 +295,15 @@ mod test { make_test!(ecs_credentials); make_test!(ecs_credentials_invalid_profile); - #[cfg(not(feature = "credentials-sso"))] - make_test!(sso_assume_role #[should_panic(expected = "This behavior requires following cargo feature(s) enabled: credentials-sso")]); - #[cfg(not(feature = "credentials-sso"))] - make_test!(sso_no_token_file #[should_panic(expected = "This behavior requires following cargo feature(s) enabled: credentials-sso")]); + #[cfg(not(feature = "sso"))] + make_test!(sso_assume_role #[should_panic(expected = "This behavior requires following cargo feature(s) enabled: sso")]); + #[cfg(not(feature = "sso"))] + make_test!(sso_no_token_file #[should_panic(expected = "This behavior requires following cargo feature(s) enabled: sso")]); - #[cfg(feature = "credentials-sso")] + #[cfg(feature = "sso")] make_test!(sso_assume_role); - #[cfg(feature = "credentials-sso")] + #[cfg(feature = "sso")] make_test!(sso_no_token_file); #[cfg(feature = "credentials-sso")] diff --git a/aws/rust-runtime/aws-config/src/imds/client/token.rs b/aws/rust-runtime/aws-config/src/imds/client/token.rs index f5b609b9413ae7bd8e1afb0a601fa8f764918258..76b286583d1a2514a7ab55d798ddac7da0783908 100644 --- a/aws/rust-runtime/aws-config/src/imds/client/token.rs +++ b/aws/rust-runtime/aws-config/src/imds/client/token.rs @@ -194,7 +194,11 @@ fn parse_token_response(response: &HttpResponse, now: SystemTime) -> Result(&'a self, _config_bag: &'a ConfigBag) -> IdentityFuture<'a> { + fn resolve_identity<'a>( + &'a self, + _components: &'a RuntimeComponents, + _config_bag: &'a ConfigBag, + ) -> IdentityFuture<'a> { IdentityFuture::new(async { let preloaded_token = self .inner diff --git a/aws/rust-runtime/aws-config/src/lib.rs b/aws/rust-runtime/aws-config/src/lib.rs index ba1cb953d84038f77e227fa20f9892a052ef18ed..a12dea2a3b0c42ef9786873efe00bf80964b2d84 100644 --- a/aws/rust-runtime/aws-config/src/lib.rs +++ b/aws/rust-runtime/aws-config/src/lib.rs @@ -121,7 +121,7 @@ pub mod meta; pub mod profile; pub mod provider_config; pub mod retry; -#[cfg(feature = "credentials-sso")] +#[cfg(feature = "sso")] pub mod sso; pub(crate) mod standard_property; pub mod sts; 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 c6108a18f180f902ac81459cf5724f2579dcf147..d393e9469942f6a6d377843d4429fc260c82dc02 100644 --- a/aws/rust-runtime/aws-config/src/profile/credentials/exec.rs +++ b/aws/rust-runtime/aws-config/src/profile/credentials/exec.rs @@ -7,8 +7,6 @@ use super::repr::{self, BaseProvider}; use crate::credential_process::CredentialProcessProvider; use crate::profile::credentials::ProfileFileError; use crate::provider_config::ProviderConfig; -#[cfg(feature = "credentials-sso")] -use crate::sso::{SsoCredentialsProvider, SsoProviderConfig}; use crate::sts; use crate::web_identity_token::{StaticConfiguration, WebIdentityTokenCredentialsProvider}; use aws_credential_types::provider::{ @@ -119,21 +117,25 @@ impl ProviderChain { sso_role_name, sso_start_url, } => { - #[cfg(feature = "credentials-sso")] + #[cfg(feature = "sso")] { + use crate::sso::{credentials::SsoProviderConfig, SsoCredentialsProvider}; use aws_types::region::Region; + let sso_config = SsoProviderConfig { 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()), + // TODO(https://github.com/awslabs/aws-sdk-rust/issues/703): Implement sso_session_name profile property + session_name: None, }; Arc::new(SsoCredentialsProvider::new(provider_config, sso_config)) } - #[cfg(not(feature = "credentials-sso"))] + #[cfg(not(feature = "sso"))] { Err(ProfileFileError::FeatureNotEnabled { - feature: "credentials-sso".into(), + feature: "sso".into(), })? } } diff --git a/aws/rust-runtime/aws-config/src/sso.rs b/aws/rust-runtime/aws-config/src/sso.rs index fae2248b35f3b5122b250612aca6aeb1fb008407..69ed37ed0fcc359bad121a9fc1b10133ff385972 100644 --- a/aws/rust-runtime/aws-config/src/sso.rs +++ b/aws/rust-runtime/aws-config/src/sso.rs @@ -3,444 +3,14 @@ * 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. +//! SSO Credentials and Token providers -use crate::fs_util::{home_dir, Os}; -use crate::json_credentials::{json_parse_loop, InvalidJsonCredentials}; -use crate::provider_config::ProviderConfig; -use aws_credential_types::cache::CredentialsCache; -use aws_credential_types::provider::{self, error::CredentialsError, future, ProvideCredentials}; -use aws_credential_types::Credentials; -use aws_sdk_sso::types::RoleCredentials; -use aws_sdk_sso::Client as SsoClient; -use aws_smithy_json::deserialize::Token; -use aws_smithy_types::date_time::Format; -use aws_smithy_types::DateTime; -use aws_types::os_shim_internal::{Env, Fs}; -use aws_types::region::Region; -use aws_types::SdkConfig; -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; +pub mod credentials; -/// 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_provider_config: SsoProviderConfig, - sdk_config: SdkConfig, -} +pub use credentials::SsoCredentialsProvider; -impl SsoCredentialsProvider { - /// Creates a builder for [`SsoCredentialsProvider`] - pub fn builder() -> Builder { - Builder::new() - } +pub mod token; - pub(crate) fn new( - provider_config: &ProviderConfig, - sso_provider_config: SsoProviderConfig, - ) -> Self { - let fs = provider_config.fs(); - let env = provider_config.env(); +pub use token::SsoTokenProvider; - SsoCredentialsProvider { - fs, - env, - sso_provider_config, - sdk_config: provider_config.client_config(), - } - } - - async fn credentials(&self) -> provider::Result { - load_sso_credentials( - &self.sso_provider_config, - &self.sdk_config, - &self.env, - &self.fs, - ) - .await - } -} - -impl ProvideCredentials for SsoCredentialsProvider { - fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a> - where - Self: 'a, - { - 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 = SsoProviderConfig { - 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 SsoProviderConfig { - 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_provider_config: &SsoProviderConfig, - sdk_config: &SdkConfig, - env: &Env, - fs: &Fs, -) -> provider::Result { - let token = load_token(&sso_provider_config.start_url, env, fs) - .await - .map_err(CredentialsError::provider_error)?; - let config = sdk_config - .to_builder() - .region(sso_provider_config.region.clone()) - .credentials_cache(CredentialsCache::no_caching()) - .build(); - // TODO(enableNewSmithyRuntimeCleanup): Use `customize().config_override()` to set the region instead of creating a new client once middleware is removed - let client = SsoClient::new(&config); - let resp = client - .get_role_credentials() - .role_name(&sso_provider_config.role_name) - .access_token(&*token.access_token) - .account_id(&sso_provider_config.account_id) - .send() - .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, value) { - (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("accessToken") => { - acccess_token = Some(value.to_unescaped()?.to_string()) - } - (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("expiresAt") => { - expires_at = Some(value.to_unescaped()?) - } - (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("region") => { - region = Some(value.to_unescaped()?.to_string()) - } - (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("startUrl") => { - start_url = Some(value.to_unescaped()?.to_string()) - } - _other => {} // ignored - }; - Ok(()) - })?; - 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 - ); - } -} +mod cache; diff --git a/aws/rust-runtime/aws-config/src/sso/cache.rs b/aws/rust-runtime/aws-config/src/sso/cache.rs new file mode 100644 index 0000000000000000000000000000000000000000..5d78798f87d9d22298c35aaec5462c3ff3325c54 --- /dev/null +++ b/aws/rust-runtime/aws-config/src/sso/cache.rs @@ -0,0 +1,594 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::fs_util::{home_dir, Os}; +use aws_smithy_json::deserialize::token::skip_value; +use aws_smithy_json::deserialize::Token; +use aws_smithy_json::deserialize::{json_token_iter, EscapeError}; +use aws_smithy_json::serialize::JsonObjectWriter; +use aws_smithy_types::date_time::{DateTimeFormatError, Format}; +use aws_smithy_types::DateTime; +use aws_types::os_shim_internal::{Env, Fs}; +use ring::digest; +use std::borrow::Cow; +use std::error::Error as StdError; +use std::fmt; +use std::path::PathBuf; +use std::time::SystemTime; +use zeroize::Zeroizing; + +#[cfg_attr(test, derive(Eq, PartialEq))] +#[derive(Clone)] +pub(super) struct CachedSsoToken { + pub(super) access_token: Zeroizing, + pub(super) client_id: Option, + pub(super) client_secret: Option>, + pub(super) expires_at: SystemTime, + pub(super) refresh_token: Option>, + pub(super) region: Option, + pub(super) registration_expires_at: Option, + pub(super) start_url: Option, +} + +impl CachedSsoToken { + /// True if the information required to refresh this token is present. + /// + /// The expiration times are not considered by this function. + pub(super) fn refreshable(&self) -> bool { + self.client_id.is_some() + && self.client_secret.is_some() + && self.refresh_token.is_some() + && self.registration_expires_at.is_some() + } +} + +impl fmt::Debug for CachedSsoToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CachedSsoToken") + .field("access_token", &"** redacted **") + .field("client_id", &self.client_id) + .field("client_secret", &"** redacted **") + .field("expires_at", &self.expires_at) + .field("refresh_token", &"** redacted **") + .field("region", &self.region) + .field("registration_expires_at", &self.registration_expires_at) + .field("start_url", &self.start_url) + .finish() + } +} + +#[derive(Debug)] +pub(super) enum CachedSsoTokenError { + FailedToFormatDateTime { + source: Box, + }, + InvalidField { + field: &'static str, + source: Box, + }, + IoError { + what: &'static str, + path: PathBuf, + source: std::io::Error, + }, + JsonError(Box), + MissingField(&'static str), + NoHomeDirectory, + Other(Cow<'static, str>), +} + +impl fmt::Display for CachedSsoTokenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FailedToFormatDateTime { .. } => write!(f, "failed to format date time"), + Self::InvalidField { field, .. } => write!( + f, + "invalid value for the `{field}` field in the cached SSO token file" + ), + Self::IoError { what, path, .. } => write!(f, "failed to {what} `{}`", path.display()), + Self::JsonError(_) => write!(f, "invalid JSON in cached SSO token file"), + Self::MissingField(field) => { + write!(f, "missing field `{field}` in cached SSO token file") + } + Self::NoHomeDirectory => write!(f, "couldn't resolve a home directory"), + Self::Other(message) => f.write_str(message), + } + } +} + +impl StdError for CachedSsoTokenError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::FailedToFormatDateTime { source } => Some(source.as_ref()), + Self::InvalidField { source, .. } => Some(source.as_ref()), + Self::IoError { source, .. } => Some(source), + Self::JsonError(source) => Some(source.as_ref()), + Self::MissingField(_) => None, + Self::NoHomeDirectory => None, + Self::Other(_) => None, + } + } +} + +impl From for CachedSsoTokenError { + fn from(err: EscapeError) -> Self { + Self::JsonError(err.into()) + } +} + +impl From for CachedSsoTokenError { + fn from(err: aws_smithy_json::deserialize::error::DeserializeError) -> Self { + Self::JsonError(err.into()) + } +} + +impl From for CachedSsoTokenError { + fn from(value: DateTimeFormatError) -> Self { + Self::FailedToFormatDateTime { + source: value.into(), + } + } +} + +/// Determine the SSO cached token path for a given identifier. +/// +/// The `identifier` is the `sso_start_url` for credentials providers, and `sso_session_name` for token providers. +fn cached_token_path(identifier: &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, + identifier.as_bytes(), + ))); + out.set_extension("json"); + out +} + +/// Load the token for `identifier` from `~/.aws/sso/cache/.json` +/// +/// The `identifier` is the `sso_start_url` for credentials providers, and `sso_session_name` for token providers. +pub(super) async fn load_cached_token( + env: &Env, + fs: &Fs, + identifier: &str, +) -> Result { + let home = home_dir(env, Os::real()).ok_or(CachedSsoTokenError::NoHomeDirectory)?; + let path = cached_token_path(identifier, &home); + let data = Zeroizing::new(fs.read_to_end(&path).await.map_err(|source| { + CachedSsoTokenError::IoError { + what: "read", + path, + source, + } + })?); + parse_cached_token(&data) +} + +/// Parse SSO token JSON from input +fn parse_cached_token( + cached_token_file_contents: &[u8], +) -> Result { + use CachedSsoTokenError as Error; + + let mut access_token = None; + let mut expires_at = None; + let mut client_id = None; + let mut client_secret = None; + let mut refresh_token = None; + let mut region = None; + let mut registration_expires_at = None; + let mut start_url = None; + json_parse_loop(cached_token_file_contents, |key, value| { + match (key, value) { + /* + // Required fields: + "accessToken": "string", + "expiresAt": "2019-11-14T04:05:45Z", + + // Optional fields: + "refreshToken": "string", + "clientId": "ABCDEFG323242423121312312312312312", + "clientSecret": "ABCDE123", + "registrationExpiresAt": "2022-03-06T19:53:17Z", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + */ + (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("accessToken") => { + access_token = Some(Zeroizing::new(value.to_unescaped()?.into_owned())); + } + (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("expiresAt") => { + expires_at = Some(value.to_unescaped()?); + } + (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("clientId") => { + client_id = Some(value.to_unescaped()?); + } + (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("clientSecret") => { + client_secret = Some(Zeroizing::new(value.to_unescaped()?.into_owned())); + } + (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("refreshToken") => { + refresh_token = Some(Zeroizing::new(value.to_unescaped()?.into_owned())); + } + (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("region") => { + region = Some(value.to_unescaped()?.into_owned()); + } + (key, Token::ValueString { value, .. }) + if key.eq_ignore_ascii_case("registrationExpiresAt") => + { + registration_expires_at = Some(value.to_unescaped()?); + } + (key, Token::ValueString { value, .. }) if key.eq_ignore_ascii_case("startUrl") => { + start_url = Some(value.to_unescaped()?.into_owned()); + } + _ => {} + }; + Ok(()) + })?; + + Ok(CachedSsoToken { + access_token: access_token.ok_or(Error::MissingField("accessToken"))?, + expires_at: expires_at + .ok_or(Error::MissingField("expiresAt")) + .and_then(|expires_at| { + DateTime::from_str(expires_at.as_ref(), Format::DateTime) + .map_err(|err| Error::InvalidField { field: "expiresAt", source: err.into() }) + .and_then(|date_time| { + SystemTime::try_from(date_time).map_err(|_| { + Error::Other( + "SSO token expiration time cannot be represented by a SystemTime" + .into(), + ) + }) + }) + })?, + client_id: client_id.map(Cow::into_owned), + client_secret, + refresh_token, + region, + registration_expires_at: Ok(registration_expires_at).and_then(|maybe_expires_at| { + if let Some(expires_at) = maybe_expires_at { + Some( + DateTime::from_str(expires_at.as_ref(), Format::DateTime) + .map_err(|err| Error::InvalidField { field: "registrationExpiresAt", source: err.into()}) + .and_then(|date_time| { + SystemTime::try_from(date_time).map_err(|_| { + Error::Other( + "SSO registration expiration time cannot be represented by a SystemTime" + .into(), + ) + }) + }), + ) + .transpose() + } else { + Ok(None) + } + })?, + start_url, + }) +} + +fn json_parse_loop<'a>( + input: &'a [u8], + mut f: impl FnMut(Cow<'a, str>, &Token<'a>) -> Result<(), CachedSsoTokenError>, +) -> Result<(), CachedSsoTokenError> { + use CachedSsoTokenError as Error; + let mut tokens = json_token_iter(input).peekable(); + if !matches!(tokens.next().transpose()?, Some(Token::StartObject { .. })) { + return Err(Error::Other( + "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)) = tokens.peek() { + let key = key.to_unescaped()?; + f(key, token)? + } + skip_value(&mut tokens)?; + } + other => { + return Err(Error::Other( + format!("expected object key, found: {:?}", other).into(), + )); + } + } + } + if tokens.next().is_some() { + return Err(Error::Other( + "found more JSON tokens after completing parsing".into(), + )); + } + Ok(()) +} + +pub(super) async fn save_cached_token( + env: &Env, + fs: &Fs, + identifier: &str, + token: &CachedSsoToken, +) -> Result<(), CachedSsoTokenError> { + let expires_at = DateTime::from(token.expires_at).fmt(Format::DateTime)?; + let registration_expires_at = token + .registration_expires_at + .map(|time| DateTime::from(time).fmt(Format::DateTime)) + .transpose()?; + + let mut out = Zeroizing::new(String::new()); + let mut writer = JsonObjectWriter::new(&mut out); + writer.key("accessToken").string(&token.access_token); + writer.key("expiresAt").string(&expires_at); + if let Some(refresh_token) = &token.refresh_token { + writer.key("refreshToken").string(refresh_token); + } + if let Some(client_id) = &token.client_id { + writer.key("clientId").string(client_id); + } + if let Some(client_secret) = &token.client_secret { + writer.key("clientSecret").string(client_secret); + } + if let Some(registration_expires_at) = registration_expires_at { + writer + .key("registrationExpiresAt") + .string(®istration_expires_at); + } + if let Some(region) = &token.region { + writer.key("region").string(region); + } + if let Some(start_url) = &token.start_url { + writer.key("startUrl").string(start_url); + } + writer.finish(); + + let home = home_dir(env, Os::real()).ok_or(CachedSsoTokenError::NoHomeDirectory)?; + let path = cached_token_path(identifier, &home); + fs.write(&path, out.as_bytes()) + .await + .map_err(|err| CachedSsoTokenError::IoError { + what: "write", + path, + source: err, + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::time::Duration; + + #[test] + fn redact_fields_in_token_debug() { + let token = CachedSsoToken { + access_token: Zeroizing::new("!!SENSITIVE!!".into()), + client_id: Some("clientid".into()), + client_secret: Some(Zeroizing::new("!!SENSITIVE!!".into())), + expires_at: SystemTime::now(), + refresh_token: Some(Zeroizing::new("!!SENSITIVE!!".into())), + region: Some("region".into()), + registration_expires_at: Some(SystemTime::now()), + start_url: Some("starturl".into()), + }; + let debug_str = format!("{:?}", token); + assert!(!debug_str.contains("!!SENSITIVE!!"), "The `Debug` impl for `CachedSsoToken` isn't properly redacting sensitive fields: {debug_str}"); + } + + // Valid token with all fields + #[test] + fn parse_valid_token() { + let file_contents = r#" + { + "startUrl": "https://d-123.awsapps.com/start", + "region": "us-west-2", + "accessToken": "cachedtoken", + "expiresAt": "2021-12-25T21:30:00Z", + "clientId": "clientid", + "clientSecret": "YSBzZWNyZXQ=", + "registrationExpiresAt": "2022-12-25T13:30:00Z", + "refreshToken": "cachedrefreshtoken" + } + "#; + let cached = parse_cached_token(file_contents.as_bytes()).expect("success"); + assert_eq!("cachedtoken", cached.access_token.as_str()); + assert_eq!( + SystemTime::UNIX_EPOCH + Duration::from_secs(1640467800), + cached.expires_at + ); + assert_eq!("clientid", cached.client_id.expect("client id is present")); + assert_eq!( + "YSBzZWNyZXQ=", + cached + .client_secret + .expect("client secret is present") + .as_str() + ); + assert_eq!( + "cachedrefreshtoken", + cached + .refresh_token + .expect("refresh token is present") + .as_str() + ); + assert_eq!( + SystemTime::UNIX_EPOCH + Duration::from_secs(1671975000), + cached + .registration_expires_at + .expect("registration expiration is present") + ); + assert_eq!("us-west-2", cached.region.expect("region is present")); + assert_eq!( + "https://d-123.awsapps.com/start", + cached.start_url.expect("startUrl is present") + ); + } + + // Minimal valid cached token + #[test] + fn parse_valid_token_with_optional_fields_absent() { + let file_contents = r#" + { + "accessToken": "cachedtoken", + "expiresAt": "2021-12-25T21:30:00Z" + } + "#; + let cached = parse_cached_token(file_contents.as_bytes()).expect("success"); + assert_eq!("cachedtoken", cached.access_token.as_str()); + assert_eq!( + SystemTime::UNIX_EPOCH + Duration::from_secs(1640467800), + cached.expires_at + ); + assert!(cached.client_id.is_none()); + assert!(cached.client_secret.is_none()); + assert!(cached.refresh_token.is_none()); + assert!(cached.registration_expires_at.is_none()); + } + + #[test] + fn parse_invalid_timestamp() { + let token = br#" + { + "accessToken": "base64string", + "expiresAt": "notatimestamp", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + }"#; + let err = parse_cached_token(token).expect_err("invalid timestamp"); + let expected = "invalid value for the `expiresAt` field in the cached SSO token file"; + let actual = format!("{err}"); + assert!( + actual.contains(expected), + "expected error to contain `{expected}`, but was `{actual}`", + ); + } + + #[test] + fn parse_missing_fields() { + // Token missing accessToken field + let token = br#" + { + "expiresAt": "notatimestamp", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + }"#; + let err = parse_cached_token(token).expect_err("missing akid"); + assert!( + matches!(err, CachedSsoTokenError::MissingField("accessToken")), + "incorrect error: {:?}", + err + ); + + // Token missing expiresAt field + let token = br#" + { + "accessToken": "akid", + "region": "us-west-2", + "startUrl": "https://d-abc123.awsapps.com/start" + }"#; + let err = parse_cached_token(token).expect_err("missing expiry"); + assert!( + matches!(err, CachedSsoTokenError::MissingField("expiresAt")), + "incorrect error: {:?}", + err + ); + } + + #[tokio::test] + async fn gracefully_handle_missing_files() { + let err = load_cached_token( + &Env::from_slice(&[("HOME", "/home")]), + &Fs::from_slice(&[]), + "asdf", + ) + .await + .expect_err("should fail, file is missing"); + assert!( + matches!(err, CachedSsoTokenError::IoError { .. }), + "should be io error, got {}", + err + ); + } + + #[test] + fn determine_correct_cache_filenames() { + assert_eq!( + "/home/someuser/.aws/sso/cache/d033e22ae348aeb5660fc2140aec35850c4da997.json", + cached_token_path("admin", "/home/someuser").as_os_str() + ); + assert_eq!( + "/home/someuser/.aws/sso/cache/75e4d41276d8bd17f85986fc6cccef29fd725ce3.json", + cached_token_path("dev-scopes", "/home/someuser").as_os_str() + ); + assert_eq!( + "/home/me/.aws/sso/cache/13f9d35043871d073ab260e020f0ffde092cb14b.json", + cached_token_path("https://d-92671207e4.awsapps.com/start", "/home/me").as_os_str(), + ); + assert_eq!( + "/home/me/.aws/sso/cache/13f9d35043871d073ab260e020f0ffde092cb14b.json", + cached_token_path("https://d-92671207e4.awsapps.com/start", "/home/me/").as_os_str(), + ); + } + + #[tokio::test] + async fn save_cached_token() { + let expires_at = SystemTime::UNIX_EPOCH + Duration::from_secs(50_000_000); + let reg_expires_at = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000_000); + let token = CachedSsoToken { + access_token: Zeroizing::new("access-token".into()), + client_id: Some("client-id".into()), + client_secret: Some(Zeroizing::new("client-secret".into())), + expires_at, + refresh_token: Some(Zeroizing::new("refresh-token".into())), + region: Some("region".into()), + registration_expires_at: Some(reg_expires_at), + start_url: Some("start-url".into()), + }; + + let env = Env::from_slice(&[("HOME", "/home/user")]); + let fs = Fs::from_map(HashMap::<_, Vec>::new()); + super::save_cached_token(&env, &fs, "test", &token) + .await + .expect("success"); + + let contents = fs + .read_to_end("/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json") + .await + .expect("correct file written"); + let contents_str = String::from_utf8(contents).expect("valid utf8"); + assert_eq!( + r#"{"accessToken":"access-token","expiresAt":"1971-08-02T16:53:20Z","refreshToken":"refresh-token","clientId":"client-id","clientSecret":"client-secret","registrationExpiresAt":"1973-03-03T09:46:40Z","region":"region","startUrl":"start-url"}"#, + contents_str, + ); + } + + #[tokio::test] + async fn round_trip_token() { + let expires_at = SystemTime::UNIX_EPOCH + Duration::from_secs(50_000_000); + let reg_expires_at = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000_000); + let original = CachedSsoToken { + access_token: Zeroizing::new("access-token".into()), + client_id: Some("client-id".into()), + client_secret: Some(Zeroizing::new("client-secret".into())), + expires_at, + refresh_token: Some(Zeroizing::new("refresh-token".into())), + region: Some("region".into()), + registration_expires_at: Some(reg_expires_at), + start_url: Some("start-url".into()), + }; + + let env = Env::from_slice(&[("HOME", "/home/user")]); + let fs = Fs::from_map(HashMap::<_, Vec>::new()); + + super::save_cached_token(&env, &fs, "test", &original) + .await + .unwrap(); + + let roundtripped = load_cached_token(&env, &fs, "test").await.unwrap(); + assert_eq!(original, roundtripped) + } +} diff --git a/aws/rust-runtime/aws-config/src/sso/credentials.rs b/aws/rust-runtime/aws-config/src/sso/credentials.rs new file mode 100644 index 0000000000000000000000000000000000000000..15e57546c524abcda737bf9cba671c06d5dea89d --- /dev/null +++ b/aws/rust-runtime/aws-config/src/sso/credentials.rs @@ -0,0 +1,292 @@ +/* + * 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 super::cache::load_cached_token; +use crate::provider_config::ProviderConfig; +use crate::sso::SsoTokenProvider; +use aws_credential_types::cache::CredentialsCache; +use aws_credential_types::provider::{self, error::CredentialsError, future, ProvideCredentials}; +use aws_credential_types::Credentials; +use aws_sdk_sso::types::RoleCredentials; +use aws_sdk_sso::Client as SsoClient; +use aws_smithy_async::time::SharedTimeSource; +use aws_smithy_types::DateTime; +use aws_types::os_shim_internal::{Env, Fs}; +use aws_types::region::Region; +use aws_types::SdkConfig; +use std::convert::TryInto; + +/// 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`. +/// Two different values will be tried for `` in order: +/// 1. The configured [`session_name`](Builder::session_name). +/// 2. The configured [`start_url`](Builder::start_url). +#[derive(Debug)] +pub struct SsoCredentialsProvider { + fs: Fs, + env: Env, + sso_provider_config: SsoProviderConfig, + sdk_config: SdkConfig, + token_provider: Option, + time_source: SharedTimeSource, +} + +impl SsoCredentialsProvider { + /// Creates a builder for [`SsoCredentialsProvider`] + pub fn builder() -> Builder { + Builder::new() + } + + pub(crate) fn new( + provider_config: &ProviderConfig, + sso_provider_config: SsoProviderConfig, + ) -> Self { + let fs = provider_config.fs(); + let env = provider_config.env(); + + let token_provider = if let Some(session_name) = &sso_provider_config.session_name { + Some( + SsoTokenProvider::builder() + .configure(&provider_config.client_config()) + .start_url(&sso_provider_config.start_url) + .session_name(session_name) + .region(sso_provider_config.region.clone()) + .build_sync(), + ) + } else { + None + }; + + SsoCredentialsProvider { + fs, + env, + sso_provider_config, + sdk_config: provider_config.client_config(), + token_provider, + time_source: provider_config.time_source(), + } + } + + async fn credentials(&self) -> provider::Result { + load_sso_credentials( + &self.sso_provider_config, + &self.sdk_config, + self.token_provider.as_ref(), + &self.env, + &self.fs, + self.time_source.clone(), + ) + .await + } +} + +impl ProvideCredentials for SsoCredentialsProvider { + fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a> + where + Self: 'a, + { + future::ProvideCredentials::new(self.credentials()) + } +} + +/// Builder for [`SsoCredentialsProvider`] +#[derive(Default, Debug, Clone)] +pub struct Builder { + provider_config: Option, + account_id: Option, + region: Option, + role_name: Option, + start_url: Option, + session_name: 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 + /// + /// This is a required field. + pub fn account_id(mut self, account_id: impl Into) -> Self { + self.account_id = Some(account_id.into()); + self + } + + /// Set the account id used for SSO + /// + /// This is a required field. + pub fn set_account_id(&mut self, account_id: Option) -> &mut Self { + self.account_id = account_id; + self + } + + /// Set the region used for SSO + /// + /// This is a required field. + pub fn region(mut self, region: Region) -> Self { + self.region = Some(region); + self + } + + /// Set the region used for SSO + /// + /// This is a required field. + pub fn set_region(&mut self, region: Option) -> &mut Self { + self.region = region; + self + } + + /// Set the role name used for SSO + /// + /// This is a required field. + pub fn role_name(mut self, role_name: impl Into) -> Self { + self.role_name = Some(role_name.into()); + self + } + + /// Set the role name used for SSO + /// + /// This is a required field. + pub fn set_role_name(&mut self, role_name: Option) -> &mut Self { + self.role_name = role_name; + self + } + + /// Set the start URL used for SSO + /// + /// This is a required field. + pub fn start_url(mut self, start_url: impl Into) -> Self { + self.start_url = Some(start_url.into()); + self + } + + /// Set the start URL used for SSO + /// + /// This is a required field. + pub fn set_start_url(&mut self, start_url: Option) -> &mut Self { + self.start_url = start_url; + self + } + + /// Set the session name used for SSO + pub fn session_name(mut self, session_name: impl Into) -> Self { + self.session_name = Some(session_name.into()); + self + } + + /// Set the session name used for SSO + pub fn set_session_name(&mut self, session_name: Option) -> &mut Self { + self.session_name = session_name; + 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 = SsoProviderConfig { + account_id: self.account_id.expect("account_id must be set"), + region: self.region.expect("region 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"), + session_name: self.session_name, + }; + SsoCredentialsProvider::new(&provider_config, sso_config) + } +} + +#[derive(Debug)] +pub(crate) struct SsoProviderConfig { + pub(crate) account_id: String, + pub(crate) role_name: String, + pub(crate) start_url: String, + pub(crate) region: Region, + pub(crate) session_name: Option, +} + +async fn load_sso_credentials( + sso_provider_config: &SsoProviderConfig, + sdk_config: &SdkConfig, + token_provider: Option<&SsoTokenProvider>, + env: &Env, + fs: &Fs, + time_source: SharedTimeSource, +) -> provider::Result { + let token = if let Some(token_provider) = token_provider { + token_provider + .resolve_token(time_source) + .await + .map_err(CredentialsError::provider_error)? + } else { + // Backwards compatible token loading that uses `start_url` instead of `session_name` + load_cached_token(env, fs, &sso_provider_config.start_url) + .await + .map_err(CredentialsError::provider_error)? + }; + + let config = sdk_config + .to_builder() + .region(sso_provider_config.region.clone()) + .credentials_cache(CredentialsCache::no_caching()) + .build(); + // TODO(enableNewSmithyRuntimeCleanup): Use `customize().config_override()` to set the region instead of creating a new client once middleware is removed + let client = SsoClient::new(&config); + let resp = client + .get_role_credentials() + .role_name(&sso_provider_config.role_name) + .access_token(&*token.access_token) + .account_id(&sso_provider_config.account_id) + .send() + .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", + )) +} diff --git a/aws/rust-runtime/aws-config/src/sso/token.rs b/aws/rust-runtime/aws-config/src/sso/token.rs new file mode 100644 index 0000000000000000000000000000000000000000..d4e1cc8b79b1e5df12814de4c8aac9cc05293982 --- /dev/null +++ b/aws/rust-runtime/aws-config/src/sso/token.rs @@ -0,0 +1,865 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! SSO Token Provider +//! +//! This token provider enables loading an access token from `~/.aws/sso/cache`. For more information, +//! see [AWS Builder ID for developers](https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/builder-id.html). +//! +//! This provider is included automatically when profiles are loaded. + +use crate::sso::cache::{ + load_cached_token, save_cached_token, CachedSsoToken, CachedSsoTokenError, +}; +use aws_credential_types::cache::{CredentialsCache, ExpiringCache}; +use aws_sdk_ssooidc::error::DisplayErrorContext; +use aws_sdk_ssooidc::operation::create_token::CreateTokenOutput; +use aws_sdk_ssooidc::Client as SsoOidcClient; +use aws_smithy_async::time::SharedTimeSource; +use aws_smithy_runtime_api::client::identity::http::Token; +use aws_smithy_runtime_api::client::identity::{Identity, IdentityFuture, ResolveIdentity}; +use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents; +use aws_smithy_types::config_bag::ConfigBag; +use aws_types::os_shim_internal::{Env, Fs}; +use aws_types::region::Region; +use aws_types::SdkConfig; +use std::error::Error as StdError; +use std::fmt; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; +use zeroize::Zeroizing; + +const REFRESH_BUFFER_TIME: Duration = Duration::from_secs(5 * 60 /* 5 minutes */); +const MIN_TIME_BETWEEN_REFRESH: Duration = Duration::from_secs(30); + +/// SSO Token Provider +/// +/// This token provider will use cached SSO tokens stored in `~/.aws/sso/cache/.json`. +/// `` is computed based on the configured [`session_namej`](Builder::session_name). +/// +/// If possible, the cached token will be refreshed when it gets close to expiring. +#[derive(Debug)] +pub struct SsoTokenProvider { + inner: Arc, + token_cache: ExpiringCache, +} + +#[derive(Debug)] +struct Inner { + env: Env, + fs: Fs, + region: Region, + session_name: String, + start_url: String, + sdk_config: SdkConfig, + last_refresh_attempt: Mutex>, +} + +impl SsoTokenProvider { + /// Creates a `SsoTokenProvider` builder. + pub fn builder() -> Builder { + Default::default() + } + + async fn refresh_cached_token( + inner: &Inner, + cached_token: &CachedSsoToken, + identifier: &str, + now: SystemTime, + ) -> Result, SsoTokenProviderError> { + // TODO(enableNewSmithyRuntimeCleanup): Use `customize().config_override()` to set the region instead of creating a new client once middleware is removed + let config = inner + .sdk_config + .to_builder() + .region(Some(inner.region.clone())) + .credentials_cache(CredentialsCache::no_caching()) + .build(); + let client = SsoOidcClient::new(&config); + let resp = client + .create_token() + .grant_type("refresh_token") + .client_id( + cached_token + .client_id + .as_ref() + .expect("required for token refresh") + .clone(), + ) + .client_secret( + cached_token + .client_secret + .as_ref() + .expect("required for token refresh") + .as_str(), + ) + .refresh_token( + cached_token + .refresh_token + .as_ref() + .expect("required for token refresh") + .as_str(), + ) + .send() + .await; + match resp { + Ok(CreateTokenOutput { + access_token: Some(access_token), + refresh_token, + expires_in, + .. + }) => { + let refreshed_token = CachedSsoToken { + access_token: Zeroizing::new(access_token), + client_id: cached_token.client_id.clone(), + client_secret: cached_token.client_secret.clone(), + expires_at: now + + Duration::from_secs( + u64::try_from(expires_in) + .map_err(|_| SsoTokenProviderError::BadExpirationTimeFromSsoOidc)?, + ), + refresh_token: refresh_token + .map(Zeroizing::new) + .or_else(|| cached_token.refresh_token.clone()), + region: Some(inner.region.to_string()), + registration_expires_at: cached_token.registration_expires_at, + start_url: Some(inner.start_url.clone()), + }; + save_cached_token(&inner.env, &inner.fs, identifier, &refreshed_token).await?; + tracing::debug!("saved refreshed SSO token"); + Ok(Some(refreshed_token)) + } + Ok(_) => { + tracing::debug!("SSO OIDC CreateToken responded without an access token"); + Ok(None) + } + Err(err) => { + tracing::debug!( + "call to SSO OIDC CreateToken for SSO token refresh failed: {}", + DisplayErrorContext(&err) + ); + Ok(None) + } + } + } + + pub(super) fn resolve_token( + &self, + time_source: SharedTimeSource, + ) -> impl std::future::Future> + 'static + { + let token_cache = self.token_cache.clone(); + let inner = self.inner.clone(); + + async move { + if let Some(token) = token_cache + .yield_or_clear_if_expired(time_source.now()) + .await + { + tracing::debug!("using cached SSO token"); + return Ok(token); + } + let token = token_cache + .get_or_load(|| async move { + tracing::debug!("expiring cache asked for an updated SSO token"); + let mut token = + load_cached_token(&inner.env, &inner.fs, &inner.session_name).await?; + tracing::debug!("loaded cached SSO token"); + + let now = time_source.now(); + let expired = token.expires_at <= now; + let expires_soon = token.expires_at - REFRESH_BUFFER_TIME <= now; + let last_refresh = *inner.last_refresh_attempt.lock().unwrap(); + let min_time_passed = last_refresh + .map(|lr| { + now.duration_since(lr).expect("last_refresh is in the past") + >= MIN_TIME_BETWEEN_REFRESH + }) + .unwrap_or(true); + let registration_expired = token + .registration_expires_at + .map(|t| t <= now) + .unwrap_or(true); + let refreshable = + token.refreshable() && min_time_passed && !registration_expired; + + tracing::debug!( + expired = ?expired, + expires_soon = ?expires_soon, + min_time_passed = ?min_time_passed, + registration_expired = ?registration_expired, + refreshable = ?refreshable, + will_refresh = ?(expires_soon && refreshable), + "cached SSO token refresh decision" + ); + + // Fail fast if the token has expired and we can't refresh it + if expired && !refreshable { + tracing::debug!("cached SSO token is expired and cannot be refreshed"); + return Err(SsoTokenProviderError::ExpiredToken); + } + + // Refresh the token if it is going to expire soon + if expires_soon && refreshable { + tracing::debug!("attempting to refresh SSO token"); + if let Some(refreshed_token) = + Self::refresh_cached_token(&inner, &token, &inner.session_name, now) + .await? + { + token = refreshed_token; + } + *inner.last_refresh_attempt.lock().unwrap() = Some(now); + } + + let expires_at = token.expires_at; + Ok((token, expires_at)) + }) + .await?; + + Ok(token) + } + } +} + +impl ResolveIdentity for SsoTokenProvider { + fn resolve_identity<'a>( + &'a self, + runtime_components: &'a RuntimeComponents, + _config_bag: &'a ConfigBag, + ) -> IdentityFuture<'a> { + let time_source = runtime_components + .time_source() + .expect("a time source required by SsoTokenProvider"); + let token_future = self.resolve_token(time_source); + IdentityFuture::new(Box::pin(async move { + let token = token_future.await?; + Ok(Identity::new( + Token::new(token.access_token.as_str(), Some(token.expires_at)), + Some(token.expires_at), + )) + })) + } +} + +/// Builder for [`SsoTokenProvider`]. +#[derive(Debug, Default)] +pub struct Builder { + sdk_config: Option, + region: Option, + session_name: Option, + start_url: Option, +} + +impl Builder { + /// Creates a new builder for [`SsoTokenProvider`]. + pub fn new() -> Self { + Default::default() + } + + /// Override the configuration used for this provider + pub fn configure(mut self, sdk_config: &SdkConfig) -> Self { + self.sdk_config = Some(sdk_config.clone()); + self + } + + /// Sets the SSO region. + /// + /// This is a required field. + pub fn region(mut self, region: impl Into) -> Self { + self.region = Some(region.into()); + self + } + + /// Sets the SSO region. + /// + /// This is a required field. + pub fn set_region(&mut self, region: Option) -> &mut Self { + self.region = region; + self + } + + /// Sets the SSO session name. + /// + /// This is a required field. + pub fn session_name(mut self, session_name: impl Into) -> Self { + self.session_name = Some(session_name.into()); + self + } + + /// Sets the SSO session name. + /// + /// This is a required field. + pub fn set_session_name(&mut self, session_name: Option) -> &mut Self { + self.session_name = session_name; + self + } + + /// Sets the SSO start URL. + /// + /// This is a required field. + pub fn start_url(mut self, start_url: impl Into) -> Self { + self.start_url = Some(start_url.into()); + self + } + + /// Sets the SSO start URL. + /// + /// This is a required field. + pub fn set_start_url(&mut self, start_url: Option) -> &mut Self { + self.start_url = start_url; + self + } + + /// Builds the [`SsoTokenProvider`]. + /// + /// # Panics + /// + /// This will panic if any of the required fields are not given. + pub async fn build(mut self) -> SsoTokenProvider { + if self.sdk_config.is_none() { + self.sdk_config = Some(crate::load_from_env().await); + } + self.build_with(Env::real(), Fs::real()) + } + + pub(crate) fn build_sync(self) -> SsoTokenProvider { + self.build_with(Env::real(), Fs::real()) + } + + fn build_with(self, env: Env, fs: Fs) -> SsoTokenProvider { + SsoTokenProvider { + inner: Arc::new(Inner { + env, + fs, + region: self.region.expect("region is required"), + session_name: self.session_name.expect("session_name is required"), + start_url: self.start_url.expect("start_url is required"), + sdk_config: self.sdk_config.expect("sdk_config is required"), + last_refresh_attempt: Mutex::new(None), + }), + token_cache: ExpiringCache::new(REFRESH_BUFFER_TIME), + } + } +} + +#[derive(Debug)] +pub(super) enum SsoTokenProviderError { + BadExpirationTimeFromSsoOidc, + FailedToLoadToken { + source: Box, + }, + ExpiredToken, +} + +impl fmt::Display for SsoTokenProviderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BadExpirationTimeFromSsoOidc => { + f.write_str("SSO OIDC responded with a negative expiration duration") + } + Self::ExpiredToken => f.write_str("the SSO token has expired and cannot be refreshed"), + Self::FailedToLoadToken { .. } => f.write_str("failed to load the cached SSO token"), + } + } +} + +impl StdError for SsoTokenProviderError { + fn cause(&self) -> Option<&dyn StdError> { + match self { + Self::BadExpirationTimeFromSsoOidc => None, + Self::ExpiredToken => None, + Self::FailedToLoadToken { source } => Some(source.as_ref()), + } + } +} + +impl From for SsoTokenProviderError { + fn from(source: CachedSsoTokenError) -> Self { + Self::FailedToLoadToken { + source: source.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aws_sdk_sso::config::{AsyncSleep, SharedAsyncSleep}; + use aws_smithy_async::rt::sleep::TokioSleep; + use aws_smithy_async::test_util::instant_time_and_sleep; + use aws_smithy_async::time::{StaticTimeSource, TimeSource}; + use aws_smithy_http::body::SdkBody; + use aws_smithy_runtime::client::http::test_util::{ + capture_request, ReplayEvent, StaticReplayClient, + }; + use aws_smithy_runtime::test_util::capture_test_logs::capture_test_logs; + use aws_smithy_runtime_api::client::http::HttpClient; + use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder; + use aws_smithy_types::date_time::Format; + use aws_smithy_types::retry::RetryConfig; + use aws_smithy_types::DateTime; + + fn time(s: &str) -> SystemTime { + SystemTime::try_from(DateTime::from_str(s, Format::DateTime).unwrap()).unwrap() + } + + struct TestHarness { + time_source: SharedTimeSource, + token_provider: SsoTokenProvider, + env: Env, + fs: Fs, + } + + impl TestHarness { + fn new( + time_source: impl TimeSource + 'static, + sleep_impl: impl AsyncSleep + 'static, + http_client: impl HttpClient + 'static, + fs: Fs, + ) -> Self { + let env = Env::from_slice(&[("HOME", "/home/user")]); + let time_source = SharedTimeSource::new(time_source); + let config = SdkConfig::builder() + .http_client(http_client) + .time_source(time_source.clone()) + .sleep_impl(SharedAsyncSleep::new(sleep_impl)) + // disable retry to simplify testing + .retry_config(RetryConfig::disabled()) + .build(); + Self { + time_source, + token_provider: SsoTokenProvider::builder() + .configure(&config) + .session_name("test") + .region(Region::new("us-west-2")) + .start_url("https://d-123.awsapps.com/start") + .build_with(env.clone(), fs.clone()), + env, + fs, + } + } + + async fn expect_sso_token(&self, value: &str, expires_at: &str) -> CachedSsoToken { + let token = self + .token_provider + .resolve_token(self.time_source.clone()) + .await + .unwrap(); + assert_eq!(value, token.access_token.as_str()); + assert_eq!(time(expires_at), token.expires_at); + token + } + + async fn expect_token(&self, value: &str, expires_at: &str) { + let runtime_components = RuntimeComponentsBuilder::for_tests() + .with_time_source(Some(self.time_source.clone())) + .build() + .unwrap(); + let config_bag = ConfigBag::base(); + let identity = self + .token_provider + .resolve_identity(&runtime_components, &config_bag) + .await + .unwrap(); + let token = identity.data::().unwrap().clone(); + assert_eq!(value, token.token()); + assert_eq!(time(expires_at), *identity.expiration().unwrap()); + } + + async fn expect_expired_token_err(&self) { + let err = self + .token_provider + .resolve_token(self.time_source.clone()) + .await + .expect_err("expected failure"); + assert!( + matches!(err, SsoTokenProviderError::ExpiredToken), + "expected {err:?} to be `ExpiredToken`" + ); + } + + fn last_refresh_attempt_time(&self) -> Option { + self.token_provider + .inner + .last_refresh_attempt + .lock() + .unwrap() + .map(|time| { + DateTime::try_from(time) + .unwrap() + .fmt(Format::DateTime) + .unwrap() + }) + } + } + + #[tokio::test] + async fn use_unexpired_cached_token() { + let fs = Fs::from_slice(&[( + "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json", + r#" + { "accessToken": "some-token", + "expiresAt": "1975-01-01T00:00:00Z" } + "#, + )]); + + let now = time("1974-12-25T00:00:00Z"); + let time_source = SharedTimeSource::new(StaticTimeSource::new(now)); + + let (conn, req_rx) = capture_request(None); + let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs); + + harness + .expect_token("some-token", "1975-01-01T00:00:00Z") + .await; + // it can't refresh this token + req_rx.expect_no_request(); + } + + #[tokio::test] + async fn expired_cached_token() { + let fs = Fs::from_slice(&[( + "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json", + r#" + { "accessToken": "some-token", + "expiresAt": "1999-12-15T00:00:00Z" } + "#, + )]); + + let now = time("2023-01-01T00:00:00Z"); + let time_source = SharedTimeSource::new(StaticTimeSource::new(now)); + + let (conn, req_rx) = capture_request(None); + let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs); + + harness.expect_expired_token_err().await; + // it can't refresh this token + req_rx.expect_no_request(); + } + + #[tokio::test] + async fn expired_token_and_expired_client_registration() { + let fs = Fs::from_slice(&[( + "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json", + r#" + { "startUrl": "https://d-123.awsapps.com/start", + "region": "us-west-2", + "accessToken": "cachedtoken", + "expiresAt": "2021-10-25T13:00:00Z", + "clientId": "clientid", + "clientSecret": "YSBzZWNyZXQ=", + "registrationExpiresAt": "2021-11-25T13:30:00Z", + "refreshToken": "cachedrefreshtoken" } + "#, + )]); + + let now = time("2023-08-11T04:11:17Z"); + let time_source = SharedTimeSource::new(StaticTimeSource::new(now)); + + let (conn, req_rx) = capture_request(None); + let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs); + + // the registration has expired, so the token can't be refreshed + harness.expect_expired_token_err().await; + req_rx.expect_no_request(); + } + + #[tokio::test] + async fn expired_token_refresh_with_refresh_token() { + let fs = Fs::from_slice(&[( + "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json", + r#" + { "startUrl": "https://d-123.awsapps.com/start", + "region": "us-west-2", + "accessToken": "cachedtoken", + "expiresAt": "2021-12-25T13:00:00Z", + "clientId": "clientid", + "clientSecret": "YSBzZWNyZXQ=", + "registrationExpiresAt": "2022-12-25T13:30:00Z", + "refreshToken": "cachedrefreshtoken" } + "#, + )]); + + let now = time("2021-12-25T13:30:00Z"); + let time_source = SharedTimeSource::new(StaticTimeSource::new(now)); + + let (conn, req_rx) = capture_request(Some( + http::Response::builder() + .status(200) + .body(SdkBody::from( + r#" + { "tokenType": "Bearer", + "accessToken": "newtoken", + "expiresIn": 28800, + "refreshToken": "newrefreshtoken" } + "#, + )) + .unwrap(), + )); + let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs); + + let returned_token = harness + .expect_sso_token("newtoken", "2021-12-25T21:30:00Z") + .await; + let cached_token = load_cached_token(&harness.env, &harness.fs, "test") + .await + .unwrap(); + assert_eq!(returned_token, cached_token); + assert_eq!( + "newrefreshtoken", + returned_token.refresh_token.unwrap().as_str() + ); + assert_eq!( + "https://d-123.awsapps.com/start", + returned_token.start_url.unwrap() + ); + assert_eq!("us-west-2", returned_token.region.unwrap().to_string()); + assert_eq!("clientid", returned_token.client_id.unwrap()); + assert_eq!( + "YSBzZWNyZXQ=", + returned_token.client_secret.unwrap().as_str() + ); + assert_eq!( + SystemTime::UNIX_EPOCH + Duration::from_secs(1_671_975_000), + returned_token.registration_expires_at.unwrap() + ); + + let refresh_req = req_rx.expect_request(); + let parsed_req: serde_json::Value = + serde_json::from_slice(refresh_req.body().bytes().unwrap()).unwrap(); + let parsed_req = parsed_req.as_object().unwrap(); + assert_eq!( + "clientid", + parsed_req.get("clientId").unwrap().as_str().unwrap() + ); + assert_eq!( + "YSBzZWNyZXQ=", + parsed_req.get("clientSecret").unwrap().as_str().unwrap() + ); + assert_eq!( + "refresh_token", + parsed_req.get("grantType").unwrap().as_str().unwrap() + ); + assert_eq!( + "cachedrefreshtoken", + parsed_req.get("refreshToken").unwrap().as_str().unwrap() + ); + } + + #[tokio::test] + async fn expired_token_refresh_fails() { + let fs = Fs::from_slice(&[( + "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json", + r#" + { "startUrl": "https://d-123.awsapps.com/start", + "region": "us-west-2", + "accessToken": "cachedtoken", + "expiresAt": "2021-12-25T13:00:00Z", + "clientId": "clientid", + "clientSecret": "YSBzZWNyZXQ=", + "registrationExpiresAt": "2022-12-25T13:30:00Z", + "refreshToken": "cachedrefreshtoken" } + "#, + )]); + + let now = time("2021-12-25T13:30:00Z"); + let time_source = SharedTimeSource::new(StaticTimeSource::new(now)); + + let (conn, req_rx) = capture_request(Some( + http::Response::builder() + .status(500) + .body(SdkBody::from("")) + .unwrap(), + )); + let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs); + + // it should return the previous token since refresh failed and it hasn't expired yet + let returned_token = harness + .expect_sso_token("cachedtoken", "2021-12-25T13:00:00Z") + .await; + let cached_token = load_cached_token(&harness.env, &harness.fs, "test") + .await + .unwrap(); + assert_eq!(returned_token, cached_token); + + let _ = req_rx.expect_request(); + } + + // Expired token refresh without new refresh token + #[tokio::test] + async fn expired_token_refresh_without_new_refresh_token() { + let fs = Fs::from_slice(&[( + "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json", + r#" + { "startUrl": "https://d-123.awsapps.com/start", + "region": "us-west-2", + "accessToken": "cachedtoken", + "expiresAt": "2021-12-25T13:00:00Z", + "clientId": "clientid", + "clientSecret": "YSBzZWNyZXQ=", + "registrationExpiresAt": "2022-12-25T13:30:00Z", + "refreshToken": "cachedrefreshtoken" } + "#, + )]); + + let now = time("2021-12-25T13:30:00Z"); + let time_source = SharedTimeSource::new(StaticTimeSource::new(now)); + + let (conn, req_rx) = capture_request(Some( + http::Response::builder() + .status(200) + .body(SdkBody::from( + r#" + { "tokenType": "Bearer", + "accessToken": "newtoken", + "expiresIn": 28800 } + "#, + )) + .unwrap(), + )); + let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs); + + let returned_token = harness + .expect_sso_token("newtoken", "2021-12-25T21:30:00Z") + .await; + let cached_token = load_cached_token(&harness.env, &harness.fs, "test") + .await + .unwrap(); + assert_eq!(returned_token, cached_token); + assert_eq!( + "cachedrefreshtoken", + returned_token.refresh_token.unwrap().as_str(), + "it should have kept the old refresh token" + ); + + let _ = req_rx.expect_request(); + } + + #[tokio::test] + async fn refresh_timings() { + let _logs = capture_test_logs(); + + let start_time = DateTime::from_str("2023-01-01T00:00:00Z", Format::DateTime).unwrap(); + let (time_source, sleep_impl) = instant_time_and_sleep(start_time.try_into().unwrap()); + let shared_time_source = SharedTimeSource::new(time_source.clone()); + + let fs = Fs::from_slice(&[( + "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json", + r#" + { "startUrl": "https://d-123.awsapps.com/start", + "region": "us-west-2", + "accessToken": "first_token", + "_comment_expiresAt": "-------- Ten minutes after the start time: ------", + "expiresAt": "2023-01-01T00:10:00Z", + "clientId": "clientid", + "clientSecret": "YSBzZWNyZXQ=", + "registrationExpiresAt": "2023-01-02T12:00:00Z", + "refreshToken": "cachedrefreshtoken" } + "#, + )]); + + let events = vec![ + // First refresh attempt should fail + ReplayEvent::new( + http::Request::new(SdkBody::from("")), // don't really care what the request looks like + http::Response::builder() + .status(500) + .body(SdkBody::from("")) + .unwrap(), + ), + // Second refresh attempt should also fail + ReplayEvent::new( + http::Request::new(SdkBody::from("")), // don't really care what the request looks like + http::Response::builder() + .status(500) + .body(SdkBody::from("")) + .unwrap(), + ), + // Third refresh attempt will succeed + ReplayEvent::new( + http::Request::new(SdkBody::from("")), // don't really care what the request looks like + http::Response::builder() + .status(200) + .body(SdkBody::from( + r#" + { "tokenType": "Bearer", + "accessToken": "second_token", + "expiresIn": 28800 } + "#, + )) + .unwrap(), + ), + ]; + let http_client = StaticReplayClient::new(events); + let harness = TestHarness::new(shared_time_source, sleep_impl, http_client, fs); + + tracing::info!("test: first token retrieval should return the cached token"); + assert!( + harness.last_refresh_attempt_time().is_none(), + "the last attempt time should start empty" + ); + harness + .expect_token("first_token", "2023-01-01T00:10:00Z") + .await; + assert!( + harness.last_refresh_attempt_time().is_none(), + "it shouldn't have tried to refresh, so the last refresh attempt time shouldn't be set" + ); + + tracing::info!("test: advance 3 minutes"); + time_source.advance(Duration::from_secs(3 * 60)); + + tracing::info!("test: the token shouldn't get refreshed since it's not in the 5 minute buffer time yet"); + harness + .expect_token("first_token", "2023-01-01T00:10:00Z") + .await; + assert!( + harness.last_refresh_attempt_time().is_none(), + "it shouldn't have tried to refresh since the token isn't expiring soon" + ); + + tracing::info!("test: advance 2 minutes"); + time_source.advance(Duration::from_secs(2 * 60)); + + tracing::info!( + "test: the token will fail to refresh, and the old cached token will be returned" + ); + harness + .expect_token("first_token", "2023-01-01T00:10:00Z") + .await; + assert_eq!( + Some("2023-01-01T00:05:00Z"), + harness.last_refresh_attempt_time().as_deref(), + "it should update the last refresh attempt time since the expiration time is soon" + ); + + tracing::info!("test: advance 15 seconds"); + time_source.advance(Duration::from_secs(15)); + + tracing::info!( + "test: the token will not refresh because the minimum time hasn't passed between attempts" + ); + harness + .expect_token("first_token", "2023-01-01T00:10:00Z") + .await; + + tracing::info!("test: advance 15 seconds"); + time_source.advance(Duration::from_secs(15)); + + tracing::info!( + "test: the token will fail to refresh, and the old cached token will be returned" + ); + harness + .expect_token("first_token", "2023-01-01T00:10:00Z") + .await; + + tracing::info!("test: advance 30 seconds"); + time_source.advance(Duration::from_secs(30)); + + tracing::info!("test: the token will refresh successfully"); + harness + .expect_token("second_token", "2023-01-01T08:06:00Z") + .await; + } +} diff --git a/aws/rust-runtime/aws-runtime/src/identity.rs b/aws/rust-runtime/aws-runtime/src/identity.rs index d919a7d8a1ba7b66860ebf06aedbfa039b7692d7..3f2753cc16f5ca3c153062b1af35a4a9baadffd0 100644 --- a/aws/rust-runtime/aws-runtime/src/identity.rs +++ b/aws/rust-runtime/aws-runtime/src/identity.rs @@ -7,6 +7,7 @@ pub mod credentials { use aws_credential_types::cache::SharedCredentialsCache; use aws_smithy_runtime_api::client::identity::{Identity, IdentityFuture, ResolveIdentity}; + use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents; use aws_smithy_types::config_bag::ConfigBag; /// Smithy identity resolver for AWS credentials. @@ -23,7 +24,11 @@ pub mod credentials { } impl ResolveIdentity for CredentialsIdentityResolver { - fn resolve_identity<'a>(&'a self, _config_bag: &'a ConfigBag) -> IdentityFuture<'a> { + fn resolve_identity<'a>( + &'a self, + _runtime_components: &'a RuntimeComponents, + _config_bag: &'a ConfigBag, + ) -> IdentityFuture<'a> { let cache = self.credentials_cache.clone(); IdentityFuture::new(async move { let credentials = cache.as_ref().provide_cached_credentials().await?; diff --git a/aws/rust-runtime/aws-types/Cargo.toml b/aws/rust-runtime/aws-types/Cargo.toml index 2570ccff73ec4b4f02b69fd920fb7c4994dbcca2..3c143291e033cd5ebc8c889836d704186ae025a8 100644 --- a/aws/rust-runtime/aws-types/Cargo.toml +++ b/aws/rust-runtime/aws-types/Cargo.toml @@ -26,9 +26,10 @@ http = "0.2.6" hyper-rustls = { version = "0.24", optional = true, features = ["rustls-native-certs", "http2", "webpki-roots"] } [dev-dependencies] -futures-util = { version = "0.3.16", default-features = false } http = "0.2.4" +tempfile = "3" tracing-test = "0.2.4" +tokio = { version = "1", features = ["rt", "macros"] } [build-dependencies] rustc_version = "0.4.0" diff --git a/aws/rust-runtime/aws-types/src/os_shim_internal.rs b/aws/rust-runtime/aws-types/src/os_shim_internal.rs index 85f67cd4c2ad4c4d6144320bf4e93e1f070cff12..53b26706f795da8d8dcdf548f0005b42b604deb0 100644 --- a/aws/rust-runtime/aws-types/src/os_shim_internal.rs +++ b/aws/rust-runtime/aws-types/src/os_shim_internal.rs @@ -12,7 +12,7 @@ use std::env::VarError; use std::ffi::OsString; use std::fmt::Debug; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crate::os_shim_internal::fs::Fake; @@ -50,7 +50,7 @@ impl Fs { } pub fn from_raw_map(fs: HashMap>) -> Self { - Fs(fs::Inner::Fake(Arc::new(Fake::MapFs(fs)))) + Fs(fs::Inner::Fake(Arc::new(Fake::MapFs(Mutex::new(fs))))) } pub fn from_map(data: HashMap>>) -> Self { @@ -125,9 +125,12 @@ impl Fs { use fs::Inner; let path = path.as_ref(); match &self.0 { + // TODO(https://github.com/awslabs/aws-sdk-rust/issues/867): Use async IO below Inner::Real => std::fs::read(path), Inner::Fake(fake) => match fake.as_ref() { Fake::MapFs(fs) => fs + .lock() + .unwrap() .get(path.as_os_str()) .cloned() .ok_or_else(|| std::io::ErrorKind::NotFound.into()), @@ -143,13 +146,48 @@ impl Fs { }, } } + + /// Write a slice as the entire contents of a file. + /// + /// This is equivalent to `std::fs::write`. + pub async fn write( + &self, + path: impl AsRef, + contents: impl AsRef<[u8]>, + ) -> std::io::Result<()> { + use fs::Inner; + match &self.0 { + // TODO(https://github.com/awslabs/aws-sdk-rust/issues/867): Use async IO below + Inner::Real => { + std::fs::write(path, contents)?; + } + Inner::Fake(fake) => match fake.as_ref() { + Fake::MapFs(fs) => { + fs.lock() + .unwrap() + .insert(path.as_ref().as_os_str().into(), contents.as_ref().to_vec()); + } + Fake::NamespacedFs { + real_path, + namespaced_to, + } => { + let actual_path = path + .as_ref() + .strip_prefix(namespaced_to) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::NotFound))?; + std::fs::write(real_path.join(actual_path), contents)?; + } + }, + } + Ok(()) + } } mod fs { use std::collections::HashMap; use std::ffi::OsString; use std::path::PathBuf; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; #[derive(Clone, Debug)] pub(super) enum Inner { @@ -159,7 +197,7 @@ mod fs { #[derive(Debug)] pub(super) enum Fake { - MapFs(HashMap>), + MapFs(Mutex>>), NamespacedFs { real_path: PathBuf, namespaced_to: PathBuf, @@ -242,8 +280,6 @@ mod env { mod test { use std::env::VarError; - use futures_util::FutureExt; - use crate::os_shim_internal::{Env, Fs}; #[test] @@ -256,19 +292,33 @@ mod test { ) } - #[test] - fn fs_works() { + #[tokio::test] + async fn fs_from_test_dir_works() { let fs = Fs::from_test_dir(".", "/users/test-data"); let _ = fs .read_to_end("/users/test-data/Cargo.toml") - .now_or_never() - .expect("future should not poll") + .await .expect("file exists"); let _ = fs .read_to_end("doesntexist") - .now_or_never() - .expect("future should not poll") + .await .expect_err("file doesnt exists"); } + + #[tokio::test] + async fn fs_round_trip_file_with_real() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("test-file"); + + let fs = Fs::real(); + fs.read_to_end(&path) + .await + .expect_err("file doesn't exist yet"); + + fs.write(&path, b"test").await.expect("success"); + + let result = fs.read_to_end(&path).await.expect("success"); + assert_eq!(b"test", &result[..]); + } } diff --git a/aws/sdk-codegen/src/main/kotlin/software/amazon/smithy/rustsdk/AwsDocs.kt b/aws/sdk-codegen/src/main/kotlin/software/amazon/smithy/rustsdk/AwsDocs.kt index e103e43ffc99f5f034bbe87b9ac29a5a02efefce..6263d54497d39e7e6ebf5a0bd86d8658d53c82e0 100644 --- a/aws/sdk-codegen/src/main/kotlin/software/amazon/smithy/rustsdk/AwsDocs.kt +++ b/aws/sdk-codegen/src/main/kotlin/software/amazon/smithy/rustsdk/AwsDocs.kt @@ -21,8 +21,9 @@ object AwsDocs { fun canRelyOnAwsConfig(codegenContext: ClientCodegenContext): Boolean = SdkSettings.from(codegenContext.settings).awsConfigVersion != null && !setOf( - ShapeId.from("com.amazonaws.sts#AWSSecurityTokenServiceV20110615"), ShapeId.from("com.amazonaws.sso#SWBPortalService"), + ShapeId.from("com.amazonaws.ssooidc#AWSSSOOIDCService"), + ShapeId.from("com.amazonaws.sts#AWSSecurityTokenServiceV20110615"), ).contains(codegenContext.serviceShape.id) fun constructClient(codegenContext: ClientCodegenContext, indent: String): Writable { diff --git a/aws/sdk/aws-models/sso-oidc.json b/aws/sdk/aws-models/sso-oidc.json new file mode 100644 index 0000000000000000000000000000000000000000..21fa139b314a6b05042a4078f732daca79b0999b --- /dev/null +++ b/aws/sdk/aws-models/sso-oidc.json @@ -0,0 +1,1590 @@ +{ + "smithy": "2.0", + "metadata": { + "suppressions": [ + { + "id": "HttpMethodSemantics", + "namespace": "*" + }, + { + "id": "HttpResponseCodeSemantics", + "namespace": "*" + }, + { + "id": "PaginatedTrait", + "namespace": "*" + }, + { + "id": "HttpHeaderTrait", + "namespace": "*" + }, + { + "id": "HttpUriConflict", + "namespace": "*" + }, + { + "id": "Service", + "namespace": "*" + } + ] + }, + "shapes": { + "com.amazonaws.ssooidc#AWSSSOOIDCService": { + "type": "service", + "version": "2019-06-10", + "operations": [ + { + "target": "com.amazonaws.ssooidc#CreateToken" + }, + { + "target": "com.amazonaws.ssooidc#RegisterClient" + }, + { + "target": "com.amazonaws.ssooidc#StartDeviceAuthorization" + } + ], + "traits": { + "aws.api#service": { + "sdkId": "SSO OIDC", + "arnNamespace": "awsssooidc", + "cloudFormationName": "SSOOIDC", + "cloudTrailEventSource": "ssooidc.amazonaws.com", + "endpointPrefix": "oidc" + }, + "aws.auth#sigv4": { + "name": "awsssooidc" + }, + "aws.protocols#restJson1": {}, + "smithy.api#documentation": "

AWS IAM Identity Center (successor to AWS Single Sign-On) OpenID Connect (OIDC) is a web service that enables a client (such as AWS CLI\n or a native application) to register with IAM Identity Center. The service also enables the client to\n fetch the user’s access token upon successful authentication and authorization with\n IAM Identity Center.

\n \n

Although AWS Single Sign-On was renamed, the sso and\n identitystore API namespaces will continue to retain their original name for\n backward compatibility purposes. For more information, see IAM Identity Center rename.

\n
\n

\n Considerations for Using This Guide\n

\n

Before you begin using this guide, we recommend that you first review the following\n important information about how the IAM Identity Center OIDC service works.

\n
    \n
  • \n

    The IAM Identity Center OIDC service currently implements only the portions of the OAuth 2.0\n Device Authorization Grant standard (https://tools.ietf.org/html/rfc8628) that are necessary to enable single\n sign-on authentication with the AWS CLI. Support for other OIDC flows frequently needed\n for native applications, such as Authorization Code Flow (+ PKCE), will be addressed in\n future releases.

    \n
  • \n
  • \n

    The service emits only OIDC access tokens, such that obtaining a new token (For\n example, token refresh) requires explicit user re-authentication.

    \n
  • \n
  • \n

    The access tokens provided by this service grant access to all AWS account\n entitlements assigned to an IAM Identity Center user, not just a particular application.

    \n
  • \n
  • \n

    The documentation in this guide does not describe the mechanism to convert the access\n token into AWS Auth (“sigv4”) credentials for use with IAM-protected AWS service\n endpoints. For more information, see GetRoleCredentials in the IAM Identity Center Portal API Reference\n Guide.

    \n
  • \n
\n\n

For general information about IAM Identity Center, see What is\n IAM Identity Center? in the IAM Identity Center User Guide.

", + "smithy.api#title": "AWS SSO OIDC", + "smithy.rules#endpointRuleSet": { + "version": "1.0", + "parameters": { + "Region": { + "builtIn": "AWS::Region", + "required": false, + "documentation": "The AWS region used to dispatch the request.", + "type": "String" + }, + "UseDualStack": { + "builtIn": "AWS::UseDualStack", + "required": true, + "default": false, + "documentation": "When true, use the dual-stack endpoint. If the configured endpoint does not support dual-stack, dispatching the request MAY return an error.", + "type": "Boolean" + }, + "UseFIPS": { + "builtIn": "AWS::UseFIPS", + "required": true, + "default": false, + "documentation": "When true, send this request to the FIPS-compliant regional endpoint. If the configured endpoint does not have a FIPS compliant endpoint, dispatching the request will return an error.", + "type": "Boolean" + }, + "Endpoint": { + "builtIn": "SDK::Endpoint", + "required": false, + "documentation": "Override the endpoint used to send this request", + "type": "String" + } + }, + "rules": [ + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Endpoint" + } + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + } + ], + "error": "Invalid Configuration: FIPS and custom endpoint are not supported", + "type": "error" + }, + { + "conditions": [], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + true + ] + } + ], + "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", + "type": "error" + }, + { + "conditions": [], + "endpoint": { + "url": { + "ref": "Endpoint" + }, + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ] + } + ] + }, + { + "conditions": [], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Region" + } + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "aws.partition", + "argv": [ + { + "ref": "Region" + } + ], + "assign": "PartitionResult" + } + ], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + true + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + true, + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "supportsFIPS" + ] + } + ] + }, + { + "fn": "booleanEquals", + "argv": [ + true, + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "supportsDualStack" + ] + } + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [], + "type": "tree", + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://oidc-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ] + } + ] + }, + { + "conditions": [], + "error": "FIPS and DualStack are enabled, but this partition does not support one or both", + "type": "error" + } + ] + }, + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + true, + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "supportsFIPS" + ] + } + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [], + "type": "tree", + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://oidc-fips.{Region}.{PartitionResult#dnsSuffix}", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ] + } + ] + }, + { + "conditions": [], + "error": "FIPS is enabled but this partition does not support FIPS", + "type": "error" + } + ] + }, + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + true + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + true, + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "supportsDualStack" + ] + } + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [], + "type": "tree", + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://oidc.{Region}.{PartitionResult#dualStackDnsSuffix}", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ] + } + ] + }, + { + "conditions": [], + "error": "DualStack is enabled but this partition does not support DualStack", + "type": "error" + } + ] + }, + { + "conditions": [], + "type": "tree", + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://oidc.{Region}.{PartitionResult#dnsSuffix}", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ] + } + ] + } + ] + }, + { + "conditions": [], + "error": "Invalid Configuration: Missing Region", + "type": "error" + } + ] + } + ] + }, + "smithy.rules#endpointTests": { + "testCases": [ + { + "documentation": "For region ap-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.ap-east-1.amazonaws.com" + } + }, + "params": { + "Region": "ap-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region ap-northeast-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.ap-northeast-1.amazonaws.com" + } + }, + "params": { + "Region": "ap-northeast-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region ap-northeast-2 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.ap-northeast-2.amazonaws.com" + } + }, + "params": { + "Region": "ap-northeast-2", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region ap-northeast-3 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.ap-northeast-3.amazonaws.com" + } + }, + "params": { + "Region": "ap-northeast-3", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region ap-south-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.ap-south-1.amazonaws.com" + } + }, + "params": { + "Region": "ap-south-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region ap-southeast-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.ap-southeast-1.amazonaws.com" + } + }, + "params": { + "Region": "ap-southeast-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region ap-southeast-2 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.ap-southeast-2.amazonaws.com" + } + }, + "params": { + "Region": "ap-southeast-2", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region ca-central-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.ca-central-1.amazonaws.com" + } + }, + "params": { + "Region": "ca-central-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region eu-central-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.eu-central-1.amazonaws.com" + } + }, + "params": { + "Region": "eu-central-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region eu-north-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.eu-north-1.amazonaws.com" + } + }, + "params": { + "Region": "eu-north-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region eu-south-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.eu-south-1.amazonaws.com" + } + }, + "params": { + "Region": "eu-south-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region eu-west-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.eu-west-1.amazonaws.com" + } + }, + "params": { + "Region": "eu-west-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region eu-west-2 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.eu-west-2.amazonaws.com" + } + }, + "params": { + "Region": "eu-west-2", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region eu-west-3 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.eu-west-3.amazonaws.com" + } + }, + "params": { + "Region": "eu-west-3", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region me-south-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.me-south-1.amazonaws.com" + } + }, + "params": { + "Region": "me-south-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region sa-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.sa-east-1.amazonaws.com" + } + }, + "params": { + "Region": "sa-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-east-1.amazonaws.com" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-east-2 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-east-2.amazonaws.com" + } + }, + "params": { + "Region": "us-east-2", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-west-2 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-west-2.amazonaws.com" + } + }, + "params": { + "Region": "us-west-2", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-east-1 with FIPS enabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://oidc-fips.us-east-1.api.aws" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region us-east-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc-fips.us-east-1.amazonaws.com" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-east-1 with FIPS disabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-east-1.api.aws" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region cn-north-1 with FIPS enabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://oidc-fips.cn-north-1.api.amazonwebservices.com.cn" + } + }, + "params": { + "Region": "cn-north-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region cn-north-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc-fips.cn-north-1.amazonaws.com.cn" + } + }, + "params": { + "Region": "cn-north-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region cn-north-1 with FIPS disabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://oidc.cn-north-1.api.amazonwebservices.com.cn" + } + }, + "params": { + "Region": "cn-north-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region cn-north-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.cn-north-1.amazonaws.com.cn" + } + }, + "params": { + "Region": "cn-north-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-gov-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-gov-east-1.amazonaws.com" + } + }, + "params": { + "Region": "us-gov-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-gov-west-1.amazonaws.com" + } + }, + "params": { + "Region": "us-gov-west-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-gov-east-1 with FIPS enabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://oidc-fips.us-gov-east-1.api.aws" + } + }, + "params": { + "Region": "us-gov-east-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region us-gov-east-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc-fips.us-gov-east-1.amazonaws.com" + } + }, + "params": { + "Region": "us-gov-east-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-gov-east-1 with FIPS disabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-gov-east-1.api.aws" + } + }, + "params": { + "Region": "us-gov-east-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region us-iso-east-1 with FIPS enabled and DualStack enabled", + "expect": { + "error": "FIPS and DualStack are enabled, but this partition does not support one or both" + }, + "params": { + "Region": "us-iso-east-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region us-iso-east-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc-fips.us-iso-east-1.c2s.ic.gov" + } + }, + "params": { + "Region": "us-iso-east-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-iso-east-1 with FIPS disabled and DualStack enabled", + "expect": { + "error": "DualStack is enabled but this partition does not support DualStack" + }, + "params": { + "Region": "us-iso-east-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region us-iso-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-iso-east-1.c2s.ic.gov" + } + }, + "params": { + "Region": "us-iso-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-isob-east-1 with FIPS enabled and DualStack enabled", + "expect": { + "error": "FIPS and DualStack are enabled, but this partition does not support one or both" + }, + "params": { + "Region": "us-isob-east-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region us-isob-east-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc-fips.us-isob-east-1.sc2s.sgov.gov" + } + }, + "params": { + "Region": "us-isob-east-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-isob-east-1 with FIPS disabled and DualStack enabled", + "expect": { + "error": "DualStack is enabled but this partition does not support DualStack" + }, + "params": { + "Region": "us-isob-east-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region us-isob-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://oidc.us-isob-east-1.sc2s.sgov.gov" + } + }, + "params": { + "Region": "us-isob-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For custom endpoint with region set and fips disabled and dualstack disabled", + "expect": { + "endpoint": { + "url": "https://example.com" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": false, + "UseDualStack": false, + "Endpoint": "https://example.com" + } + }, + { + "documentation": "For custom endpoint with region not set and fips disabled and dualstack disabled", + "expect": { + "endpoint": { + "url": "https://example.com" + } + }, + "params": { + "UseFIPS": false, + "UseDualStack": false, + "Endpoint": "https://example.com" + } + }, + { + "documentation": "For custom endpoint with fips enabled and dualstack disabled", + "expect": { + "error": "Invalid Configuration: FIPS and custom endpoint are not supported" + }, + "params": { + "Region": "us-east-1", + "UseFIPS": true, + "UseDualStack": false, + "Endpoint": "https://example.com" + } + }, + { + "documentation": "For custom endpoint with fips disabled and dualstack enabled", + "expect": { + "error": "Invalid Configuration: Dualstack and custom endpoint are not supported" + }, + "params": { + "Region": "us-east-1", + "UseFIPS": false, + "UseDualStack": true, + "Endpoint": "https://example.com" + } + }, + { + "documentation": "Missing region", + "expect": { + "error": "Invalid Configuration: Missing Region" + } + } + ], + "version": "1.0" + } + } + }, + "com.amazonaws.ssooidc#AccessDeniedException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

You do not have sufficient access to perform this action.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#AccessToken": { + "type": "string" + }, + "com.amazonaws.ssooidc#AuthCode": { + "type": "string" + }, + "com.amazonaws.ssooidc#AuthorizationPendingException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that a request to authorize a client with an access user session token is\n pending.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#ClientId": { + "type": "string" + }, + "com.amazonaws.ssooidc#ClientName": { + "type": "string" + }, + "com.amazonaws.ssooidc#ClientSecret": { + "type": "string" + }, + "com.amazonaws.ssooidc#ClientType": { + "type": "string" + }, + "com.amazonaws.ssooidc#CreateToken": { + "type": "operation", + "input": { + "target": "com.amazonaws.ssooidc#CreateTokenRequest" + }, + "output": { + "target": "com.amazonaws.ssooidc#CreateTokenResponse" + }, + "errors": [ + { + "target": "com.amazonaws.ssooidc#AccessDeniedException" + }, + { + "target": "com.amazonaws.ssooidc#AuthorizationPendingException" + }, + { + "target": "com.amazonaws.ssooidc#ExpiredTokenException" + }, + { + "target": "com.amazonaws.ssooidc#InternalServerException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidClientException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidGrantException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidRequestException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidScopeException" + }, + { + "target": "com.amazonaws.ssooidc#SlowDownException" + }, + { + "target": "com.amazonaws.ssooidc#UnauthorizedClientException" + }, + { + "target": "com.amazonaws.ssooidc#UnsupportedGrantTypeException" + } + ], + "traits": { + "smithy.api#auth": [], + "smithy.api#documentation": "

Creates and returns an access token for the authorized client. The access token issued\n will be used to fetch short-term credentials for the assigned roles in the AWS\n account.

", + "smithy.api#http": { + "method": "POST", + "uri": "/token", + "code": 200 + }, + "smithy.api#optionalAuth": {} + } + }, + "com.amazonaws.ssooidc#CreateTokenRequest": { + "type": "structure", + "members": { + "clientId": { + "target": "com.amazonaws.ssooidc#ClientId", + "traits": { + "smithy.api#documentation": "

The unique identifier string for each client. This value should come from the persisted\n result of the RegisterClient API.

", + "smithy.api#required": {} + } + }, + "clientSecret": { + "target": "com.amazonaws.ssooidc#ClientSecret", + "traits": { + "smithy.api#documentation": "

A secret string generated for the client. This value should come from the persisted result\n of the RegisterClient API.

", + "smithy.api#required": {} + } + }, + "grantType": { + "target": "com.amazonaws.ssooidc#GrantType", + "traits": { + "smithy.api#documentation": "

Supports grant types for the authorization code, refresh token, and device code request.\n For device code requests, specify the following value:

\n\n

\n urn:ietf:params:oauth:grant-type:device_code\n \n

\n\n

For information about how to obtain the device code, see the StartDeviceAuthorization topic.

", + "smithy.api#required": {} + } + }, + "deviceCode": { + "target": "com.amazonaws.ssooidc#DeviceCode", + "traits": { + "smithy.api#documentation": "

Used only when calling this API for the device code grant type. This short-term code is\n used to identify this authentication attempt. This should come from an in-memory reference to\n the result of the StartDeviceAuthorization API.

" + } + }, + "code": { + "target": "com.amazonaws.ssooidc#AuthCode", + "traits": { + "smithy.api#documentation": "

The authorization code received from the authorization service. This parameter is required\n to perform an authorization grant request to get access to a token.

" + } + }, + "refreshToken": { + "target": "com.amazonaws.ssooidc#RefreshToken", + "traits": { + "smithy.api#documentation": "

Currently, refreshToken is not yet implemented and is not supported. For more\n information about the features and limitations of the current IAM Identity Center OIDC implementation,\n see Considerations for Using this Guide in the IAM Identity Center\n OIDC API Reference.

\n

The token used to obtain an access token in the event that the access token is invalid or\n expired.

" + } + }, + "scope": { + "target": "com.amazonaws.ssooidc#Scopes", + "traits": { + "smithy.api#documentation": "

The list of scopes that is defined by the client. Upon authorization, this list is used to\n restrict permissions when granting an access token.

" + } + }, + "redirectUri": { + "target": "com.amazonaws.ssooidc#URI", + "traits": { + "smithy.api#documentation": "

The location of the application that will receive the authorization code. Users authorize\n the service to send the request to this location.

" + } + } + } + }, + "com.amazonaws.ssooidc#CreateTokenResponse": { + "type": "structure", + "members": { + "accessToken": { + "target": "com.amazonaws.ssooidc#AccessToken", + "traits": { + "smithy.api#documentation": "

An opaque token to access IAM Identity Center resources assigned to a user.

" + } + }, + "tokenType": { + "target": "com.amazonaws.ssooidc#TokenType", + "traits": { + "smithy.api#documentation": "

Used to notify the client that the returned token is an access token. The supported type\n is BearerToken.

" + } + }, + "expiresIn": { + "target": "com.amazonaws.ssooidc#ExpirationInSeconds", + "traits": { + "smithy.api#default": 0, + "smithy.api#documentation": "

Indicates the time in seconds when an access token will expire.

" + } + }, + "refreshToken": { + "target": "com.amazonaws.ssooidc#RefreshToken", + "traits": { + "smithy.api#documentation": "

Currently, refreshToken is not yet implemented and is not supported. For more\n information about the features and limitations of the current IAM Identity Center OIDC implementation,\n see Considerations for Using this Guide in the IAM Identity Center\n OIDC API Reference.

\n

A token that, if present, can be used to refresh a previously issued access token that\n might have expired.

" + } + }, + "idToken": { + "target": "com.amazonaws.ssooidc#IdToken", + "traits": { + "smithy.api#documentation": "

Currently, idToken is not yet implemented and is not supported. For more\n information about the features and limitations of the current IAM Identity Center OIDC implementation,\n see Considerations for Using this Guide in the IAM Identity Center\n OIDC API Reference.

\n

The identifier of the user that associated with the access token, if present.

" + } + } + } + }, + "com.amazonaws.ssooidc#DeviceCode": { + "type": "string" + }, + "com.amazonaws.ssooidc#Error": { + "type": "string" + }, + "com.amazonaws.ssooidc#ErrorDescription": { + "type": "string" + }, + "com.amazonaws.ssooidc#ExpirationInSeconds": { + "type": "integer", + "traits": { + "smithy.api#default": 0 + } + }, + "com.amazonaws.ssooidc#ExpiredTokenException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that the token issued by the service is expired and is no longer valid.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#GrantType": { + "type": "string" + }, + "com.amazonaws.ssooidc#IdToken": { + "type": "string" + }, + "com.amazonaws.ssooidc#InternalServerException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that an error from the service occurred while trying to process a\n request.

", + "smithy.api#error": "server", + "smithy.api#httpError": 500 + } + }, + "com.amazonaws.ssooidc#IntervalInSeconds": { + "type": "integer", + "traits": { + "smithy.api#default": 0 + } + }, + "com.amazonaws.ssooidc#InvalidClientException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that the clientId or clientSecret in the request is\n invalid. For example, this can occur when a client sends an incorrect clientId or\n an expired clientSecret.

", + "smithy.api#error": "client", + "smithy.api#httpError": 401 + } + }, + "com.amazonaws.ssooidc#InvalidClientMetadataException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that the client information sent in the request during registration is\n invalid.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#InvalidGrantException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that a request contains an invalid grant. This can occur if a client makes a\n CreateToken request with an invalid grant type.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#InvalidRequestException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that something is wrong with the input to the request. For example, a required\n parameter might be missing or out of range.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#InvalidScopeException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that the scope provided in the request is invalid.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#LongTimeStampType": { + "type": "long", + "traits": { + "smithy.api#default": 0 + } + }, + "com.amazonaws.ssooidc#RefreshToken": { + "type": "string" + }, + "com.amazonaws.ssooidc#RegisterClient": { + "type": "operation", + "input": { + "target": "com.amazonaws.ssooidc#RegisterClientRequest" + }, + "output": { + "target": "com.amazonaws.ssooidc#RegisterClientResponse" + }, + "errors": [ + { + "target": "com.amazonaws.ssooidc#InternalServerException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidClientMetadataException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidRequestException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidScopeException" + } + ], + "traits": { + "smithy.api#auth": [], + "smithy.api#documentation": "

Registers a client with IAM Identity Center. This allows clients to initiate device authorization.\n The output should be persisted for reuse through many authentication requests.

", + "smithy.api#http": { + "method": "POST", + "uri": "/client/register", + "code": 200 + }, + "smithy.api#optionalAuth": {} + } + }, + "com.amazonaws.ssooidc#RegisterClientRequest": { + "type": "structure", + "members": { + "clientName": { + "target": "com.amazonaws.ssooidc#ClientName", + "traits": { + "smithy.api#documentation": "

The friendly name of the client.

", + "smithy.api#required": {} + } + }, + "clientType": { + "target": "com.amazonaws.ssooidc#ClientType", + "traits": { + "smithy.api#documentation": "

The type of client. The service supports only public as a client type.\n Anything other than public will be rejected by the service.

", + "smithy.api#required": {} + } + }, + "scopes": { + "target": "com.amazonaws.ssooidc#Scopes", + "traits": { + "smithy.api#documentation": "

The list of scopes that are defined by the client. Upon authorization, this list is used\n to restrict permissions when granting an access token.

" + } + } + } + }, + "com.amazonaws.ssooidc#RegisterClientResponse": { + "type": "structure", + "members": { + "clientId": { + "target": "com.amazonaws.ssooidc#ClientId", + "traits": { + "smithy.api#documentation": "

The unique identifier string for each client. This client uses this identifier to get\n authenticated by the service in subsequent calls.

" + } + }, + "clientSecret": { + "target": "com.amazonaws.ssooidc#ClientSecret", + "traits": { + "smithy.api#documentation": "

A secret string generated for the client. The client will use this string to get\n authenticated by the service in subsequent calls.

" + } + }, + "clientIdIssuedAt": { + "target": "com.amazonaws.ssooidc#LongTimeStampType", + "traits": { + "smithy.api#default": 0, + "smithy.api#documentation": "

Indicates the time at which the clientId and clientSecret were\n issued.

" + } + }, + "clientSecretExpiresAt": { + "target": "com.amazonaws.ssooidc#LongTimeStampType", + "traits": { + "smithy.api#default": 0, + "smithy.api#documentation": "

Indicates the time at which the clientId and clientSecret will\n become invalid.

" + } + }, + "authorizationEndpoint": { + "target": "com.amazonaws.ssooidc#URI", + "traits": { + "smithy.api#documentation": "

The endpoint where the client can request authorization.

" + } + }, + "tokenEndpoint": { + "target": "com.amazonaws.ssooidc#URI", + "traits": { + "smithy.api#documentation": "

The endpoint where the client can get an access token.

" + } + } + } + }, + "com.amazonaws.ssooidc#Scope": { + "type": "string" + }, + "com.amazonaws.ssooidc#Scopes": { + "type": "list", + "member": { + "target": "com.amazonaws.ssooidc#Scope" + } + }, + "com.amazonaws.ssooidc#SlowDownException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that the client is making the request too frequently and is more than the\n service can handle.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#StartDeviceAuthorization": { + "type": "operation", + "input": { + "target": "com.amazonaws.ssooidc#StartDeviceAuthorizationRequest" + }, + "output": { + "target": "com.amazonaws.ssooidc#StartDeviceAuthorizationResponse" + }, + "errors": [ + { + "target": "com.amazonaws.ssooidc#InternalServerException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidClientException" + }, + { + "target": "com.amazonaws.ssooidc#InvalidRequestException" + }, + { + "target": "com.amazonaws.ssooidc#SlowDownException" + }, + { + "target": "com.amazonaws.ssooidc#UnauthorizedClientException" + } + ], + "traits": { + "smithy.api#auth": [], + "smithy.api#documentation": "

Initiates device authorization by requesting a pair of verification codes from the\n authorization service.

", + "smithy.api#http": { + "method": "POST", + "uri": "/device_authorization", + "code": 200 + }, + "smithy.api#optionalAuth": {} + } + }, + "com.amazonaws.ssooidc#StartDeviceAuthorizationRequest": { + "type": "structure", + "members": { + "clientId": { + "target": "com.amazonaws.ssooidc#ClientId", + "traits": { + "smithy.api#documentation": "

The unique identifier string for the client that is registered with IAM Identity Center. This value\n should come from the persisted result of the RegisterClient API\n operation.

", + "smithy.api#required": {} + } + }, + "clientSecret": { + "target": "com.amazonaws.ssooidc#ClientSecret", + "traits": { + "smithy.api#documentation": "

A secret string that is generated for the client. This value should come from the\n persisted result of the RegisterClient API operation.

", + "smithy.api#required": {} + } + }, + "startUrl": { + "target": "com.amazonaws.ssooidc#URI", + "traits": { + "smithy.api#documentation": "

The URL for the AWS access portal. For more information, see Using\n the AWS access portal in the IAM Identity Center User Guide.

", + "smithy.api#required": {} + } + } + } + }, + "com.amazonaws.ssooidc#StartDeviceAuthorizationResponse": { + "type": "structure", + "members": { + "deviceCode": { + "target": "com.amazonaws.ssooidc#DeviceCode", + "traits": { + "smithy.api#documentation": "

The short-lived code that is used by the device when polling for a session token.

" + } + }, + "userCode": { + "target": "com.amazonaws.ssooidc#UserCode", + "traits": { + "smithy.api#documentation": "

A one-time user verification code. This is needed to authorize an in-use device.

" + } + }, + "verificationUri": { + "target": "com.amazonaws.ssooidc#URI", + "traits": { + "smithy.api#documentation": "

The URI of the verification page that takes the userCode to authorize the\n device.

" + } + }, + "verificationUriComplete": { + "target": "com.amazonaws.ssooidc#URI", + "traits": { + "smithy.api#documentation": "

An alternate URL that the client can use to automatically launch a browser. This process\n skips the manual step in which the user visits the verification page and enters their\n code.

" + } + }, + "expiresIn": { + "target": "com.amazonaws.ssooidc#ExpirationInSeconds", + "traits": { + "smithy.api#default": 0, + "smithy.api#documentation": "

Indicates the number of seconds in which the verification code will become invalid.

" + } + }, + "interval": { + "target": "com.amazonaws.ssooidc#IntervalInSeconds", + "traits": { + "smithy.api#default": 0, + "smithy.api#documentation": "

Indicates the number of seconds the client must wait between attempts when polling for a\n session.

" + } + } + } + }, + "com.amazonaws.ssooidc#TokenType": { + "type": "string" + }, + "com.amazonaws.ssooidc#URI": { + "type": "string" + }, + "com.amazonaws.ssooidc#UnauthorizedClientException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that the client is not currently authorized to make the request. This can happen\n when a clientId is not issued for a public client.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#UnsupportedGrantTypeException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.ssooidc#Error" + }, + "error_description": { + "target": "com.amazonaws.ssooidc#ErrorDescription" + } + }, + "traits": { + "smithy.api#documentation": "

Indicates that the grant type in the request is not supported by the service.

", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.ssooidc#UserCode": { + "type": "string" + } + } +} diff --git a/rust-runtime/aws-smithy-runtime-api/src/client/identity.rs b/rust-runtime/aws-smithy-runtime-api/src/client/identity.rs index 828c15202c6cf9db2fc5ace4beb2f25fc8f7c6ec..c9377f9c9f2488c116753e7561fba6eee0485579 100644 --- a/rust-runtime/aws-smithy-runtime-api/src/client/identity.rs +++ b/rust-runtime/aws-smithy-runtime-api/src/client/identity.rs @@ -5,6 +5,7 @@ use crate::box_error::BoxError; use crate::client::auth::AuthSchemeId; +use crate::client::runtime_components::RuntimeComponents; use crate::impl_shared_conversions; use aws_smithy_types::config_bag::ConfigBag; use std::any::Any; @@ -37,7 +38,11 @@ pub use ResolveIdentity as IdentityResolver; /// There is no fallback to other auth schemes in the absence of an identity. pub trait ResolveIdentity: Send + Sync + Debug { /// Asynchronously resolves an identity for a request using the given config. - fn resolve_identity<'a>(&'a self, config_bag: &'a ConfigBag) -> IdentityFuture<'a>; + fn resolve_identity<'a>( + &'a self, + runtime_components: &'a RuntimeComponents, + config_bag: &'a ConfigBag, + ) -> IdentityFuture<'a>; } /// Container for a shared identity resolver. @@ -52,8 +57,12 @@ impl SharedIdentityResolver { } impl ResolveIdentity for SharedIdentityResolver { - fn resolve_identity<'a>(&'a self, config_bag: &'a ConfigBag) -> IdentityFuture<'a> { - self.0.resolve_identity(config_bag) + fn resolve_identity<'a>( + &'a self, + runtime_components: &'a RuntimeComponents, + config_bag: &'a ConfigBag, + ) -> IdentityFuture<'a> { + self.0.resolve_identity(runtime_components, config_bag) } } diff --git a/rust-runtime/aws-smithy-runtime-api/src/client/identity/http.rs b/rust-runtime/aws-smithy-runtime-api/src/client/identity/http.rs index 6f82e6e6ff597502cacf0b3bc27f06712278d028..5ec332cf3e3372755afafcdf88066edf8768fbcd 100644 --- a/rust-runtime/aws-smithy-runtime-api/src/client/identity/http.rs +++ b/rust-runtime/aws-smithy-runtime-api/src/client/identity/http.rs @@ -6,6 +6,7 @@ //! Identity types for HTTP auth use crate::client::identity::{Identity, IdentityFuture, ResolveIdentity}; +use crate::client::runtime_components::RuntimeComponents; use aws_smithy_types::config_bag::ConfigBag; use std::fmt::Debug; use std::sync::Arc; @@ -64,7 +65,11 @@ impl From for Token { } impl ResolveIdentity for Token { - fn resolve_identity<'a>(&'a self, _config_bag: &'a ConfigBag) -> IdentityFuture<'a> { + fn resolve_identity<'a>( + &'a self, + _runtime_components: &'a RuntimeComponents, + _config_bag: &'a ConfigBag, + ) -> IdentityFuture<'a> { IdentityFuture::ready(Ok(Identity::new(self.clone(), self.0.expiration))) } } @@ -123,7 +128,11 @@ impl Login { } impl ResolveIdentity for Login { - fn resolve_identity<'a>(&'a self, _config_bag: &'a ConfigBag) -> IdentityFuture<'a> { + fn resolve_identity<'a>( + &'a self, + _runtime_components: &'a RuntimeComponents, + _config_bag: &'a ConfigBag, + ) -> IdentityFuture<'a> { IdentityFuture::ready(Ok(Identity::new(self.clone(), self.0.expiration))) } } diff --git a/rust-runtime/aws-smithy-runtime-api/src/client/runtime_components.rs b/rust-runtime/aws-smithy-runtime-api/src/client/runtime_components.rs index e7e6ddf55ec6d0fba1630366339c2f37f7c749ab..b98a45d5b6228199de1235bc59be2b68a68e805c 100644 --- a/rust-runtime/aws-smithy-runtime-api/src/client/runtime_components.rs +++ b/rust-runtime/aws-smithy-runtime-api/src/client/runtime_components.rs @@ -606,7 +606,11 @@ impl RuntimeComponentsBuilder { #[derive(Debug)] struct FakeIdentityResolver; impl ResolveIdentity for FakeIdentityResolver { - fn resolve_identity<'a>(&'a self, _: &'a ConfigBag) -> IdentityFuture<'a> { + fn resolve_identity<'a>( + &'a self, + _: &RuntimeComponents, + _: &'a ConfigBag, + ) -> IdentityFuture<'a> { unreachable!("fake identity resolver must be overridden for this test") } } diff --git a/rust-runtime/aws-smithy-runtime/src/client/auth/http.rs b/rust-runtime/aws-smithy-runtime/src/client/auth/http.rs index 6850039a91ca352cde5249f4cedab8913bc7fb1b..affff101d0d7463a4cc7d31580cf5e38f428f36e 100644 --- a/rust-runtime/aws-smithy-runtime/src/client/auth/http.rs +++ b/rust-runtime/aws-smithy-runtime/src/client/auth/http.rs @@ -405,4 +405,32 @@ mod tests { request.headers().get("Authorization").unwrap() ); } + + #[test] + fn test_bearer_auth_overwrite_existing_header() { + let signer = BearerAuthSigner; + + let config_bag = ConfigBag::base(); + let runtime_components = RuntimeComponentsBuilder::for_tests().build().unwrap(); + let identity = Identity::new(Token::new("some-token", None), None); + let mut request = http::Request::builder() + .header("Authorization", "wrong") + .body(SdkBody::empty()) + .unwrap() + .try_into() + .unwrap(); + signer + .sign_http_request( + &mut request, + &identity, + AuthSchemeEndpointConfig::empty(), + &runtime_components, + &config_bag, + ) + .expect("success"); + assert_eq!( + "Bearer some-token", + request.headers().get("Authorization").unwrap() + ); + } } diff --git a/rust-runtime/aws-smithy-runtime/src/client/identity/no_auth.rs b/rust-runtime/aws-smithy-runtime/src/client/identity/no_auth.rs index 0d4a7f4a6ba44e6d4cfd562b640b1c117eacf748..eb96f57b3efe926b8957ddd166c5e9409ec85d5b 100644 --- a/rust-runtime/aws-smithy-runtime/src/client/identity/no_auth.rs +++ b/rust-runtime/aws-smithy-runtime/src/client/identity/no_auth.rs @@ -4,6 +4,7 @@ */ use aws_smithy_runtime_api::client::identity::{Identity, IdentityFuture, ResolveIdentity}; +use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents; use aws_smithy_types::config_bag::ConfigBag; /// Identity for the [`NoAuthScheme`](crate::client::auth::no_auth::NoAuthScheme) auth scheme. @@ -29,7 +30,11 @@ impl NoAuthIdentityResolver { } impl ResolveIdentity for NoAuthIdentityResolver { - fn resolve_identity<'a>(&'a self, _: &'a ConfigBag) -> IdentityFuture<'a> { + fn resolve_identity<'a>( + &'a self, + _runtime_components: &'a RuntimeComponents, + _: &'a ConfigBag, + ) -> IdentityFuture<'a> { IdentityFuture::ready(Ok(Identity::new(NoAuthIdentity::new(), None))) } } diff --git a/rust-runtime/aws-smithy-runtime/src/client/orchestrator/auth.rs b/rust-runtime/aws-smithy-runtime/src/client/orchestrator/auth.rs index 2aab26de8494137fb41aabc1d2ec5913a6dd704e..d5c685ead2bafe317ac58438a2f76d98786005b9 100644 --- a/rust-runtime/aws-smithy-runtime/src/client/orchestrator/auth.rs +++ b/rust-runtime/aws-smithy-runtime/src/client/orchestrator/auth.rs @@ -77,7 +77,9 @@ pub(super) async fn orchestrate_auth( Ok(auth_scheme_endpoint_config) => { trace!(auth_scheme_endpoint_config = ?auth_scheme_endpoint_config, "extracted auth scheme endpoint config"); - let identity = identity_resolver.resolve_identity(cfg).await?; + let identity = identity_resolver + .resolve_identity(runtime_components, cfg) + .await?; trace!(identity = ?identity, "resolved identity"); trace!("signing request"); @@ -149,7 +151,7 @@ mod tests { use aws_smithy_runtime_api::client::interceptors::context::{Input, InterceptorContext}; use aws_smithy_runtime_api::client::orchestrator::HttpRequest; use aws_smithy_runtime_api::client::runtime_components::{ - GetIdentityResolver, RuntimeComponentsBuilder, + GetIdentityResolver, RuntimeComponents, RuntimeComponentsBuilder, }; use aws_smithy_types::config_bag::Layer; use std::collections::HashMap; @@ -159,7 +161,11 @@ mod tests { #[derive(Debug)] struct TestIdentityResolver; impl ResolveIdentity for TestIdentityResolver { - fn resolve_identity<'a>(&'a self, _config_bag: &'a ConfigBag) -> IdentityFuture<'a> { + fn resolve_identity<'a>( + &'a self, + _runtime_components: &'a RuntimeComponents, + _config_bag: &'a ConfigBag, + ) -> IdentityFuture<'a> { IdentityFuture::ready(Ok(Identity::new("doesntmatter", None))) } }