Skip to content

Commit

Permalink
Implement support for sso-session in AWS config file (#3379)
Browse files Browse the repository at this point in the history
This PR implements parsing support for `[sso-session name]` in the
`~/.aws/config` file.

----

_By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice._
  • Loading branch information
jdisanti authored Jan 24, 2024
1 parent 5e20575 commit b50c2ba
Show file tree
Hide file tree
Showing 25 changed files with 1,229 additions and 356 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.next.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ message = "The types in the aws-http crate were moved into aws-runtime. Deprecat
references = ["smithy-rs#3355"]
meta = { "breaking" = false, "tada" = false, "bug" = false }
author = "jdisanti"

[[aws-sdk-rust]]
message = "Add support for `[sso-session]` in AWS config file for AWS Identity Center SSO credentials. Note that this does not include support for AWS Builder ID SSO sessions for services such as Code Catalyst (these lack the `sso_account_id` and `sso_role_name` fields in the profile config). Support for AWS Builder IDs is still being tracked in https://github.com/awslabs/aws-sdk-rust/issues/703."
references = ["aws-sdk-rust#703", "smithy-rs#3379"]
meta = { "breaking" = false, "tada" = true, "bug" = false }
author = "jdisanti"
46 changes: 46 additions & 0 deletions aws/rust-runtime/aws-config/src/profile/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,29 @@ pub enum ProfileFileError {
/// Additional information about the missing feature
message: Option<Cow<'static, str>>,
},

/// Missing sso-session section in config
#[non_exhaustive]
MissingSsoSession {
/// The name of the profile that specified `sso_session`
profile: String,
/// SSO session name
sso_session: String,
},

/// Invalid SSO configuration
#[non_exhaustive]
InvalidSsoConfig {
/// The name of the profile that the error originates in
profile: String,
/// Error message
message: Cow<'static, str>,
},

/// Profile is intended to be used in the token provider chain rather
/// than in the credentials chain.
#[non_exhaustive]
TokenProviderConfig {},
}

impl ProfileFileError {
Expand Down Expand Up @@ -324,6 +347,25 @@ impl Display for ProfileFileError {
"This behavior requires following cargo feature(s) enabled: {feature}. {message}",
)
}
ProfileFileError::MissingSsoSession {
profile,
sso_session,
} => {
write!(f, "sso-session named `{sso_session}` (referenced by profile `{profile}`) was not found")
}
ProfileFileError::InvalidSsoConfig { profile, message } => {
write!(f, "profile `{profile}` has invalid SSO config: {message}")
}
ProfileFileError::TokenProviderConfig { .. } => {
// TODO(https://github.com/awslabs/aws-sdk-rust/issues/703): Update error message once token support is added
write!(
f,
"selected profile will resolve an access token instead of credentials \
since it doesn't have `sso_account_id` and `sso_role_name` set. Access token \
support for services such as Code Catalyst hasn't been implemented yet and is \
being tracked in https://github.com/awslabs/aws-sdk-rust/issues/703"
)
}
}
}
}
Expand Down Expand Up @@ -497,4 +539,8 @@ mod test {
make_test!(credential_process_failure);
#[cfg(feature = "credentials-process")]
make_test!(credential_process_invalid);
#[cfg(feature = "sso")]
make_test!(sso_credentials);
#[cfg(feature = "sso")]
make_test!(sso_token);
}
9 changes: 7 additions & 2 deletions aws/rust-runtime/aws-config/src/profile/credentials/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,24 @@ impl ProviderChain {
sso_region,
sso_role_name,
sso_start_url,
sso_session_name,
} => {
#[cfg(feature = "sso")]
{
use crate::sso::{credentials::SsoProviderConfig, SsoCredentialsProvider};
use aws_types::region::Region;

let (Some(sso_account_id), Some(sso_role_name)) =
(sso_account_id, sso_role_name)
else {
return Err(ProfileFileError::TokenProviderConfig {});
};
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,
session_name: sso_session_name.map(|s| s.to_string()),
};
Arc::new(SsoCredentialsProvider::new(provider_config, sso_config))
}
Expand Down
145 changes: 108 additions & 37 deletions aws/rust-runtime/aws-config/src/profile/credentials/repr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ pub(super) enum BaseProvider<'a> {

