Skip to main content

reqsign_aws_core/
assume_role.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use 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/// A typed [AWS STS `AssumeRole`] authority transition.
41///
42/// The source credential authorizes this transition, but the returned
43/// credential derives its permissions from the target role and optional
44/// session policies. It is not necessarily a monotonic downscope of the
45/// source principal's direct permissions.
46///
47/// Validation is performed before the STS request is signed or sent. AWS
48/// still owns trust-policy evaluation, the target role's configured maximum
49/// session duration, the one-hour role-chaining limit, inherited
50/// transitive-tag conflicts, and packed-policy limits that cannot be
51/// determined from reqsign's opaque source credential locally.
52///
53/// [AWS STS `AssumeRole`]: https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
54#[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    /// Create a grant for one target role and auditable role session name.
74    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    /// Set the external ID required by the target role's trust policy.
88    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    /// Set one inline JSON session policy.
94    ///
95    /// Session policies restrict the target role session. They cannot grant
96    /// permissions beyond the target role's identity-based policy.
97    pub fn with_policy(mut self, policy: impl Into<String>) -> Self {
98        self.policy = Some(policy.into());
99        self
100    }
101
102    /// Set up to ten managed session policy ARNs.
103    pub fn with_policy_arns(mut self, policy_arns: Vec<String>) -> Self {
104        self.policy_arns = policy_arns;
105        self
106    }
107
108    /// Set up to fifty session tags.
109    pub fn with_tags(mut self, tags: Vec<(String, String)>) -> Self {
110        self.tags = tags;
111        self
112    }
113
114    /// Bind the MFA device serial number and current six-digit token code.
115    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/// Shared, redacted AWS STS `AssumeRole` execution machinery.
156///
157/// This type is public only so the SigV4 service crate can share the exact
158/// request, signing orchestration, response, expiration, and error path with
159/// [`crate::AssumeRoleCredentialProvider`].
160#[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    /// Create a validated operation for a trusted STS endpoint authority.
175    #[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    /// Sign with the configured signer, send through `Context`, and parse the
193    /// returned expiration-aware AWS credential.
194    #[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    /// Build the unsigned request shared by fixed and explicit-source flows.
212    #[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    /// Send an already signed request and parse its expiration-aware credential.
239    #[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/// Return the standard regional STS endpoint after validating the region.
249#[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&#10;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}