Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions src/providers/aws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ pub const AWS_REGION_GROUPS: &[(&str, usize, usize)] = &[
struct AwsCredentials {
access_key: String,
secret_key: String,
/// `aws_session_token` / `AWS_SESSION_TOKEN`. Present for temporary
/// credentials (access key IDs starting with `ASIA`) issued by STS via
/// AssumeRole, IAM Identity Center (SSO) or GetSessionToken. Must be sent
/// as a signed `x-amz-security-token` header or AWS rejects the request.
session_token: Option<String>,
}

fn resolve_credentials(
Expand All @@ -78,12 +83,18 @@ fn resolve_credentials(
if !profile.is_empty() {
return read_credentials_file(profile, env);
}
// Token field: ACCESS_KEY_ID:SECRET_ACCESS_KEY
if let Some((ak, sk)) = token.split_once(':') {
// Token field: ACCESS_KEY_ID:SECRET_ACCESS_KEY[:SESSION_TOKEN]
if let Some((ak, rest)) = token.split_once(':') {
let (sk, st) = match rest.split_once(':') {
Some((sk, st)) if !st.is_empty() => (sk, Some(st.to_string())),
Some((sk, _)) => (sk, None),
None => (rest, None),
};
if !ak.is_empty() && !sk.is_empty() {
return Ok(AwsCredentials {
access_key: ak.to_string(),
secret_key: sk.to_string(),
session_token: st,
});
}
}
Expand All @@ -93,6 +104,7 @@ fn resolve_credentials(
return Ok(AwsCredentials {
access_key: ak.to_string(),
secret_key: sk.to_string(),
session_token: env.aws_session_token().map(str::to_string),
});
}
}
Expand All @@ -105,6 +117,7 @@ fn parse_credentials(content: &str, profile: &str) -> Option<AwsCredentials> {
let mut in_section = false;
let mut access_key = String::new();
let mut secret_key = String::new();
let mut session_token = String::new();

for line in content.lines() {
let trimmed = line.trim();
Expand All @@ -119,6 +132,7 @@ fn parse_credentials(content: &str, profile: &str) -> Option<AwsCredentials> {
match key.trim() {
"aws_access_key_id" => access_key = value.trim().to_string(),
"aws_secret_access_key" => secret_key = value.trim().to_string(),
"aws_session_token" => session_token = value.trim().to_string(),
_ => {}
}
}
Expand All @@ -130,6 +144,7 @@ fn parse_credentials(content: &str, profile: &str) -> Option<AwsCredentials> {
Some(AwsCredentials {
access_key,
secret_key,
session_token: (!session_token.is_empty()).then_some(session_token),
})
}
}
Expand Down Expand Up @@ -194,8 +209,21 @@ fn sign_request(
datestamp: &str,
) -> String {
let payload_hash = hex_encode(&sha256_hash(b""));
let canonical_headers = format!("host:{}\nx-amz-date:{}\n", host, timestamp);
let signed_headers = "host;x-amz-date";
// Canonical headers must be sorted by lowercase header name. With
// temporary credentials `x-amz-security-token` sorts after `x-amz-date`.
let (canonical_headers, signed_headers) = match &creds.session_token {
Some(token) => (
format!(
"host:{}\nx-amz-date:{}\nx-amz-security-token:{}\n",
host, timestamp, token
),
"host;x-amz-date;x-amz-security-token",
),
None => (
format!("host:{}\nx-amz-date:{}\n", host, timestamp),
"host;x-amz-date",
),
};

let canonical_request = format!(
"GET\n/\n{}\n{}\n{}\n{}",
Expand Down Expand Up @@ -345,12 +373,14 @@ fn ec2_get(
let auth = sign_request(creds, region, &host, &query_string, &timestamp, &datestamp);
let url = format!("{}/?{}", endpoint, query_string);

let mut resp = agent
let mut req = agent
.get(&url)
.header("Authorization", &auth)
.header("x-amz-date", &timestamp)
.call()
.map_err(super::map_ureq_error)?;
.header("x-amz-date", &timestamp);
if let Some(token) = &creds.session_token {
req = req.header("x-amz-security-token", token);
}
let mut resp = req.call().map_err(super::map_ureq_error)?;

resp.body_mut()
.read_to_string()
Expand Down
225 changes: 224 additions & 1 deletion src/providers/aws_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ fn test_sign_request_format() {
let creds = AwsCredentials {
access_key: "AKIDEXAMPLE".to_string(),
secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
session_token: None,
};
let auth = sign_request(
&creds,
Expand All @@ -143,6 +144,7 @@ fn test_sign_request_deterministic() {
let creds = AwsCredentials {
access_key: "AK".to_string(),
secret_key: "SK".to_string(),
session_token: None,
};
let a = sign_request(
&creds,
Expand All @@ -168,6 +170,7 @@ fn test_sign_request_different_regions() {
let creds = AwsCredentials {
access_key: "AK".to_string(),
secret_key: "SK".to_string(),
session_token: None,
};
let a = sign_request(
&creds,
Expand Down Expand Up @@ -231,10 +234,134 @@ fn test_parse_credentials_whitespace_handling() {

#[test]
fn test_parse_credentials_extra_keys_ignored() {
let content = "[default]\naws_access_key_id = AK\naws_secret_access_key = SK\naws_session_token = TOKEN\nregion = us-east-1\n";
let content =
"[default]\naws_access_key_id = AK\naws_secret_access_key = SK\nregion = us-east-1\n";
let creds = parse_credentials(content, "default").unwrap();
assert_eq!(creds.access_key, "AK");
assert_eq!(creds.secret_key, "SK");
assert_eq!(creds.session_token, None);
}

#[test]
fn test_parse_credentials_session_token() {
let content = "[default]\naws_access_key_id = ASIAEXAMPLE\naws_secret_access_key = SK\naws_session_token = TOKEN\n";
let creds = parse_credentials(content, "default").unwrap();
assert_eq!(creds.access_key, "ASIAEXAMPLE");
assert_eq!(creds.secret_key, "SK");
assert_eq!(creds.session_token.as_deref(), Some("TOKEN"));
}

#[test]
fn test_sign_request_includes_security_token_when_present() {
let creds = AwsCredentials {
access_key: "ASIAEXAMPLE".to_string(),
secret_key: "SK".to_string(),
session_token: Some("TOKEN".to_string()),
};
let auth = sign_request(
&creds,
"eu-central-1",
"ec2.eu-central-1.amazonaws.com",
"Action=DescribeInstances&Version=2016-11-15",
"20240101T000000Z",
"20240101",
);
assert!(auth.contains("SignedHeaders=host;x-amz-date;x-amz-security-token,"));
}

#[test]
fn test_sign_request_session_token_changes_signature() {
let base = AwsCredentials {
access_key: "ASIAEXAMPLE".to_string(),
secret_key: "SK".to_string(),
session_token: None,
};
let with_token = AwsCredentials {
access_key: "ASIAEXAMPLE".to_string(),
secret_key: "SK".to_string(),
session_token: Some("TOKEN".to_string()),
};
let args = (
"eu-central-1",
"ec2.eu-central-1.amazonaws.com",
"Action=DescribeInstances",
"20240101T000000Z",
"20240101",
);
let a = sign_request(&base, args.0, args.1, args.2, args.3, args.4);
let b = sign_request(&with_token, args.0, args.1, args.2, args.3, args.4);
assert_ne!(a, b);
}

#[test]
fn test_resolve_credentials_env_without_session_token() {
let env = crate::runtime::env::Env::for_test("/tmp/x")
.with_var("AWS_ACCESS_KEY_ID", "AKIDEXAMPLE")
.with_var("AWS_SECRET_ACCESS_KEY", "SECRET");
let creds = resolve_credentials("", "", &env).unwrap();
assert_eq!(creds.access_key, "AKIDEXAMPLE");
assert_eq!(creds.secret_key, "SECRET");
assert_eq!(creds.session_token, None);
}

#[test]
fn test_resolve_credentials_env_with_session_token() {
let env = crate::runtime::env::Env::for_test("/tmp/x")
.with_var("AWS_ACCESS_KEY_ID", "ASIAEXAMPLE")
.with_var("AWS_SECRET_ACCESS_KEY", "SECRET")
.with_var("AWS_SESSION_TOKEN", "TOKEN");
let creds = resolve_credentials("", "", &env).unwrap();
assert_eq!(creds.access_key, "ASIAEXAMPLE");
assert_eq!(creds.secret_key, "SECRET");
assert_eq!(creds.session_token.as_deref(), Some("TOKEN"));
}

#[test]
fn test_resolve_credentials_profile_shadows_env_session_token() {
// A configured profile wins over the environment, so every field comes
// from the credentials file even when both sources are populated.
let home = tempfile::tempdir().expect("tempdir");
let aws_dir = home.path().join(".aws");
std::fs::create_dir_all(&aws_dir).expect("create .aws");
std::fs::write(
aws_dir.join("credentials"),
"[default]\naws_access_key_id = ASIAFROMFILE\naws_secret_access_key = FILESECRET\naws_session_token = FILETOKEN\n",
)
.expect("write credentials file");

let env = crate::runtime::env::Env::for_test(home.path())
.with_var("AWS_ACCESS_KEY_ID", "ASIAFROMENV")
.with_var("AWS_SECRET_ACCESS_KEY", "ENVSECRET")
.with_var("AWS_SESSION_TOKEN", "ENVTOKEN");

let creds = resolve_credentials("", "default", &env).expect("profile resolves from file");
assert_eq!(creds.access_key, "ASIAFROMFILE");
assert_eq!(creds.secret_key, "FILESECRET");
assert_eq!(creds.session_token.as_deref(), Some("FILETOKEN"));
}

#[test]
fn test_resolve_credentials_token_with_session_token() {
let creds = resolve_credentials(
"ASIAEXAMPLE:SECRET:TOKEN",
"",
&crate::runtime::env::Env::empty(),
)
.unwrap();
assert_eq!(creds.access_key, "ASIAEXAMPLE");
assert_eq!(creds.secret_key, "SECRET");
assert_eq!(creds.session_token.as_deref(), Some("TOKEN"));
}

#[test]
fn test_resolve_credentials_token_trailing_separator() {
// An empty third component leaves the secret key intact and yields no
// session token.
let creds = resolve_credentials("AKID:SECRET:", "", &crate::runtime::env::Env::empty())
.expect("access key and secret are both present");
assert_eq!(creds.access_key, "AKID");
assert_eq!(creds.secret_key, "SECRET");
assert_eq!(creds.session_token, None);
}

#[test]
Expand Down Expand Up @@ -966,3 +1093,99 @@ fn fetch_from_maps_auth_failure_to_provider_error() {
"a 401 from the region must surface as AuthFailed, got {result:?}"
);
}

#[test]
fn fetch_sends_security_token_header_for_temporary_credentials() {
// Both EC2 calls must carry x-amz-security-token, because the signature
// declares it in SignedHeaders.
let mut server = mockito::Server::new();
let instances = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded(
"Action".into(),
"DescribeInstances".into(),
))
.match_header("x-amz-security-token", "TOKEN")
.with_status(200)
.with_header("content-type", "text/xml")
.with_body(
r#"<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
<reservationSet><item><instancesSet><item>
<instanceId>i-1234567890</instanceId>
<instanceState><name>running</name></instanceState>
<ipAddress>54.1.2.3</ipAddress>
<imageId>ami-12345678</imageId>
<tagSet><item><key>Name</key><value>web-1</value></item></tagSet>
</item></instancesSet></item></reservationSet>
</DescribeInstancesResponse>"#,
)
.create();
let images = server
.mock("GET", "/")
.match_query(mockito::Matcher::UrlEncoded(
"Action".into(),
"DescribeImages".into(),
))
.match_header("x-amz-security-token", "TOKEN")
.with_status(200)
.with_header("content-type", "text/xml")
.with_body(
r#"<DescribeImagesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
<imagesSet><item><imageId>ami-12345678</imageId><name>amzn2-ami-hvm-2.0</name></item></imagesSet>
</DescribeImagesResponse>"#,
)
.create();

let aws = Aws {
regions: vec!["us-east-1".to_string()],
profile: String::new(),
};
let url = server.url();
let hosts = aws
.fetch_with_endpoint(
|_region| url.clone(),
"ASIAEXAMPLE:SECRET:TOKEN",
&AtomicBool::new(false),
&crate::runtime::env::Env::empty(),
&|_| {},
)
.expect("a signed request carrying the session token must reach the mock");
instances.assert();
images.assert();
assert_eq!(hosts.len(), 1);
}

#[test]
fn fetch_omits_security_token_header_for_static_credentials() {
// Long-lived keys sign without the token, so the header must be absent.
let mut server = mockito::Server::new();
let instances = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.match_header("x-amz-security-token", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "text/xml")
.with_body(
r#"<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
<reservationSet></reservationSet>
</DescribeInstancesResponse>"#,
)
.create();

let aws = Aws {
regions: vec!["us-east-1".to_string()],
profile: String::new(),
};
let url = server.url();
let hosts = aws
.fetch_with_endpoint(
|_region| url.clone(),
"AKID:SECRET",
&AtomicBool::new(false),
&crate::runtime::env::Env::empty(),
&|_| {},
)
.expect("static credentials must reach the mock without a token header");
instances.assert();
assert!(hosts.is_empty());
}
Loading