/// An SSO Provider
Sso {
sso_account_id: &'a str,
sso_session_name: Option<&'a str>,
sso_region: &'a str,
sso_role_name: &'a str,
sso_start_url: &'a str,

// Credentials from SSO fields
sso_account_id: Option<&'a str>,
sso_role_name: Option<&'a str>,
},

/// A profile that specifies a `credential_process`
Expand Down Expand Up @@ -172,7 +175,7 @@ pub(super) fn resolve_chain(
chain.push(role_provider);
next
} else {
break base_provider(profile).map_err(|err| {
break base_provider(profile_set, profile).map_err(|err| {
// It's possible for base_provider to return a `ProfileFileError::ProfileDidNotContainCredentials`
// if we're still looking at the first provider we want to surface it. However,
// if we're looking at any provider after the first we want to instead return a `ProfileFileError::InvalidCredentialSource`
Expand All @@ -193,7 +196,7 @@ pub(super) fn resolve_chain(
// self referential profile, don't go through the loop because it will error
// on the infinite loop check. Instead, reload this profile as a base profile
// and exit.
break base_provider(profile)?;
break base_provider(profile_set, profile)?;
}
NextProfile::Named(name) => source_profile_name = name,
}
Expand All @@ -216,6 +219,7 @@ mod sso {
pub(super) const REGION: &str = "sso_region";
pub(super) const ROLE_NAME: &str = "sso_role_name";
pub(super) const START_URL: &str = "sso_start_url";
pub(super) const SESSION_NAME: &str = "sso_session";
}

mod web_identity_token {
Expand All @@ -234,12 +238,15 @@ mod credential_process {

const PROVIDER_NAME: &str = "ProfileFile";

fn base_provider(profile: &Profile) -> Result<BaseProvider<'_>, ProfileFileError> {
fn base_provider<'a>(
profile_set: &'a ProfileSet,
profile: &'a Profile,
) -> Result<BaseProvider<'a>, ProfileFileError> {
// the profile must define either a `CredentialsSource` or a concrete set of access keys
match profile.get(role::CREDENTIAL_SOURCE) {
Some(source) => Ok(BaseProvider::NamedSource(source)),
None => web_identity_token_from_profile(profile)
.or_else(|| sso_from_profile(profile))
.or_else(|| sso_from_profile(profile_set, profile).transpose())
.or_else(|| credential_process_from_profile(profile))
.unwrap_or_else(|| Ok(BaseProvider::AccessKey(static_creds_from_profile(profile)?))),
}
Expand Down Expand Up @@ -292,39 +299,93 @@ fn role_arn_from_profile(profile: &Profile) -> Option<RoleArn<'_>> {
})
}

fn sso_from_profile(profile: &Profile) -> Option<Result<BaseProvider<'_>, ProfileFileError>> {
fn sso_from_profile<'a>(
profile_set: &'a ProfileSet,
profile: &'a Profile,
) -> Result<Option<BaseProvider<'a>>, ProfileFileError> {
/*
Sample:
-- Sample without sso-session: --
[profile sample-profile]
sso_account_id = 012345678901
sso_region = us-east-1
sso_role_name = SampleRole
sso_start_url = https://d-abc123.awsapps.com/start-beta
-- Sample with sso-session: --
[profile sample-profile]
sso_session = dev
sso_account_id = 012345678901
sso_role_name = SampleRole
[sso-session dev]
sso_region = us-east-1
sso_start_url = https://d-abc123.awsapps.com/start-beta
*/
let account_id = profile.get(sso::ACCOUNT_ID);
let region = profile.get(sso::REGION);
let role_name = profile.get(sso::ROLE_NAME);
let start_url = profile.get(sso::START_URL);
if [account_id, region, role_name, start_url]
.iter()
.all(|field| field.is_none())
let sso_account_id = profile.get(sso::ACCOUNT_ID);
let mut sso_region = profile.get(sso::REGION);
let sso_role_name = profile.get(sso::ROLE_NAME);
let mut sso_start_url = profile.get(sso::START_URL);
let sso_session_name = profile.get(sso::SESSION_NAME);
if [
sso_account_id,
sso_region,
sso_role_name,
sso_start_url,
sso_session_name,
]
.iter()
.all(Option::is_none)
{
return None;
return Ok(None);
}
let missing_field = |s| move || ProfileFileError::missing_field(profile, s);
let parse_profile = || {
let sso_account_id = account_id.ok_or_else(missing_field(sso::ACCOUNT_ID))?;
let sso_region = region.ok_or_else(missing_field(sso::REGION))?;
let sso_role_name = role_name.ok_or_else(missing_field(sso::ROLE_NAME))?;
let sso_start_url = start_url.ok_or_else(missing_field(sso::START_URL))?;
Ok(BaseProvider::Sso {
sso_account_id,
sso_region,
sso_role_name,
sso_start_url,
})

let invalid_sso_config = |s: &str| ProfileFileError::InvalidSsoConfig {
profile: profile.name().into(),
message: format!(
"`{s}` can only be specified in the [sso-session] config when a session name is given"
)
.into(),
};
if let Some(sso_session_name) = sso_session_name {
if sso_start_url.is_some() {
return Err(invalid_sso_config(sso::START_URL));
}
if sso_region.is_some() {
return Err(invalid_sso_config(sso::REGION));
}
if let Some(session) = profile_set.sso_session(sso_session_name) {
sso_start_url = session.get(sso::START_URL);
sso_region = session.get(sso::REGION);
} else {
return Err(ProfileFileError::MissingSsoSession {
profile: profile.name().into(),
sso_session: sso_session_name.into(),
});
}
}

let invalid_sso_creds = |left: &str, right: &str| ProfileFileError::InvalidSsoConfig {
profile: profile.name().into(),
message: format!("if `{left}` is set, then `{right}` must also be set").into(),
};
Some(parse_profile())
match (sso_account_id, sso_role_name) {
(Some(_), Some(_)) | (None, None) => { /* good */ }
(Some(_), None) => return Err(invalid_sso_creds(sso::ACCOUNT_ID, sso::ROLE_NAME)),
(None, Some(_)) => return Err(invalid_sso_creds(sso::ROLE_NAME, sso::ACCOUNT_ID)),
}

let missing_field = |s| move || ProfileFileError::missing_field(profile, s);
let sso_region = sso_region.ok_or_else(missing_field(sso::REGION))?;
let sso_start_url = sso_start_url.ok_or_else(missing_field(sso::START_URL))?;
Ok(Some(BaseProvider::Sso {
sso_account_id,
sso_region,
sso_role_name,
sso_start_url,
sso_session_name,
}))
}

fn web_identity_token_from_profile(
Expand Down Expand Up @@ -429,7 +490,11 @@ mod tests {
}

fn check(test_case: TestCase) {
let source = ProfileSet::new(test_case.input.profile, test_case.input.selected_profile);
let source = ProfileSet::new(
test_case.input.profiles,
test_case.input.selected_profile,
test_case.input.sso_sessions,
);
let actual = resolve_chain(&source);
let expected = test_case.output;
match (expected, actual) {
Expand Down Expand Up @@ -458,8 +523,10 @@ mod tests {

#[derive(Deserialize)]
struct TestInput {
profile: HashMap<String, HashMap<String, String>>,
profiles: HashMap<String, HashMap<String, String>>,
selected_profile: String,
#[serde(default)]
sso_sessions: HashMap<String, HashMap<String, String>>,
}

fn to_test_output(profile_chain: ProfileChain<'_>) -> Vec<Provider> {
Expand All @@ -484,15 +551,17 @@ mod tests {
role_session_name: session_name.map(|sess| sess.to_string()),
}),
BaseProvider::Sso {
sso_account_id,
sso_region,
sso_role_name,
sso_start_url,
sso_session_name,
sso_account_id,
sso_role_name,
} => output.push(Provider::Sso {
sso_account_id: sso_account_id.into(),
sso_region: sso_region.into(),
sso_role_name: sso_role_name.into(),
sso_start_url: sso_start_url.into(),
sso_session: sso_session_name.map(|s| s.to_string()),
sso_account_id: sso_account_id.map(|s| s.to_string()),
sso_role_name: sso_role_name.map(|s| s.to_string()),
}),
};
for role in profile_chain.chain {
Expand Down Expand Up @@ -531,10 +600,12 @@ mod tests {
role_session_name: Option<String>,
},
Sso {
sso_account_id: String,
sso_region: String,
sso_role_name: String,
sso_start_url: String,
sso_session: Option<String>,

sso_account_id: Option<String>,
sso_role_name: Option<String>,
},
}

Expand Down
Loading

0 comments on commit b50c2ba

Please sign in to comment.