1use std::collections::HashSet;
19use std::fmt::{Debug, Formatter};
20
21use bytes::Bytes;
22use form_urlencoded::Serializer;
23use quick_xml::de;
24use reqsign_core::hash::hex_sha256;
25use reqsign_core::time::Timestamp;
26use reqsign_core::{Context, Error, Result, Signer, SigningCredential};
27use serde::Deserialize;
28
29use crate::Credential;
30use crate::constants::X_AMZ_CONTENT_SHA_256;
31use crate::provide_credential::utils::{parse_sts_error, partition_for_region, sts_endpoint};
32
33const MIN_DURATION_SECONDS: u32 = 900;
34const MAX_DURATION_SECONDS: u32 = 43_200;
35const MAX_POLICY_ARNS: usize = 10;
36const MAX_POLICY_PLAINTEXT_CHARACTERS: usize = 2_048;
37const MAX_SESSION_TAGS: usize = 50;
38const MAX_GET_URI_LENGTH: usize = 2_048;
39
40#[derive(Clone)]
55pub struct AssumeRoleGrant {
56 pub(crate) role_arn: String,
57 pub(crate) role_session_name: String,
58 pub(crate) external_id: Option<String>,
59 pub(crate) tags: Vec<(String, String)>,
60 pub(crate) policy: Option<String>,
61 pub(crate) policy_arns: Vec<String>,
62 pub(crate) serial_number: Option<String>,
63 pub(crate) token_code: Option<String>,
64}
65
66impl Debug for AssumeRoleGrant {
67 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("AssumeRoleGrant").finish_non_exhaustive()
69 }
70}
71
72impl AssumeRoleGrant {
73 pub fn new(role_arn: impl Into<String>, role_session_name: impl Into<String>) -> Self {
75 Self {
76 role_arn: role_arn.into(),
77 role_session_name: role_session_name.into(),
78 external_id: None,
79 tags: Vec::new(),
80 policy: None,
81 policy_arns: Vec::new(),
82 serial_number: None,
83 token_code: None,
84 }
85 }
86
87 pub fn with_external_id(mut self, external_id: impl Into<String>) -> Self {
89 self.external_id = Some(external_id.into());
90 self
91 }
92
93 pub fn with_policy(mut self, policy: impl Into<String>) -> Self {
98 self.policy = Some(policy.into());
99 self
100 }
101
102 pub fn with_policy_arns(mut self, policy_arns: Vec<String>) -> Self {
104 self.policy_arns = policy_arns;
105 self
106 }
107
108 pub fn with_tags(mut self, tags: Vec<(String, String)>) -> Self {
110 self.tags = tags;
111 self
112 }
113
114 pub fn with_mfa(
116 mut self,
117 serial_number: impl Into<String>,
118 token_code: impl Into<String>,
119 ) -> Self {
120 self.serial_number = Some(serial_number.into());
121 self.token_code = Some(token_code.into());
122 self
123 }
124
125 fn validate(&self) -> Result<IamArn<'_>> {
126 let role = validate_role_arn(&self.role_arn)?;
127 validate_role_session_name(&self.role_session_name)?;
128
129 if let Some(external_id) = &self.external_id {
130 validate_external_id(external_id)?;
131 }
132
133 validate_session_policies(self.policy.as_deref(), &self.policy_arns, role)?;
134 validate_tags(&self.tags)?;
135 validate_mfa(self.serial_number.as_deref(), self.token_code.as_deref())?;
136 Ok(role)
137 }
138
139 #[doc(hidden)]
140 pub fn validate_for_region(&self, region: &str) -> Result<()> {
141 let partition = partition_for_region(region)?;
142 self.validate_for_partition(partition.id)
143 }
144
145 pub(crate) fn validate_for_partition(&self, expected_partition: &str) -> Result<()> {
146 if self.validate()?.partition != expected_partition {
147 return Err(Error::request_invalid(
148 "AWS STS AssumeRole role partition does not match the signing region",
149 ));
150 }
151 Ok(())
152 }
153}
154
155#[doc(hidden)]
161pub struct AssumeRoleOperation {
162 endpoint: String,
163 parameters: String,
164}
165
166impl Debug for AssumeRoleOperation {
167 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("AssumeRoleOperation")
169 .finish_non_exhaustive()
170 }
171}
172
173impl AssumeRoleOperation {
174 #[doc(hidden)]
176 pub fn new(
177 endpoint: impl Into<String>,
178 grant: &AssumeRoleGrant,
179 duration_seconds: Option<u32>,
180 ) -> Result<Self> {
181 let endpoint = endpoint.into();
182 validate_sts_endpoint(&endpoint)?;
183 validate_duration_seconds(duration_seconds)?;
184 grant.validate()?;
185
186 Ok(Self {
187 parameters: build_assume_role_query(grant, duration_seconds),
188 endpoint,
189 })
190 }
191
192 #[doc(hidden)]
195 pub async fn execute(
196 &self,
197 ctx: &Context,
198 sts_signer: &Signer<Credential>,
199 ) -> Result<Credential> {
200 let request = self.build_request()?;
201 let (mut parts, body) = request.into_parts();
202 sts_signer.sign(&mut parts, None).await.map_err(|err| {
203 Error::new(err.kind(), "failed to sign AWS STS AssumeRole request")
204 .set_retryable(err.is_retryable())
205 })?;
206 let request = http::Request::from_parts(parts, body);
207
208 self.send(ctx, request).await
209 }
210
211 #[doc(hidden)]
213 pub fn build_request(&self) -> Result<http::Request<Bytes>> {
214 let get_uri = format!("https://{}/?{}", self.endpoint, self.parameters);
215 let (method, uri, body) = if get_uri.len() <= MAX_GET_URI_LENGTH {
216 (http::Method::GET, get_uri, Bytes::new())
217 } else {
218 (
219 http::Method::POST,
220 format!("https://{}/", self.endpoint),
221 Bytes::from(self.parameters.clone()),
222 )
223 };
224 let payload_hash = hex_sha256(&body);
225
226 http::Request::builder()
227 .method(method)
228 .uri(uri)
229 .header(
230 http::header::CONTENT_TYPE,
231 "application/x-www-form-urlencoded",
232 )
233 .header(X_AMZ_CONTENT_SHA_256, payload_hash)
234 .body(body)
235 .map_err(|_| Error::request_invalid("failed to build AWS STS AssumeRole request"))
236 }
237
238 #[doc(hidden)]
240 pub async fn send(&self, ctx: &Context, request: http::Request<Bytes>) -> Result<Credential> {
241 let response = ctx.http_send(request).await.map_err(|_| {
242 Error::unexpected("failed to send AWS STS AssumeRole request").set_retryable(true)
243 })?;
244 parse_assume_role_response(response, Timestamp::now())
245 }
246}
247
248#[doc(hidden)]
250pub fn regional_sts_endpoint(region: &str, grant: &AssumeRoleGrant) -> Result<String> {
251 grant.validate_for_region(region)?;
252 sts_endpoint(Some(region), true)
253}
254
255fn build_assume_role_query(grant: &AssumeRoleGrant, duration_seconds: Option<u32>) -> String {
256 let mut serializer = Serializer::new(String::new());
257 serializer
258 .append_pair("Action", "AssumeRole")
259 .append_pair("RoleArn", &grant.role_arn)
260 .append_pair("Version", "2011-06-15")
261 .append_pair("RoleSessionName", &grant.role_session_name);
262
263 if let Some(external_id) = &grant.external_id {
264 serializer.append_pair("ExternalId", external_id);
265 }
266 if let Some(duration_seconds) = duration_seconds {
267 serializer.append_pair("DurationSeconds", &duration_seconds.to_string());
268 }
269 if let Some(policy) = &grant.policy {
270 serializer.append_pair("Policy", policy);
271 }
272 for (index, arn) in grant.policy_arns.iter().enumerate() {
273 serializer.append_pair(&format!("PolicyArns.member.{}.arn", index + 1), arn);
274 }
275 for (index, (key, value)) in grant.tags.iter().enumerate() {
276 let index = index + 1;
277 serializer
278 .append_pair(&format!("Tags.member.{index}.Key"), key)
279 .append_pair(&format!("Tags.member.{index}.Value"), value);
280 }
281 if let (Some(serial_number), Some(token_code)) = (&grant.serial_number, &grant.token_code) {
282 serializer
283 .append_pair("SerialNumber", serial_number)
284 .append_pair("TokenCode", token_code);
285 }
286
287 serializer.finish()
288}
289
290fn parse_assume_role_response(
291 response: http::Response<Bytes>,
292 response_time: Timestamp,
293) -> Result<Credential> {
294 let status = response.status();
295 let body = response.into_body();
296 let body = String::from_utf8_lossy(&body);
297
298 if status != http::StatusCode::OK {
299 return Err(parse_sts_error("AssumeRole", status, &body));
300 }
301
302 let response: AssumeRoleResponse = de::from_str(&body).map_err(|_| {
303 Error::unexpected("failed to parse AWS STS AssumeRole response")
304 .with_context(format!("response_length: {}", body.len()))
305 })?;
306 let response = response.result.credentials;
307 if !(16..=128).contains(&response.access_key_id.chars().count())
308 || !response.access_key_id.bytes().all(is_access_key_character)
309 || response.secret_access_key.is_empty()
310 || response.session_token.trim().is_empty()
311 || http::HeaderValue::try_from(response.session_token.as_str()).is_err()
312 {
313 return Err(Error::unexpected(
314 "AWS STS AssumeRole response contains malformed credentials",
315 ));
316 }
317
318 let expires_at: Timestamp = response.expiration.parse().map_err(|_| {
319 Error::unexpected("failed to parse AWS STS AssumeRole credential expiration")
320 })?;
321 let credential = Credential {
322 access_key_id: response.access_key_id,
323 secret_access_key: response.secret_access_key,
324 session_token: Some(response.session_token),
325 expires_in: Some(expires_at),
326 };
327 if !credential.is_valid_at(response_time) {
328 return Err(Error::credential_invalid(
329 "AWS STS AssumeRole returned credentials that are already expired",
330 ));
331 }
332
333 Ok(credential)
334}
335
336fn validate_duration_seconds(duration_seconds: Option<u32>) -> Result<()> {
337 if duration_seconds
338 .is_some_and(|seconds| !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds))
339 {
340 return Err(Error::request_invalid(
341 "AWS STS AssumeRole duration must be between 900 and 43200 seconds",
342 ));
343 }
344 Ok(())
345}
346
347fn validate_sts_endpoint(endpoint: &str) -> Result<()> {
348 let authority: http::uri::Authority = endpoint
349 .parse()
350 .map_err(|_| Error::config_invalid("AWS STS endpoint authority is invalid"))?;
351 if authority.as_str() != endpoint
352 || authority.host().is_empty()
353 || authority.port().is_some()
354 || endpoint.contains('@')
355 {
356 return Err(Error::config_invalid(
357 "AWS STS endpoint authority is invalid",
358 ));
359 }
360 Ok(())
361}
362
363#[derive(Clone, Copy)]
364struct IamArn<'a> {
365 partition: &'a str,
366 account: &'a str,
367}
368
369fn validate_role_arn(role_arn: &str) -> Result<IamArn<'_>> {
370 let arn = validate_iam_arn(role_arn, "role/", 64)
371 .ok_or_else(|| Error::request_invalid("AWS STS AssumeRole role ARN is invalid"))?;
372 Ok(arn)
373}
374
375fn validate_policy_arn(policy_arn: &str) -> Result<IamArn<'_>> {
376 let arn = validate_iam_arn(policy_arn, "policy/", 128).ok_or_else(|| {
377 Error::request_invalid("AWS STS AssumeRole managed policy ARN is invalid")
378 })?;
379 Ok(arn)
380}
381
382fn validate_iam_arn<'a>(
383 value: &'a str,
384 resource_prefix: &str,
385 maximum_name_length: usize,
386) -> Option<IamArn<'a>> {
387 if !(20..=2_048).contains(&value.chars().count()) {
388 return None;
389 }
390
391 let mut fields = value.splitn(6, ':');
392 let (Some(arn), Some(partition), Some(service), Some(region), Some(account), Some(resource)) = (
393 fields.next(),
394 fields.next(),
395 fields.next(),
396 fields.next(),
397 fields.next(),
398 fields.next(),
399 ) else {
400 return None;
401 };
402
403 if arn != "arn"
404 || !matches!(
405 partition,
406 "aws"
407 | "aws-cn"
408 | "aws-eusc"
409 | "aws-iso"
410 | "aws-iso-b"
411 | "aws-iso-e"
412 | "aws-iso-f"
413 | "aws-us-gov"
414 )
415 || service != "iam"
416 || !region.is_empty()
417 || account.len() != 12
418 || !account.bytes().all(|byte| byte.is_ascii_digit())
419 {
420 return None;
421 }
422
423 let resource = resource.strip_prefix(resource_prefix)?;
424 if !validate_iam_resource_path(resource, maximum_name_length) {
425 return None;
426 }
427
428 Some(IamArn { partition, account })
429}
430
431fn validate_iam_resource_path(resource: &str, maximum_name_length: usize) -> bool {
432 let name = match resource.rsplit_once('/') {
433 Some((path, name)) => {
434 if path.is_empty()
435 || path.len() + 2 > 512
436 || !path.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
437 {
438 return false;
439 }
440 name
441 }
442 None => resource,
443 };
444
445 (1..=maximum_name_length).contains(&name.chars().count())
446 && name.chars().all(is_aws_word_character)
447}
448
449fn validate_role_session_name(role_session_name: &str) -> Result<()> {
450 if !(2..=64).contains(&role_session_name.chars().count())
451 || !role_session_name.chars().all(is_aws_word_character)
452 {
453 return Err(Error::request_invalid(
454 "AWS STS AssumeRole session name is invalid",
455 ));
456 }
457 Ok(())
458}
459
460fn validate_external_id(external_id: &str) -> Result<()> {
461 if !(2..=1_224).contains(&external_id.chars().count())
462 || !external_id.chars().all(|character| {
463 is_aws_word_character(character) || character == ':' || character == '/'
464 })
465 {
466 return Err(Error::request_invalid(
467 "AWS STS AssumeRole external ID is invalid",
468 ));
469 }
470 Ok(())
471}
472
473fn is_aws_word_character(character: char) -> bool {
474 character.is_ascii_alphanumeric() || "_+=,.@-".contains(character)
475}
476
477fn is_access_key_character(byte: u8) -> bool {
478 byte.is_ascii_alphanumeric() || byte == b'_'
479}
480
481fn validate_session_policies(
482 policy: Option<&str>,
483 policy_arns: &[String],
484 role: IamArn<'_>,
485) -> Result<()> {
486 if policy_arns.len() > MAX_POLICY_ARNS {
487 return Err(Error::request_invalid(
488 "AWS STS AssumeRole accepts at most ten managed session policies",
489 ));
490 }
491
492 for policy_arn in policy_arns {
493 let policy = validate_policy_arn(policy_arn)?;
494 if policy.partition != role.partition || policy.account != role.account {
495 return Err(Error::request_invalid(
496 "AWS STS AssumeRole managed session policies must match the role partition and account",
497 ));
498 }
499 }
500
501 if let Some(policy) = policy {
502 if policy.is_empty() || !policy.chars().all(is_session_policy_character) {
503 return Err(Error::request_invalid(
504 "AWS STS AssumeRole inline session policy contains invalid characters",
505 ));
506 }
507 let value: serde_json::Value = serde_json::from_str(policy).map_err(|_| {
508 Error::request_invalid("AWS STS AssumeRole inline session policy must be valid JSON")
509 })?;
510 if !value.is_object() {
511 return Err(Error::request_invalid(
512 "AWS STS AssumeRole inline session policy must be a JSON object",
513 ));
514 }
515 }
516
517 let plaintext_characters = policy.map_or(0, |value| value.chars().count())
518 + policy_arns
519 .iter()
520 .map(|value| value.chars().count())
521 .sum::<usize>();
522 if plaintext_characters > MAX_POLICY_PLAINTEXT_CHARACTERS {
523 return Err(Error::request_invalid(
524 "AWS STS AssumeRole session policy plaintext exceeds 2048 characters",
525 ));
526 }
527 Ok(())
528}
529
530fn is_session_policy_character(character: char) -> bool {
531 matches!(character, '\t' | '\n' | '\r') || (' '..='\u{00ff}').contains(&character)
532}
533
534fn validate_tags(tags: &[(String, String)]) -> Result<()> {
535 if tags.len() > MAX_SESSION_TAGS {
536 return Err(Error::request_invalid(
537 "AWS STS AssumeRole accepts at most fifty session tags",
538 ));
539 }
540
541 let mut unique_keys = HashSet::with_capacity(tags.len());
542 for (key, value) in tags {
543 if !(1..=128).contains(&key.chars().count()) || !key.chars().all(is_session_tag_character) {
544 return Err(Error::request_invalid(
545 "AWS STS AssumeRole session tag key is invalid",
546 ));
547 }
548 if value.chars().count() > 256 || !value.chars().all(is_session_tag_character) {
549 return Err(Error::request_invalid(
550 "AWS STS AssumeRole session tag value is invalid",
551 ));
552 }
553 if !unique_keys.insert(key.to_lowercase()) {
554 return Err(Error::request_invalid(
555 "AWS STS AssumeRole session tag keys are case-insensitively unique",
556 ));
557 }
558 }
559 Ok(())
560}
561
562fn is_session_tag_character(character: char) -> bool {
563 character.is_alphanumeric()
564 || character.is_whitespace() && !character.is_control()
565 || "_:./=+-@".contains(character)
566}
567
568fn validate_mfa(serial_number: Option<&str>, token_code: Option<&str>) -> Result<()> {
569 let (Some(serial_number), Some(token_code)) = (serial_number, token_code) else {
570 if serial_number.is_some() || token_code.is_some() {
571 return Err(Error::request_invalid(
572 "AWS STS AssumeRole MFA serial number and token code must be provided together",
573 ));
574 }
575 return Ok(());
576 };
577
578 if !(9..=256).contains(&serial_number.chars().count())
579 || !serial_number.chars().all(|character| {
580 is_aws_word_character(character) || character == '/' || character == ':'
581 })
582 {
583 return Err(Error::request_invalid(
584 "AWS STS AssumeRole MFA serial number is invalid",
585 ));
586 }
587 if token_code.len() != 6 || !token_code.bytes().all(|byte| byte.is_ascii_digit()) {
588 return Err(Error::request_invalid(
589 "AWS STS AssumeRole MFA token code must contain six digits",
590 ));
591 }
592 Ok(())
593}
594
595#[derive(Default, Deserialize)]
596#[serde(default, rename_all = "PascalCase")]
597struct AssumeRoleResponse {
598 #[serde(rename = "AssumeRoleResult")]
599 result: AssumeRoleResult,
600}
601
602#[derive(Default, Deserialize)]
603#[serde(default, rename_all = "PascalCase")]
604struct AssumeRoleResult {
605 credentials: AssumeRoleCredentials,
606}
607
608#[derive(Default, Deserialize)]
609#[serde(default, rename_all = "PascalCase")]
610struct AssumeRoleCredentials {
611 access_key_id: String,
612 secret_access_key: String,
613 session_token: String,
614 expiration: String,
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 #[test]
622 fn builds_existing_assume_role_query_shape() {
623 let grant = AssumeRoleGrant::new(
624 "arn:aws:iam::123456789012:role/test-role",
625 "reqsign",
626 )
627 .with_external_id("external/id")
628 .with_policy(
629 r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}"#,
630 )
631 .with_policy_arns(vec![
632 "arn:aws:iam::123456789012:policy/ReadOnlyAccess".to_string(),
633 "arn:aws:iam::123456789012:policy/ExamplePolicy".to_string(),
634 ])
635 .with_tags(vec![("Project".to_string(), "reqsign".to_string())])
636 .with_mfa("arn:aws:iam::123456789012:mfa/user", "123456");
637
638 let query = build_assume_role_query(&grant, Some(3_600));
639 assert!(query.starts_with(
640 "Action=AssumeRole&RoleArn=arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Ftest-role&Version=2011-06-15&RoleSessionName=reqsign"
641 ));
642 assert!(query.contains("ExternalId=external%2Fid"));
643 assert!(query.contains("DurationSeconds=3600"));
644 assert!(query.contains(
645 "PolicyArns.member.1.arn=arn%3Aaws%3Aiam%3A%3A123456789012%3Apolicy%2FReadOnlyAccess"
646 ));
647 assert!(query.contains("Tags.member.1.Key=Project&Tags.member.1.Value=reqsign"));
648 assert!(query.ends_with(
649 "SerialNumber=arn%3Aaws%3Aiam%3A%3A123456789012%3Amfa%2Fuser&TokenCode=123456"
650 ));
651 }
652
653 #[test]
654 fn parses_expiration_aware_credentials_without_debuggable_response_secrets() {
655 let response = http::Response::builder()
656 .status(http::StatusCode::OK)
657 .body(Bytes::from_static(
658 br#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
659 <AssumeRoleResult>
660 <Credentials>
661 <AccessKeyId>ASIAIOSFODNN7EXAMPLE</AccessKeyId>
662 <SecretAccessKey>returned-secret</SecretAccessKey>
663 <SessionToken>returned-session-token</SessionToken>
664 <Expiration>2035-11-09T13:34:41Z</Expiration>
665 </Credentials>
666 </AssumeRoleResult>
667 </AssumeRoleResponse>"#,
668 ))
669 .expect("response must build");
670 let response_time = "2030-01-01T00:00:00Z"
671 .parse()
672 .expect("timestamp must parse");
673
674 let credential =
675 parse_assume_role_response(response, response_time).expect("response must parse");
676 assert_eq!(credential.access_key_id, "ASIAIOSFODNN7EXAMPLE");
677 assert_eq!(
678 credential.session_token.as_deref(),
679 Some("returned-session-token")
680 );
681 assert_eq!(
682 credential.expires_in,
683 Some(
684 "2035-11-09T13:34:41Z"
685 .parse()
686 .expect("timestamp must parse")
687 )
688 );
689 }
690
691 #[test]
692 fn validates_role_paths_policy_accounts_and_partitions() {
693 let grant =
694 AssumeRoleGrant::new("arn:aws:iam::123456789012:role/team!prod/Reader", "reqsign")
695 .with_policy_arns(vec![
696 "arn:aws:iam::123456789012:policy/team!prod/Reader".to_string(),
697 ]);
698 grant
699 .validate_for_region("us-east-1")
700 .expect("valid IAM paths must be accepted");
701
702 let cross_account =
703 AssumeRoleGrant::new("arn:aws:iam::123456789012:role/Reader", "reqsign")
704 .with_policy_arns(vec!["arn:aws:iam::210987654321:policy/Reader".to_string()]);
705 assert_eq!(
706 cross_account
707 .validate_for_region("us-east-1")
708 .expect_err("managed policies must match the role account")
709 .kind(),
710 reqsign_core::ErrorKind::RequestInvalid
711 );
712
713 let wrong_partition =
714 AssumeRoleGrant::new("arn:aws-cn:iam::123456789012:role/Reader", "reqsign");
715 assert_eq!(
716 wrong_partition
717 .validate_for_region("us-east-1")
718 .expect_err("role and region partitions must match")
719 .kind(),
720 reqsign_core::ErrorKind::RequestInvalid
721 );
722
723 AssumeRoleGrant::new("arn:aws-eusc:iam::123456789012:role/Reader", "reqsign")
724 .validate_for_region("eusc-de-east-1")
725 .expect("EUSC role and region partitions must match");
726 }
727
728 #[test]
729 fn uses_post_when_the_encoded_query_exceeds_the_get_limit() {
730 let tags = (0..8)
731 .map(|index| (format!("Tag{index}"), "x".repeat(256)))
732 .collect();
733 let grant = AssumeRoleGrant::new("arn:aws:iam::123456789012:role/Reader", "reqsign")
734 .with_tags(tags);
735 let operation =
736 AssumeRoleOperation::new("sts.us-east-1.amazonaws.com", &grant, Some(3_600))
737 .expect("large grant must be valid");
738 let request = operation.build_request().expect("large request must build");
739
740 assert_eq!(request.method(), http::Method::POST);
741 assert_eq!(request.uri(), "https://sts.us-east-1.amazonaws.com/");
742 assert!(request.uri().query().is_none());
743 assert!(request.body().len() > MAX_GET_URI_LENGTH);
744 assert_eq!(
745 request
746 .headers()
747 .get(X_AMZ_CONTENT_SHA_256)
748 .expect("payload hash must be present"),
749 &hex_sha256(request.body())
750 );
751 }
752
753 #[test]
754 fn rejects_credentials_that_cannot_be_used_for_aws_signing() {
755 let response = |access_key_id: &str, session_token: &str| {
756 http::Response::builder()
757 .status(http::StatusCode::OK)
758 .body(Bytes::from(format!(
759 "<AssumeRoleResponse><AssumeRoleResult><Credentials>\
760 <AccessKeyId>{access_key_id}</AccessKeyId>\
761 <SecretAccessKey>returned-secret</SecretAccessKey>\
762 <SessionToken>{session_token}</SessionToken>\
763 <Expiration>2035-11-09T13:34:41Z</Expiration>\
764 </Credentials></AssumeRoleResult></AssumeRoleResponse>"
765 )))
766 .expect("response must build")
767 };
768 let response_time = "2030-01-01T00:00:00Z"
769 .parse()
770 .expect("timestamp must parse");
771
772 for malformed in [
773 response("ASIAINVALID@OUTPUT1", "returned-token"),
774 response("ASIAINVALIDOUTPUT01", "prefix suffix"),
775 ] {
776 assert_eq!(
777 parse_assume_role_response(malformed, response_time)
778 .expect_err("unusable credentials must be rejected")
779 .kind(),
780 reqsign_core::ErrorKind::Unexpected
781 );
782 }
783 }
784}