use std::sync::atomic::AtomicBool;
use super::aws::{AwsCredentials, SigV4Request, format_utc, sign_request, uri_encode};
use super::aws_profile::RoleStep;
use super::{ProviderError, map_ureq_error};
const STS_SERVICE: &str = "sts";
const STS_API_VERSION: &str = "2011-06-15";
const DEFAULT_ROLE_SESSION_NAME: &str = "purple";
const MIN_SESSION_NAME_LEN: usize = 2;
const MAX_SESSION_NAME_LEN: usize = 64;
const MIN_DURATION_SECONDS: u32 = 900;
const MAX_DURATION_SECONDS: u32 = 43200;
const CHAINED_DURATION_SECONDS: u32 = 3600;
pub(super) const MAX_ERROR_MESSAGE: usize = 300;
pub(super) fn region_endpoint(region: &str) -> String {
format!("https://sts.{}.amazonaws.com", region)
}
pub(super) fn assume_chain_with_endpoint(
agent: &ureq::Agent,
base: AwsCredentials,
steps: &[RoleStep],
region: &str,
cancel: &AtomicBool,
resolve_endpoint: impl Fn(&str) -> String,
) -> Result<AwsCredentials, ProviderError> {
let mut creds = base;
let endpoint = resolve_endpoint(region);
for step in steps {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return Err(ProviderError::Cancelled);
}
creds = assume_role(agent, &creds, step, region, &endpoint)?;
log::debug!(
"[external] aws sts: assumed {} for profile '{}'",
step.role_arn,
step.profile
);
}
Ok(creds)
}
fn assume_role(
agent: &ureq::Agent,
creds: &AwsCredentials,
step: &RoleStep,
region: &str,
endpoint: &str,
) -> Result<AwsCredentials, ProviderError> {
let host = endpoint
.split_once("://")
.map(|(_, authority)| authority)
.unwrap_or(endpoint)
.to_string();
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (timestamp, datestamp) = format_utc(epoch);
let mut params: Vec<(String, String)> = vec![
("Action".to_string(), "AssumeRole".to_string()),
("Version".to_string(), STS_API_VERSION.to_string()),
("RoleArn".to_string(), step.role_arn.clone()),
("RoleSessionName".to_string(), session_name(step)),
];
if let Some(duration) = duration_seconds(step, creds.session_token.is_some()) {
params.push(("DurationSeconds".to_string(), duration.to_string()));
}
if !step.external_id.is_empty() {
params.push(("ExternalId".to_string(), step.external_id.clone()));
}
let mut sorted: Vec<(String, String)> = params
.into_iter()
.map(|(k, v)| (uri_encode(&k), uri_encode(&v)))
.collect();
sorted.sort();
let query_string: String = sorted
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let auth = sign_request(
creds,
region,
&SigV4Request {
method: "GET",
service: STS_SERVICE,
host: &host,
query_string: &query_string,
payload: b"",
extra_headers: &[],
},
×tamp,
&datestamp,
);
let url = format!("{}/?{}", endpoint, query_string);
let mut req = agent
.get(&url)
.header("Authorization", &auth)
.header("x-amz-date", ×tamp);
if let Some(token) = &creds.session_token {
req = req.header("x-amz-security-token", token);
}
let mut resp = req
.config()
.http_status_as_error(false)
.build()
.call()
.map_err(map_ureq_error)?;
let status = resp.status();
let body = resp
.body_mut()
.read_to_string()
.map_err(|e| ProviderError::Parse(e.to_string()))?;
if status.as_u16() == super::HTTP_TOO_MANY_REQUESTS {
return Err(ProviderError::RateLimited);
}
if let Some(error) = parse_error(&body) {
return Err(ProviderError::Execute(
crate::messages::aws_assume_role_failed(&step.role_arn, &error),
));
}
if !status.is_success() {
return Err(ProviderError::Execute(
crate::messages::aws_assume_role_failed(
&step.role_arn,
&format!("HTTP {}", status.as_u16()),
),
));
}
parse_credentials(&body).ok_or_else(|| {
ProviderError::Parse(crate::messages::aws_assume_role_unparsable(&step.role_arn))
})
}
fn session_name(step: &RoleStep) -> String {
let candidate = step.role_session_name.trim();
if is_valid_session_name(candidate) {
candidate.to_string()
} else {
DEFAULT_ROLE_SESSION_NAME.to_string()
}
}
fn is_valid_session_name(name: &str) -> bool {
let len = name.chars().count();
(MIN_SESSION_NAME_LEN..=MAX_SESSION_NAME_LEN).contains(&len)
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_+=,.@-".contains(c))
}
fn duration_seconds(step: &RoleStep, chained: bool) -> Option<u32> {
let requested = step.duration_seconds.trim().parse::<u32>().ok()?;
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&requested) {
return None;
}
Some(if chained {
requested.min(CHAINED_DURATION_SECONDS)
} else {
requested
})
}
fn parse_credentials(body: &str) -> Option<AwsCredentials> {
let access_key = element_value(body, "AccessKeyId")?;
let secret_key = element_value(body, "SecretAccessKey")?;
let session_token = element_value(body, "SessionToken")?;
if access_key.is_empty() || secret_key.is_empty() || session_token.is_empty() {
return None;
}
Some(AwsCredentials {
access_key,
secret_key,
session_token: Some(session_token),
})
}
fn parse_error(body: &str) -> Option<String> {
let code = element_prose(body, "Code")?;
let message = element_prose(body, "Message").unwrap_or_default();
let mut text = if message.is_empty() {
code
} else {
format!("{}: {}", code, message)
};
if text.chars().count() > MAX_ERROR_MESSAGE {
text = text.chars().take(MAX_ERROR_MESSAGE).collect();
}
Some(text)
}
fn element_value(body: &str, name: &str) -> Option<String> {
element_slice(body, name).map(|raw| raw.split_whitespace().collect())
}
fn element_prose(body: &str, name: &str) -> Option<String> {
element_slice(body, name).map(|raw| raw.split_whitespace().collect::<Vec<_>>().join(" "))
}
fn element_slice<'a>(body: &'a str, name: &str) -> Option<&'a str> {
let open = format!("<{}>", name);
let close = format!("</{}>", name);
let start = body.find(&open)? + open.len();
let end = body[start..].find(&close)? + start;
Some(&body[start..end])
}
#[cfg(test)]
#[path = "aws_sts_tests.rs"]
mod tests;