You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/07/31 07:03:55 UTC

(opendal-reqsign) branch xuanwo/aws-assume-role-granter created (now 0557eed)

This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a change to branch xuanwo/aws-assume-role-granter
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git


      at 0557eed  feat(aws): add STS AssumeRole granter

This branch includes the following new commits:

     new 0557eed  feat(aws): add STS AssumeRole granter

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(opendal-reqsign) 01/01: feat(aws): add STS AssumeRole granter

Posted by xu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch xuanwo/aws-assume-role-granter
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git

commit 0557eedc2a7056bb4474fe29376d4f3b52b992d1
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Fri Jul 31 15:03:37 2026 +0800

    feat(aws): add STS AssumeRole granter
---
 services/aws-core/src/assume_role.rs               |  784 ++++++++++++++
 services/aws-core/src/lib.rs                       |    4 +
 .../aws-core/src/provide_credential/assume_role.rs |  344 +-----
 .../assume_role_with_web_identity.rs               |   21 +-
 services/aws-core/src/provide_credential/mod.rs    |    2 +-
 services/aws-core/src/provide_credential/utils.rs  |  258 +++--
 services/aws-v4/src/assume_role.rs                 | 1097 ++++++++++++++++++++
 services/aws-v4/src/lib.rs                         |    4 +-
 services/aws-v4/tests/README.md                    |    4 +-
 .../tests/credential_providers/assume_role.rs      |   42 +-
 10 files changed, 2178 insertions(+), 382 deletions(-)

diff --git a/services/aws-core/src/assume_role.rs b/services/aws-core/src/assume_role.rs
new file mode 100644
index 0000000..32565b7
--- /dev/null
+++ b/services/aws-core/src/assume_role.rs
@@ -0,0 +1,784 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::HashSet;
+use std::fmt::{Debug, Formatter};
+
+use bytes::Bytes;
+use form_urlencoded::Serializer;
+use quick_xml::de;
+use reqsign_core::hash::hex_sha256;
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, Result, Signer, SigningCredential};
+use serde::Deserialize;
+
+use crate::Credential;
+use crate::constants::X_AMZ_CONTENT_SHA_256;
+use crate::provide_credential::utils::{parse_sts_error, partition_for_region, sts_endpoint};
+
+const MIN_DURATION_SECONDS: u32 = 900;
+const MAX_DURATION_SECONDS: u32 = 43_200;
+const MAX_POLICY_ARNS: usize = 10;
+const MAX_POLICY_PLAINTEXT_CHARACTERS: usize = 2_048;
+const MAX_SESSION_TAGS: usize = 50;
+const MAX_GET_URI_LENGTH: usize = 2_048;
+
+/// A typed [AWS STS `AssumeRole`] authority transition.
+///
+/// The source credential authorizes this transition, but the returned
+/// credential derives its permissions from the target role and optional
+/// session policies. It is not necessarily a monotonic downscope of the
+/// source principal's direct permissions.
+///
+/// Validation is performed before the STS request is signed or sent. AWS
+/// still owns trust-policy evaluation, the target role's configured maximum
+/// session duration, the one-hour role-chaining limit, inherited
+/// transitive-tag conflicts, and packed-policy limits that cannot be
+/// determined from reqsign's opaque source credential locally.
+///
+/// [AWS STS `AssumeRole`]: https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
+#[derive(Clone)]
+pub struct AssumeRoleGrant {
+    pub(crate) role_arn: String,
+    pub(crate) role_session_name: String,
+    pub(crate) external_id: Option<String>,
+    pub(crate) tags: Vec<(String, String)>,
+    pub(crate) policy: Option<String>,
+    pub(crate) policy_arns: Vec<String>,
+    pub(crate) serial_number: Option<String>,
+    pub(crate) token_code: Option<String>,
+}
+
+impl Debug for AssumeRoleGrant {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("AssumeRoleGrant").finish_non_exhaustive()
+    }
+}
+
+impl AssumeRoleGrant {
+    /// Create a grant for one target role and auditable role session name.
+    pub fn new(role_arn: impl Into<String>, role_session_name: impl Into<String>) -> Self {
+        Self {
+            role_arn: role_arn.into(),
+            role_session_name: role_session_name.into(),
+            external_id: None,
+            tags: Vec::new(),
+            policy: None,
+            policy_arns: Vec::new(),
+            serial_number: None,
+            token_code: None,
+        }
+    }
+
+    /// Set the external ID required by the target role's trust policy.
+    pub fn with_external_id(mut self, external_id: impl Into<String>) -> Self {
+        self.external_id = Some(external_id.into());
+        self
+    }
+
+    /// Set one inline JSON session policy.
+    ///
+    /// Session policies restrict the target role session. They cannot grant
+    /// permissions beyond the target role's identity-based policy.
+    pub fn with_policy(mut self, policy: impl Into<String>) -> Self {
+        self.policy = Some(policy.into());
+        self
+    }
+
+    /// Set up to ten managed session policy ARNs.
+    pub fn with_policy_arns(mut self, policy_arns: Vec<String>) -> Self {
+        self.policy_arns = policy_arns;
+        self
+    }
+
+    /// Set up to fifty session tags.
+    pub fn with_tags(mut self, tags: Vec<(String, String)>) -> Self {
+        self.tags = tags;
+        self
+    }
+
+    /// Bind the MFA device serial number and current six-digit token code.
+    pub fn with_mfa(
+        mut self,
+        serial_number: impl Into<String>,
+        token_code: impl Into<String>,
+    ) -> Self {
+        self.serial_number = Some(serial_number.into());
+        self.token_code = Some(token_code.into());
+        self
+    }
+
+    fn validate(&self) -> Result<IamArn<'_>> {
+        let role = validate_role_arn(&self.role_arn)?;
+        validate_role_session_name(&self.role_session_name)?;
+
+        if let Some(external_id) = &self.external_id {
+            validate_external_id(external_id)?;
+        }
+
+        validate_session_policies(self.policy.as_deref(), &self.policy_arns, role)?;
+        validate_tags(&self.tags)?;
+        validate_mfa(self.serial_number.as_deref(), self.token_code.as_deref())?;
+        Ok(role)
+    }
+
+    #[doc(hidden)]
+    pub fn validate_for_region(&self, region: &str) -> Result<()> {
+        let partition = partition_for_region(region)?;
+        self.validate_for_partition(partition.id)
+    }
+
+    pub(crate) fn validate_for_partition(&self, expected_partition: &str) -> Result<()> {
+        if self.validate()?.partition != expected_partition {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole role partition does not match the signing region",
+            ));
+        }
+        Ok(())
+    }
+}
+
+/// Shared, redacted AWS STS `AssumeRole` execution machinery.
+///
+/// This type is public only so the SigV4 service crate can share the exact
+/// request, signing orchestration, response, expiration, and error path with
+/// [`crate::AssumeRoleCredentialProvider`].
+#[doc(hidden)]
+pub struct AssumeRoleOperation {
+    endpoint: String,
+    parameters: String,
+}
+
+impl Debug for AssumeRoleOperation {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("AssumeRoleOperation")
+            .finish_non_exhaustive()
+    }
+}
+
+impl AssumeRoleOperation {
+    /// Create a validated operation for a trusted STS endpoint authority.
+    #[doc(hidden)]
+    pub fn new(
+        endpoint: impl Into<String>,
+        grant: &AssumeRoleGrant,
+        duration_seconds: Option<u32>,
+    ) -> Result<Self> {
+        let endpoint = endpoint.into();
+        validate_sts_endpoint(&endpoint)?;
+        validate_duration_seconds(duration_seconds)?;
+        grant.validate()?;
+
+        Ok(Self {
+            parameters: build_assume_role_query(grant, duration_seconds),
+            endpoint,
+        })
+    }
+
+    /// Sign with the configured signer, send through `Context`, and parse the
+    /// returned expiration-aware AWS credential.
+    #[doc(hidden)]
+    pub async fn execute(
+        &self,
+        ctx: &Context,
+        sts_signer: &Signer<Credential>,
+    ) -> Result<Credential> {
+        let request = self.build_request()?;
+        let (mut parts, body) = request.into_parts();
+        sts_signer.sign(&mut parts, None).await.map_err(|err| {
+            Error::new(err.kind(), "failed to sign AWS STS AssumeRole request")
+                .set_retryable(err.is_retryable())
+        })?;
+        let request = http::Request::from_parts(parts, body);
+
+        self.send(ctx, request).await
+    }
+
+    /// Build the unsigned request shared by fixed and explicit-source flows.
+    #[doc(hidden)]
+    pub fn build_request(&self) -> Result<http::Request<Bytes>> {
+        let get_uri = format!("https://{}/?{}", self.endpoint, self.parameters);
+        let (method, uri, body) = if get_uri.len() <= MAX_GET_URI_LENGTH {
+            (http::Method::GET, get_uri, Bytes::new())
+        } else {
+            (
+                http::Method::POST,
+                format!("https://{}/", self.endpoint),
+                Bytes::from(self.parameters.clone()),
+            )
+        };
+        let payload_hash = hex_sha256(&body);
+
+        http::Request::builder()
+            .method(method)
+            .uri(uri)
+            .header(
+                http::header::CONTENT_TYPE,
+                "application/x-www-form-urlencoded",
+            )
+            .header(X_AMZ_CONTENT_SHA_256, payload_hash)
+            .body(body)
+            .map_err(|_| Error::request_invalid("failed to build AWS STS AssumeRole request"))
+    }
+
+    /// Send an already signed request and parse its expiration-aware credential.
+    #[doc(hidden)]
+    pub async fn send(&self, ctx: &Context, request: http::Request<Bytes>) -> Result<Credential> {
+        let response = ctx.http_send(request).await.map_err(|_| {
+            Error::unexpected("failed to send AWS STS AssumeRole request").set_retryable(true)
+        })?;
+        parse_assume_role_response(response, Timestamp::now())
+    }
+}
+
+/// Return the standard regional STS endpoint after validating the region.
+#[doc(hidden)]
+pub fn regional_sts_endpoint(region: &str, grant: &AssumeRoleGrant) -> Result<String> {
+    grant.validate_for_region(region)?;
+    sts_endpoint(Some(region), true)
+}
+
+fn build_assume_role_query(grant: &AssumeRoleGrant, duration_seconds: Option<u32>) -> String {
+    let mut serializer = Serializer::new(String::new());
+    serializer
+        .append_pair("Action", "AssumeRole")
+        .append_pair("RoleArn", &grant.role_arn)
+        .append_pair("Version", "2011-06-15")
+        .append_pair("RoleSessionName", &grant.role_session_name);
+
+    if let Some(external_id) = &grant.external_id {
+        serializer.append_pair("ExternalId", external_id);
+    }
+    if let Some(duration_seconds) = duration_seconds {
+        serializer.append_pair("DurationSeconds", &duration_seconds.to_string());
+    }
+    if let Some(policy) = &grant.policy {
+        serializer.append_pair("Policy", policy);
+    }
+    for (index, arn) in grant.policy_arns.iter().enumerate() {
+        serializer.append_pair(&format!("PolicyArns.member.{}.arn", index + 1), arn);
+    }
+    for (index, (key, value)) in grant.tags.iter().enumerate() {
+        let index = index + 1;
+        serializer
+            .append_pair(&format!("Tags.member.{index}.Key"), key)
+            .append_pair(&format!("Tags.member.{index}.Value"), value);
+    }
+    if let (Some(serial_number), Some(token_code)) = (&grant.serial_number, &grant.token_code) {
+        serializer
+            .append_pair("SerialNumber", serial_number)
+            .append_pair("TokenCode", token_code);
+    }
+
+    serializer.finish()
+}
+
+fn parse_assume_role_response(
+    response: http::Response<Bytes>,
+    response_time: Timestamp,
+) -> Result<Credential> {
+    let status = response.status();
+    let body = response.into_body();
+    let body = String::from_utf8_lossy(&body);
+
+    if status != http::StatusCode::OK {
+        return Err(parse_sts_error("AssumeRole", status, &body));
+    }
+
+    let response: AssumeRoleResponse = de::from_str(&body).map_err(|_| {
+        Error::unexpected("failed to parse AWS STS AssumeRole response")
+            .with_context(format!("response_length: {}", body.len()))
+    })?;
+    let response = response.result.credentials;
+    if !(16..=128).contains(&response.access_key_id.chars().count())
+        || !response.access_key_id.bytes().all(is_access_key_character)
+        || response.secret_access_key.is_empty()
+        || response.session_token.trim().is_empty()
+        || http::HeaderValue::try_from(response.session_token.as_str()).is_err()
+    {
+        return Err(Error::unexpected(
+            "AWS STS AssumeRole response contains malformed credentials",
+        ));
+    }
+
+    let expires_at: Timestamp = response.expiration.parse().map_err(|_| {
+        Error::unexpected("failed to parse AWS STS AssumeRole credential expiration")
+    })?;
+    let credential = Credential {
+        access_key_id: response.access_key_id,
+        secret_access_key: response.secret_access_key,
+        session_token: Some(response.session_token),
+        expires_in: Some(expires_at),
+    };
+    if !credential.is_valid_at(response_time) {
+        return Err(Error::credential_invalid(
+            "AWS STS AssumeRole returned credentials that are already expired",
+        ));
+    }
+
+    Ok(credential)
+}
+
+fn validate_duration_seconds(duration_seconds: Option<u32>) -> Result<()> {
+    if duration_seconds
+        .is_some_and(|seconds| !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds))
+    {
+        return Err(Error::request_invalid(
+            "AWS STS AssumeRole duration must be between 900 and 43200 seconds",
+        ));
+    }
+    Ok(())
+}
+
+fn validate_sts_endpoint(endpoint: &str) -> Result<()> {
+    let authority: http::uri::Authority = endpoint
+        .parse()
+        .map_err(|_| Error::config_invalid("AWS STS endpoint authority is invalid"))?;
+    if authority.as_str() != endpoint
+        || authority.host().is_empty()
+        || authority.port().is_some()
+        || endpoint.contains('@')
+    {
+        return Err(Error::config_invalid(
+            "AWS STS endpoint authority is invalid",
+        ));
+    }
+    Ok(())
+}
+
+#[derive(Clone, Copy)]
+struct IamArn<'a> {
+    partition: &'a str,
+    account: &'a str,
+}
+
+fn validate_role_arn(role_arn: &str) -> Result<IamArn<'_>> {
+    let arn = validate_iam_arn(role_arn, "role/", 64)
+        .ok_or_else(|| Error::request_invalid("AWS STS AssumeRole role ARN is invalid"))?;
+    Ok(arn)
+}
+
+fn validate_policy_arn(policy_arn: &str) -> Result<IamArn<'_>> {
+    let arn = validate_iam_arn(policy_arn, "policy/", 128).ok_or_else(|| {
+        Error::request_invalid("AWS STS AssumeRole managed policy ARN is invalid")
+    })?;
+    Ok(arn)
+}
+
+fn validate_iam_arn<'a>(
+    value: &'a str,
+    resource_prefix: &str,
+    maximum_name_length: usize,
+) -> Option<IamArn<'a>> {
+    if !(20..=2_048).contains(&value.chars().count()) {
+        return None;
+    }
+
+    let mut fields = value.splitn(6, ':');
+    let (Some(arn), Some(partition), Some(service), Some(region), Some(account), Some(resource)) = (
+        fields.next(),
+        fields.next(),
+        fields.next(),
+        fields.next(),
+        fields.next(),
+        fields.next(),
+    ) else {
+        return None;
+    };
+
+    if arn != "arn"
+        || !matches!(
+            partition,
+            "aws"
+                | "aws-cn"
+                | "aws-eusc"
+                | "aws-iso"
+                | "aws-iso-b"
+                | "aws-iso-e"
+                | "aws-iso-f"
+                | "aws-us-gov"
+        )
+        || service != "iam"
+        || !region.is_empty()
+        || account.len() != 12
+        || !account.bytes().all(|byte| byte.is_ascii_digit())
+    {
+        return None;
+    }
+
+    let resource = resource.strip_prefix(resource_prefix)?;
+    if !validate_iam_resource_path(resource, maximum_name_length) {
+        return None;
+    }
+
+    Some(IamArn { partition, account })
+}
+
+fn validate_iam_resource_path(resource: &str, maximum_name_length: usize) -> bool {
+    let name = match resource.rsplit_once('/') {
+        Some((path, name)) => {
+            if path.is_empty()
+                || path.len() + 2 > 512
+                || !path.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
+            {
+                return false;
+            }
+            name
+        }
+        None => resource,
+    };
+
+    (1..=maximum_name_length).contains(&name.chars().count())
+        && name.chars().all(is_aws_word_character)
+}
+
+fn validate_role_session_name(role_session_name: &str) -> Result<()> {
+    if !(2..=64).contains(&role_session_name.chars().count())
+        || !role_session_name.chars().all(is_aws_word_character)
+    {
+        return Err(Error::request_invalid(
+            "AWS STS AssumeRole session name is invalid",
+        ));
+    }
+    Ok(())
+}
+
+fn validate_external_id(external_id: &str) -> Result<()> {
+    if !(2..=1_224).contains(&external_id.chars().count())
+        || !external_id.chars().all(|character| {
+            is_aws_word_character(character) || character == ':' || character == '/'
+        })
+    {
+        return Err(Error::request_invalid(
+            "AWS STS AssumeRole external ID is invalid",
+        ));
+    }
+    Ok(())
+}
+
+fn is_aws_word_character(character: char) -> bool {
+    character.is_ascii_alphanumeric() || "_+=,.@-".contains(character)
+}
+
+fn is_access_key_character(byte: u8) -> bool {
+    byte.is_ascii_alphanumeric() || byte == b'_'
+}
+
+fn validate_session_policies(
+    policy: Option<&str>,
+    policy_arns: &[String],
+    role: IamArn<'_>,
+) -> Result<()> {
+    if policy_arns.len() > MAX_POLICY_ARNS {
+        return Err(Error::request_invalid(
+            "AWS STS AssumeRole accepts at most ten managed session policies",
+        ));
+    }
+
+    for policy_arn in policy_arns {
+        let policy = validate_policy_arn(policy_arn)?;
+        if policy.partition != role.partition || policy.account != role.account {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole managed session policies must match the role partition and account",
+            ));
+        }
+    }
+
+    if let Some(policy) = policy {
+        if policy.is_empty() || !policy.chars().all(is_session_policy_character) {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole inline session policy contains invalid characters",
+            ));
+        }
+        let value: serde_json::Value = serde_json::from_str(policy).map_err(|_| {
+            Error::request_invalid("AWS STS AssumeRole inline session policy must be valid JSON")
+        })?;
+        if !value.is_object() {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole inline session policy must be a JSON object",
+            ));
+        }
+    }
+
+    let plaintext_characters = policy.map_or(0, |value| value.chars().count())
+        + policy_arns
+            .iter()
+            .map(|value| value.chars().count())
+            .sum::<usize>();
+    if plaintext_characters > MAX_POLICY_PLAINTEXT_CHARACTERS {
+        return Err(Error::request_invalid(
+            "AWS STS AssumeRole session policy plaintext exceeds 2048 characters",
+        ));
+    }
+    Ok(())
+}
+
+fn is_session_policy_character(character: char) -> bool {
+    matches!(character, '\t' | '\n' | '\r') || (' '..='\u{00ff}').contains(&character)
+}
+
+fn validate_tags(tags: &[(String, String)]) -> Result<()> {
+    if tags.len() > MAX_SESSION_TAGS {
+        return Err(Error::request_invalid(
+            "AWS STS AssumeRole accepts at most fifty session tags",
+        ));
+    }
+
+    let mut unique_keys = HashSet::with_capacity(tags.len());
+    for (key, value) in tags {
+        if !(1..=128).contains(&key.chars().count()) || !key.chars().all(is_session_tag_character) {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole session tag key is invalid",
+            ));
+        }
+        if value.chars().count() > 256 || !value.chars().all(is_session_tag_character) {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole session tag value is invalid",
+            ));
+        }
+        if !unique_keys.insert(key.to_lowercase()) {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole session tag keys are case-insensitively unique",
+            ));
+        }
+    }
+    Ok(())
+}
+
+fn is_session_tag_character(character: char) -> bool {
+    character.is_alphanumeric()
+        || character.is_whitespace() && !character.is_control()
+        || "_:./=+-@".contains(character)
+}
+
+fn validate_mfa(serial_number: Option<&str>, token_code: Option<&str>) -> Result<()> {
+    let (Some(serial_number), Some(token_code)) = (serial_number, token_code) else {
+        if serial_number.is_some() || token_code.is_some() {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole MFA serial number and token code must be provided together",
+            ));
+        }
+        return Ok(());
+    };
+
+    if !(9..=256).contains(&serial_number.chars().count())
+        || !serial_number.chars().all(|character| {
+            is_aws_word_character(character) || character == '/' || character == ':'
+        })
+    {
+        return Err(Error::request_invalid(
+            "AWS STS AssumeRole MFA serial number is invalid",
+        ));
+    }
+    if token_code.len() != 6 || !token_code.bytes().all(|byte| byte.is_ascii_digit()) {
+        return Err(Error::request_invalid(
+            "AWS STS AssumeRole MFA token code must contain six digits",
+        ));
+    }
+    Ok(())
+}
+
+#[derive(Default, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct AssumeRoleResponse {
+    #[serde(rename = "AssumeRoleResult")]
+    result: AssumeRoleResult,
+}
+
+#[derive(Default, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct AssumeRoleResult {
+    credentials: AssumeRoleCredentials,
+}
+
+#[derive(Default, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct AssumeRoleCredentials {
+    access_key_id: String,
+    secret_access_key: String,
+    session_token: String,
+    expiration: String,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn builds_existing_assume_role_query_shape() {
+        let grant = AssumeRoleGrant::new(
+            "arn:aws:iam::123456789012:role/test-role",
+            "reqsign",
+        )
+        .with_external_id("external/id")
+        .with_policy(
+            r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}"#,
+        )
+        .with_policy_arns(vec![
+            "arn:aws:iam::123456789012:policy/ReadOnlyAccess".to_string(),
+            "arn:aws:iam::123456789012:policy/ExamplePolicy".to_string(),
+        ])
+        .with_tags(vec![("Project".to_string(), "reqsign".to_string())])
+        .with_mfa("arn:aws:iam::123456789012:mfa/user", "123456");
+
+        let query = build_assume_role_query(&grant, Some(3_600));
+        assert!(query.starts_with(
+            "Action=AssumeRole&RoleArn=arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Ftest-role&Version=2011-06-15&RoleSessionName=reqsign"
+        ));
+        assert!(query.contains("ExternalId=external%2Fid"));
+        assert!(query.contains("DurationSeconds=3600"));
+        assert!(query.contains(
+            "PolicyArns.member.1.arn=arn%3Aaws%3Aiam%3A%3A123456789012%3Apolicy%2FReadOnlyAccess"
+        ));
+        assert!(query.contains("Tags.member.1.Key=Project&Tags.member.1.Value=reqsign"));
+        assert!(query.ends_with(
+            "SerialNumber=arn%3Aaws%3Aiam%3A%3A123456789012%3Amfa%2Fuser&TokenCode=123456"
+        ));
+    }
+
+    #[test]
+    fn parses_expiration_aware_credentials_without_debuggable_response_secrets() {
+        let response = http::Response::builder()
+            .status(http::StatusCode::OK)
+            .body(Bytes::from_static(
+                br#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
+                    <AssumeRoleResult>
+                        <Credentials>
+                            <AccessKeyId>ASIAIOSFODNN7EXAMPLE</AccessKeyId>
+                            <SecretAccessKey>returned-secret</SecretAccessKey>
+                            <SessionToken>returned-session-token</SessionToken>
+                            <Expiration>2035-11-09T13:34:41Z</Expiration>
+                        </Credentials>
+                    </AssumeRoleResult>
+                </AssumeRoleResponse>"#,
+            ))
+            .expect("response must build");
+        let response_time = "2030-01-01T00:00:00Z"
+            .parse()
+            .expect("timestamp must parse");
+
+        let credential =
+            parse_assume_role_response(response, response_time).expect("response must parse");
+        assert_eq!(credential.access_key_id, "ASIAIOSFODNN7EXAMPLE");
+        assert_eq!(
+            credential.session_token.as_deref(),
+            Some("returned-session-token")
+        );
+        assert_eq!(
+            credential.expires_in,
+            Some(
+                "2035-11-09T13:34:41Z"
+                    .parse()
+                    .expect("timestamp must parse")
+            )
+        );
+    }
+
+    #[test]
+    fn validates_role_paths_policy_accounts_and_partitions() {
+        let grant =
+            AssumeRoleGrant::new("arn:aws:iam::123456789012:role/team!prod/Reader", "reqsign")
+                .with_policy_arns(vec![
+                    "arn:aws:iam::123456789012:policy/team!prod/Reader".to_string(),
+                ]);
+        grant
+            .validate_for_region("us-east-1")
+            .expect("valid IAM paths must be accepted");
+
+        let cross_account =
+            AssumeRoleGrant::new("arn:aws:iam::123456789012:role/Reader", "reqsign")
+                .with_policy_arns(vec!["arn:aws:iam::210987654321:policy/Reader".to_string()]);
+        assert_eq!(
+            cross_account
+                .validate_for_region("us-east-1")
+                .expect_err("managed policies must match the role account")
+                .kind(),
+            reqsign_core::ErrorKind::RequestInvalid
+        );
+
+        let wrong_partition =
+            AssumeRoleGrant::new("arn:aws-cn:iam::123456789012:role/Reader", "reqsign");
+        assert_eq!(
+            wrong_partition
+                .validate_for_region("us-east-1")
+                .expect_err("role and region partitions must match")
+                .kind(),
+            reqsign_core::ErrorKind::RequestInvalid
+        );
+
+        AssumeRoleGrant::new("arn:aws-eusc:iam::123456789012:role/Reader", "reqsign")
+            .validate_for_region("eusc-de-east-1")
+            .expect("EUSC role and region partitions must match");
+    }
+
+    #[test]
+    fn uses_post_when_the_encoded_query_exceeds_the_get_limit() {
+        let tags = (0..8)
+            .map(|index| (format!("Tag{index}"), "x".repeat(256)))
+            .collect();
+        let grant = AssumeRoleGrant::new("arn:aws:iam::123456789012:role/Reader", "reqsign")
+            .with_tags(tags);
+        let operation =
+            AssumeRoleOperation::new("sts.us-east-1.amazonaws.com", &grant, Some(3_600))
+                .expect("large grant must be valid");
+        let request = operation.build_request().expect("large request must build");
+
+        assert_eq!(request.method(), http::Method::POST);
+        assert_eq!(request.uri(), "https://sts.us-east-1.amazonaws.com/");
+        assert!(request.uri().query().is_none());
+        assert!(request.body().len() > MAX_GET_URI_LENGTH);
+        assert_eq!(
+            request
+                .headers()
+                .get(X_AMZ_CONTENT_SHA_256)
+                .expect("payload hash must be present"),
+            &hex_sha256(request.body())
+        );
+    }
+
+    #[test]
+    fn rejects_credentials_that_cannot_be_used_for_aws_signing() {
+        let response = |access_key_id: &str, session_token: &str| {
+            http::Response::builder()
+                .status(http::StatusCode::OK)
+                .body(Bytes::from(format!(
+                    "<AssumeRoleResponse><AssumeRoleResult><Credentials>\
+                     <AccessKeyId>{access_key_id}</AccessKeyId>\
+                     <SecretAccessKey>returned-secret</SecretAccessKey>\
+                     <SessionToken>{session_token}</SessionToken>\
+                     <Expiration>2035-11-09T13:34:41Z</Expiration>\
+                     </Credentials></AssumeRoleResult></AssumeRoleResponse>"
+                )))
+                .expect("response must build")
+        };
+        let response_time = "2030-01-01T00:00:00Z"
+            .parse()
+            .expect("timestamp must parse");
+
+        for malformed in [
+            response("ASIAINVALID@OUTPUT1", "returned-token"),
+            response("ASIAINVALIDOUTPUT01", "prefix&#10;suffix"),
+        ] {
+            assert_eq!(
+                parse_assume_role_response(malformed, response_time)
+                    .expect_err("unusable credentials must be rejected")
+                    .kind(),
+                reqsign_core::ErrorKind::Unexpected
+            );
+        }
+    }
+}
diff --git a/services/aws-core/src/lib.rs b/services/aws-core/src/lib.rs
index e29ea2e..498744e 100644
--- a/services/aws-core/src/lib.rs
+++ b/services/aws-core/src/lib.rs
@@ -19,6 +19,10 @@
 
 pub mod constants;
 
+#[doc(hidden)]
+pub mod assume_role;
+pub use assume_role::AssumeRoleGrant;
+
 mod credential;
 pub use credential::Credential;
 
diff --git a/services/aws-core/src/provide_credential/assume_role.rs b/services/aws-core/src/provide_credential/assume_role.rs
index 7150c79..41dd20a 100644
--- a/services/aws-core/src/provide_credential/assume_role.rs
+++ b/services/aws-core/src/provide_credential/assume_role.rs
@@ -15,53 +15,40 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::EMPTY_STRING_SHA256;
-use crate::constants::X_AMZ_CONTENT_SHA_256;
-use crate::credential::Credential;
-use crate::provide_credential::utils::{parse_sts_error, sts_endpoint};
-use bytes::Bytes;
-use form_urlencoded::Serializer;
-use quick_xml::de;
-use reqsign_core::{Context, Error, ProvideCredential, Result, Signer};
-use serde::Deserialize;
+use std::fmt::{Debug, Formatter};
 
-/// AssumeRoleCredentialProvider will load credential via assume role.
-#[derive(Debug)]
-pub struct AssumeRoleCredentialProvider {
-    // Role configuration
-    role_arn: String,
-    role_session_name: String,
-    external_id: Option<String>,
-    duration_seconds: Option<u32>,
-    tags: Option<Vec<(String, String)>>,
-    policy: Option<String>,
-    policy_arns: Option<Vec<String>>,
+use reqsign_core::{Context, ProvideCredential, Result, Signer};
 
-    // MFA configuration
-    serial_number: Option<String>,
-    token_code: Option<String>,
+use crate::Credential;
+use crate::assume_role::{AssumeRoleGrant, AssumeRoleOperation};
+use crate::provide_credential::utils::sts_endpoint;
 
-    // STS configuration
+/// Loads credentials through one fixed AWS STS `AssumeRole` flow.
+///
+/// Use [`reqsign_core::Granter`] with the SigV4 crate's explicit AssumeRole
+/// granter when the source credential and grant are supplied per vending
+/// operation.
+pub struct AssumeRoleCredentialProvider {
+    grant: AssumeRoleGrant,
+    duration_seconds: Option<u32>,
     region: Option<String>,
     use_regional_sts_endpoint: bool,
-
-    // Base credential provider
     sts_signer: Signer<Credential>,
 }
 
+impl Debug for AssumeRoleCredentialProvider {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("AssumeRoleCredentialProvider")
+            .finish_non_exhaustive()
+    }
+}
+
 impl AssumeRoleCredentialProvider {
-    /// Create a new assume role loader.
+    /// Create a new fixed-flow AssumeRole credential provider.
     pub fn new(role_arn: String, sts_signer: Signer<Credential>) -> Self {
         Self {
-            role_arn,
-            role_session_name: "reqsign".to_string(),
-            external_id: None,
-            duration_seconds: Some(3600),
-            tags: None,
-            policy: None,
-            policy_arns: None,
-            serial_number: None,
-            token_code: None,
+            grant: AssumeRoleGrant::new(role_arn, "reqsign"),
+            duration_seconds: Some(3_600),
             region: None,
             use_regional_sts_endpoint: false,
             sts_signer,
@@ -70,13 +57,13 @@ impl AssumeRoleCredentialProvider {
 
     /// Set the role session name.
     pub fn with_role_session_name(mut self, name: String) -> Self {
-        self.role_session_name = name;
+        self.grant.role_session_name = name;
         self
     }
 
     /// Set the external ID.
     pub fn with_external_id(mut self, id: String) -> Self {
-        self.external_id = Some(id);
+        self.grant.external_id = Some(id);
         self
     }
 
@@ -88,47 +75,47 @@ impl AssumeRoleCredentialProvider {
 
     /// Set the session policy.
     pub fn with_policy(mut self, policy: String) -> Self {
-        self.policy = Some(policy);
+        self.grant.policy = Some(policy);
         self
     }
 
     /// Set the session policy ARNs.
     pub fn with_policy_arns(mut self, policy_arns: Vec<String>) -> Self {
-        self.policy_arns = Some(policy_arns);
+        self.grant.policy_arns = policy_arns;
         self
     }
 
-    /// Set the tags.
+    /// Set the session tags.
     pub fn with_tags(mut self, tags: Vec<(String, String)>) -> Self {
-        self.tags = Some(tags);
+        self.grant.tags = tags;
         self
     }
 
-    /// Set the region.
+    /// Set the region used to select a regional STS endpoint.
     pub fn with_region(mut self, region: String) -> Self {
         self.region = Some(region);
         self
     }
 
-    /// Use regional STS endpoint.
+    /// Use a regional STS endpoint.
     pub fn with_regional_sts_endpoint(mut self) -> Self {
         self.use_regional_sts_endpoint = true;
         self
     }
 
-    /// Set MFA serial number.
+    /// Set the MFA serial number.
     pub fn with_mfa_serial(mut self, serial_number: String) -> Self {
-        self.serial_number = Some(serial_number);
+        self.grant.serial_number = Some(serial_number);
         self
     }
 
-    /// Set MFA token code.
+    /// Set the MFA token code.
     pub fn with_mfa_code(mut self, token_code: String) -> Self {
-        self.token_code = Some(token_code);
+        self.grant.token_code = Some(token_code);
         self
     }
 
-    /// Create from environment variables.
+    /// Create a fixed-flow provider from AWS environment variables.
     pub fn from_env(ctx: &Context, sts_signer: Signer<Credential>) -> Option<Self> {
         let role_arn = ctx.env_var("AWS_ROLE_ARN")?;
         let mut provider = Self::new(role_arn, sts_signer);
@@ -136,273 +123,30 @@ impl AssumeRoleCredentialProvider {
         if let Some(name) = ctx.env_var("AWS_ROLE_SESSION_NAME") {
             provider = provider.with_role_session_name(name);
         }
-
         if let Some(id) = ctx.env_var("AWS_EXTERNAL_ID") {
             provider = provider.with_external_id(id);
         }
-
         if let Some(region) = ctx.env_var("AWS_REGION") {
             provider = provider.with_region(region);
         }
-
-        if ctx.env_var("AWS_STS_REGIONAL_ENDPOINTS") == Some("regional".to_string()) {
+        if ctx.env_var("AWS_STS_REGIONAL_ENDPOINTS").as_deref() == Some("regional") {
             provider = provider.with_regional_sts_endpoint();
         }
 
         Some(provider)
     }
 }
+
 impl ProvideCredential for AssumeRoleCredentialProvider {
     type Credential = Credential;
 
     async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
-        let endpoint = sts_endpoint(self.region.as_deref(), self.use_regional_sts_endpoint)
-            .map_err(|e| e.with_context(format!("role_arn: {}", self.role_arn)))?;
-
-        let query = build_assume_role_query(AssumeRoleQueryInput {
-            role_arn: &self.role_arn,
-            role_session_name: &self.role_session_name,
-            external_id: self.external_id.as_deref(),
-            duration_seconds: self.duration_seconds,
-            tags: self.tags.as_deref(),
-            policy: self.policy.as_deref(),
-            policy_arns: self.policy_arns.as_deref(),
-            serial_number: self.serial_number.as_deref(),
-            token_code: self.token_code.as_deref(),
-        });
-        let url = format!("https://{endpoint}/?{query}");
-
-        let req = http::request::Request::builder()
-            .method("GET")
-            .uri(url)
-            .header(
-                http::header::CONTENT_TYPE.as_str(),
-                "application/x-www-form-urlencoded",
-            )
-            // Set content sha to empty string.
-            .header(X_AMZ_CONTENT_SHA_256, EMPTY_STRING_SHA256)
-            .body(Bytes::new())
-            .map_err(|e| {
-                Error::request_invalid("failed to build STS AssumeRole request")
-                    .with_source(e)
-                    .with_context(format!("role_arn: {}", self.role_arn))
-                    .with_context(format!("endpoint: https://{endpoint}"))
-            })?;
-
-        let (mut parts, body) = req.into_parts();
-        self.sts_signer.sign(&mut parts, None).await?;
-        let req = http::Request::from_parts(parts, body);
-
-        let resp = ctx.http_send_as_string(req).await.map_err(|e| {
-            Error::unexpected("failed to send AssumeRole request to STS")
-                .with_source(e)
-                .with_context(format!("role_arn: {}", self.role_arn))
-                .with_context(format!("endpoint: https://{endpoint}"))
-                .set_retryable(true)
-        })?;
-
-        // Extract request ID and status before consuming response
-        let status = resp.status();
-        let request_id = resp
-            .headers()
-            .get("x-amzn-requestid")
-            .and_then(|v| v.to_str().ok())
-            .map(|s| s.to_string());
-
-        if status != http::StatusCode::OK {
-            let content = resp.into_body();
-            return Err(
-                parse_sts_error("AssumeRole", status, &content, request_id.as_deref())
-                    .with_context(format!("role_arn: {}", self.role_arn))
-                    .with_context(format!("session_name: {}", self.role_session_name)),
-            );
-        }
-
-        let body = resp.into_body();
-        let resp: AssumeRoleResponse = de::from_str(&body).map_err(|e| {
-            Error::unexpected("failed to parse STS AssumeRole response")
-                .with_source(e)
-                .with_context(format!("response_length: {}", body.len()))
-                .with_context(format!("role_arn: {}", self.role_arn))
-        })?;
-        let resp_cred = resp.result.credentials;
-
-        let cred = Credential {
-            access_key_id: resp_cred.access_key_id,
-            secret_access_key: resp_cred.secret_access_key,
-            session_token: Some(resp_cred.session_token),
-            expires_in: Some(resp_cred.expiration.parse().map_err(|e| {
-                Error::unexpected("failed to parse AssumeRole credential expiration")
-                    .with_source(e)
-                    .with_context(format!("expiration_value: {}", resp_cred.expiration))
-                    .with_context(format!("role_arn: {}", self.role_arn))
-            })?),
-        };
-
-        Ok(Some(cred))
-    }
-}
-
-struct AssumeRoleQueryInput<'a> {
-    role_arn: &'a str,
-    role_session_name: &'a str,
-    external_id: Option<&'a str>,
-    duration_seconds: Option<u32>,
-    tags: Option<&'a [(String, String)]>,
-    policy: Option<&'a str>,
-    policy_arns: Option<&'a [String]>,
-    serial_number: Option<&'a str>,
-    token_code: Option<&'a str>,
-}
-
-fn build_assume_role_query(input: AssumeRoleQueryInput<'_>) -> String {
-    let mut serializer = Serializer::new(String::new());
-    serializer
-        .append_pair("Action", "AssumeRole")
-        .append_pair("RoleArn", input.role_arn)
-        .append_pair("Version", "2011-06-15")
-        .append_pair("RoleSessionName", input.role_session_name);
-
-    if let Some(external_id) = input.external_id {
-        serializer.append_pair("ExternalId", external_id);
-    }
-    if let Some(duration_seconds) = input.duration_seconds {
-        serializer.append_pair("DurationSeconds", &duration_seconds.to_string());
-    }
-    if let Some(policy) = input.policy {
-        serializer.append_pair("Policy", policy);
-    }
-    if let Some(policy_arns) = input.policy_arns {
-        for (idx, arn) in policy_arns.iter().enumerate() {
-            let key = format!("PolicyArns.member.{}.arn", idx + 1);
-            serializer.append_pair(&key, arn);
-        }
-    }
-    if let Some(tags) = input.tags {
-        for (idx, (key, value)) in tags.iter().enumerate() {
-            let tag_index = idx + 1;
-            serializer
-                .append_pair(&format!("Tags.member.{tag_index}.Key"), key)
-                .append_pair(&format!("Tags.member.{tag_index}.Value"), value);
+        match self.region.as_deref() {
+            Some(region) => self.grant.validate_for_region(region)?,
+            None => self.grant.validate_for_partition("aws")?,
         }
-    }
-    if let Some(serial_number) = input.serial_number {
-        serializer.append_pair("SerialNumber", serial_number);
-    }
-    if let Some(token_code) = input.token_code {
-        serializer.append_pair("TokenCode", token_code);
-    }
-
-    serializer.finish()
-}
-
-#[derive(Default, Debug, Deserialize)]
-#[serde(default, rename_all = "PascalCase")]
-struct AssumeRoleResponse {
-    #[serde(rename = "AssumeRoleResult")]
-    result: AssumeRoleResult,
-}
-
-#[derive(Default, Debug, Deserialize)]
-#[serde(default, rename_all = "PascalCase")]
-struct AssumeRoleResult {
-    credentials: AssumeRoleCredentials,
-}
-
-#[derive(Default, Debug, Deserialize)]
-#[serde(default, rename_all = "PascalCase")]
-struct AssumeRoleCredentials {
-    access_key_id: String,
-    secret_access_key: String,
-    session_token: String,
-    expiration: String,
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use quick_xml::de;
-
-    #[test]
-    fn test_parse_assume_role_response() -> Result<()> {
-        let _ = env_logger::builder().is_test(true).try_init();
-
-        let content = r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
-  <AssumeRoleResult>
-  <SourceIdentity>Alice</SourceIdentity>
-    <AssumedRoleUser>
-      <Arn>arn:aws:sts::123456789012:assumed-role/demo/TestAR</Arn>
-      <AssumedRoleId>ARO123EXAMPLE123:TestAR</AssumedRoleId>
-    </AssumedRoleUser>
-    <Credentials>
-      <AccessKeyId>ASIAIOSFODNN7EXAMPLE</AccessKeyId>
-      <SecretAccessKey>wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY</SecretAccessKey>
-      <SessionToken>
-       AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQW
-       LWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGd
-       QrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXDvp75YU
-       9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64lIZbqBAz
-       +scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2ICCR/oLxBA==
-      </SessionToken>
-      <Expiration>2019-11-09T13:34:41Z</Expiration>
-    </Credentials>
-    <PackedPolicySize>6</PackedPolicySize>
-  </AssumeRoleResult>
-  <ResponseMetadata>
-    <RequestId>c6104cbe-af31-11e0-8154-cbc7ccf896c7</RequestId>
-  </ResponseMetadata>
-</AssumeRoleResponse>"#;
-
-        let resp: AssumeRoleResponse = de::from_str(content).expect("xml deserialize must success");
-
-        assert_eq!(
-            &resp.result.credentials.access_key_id,
-            "ASIAIOSFODNN7EXAMPLE"
-        );
-        assert_eq!(
-            &resp.result.credentials.secret_access_key,
-            "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY"
-        );
-        assert_eq!(
-            resp.result.credentials.session_token.trim(),
-            "AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQW
-       LWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGd
-       QrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXDvp75YU
-       9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64lIZbqBAz
-       +scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2ICCR/oLxBA=="
-        );
-        assert_eq!(&resp.result.credentials.expiration, "2019-11-09T13:34:41Z");
-
-        Ok(())
-    }
-
-    #[test]
-    fn test_assume_role_encodes_policy_and_policy_arns() {
-        let policy = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*","Condition":{"StringEquals":{"s3:prefix":"a b"}}}]}"#;
-        let policy_arns = vec![
-            "arn:aws:iam::aws:policy/ReadOnlyAccess".to_string(),
-            "arn:aws:iam::123456789012:policy/ExamplePolicy".to_string(),
-        ];
-        let query = build_assume_role_query(AssumeRoleQueryInput {
-            role_arn: "arn:aws:iam::123456789012:role/test-role",
-            role_session_name: "reqsign",
-            external_id: None,
-            duration_seconds: Some(3600),
-            tags: None,
-            policy: Some(policy),
-            policy_arns: Some(policy_arns.as_slice()),
-            serial_number: None,
-            token_code: None,
-        });
-
-        assert!(
-            query.contains("Policy=%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Action%22%3A%22s3%3AListBucket%22%2C%22Resource%22%3A%22*%22%2C%22Condition%22%3A%7B%22StringEquals%22%3A%7B%22s3%3Aprefix%22%3A%22a+b%22%7D%7D%7D%5D%7D")
-        );
-        assert!(query.contains(
-            "PolicyArns.member.1.arn=arn%3Aaws%3Aiam%3A%3Aaws%3Apolicy%2FReadOnlyAccess"
-        ));
-        assert!(query.contains(
-            "PolicyArns.member.2.arn=arn%3Aaws%3Aiam%3A%3A123456789012%3Apolicy%2FExamplePolicy"
-        ));
+        let endpoint = sts_endpoint(self.region.as_deref(), self.use_regional_sts_endpoint)?;
+        let operation = AssumeRoleOperation::new(endpoint, &self.grant, self.duration_seconds)?;
+        operation.execute(ctx, &self.sts_signer).await.map(Some)
     }
 }
diff --git a/services/aws-core/src/provide_credential/assume_role_with_web_identity.rs b/services/aws-core/src/provide_credential/assume_role_with_web_identity.rs
index 6109bda..ca8011d 100644
--- a/services/aws-core/src/provide_credential/assume_role_with_web_identity.rs
+++ b/services/aws-core/src/provide_credential/assume_role_with_web_identity.rs
@@ -220,25 +220,16 @@ impl ProvideCredential for AssumeRoleWithWebIdentityCredentialProvider {
                 .set_retryable(true)
         })?;
 
-        // Extract request ID and status before consuming response
         let status = resp.status();
-        let request_id = resp
-            .headers()
-            .get("x-amzn-requestid")
-            .and_then(|v| v.to_str().ok())
-            .map(|s| s.to_string());
 
         if status != http::StatusCode::OK {
             let content = resp.into_body();
-            return Err(parse_sts_error(
-                "AssumeRoleWithWebIdentity",
-                status,
-                &content,
-                request_id.as_deref(),
-            )
-            .with_context(format!("role_arn: {role_arn}"))
-            .with_context(format!("session_name: {session_name}"))
-            .with_context(format!("token_file: {token_file}")));
+            return Err(
+                parse_sts_error("AssumeRoleWithWebIdentity", status, &content)
+                    .with_context(format!("role_arn: {role_arn}"))
+                    .with_context(format!("session_name: {session_name}"))
+                    .with_context(format!("token_file: {token_file}")),
+            );
         }
 
         let body = resp.into_body();
diff --git a/services/aws-core/src/provide_credential/mod.rs b/services/aws-core/src/provide_credential/mod.rs
index 1b28997..035153e 100644
--- a/services/aws-core/src/provide_credential/mod.rs
+++ b/services/aws-core/src/provide_credential/mod.rs
@@ -52,4 +52,4 @@ pub use sso::SSOCredentialProvider;
 mod r#static;
 pub use r#static::StaticCredentialProvider;
 
-mod utils;
+pub(crate) mod utils;
diff --git a/services/aws-core/src/provide_credential/utils.rs b/services/aws-core/src/provide_credential/utils.rs
index d183af5..336852f 100644
--- a/services/aws-core/src/provide_credential/utils.rs
+++ b/services/aws-core/src/provide_credential/utils.rs
@@ -18,34 +18,91 @@
 use reqsign_core::{Error, Result};
 use serde::Deserialize;
 
-/// Get the sts endpoint.
-///
-/// The returning format may look like `sts.{region}.amazonaws.com`
-///
-/// # Notes
-///
-/// AWS could have different sts endpoint based on it's region.
-/// We can check them by region name.
-///
-/// ref: https://github.com/awslabs/aws-sdk-rust/blob/31cfae2cf23be0c68a47357070dea1aee9227e3a/sdk/sts/src/aws_endpoint.rs
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) struct AwsPartition {
+    pub(crate) id: &'static str,
+    pub(crate) dns_suffix: &'static str,
+}
+
+pub(crate) fn partition_for_region(region: &str) -> Result<AwsPartition> {
+    if !(3..=64).contains(&region.len())
+        || !region
+            .bytes()
+            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
+        || !region
+            .as_bytes()
+            .first()
+            .is_some_and(u8::is_ascii_alphanumeric)
+        || !region
+            .as_bytes()
+            .last()
+            .is_some_and(u8::is_ascii_alphanumeric)
+    {
+        return Err(Error::config_invalid("AWS STS signing region is invalid"));
+    }
+
+    let partition = if region.starts_with("cn-") {
+        AwsPartition {
+            id: "aws-cn",
+            dns_suffix: "amazonaws.com.cn",
+        }
+    } else if region.starts_with("eusc-") {
+        AwsPartition {
+            id: "aws-eusc",
+            dns_suffix: "amazonaws.eu",
+        }
+    } else if region.starts_with("us-isob-") {
+        AwsPartition {
+            id: "aws-iso-b",
+            dns_suffix: "sc2s.sgov.gov",
+        }
+    } else if region.starts_with("us-iso-") {
+        AwsPartition {
+            id: "aws-iso",
+            dns_suffix: "c2s.ic.gov",
+        }
+    } else if region.starts_with("eu-isoe-") {
+        AwsPartition {
+            id: "aws-iso-e",
+            dns_suffix: "cloud.adc-e.uk",
+        }
+    } else if region.starts_with("us-isof-") {
+        AwsPartition {
+            id: "aws-iso-f",
+            dns_suffix: "csp.hci.ic.gov",
+        }
+    } else if region.starts_with("us-gov-") {
+        AwsPartition {
+            id: "aws-us-gov",
+            dns_suffix: "amazonaws.com",
+        }
+    } else {
+        AwsPartition {
+            id: "aws",
+            dns_suffix: "amazonaws.com",
+        }
+    };
+    Ok(partition)
+}
+
+/// Return the legacy global or partition-aware regional STS endpoint.
 pub fn sts_endpoint(region: Option<&str>, use_regional: bool) -> Result<String> {
-    // use regional sts if use_regional has been set.
     if use_regional {
         let region =
             region.ok_or_else(|| Error::config_invalid("regional STS endpoint requires region"))?;
-        if region.starts_with("cn-") {
-            Ok(format!("sts.{region}.amazonaws.com.cn"))
-        } else {
-            Ok(format!("sts.{region}.amazonaws.com"))
-        }
-    } else {
-        let region = region.unwrap_or_default();
-        if region.starts_with("cn") {
-            // TODO: seems aws china doesn't support global sts?
-            Ok("sts.amazonaws.com.cn".to_string())
-        } else {
-            Ok("sts.amazonaws.com".to_string())
-        }
+        let partition = partition_for_region(region)?;
+        return Ok(format!("sts.{region}.{}", partition.dns_suffix));
+    }
+
+    match region {
+        None => Ok("sts.amazonaws.com".to_string()),
+        Some(region) => match partition_for_region(region)?.id {
+            "aws" => Ok("sts.amazonaws.com".to_string()),
+            "aws-cn" => Ok("sts.amazonaws.com.cn".to_string()),
+            _ => Err(Error::config_invalid(
+                "legacy global STS endpoint is unavailable for this AWS partition",
+            )),
+        },
     }
 }
 
@@ -60,68 +117,79 @@ pub struct AwsErrorResponse {
 pub struct AwsError {
     #[serde(rename = "Code")]
     pub code: String,
-    #[serde(rename = "Message")]
-    pub message: String,
 }
 
 /// Parse AWS STS error response and return appropriate error
 ///
 /// This function analyzes AWS error codes and maps them to the correct ErrorKind
 /// with meaningful context for debugging.
-pub fn parse_sts_error(
-    operation: &str,
-    status: http::StatusCode,
-    body: &str,
-    request_id: Option<&str>,
-) -> Error {
+pub fn parse_sts_error(operation: &str, status: http::StatusCode, body: &str) -> Error {
     // Try to parse the XML error response
     if let Ok(error_resp) = quick_xml::de::from_str::<AwsErrorResponse>(body) {
         let code = &error_resp.error.code;
-        let message = &error_resp.error.message;
-
+        let recognized_code = matches!(
+            code.as_str(),
+            "AccessDenied"
+                | "UnauthorizedAccess"
+                | "Forbidden"
+                | "ExpiredToken"
+                | "TokenRefreshRequired"
+                | "InvalidToken"
+                | "InvalidParameterValue"
+                | "MissingParameter"
+                | "InvalidParameterCombination"
+                | "RegionDisabled"
+                | "Throttling"
+                | "RequestLimitExceeded"
+                | "TooManyRequestsException"
+                | "ServiceUnavailable"
+                | "InternalError"
+                | "InternalFailure"
+                | "InvalidRequest"
+                | "MalformedQueryString"
+                | "MalformedPolicyDocument"
+                | "PackedPolicyTooLarge"
+        );
         // Map AWS error codes to appropriate ErrorKind
         let mut error = match code.as_str() {
             // Permission/Authorization errors
             "AccessDenied" | "UnauthorizedAccess" | "Forbidden" => {
-                Error::permission_denied(format!("{code}: {message}"))
+                Error::permission_denied("AWS STS request was denied")
             }
 
             // Credential errors
             "ExpiredToken" | "TokenRefreshRequired" | "InvalidToken" => {
-                Error::credential_invalid(format!("token expired or invalid: {message}"))
+                Error::credential_invalid("AWS STS rejected the source credential")
             }
 
             // Configuration errors
             "InvalidParameterValue" | "MissingParameter" | "InvalidParameterCombination" => {
-                Error::config_invalid(format!("invalid configuration: {message}"))
+                Error::config_invalid("AWS STS configuration is invalid")
             }
 
             // Rate limiting
             "Throttling" | "RequestLimitExceeded" | "TooManyRequestsException" => {
-                Error::rate_limited(format!("AWS API rate limit: {message}"))
+                Error::rate_limited("AWS STS request was rate limited")
             }
 
             // Service unavailable (retryable)
             "ServiceUnavailable" | "InternalError" | "InternalFailure" => {
-                Error::unexpected(format!("AWS service error: {message}")).set_retryable(true)
+                Error::unexpected("AWS STS service failed").set_retryable(true)
             }
 
             // Request errors
             "InvalidRequest" | "MalformedQueryString" => {
-                Error::request_invalid(format!("invalid request: {message}"))
+                Error::request_invalid("AWS STS rejected the request")
             }
 
             // Default to unexpected
-            _ => Error::unexpected(format!("AWS error [{code}]: {message}")),
+            _ => Error::unexpected("AWS STS request failed"),
         };
 
         // Add context
-        error = error
-            .with_context(format!("operation: {operation}"))
-            .with_context(format!("error_code: {code}"));
-
-        if let Some(id) = request_id {
-            error = error.with_context(format!("request_id: {id}"));
+        error = error.with_context(format!("operation: {operation}"));
+        if recognized_code {
+            error = error.with_context(format!("error_code: {code}"));
         }
 
         error
@@ -129,29 +197,21 @@ pub fn parse_sts_error(
         // Failed to parse error response, return generic error based on status code
         let mut error = match status.as_u16() {
             400..=499 if status == http::StatusCode::FORBIDDEN => {
-                Error::permission_denied(format!("STS request forbidden: {body}"))
+                Error::permission_denied("AWS STS request was denied")
             }
             400..=499 if status == http::StatusCode::UNAUTHORIZED => {
-                Error::credential_invalid(format!("STS authentication failed: {body}"))
+                Error::credential_invalid("AWS STS rejected the source credential")
             }
-            429 => Error::rate_limited(format!("STS rate limit exceeded: {body}")),
-            400..=499 => {
-                Error::request_invalid(format!("STS request failed with {status}: {body}"))
-            }
-            500..=599 => {
-                Error::unexpected(format!("STS server error {status}: {body}")).set_retryable(true)
-            }
-            _ => Error::unexpected(format!("STS request failed with {status}: {body}")),
+            429 => Error::rate_limited("AWS STS request was rate limited"),
+            400..=499 => Error::request_invalid("AWS STS rejected the request"),
+            500..=599 => Error::unexpected("AWS STS service failed").set_retryable(true),
+            _ => Error::unexpected("AWS STS request failed"),
         };
 
         error = error
             .with_context(format!("operation: {operation}"))
             .with_context(format!("http_status: {status}"));
 
-        if let Some(id) = request_id {
-            error = error.with_context(format!("request_id: {id}"));
-        }
-
         error
     }
 }
@@ -205,3 +265,79 @@ pub fn parse_imds_error(operation: &str, status: http::StatusCode, body: &str) -
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use reqsign_core::ErrorKind;
+
+    use super::*;
+
+    #[test]
+    fn resolves_partition_aware_sts_endpoints() {
+        let cases = [
+            ("us-east-1", "sts.us-east-1.amazonaws.com"),
+            ("cn-north-1", "sts.cn-north-1.amazonaws.com.cn"),
+            ("eusc-de-east-1", "sts.eusc-de-east-1.amazonaws.eu"),
+            ("us-iso-east-1", "sts.us-iso-east-1.c2s.ic.gov"),
+            ("us-isob-east-1", "sts.us-isob-east-1.sc2s.sgov.gov"),
+            ("eu-isoe-west-1", "sts.eu-isoe-west-1.cloud.adc-e.uk"),
+            ("us-isof-east-1", "sts.us-isof-east-1.csp.hci.ic.gov"),
+            ("us-gov-west-1", "sts.us-gov-west-1.amazonaws.com"),
+        ];
+
+        for (region, expected) in cases {
+            assert_eq!(
+                sts_endpoint(Some(region), true).expect("regional endpoint must resolve"),
+                expected
+            );
+        }
+        assert_eq!(
+            sts_endpoint(None, false).expect("commercial global endpoint must resolve"),
+            "sts.amazonaws.com"
+        );
+        assert_eq!(
+            sts_endpoint(Some("cn-north-1"), false).expect("China global endpoint must resolve"),
+            "sts.amazonaws.com.cn"
+        );
+        assert_eq!(
+            sts_endpoint(Some("us-iso-east-1"), false)
+                .expect_err("isolated partitions must reject the legacy global endpoint")
+                .kind(),
+            ErrorKind::ConfigInvalid
+        );
+    }
+
+    #[test]
+    fn redacts_sts_error_material_and_preserves_legacy_kinds() {
+        let cases = [
+            ("RegionDisabled", ErrorKind::Unexpected),
+            ("MalformedPolicyDocument", ErrorKind::Unexpected),
+            ("PackedPolicyTooLarge", ErrorKind::Unexpected),
+            ("InvalidRequest", ErrorKind::RequestInvalid),
+        ];
+
+        for (code, expected_kind) in cases {
+            let body = format!(
+                "<ErrorResponse><Error><Code>{code}</Code>\
+                 <Message>response-secret</Message></Error></ErrorResponse>"
+            );
+            let error = parse_sts_error(
+                "AssumeRoleWithWebIdentity",
+                http::StatusCode::BAD_REQUEST,
+                &body,
+            );
+            assert_eq!(error.kind(), expected_kind);
+            let debug = format!("{error:?}");
+            assert!(!debug.contains("response-secret"));
+        }
+
+        let error = parse_sts_error(
+            "AssumeRole",
+            http::StatusCode::FORBIDDEN,
+            "raw-response-secret",
+        );
+        let debug = format!("{error:?}");
+        assert_eq!(error.kind(), ErrorKind::PermissionDenied);
+        assert!(!debug.contains("raw-response-secret"));
+    }
+}
diff --git a/services/aws-v4/src/assume_role.rs b/services/aws-v4/src/assume_role.rs
new file mode 100644
index 0000000..e9d193a
--- /dev/null
+++ b/services/aws-v4/src/assume_role.rs
@@ -0,0 +1,1097 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::{Debug, Formatter};
+use std::time::Duration;
+
+use reqsign_aws_core::assume_role::{AssumeRoleOperation, regional_sts_endpoint};
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, GrantCredential, Result, SignRequest, SigningCredential};
+
+use crate::{AssumeRoleGrant, Credential, RequestSigner};
+
+const STS_REQUEST_HEADROOM: Duration = Duration::from_secs(20);
+const MIN_ASSUME_ROLE_DURATION: Duration = Duration::from_secs(900);
+const MAX_ASSUME_ROLE_DURATION: Duration = Duration::from_secs(43_200);
+
+/// Explicitly grants an AWS STS assumed-role credential.
+///
+/// The source credential provided to [`reqsign_core::Granter`] signs the STS
+/// request directly. Both long-lived credentials and valid temporary
+/// credentials are supported; a temporary source's session token is included
+/// in the SigV4 request.
+///
+/// `AssumeRole` is an authority transition. The returned session receives the
+/// intersection of the target role's permissions and the optional session
+/// policies in [`AssumeRoleGrant`], not necessarily a subset of the source
+/// principal's direct permissions.
+///
+/// AWS limits role chaining to one hour, but an opaque [`Credential`] does not
+/// reliably identify whether a temporary source is an assumed-role session.
+/// Callers performing role chaining must request at most one hour; STS remains
+/// authoritative for this and for the target role's configured maximum.
+///
+/// This granter uses the standard regional STS endpoint for the bound signing
+/// region. Every call performs a new STS exchange; granted credentials are
+/// never cached.
+///
+/// # Example
+///
+/// ```no_run
+/// use std::time::Duration;
+///
+/// use reqsign_aws_v4::{
+///     AssumeRoleGrant, AssumeRoleGranter, StaticCredentialProvider,
+/// };
+/// use reqsign_core::{Context, Granter};
+///
+/// # async fn example() -> reqsign_core::Result<()> {
+/// let source = StaticCredentialProvider::new("source-access-key", "source-secret-key");
+/// let grant = AssumeRoleGrant::new(
+///     "arn:aws:iam::123456789012:role/customer-reader",
+///     "customer-a",
+/// );
+/// // Supply a Context configured with an HttpSend implementation.
+/// let context = Context::new();
+/// let credential = Granter::new(
+///     context,
+///     source,
+///     AssumeRoleGranter::new("us-east-1", grant),
+/// )
+/// .grant(Some(Duration::from_secs(3600)))
+/// .await?;
+/// # let _ = credential;
+/// # Ok(())
+/// # }
+/// ```
+#[derive(Clone)]
+pub struct AssumeRoleGranter {
+    region: String,
+    grant: AssumeRoleGrant,
+    #[cfg(test)]
+    time: Option<Timestamp>,
+}
+
+impl Debug for AssumeRoleGranter {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("AssumeRoleGranter").finish_non_exhaustive()
+    }
+}
+
+impl AssumeRoleGranter {
+    /// Create a granter for a signing region and bound AssumeRole grant.
+    pub fn new(region: impl Into<String>, grant: AssumeRoleGrant) -> Self {
+        Self {
+            region: region.into(),
+            grant,
+            #[cfg(test)]
+            time: None,
+        }
+    }
+
+    /// Replace the bound authority transition.
+    pub fn with_grant(mut self, grant: AssumeRoleGrant) -> Self {
+        self.grant = grant;
+        self
+    }
+
+    fn now(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(time) = self.time {
+            return time;
+        }
+        Timestamp::now()
+    }
+
+    fn request_signer(&self) -> RequestSigner {
+        let signer = RequestSigner::new("sts", &self.region);
+        #[cfg(test)]
+        if let Some(time) = self.time {
+            return signer.with_time(time);
+        }
+        signer
+    }
+
+    fn duration_seconds(expires_in: Option<Duration>) -> Result<Option<u32>> {
+        let Some(expires_in) = expires_in else {
+            return Ok(None);
+        };
+        if !(MIN_ASSUME_ROLE_DURATION..=MAX_ASSUME_ROLE_DURATION).contains(&expires_in) {
+            return Err(Error::request_invalid(
+                "AWS STS AssumeRole duration must be between 900 and 43200 seconds",
+            ));
+        }
+        let seconds = u32::try_from(expires_in.as_secs()).map_err(|_| {
+            Error::request_invalid(
+                "AWS STS AssumeRole duration must be between 900 and 43200 seconds",
+            )
+        })?;
+        Ok(Some(seconds))
+    }
+
+    fn validate_source(&self, credential: &Credential, required_until: Timestamp) -> Result<()> {
+        if !(16..=128).contains(&credential.access_key_id.chars().count())
+            || !credential
+                .access_key_id
+                .bytes()
+                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
+            || credential.secret_access_key.is_empty()
+        {
+            return Err(Error::credential_invalid(
+                "AWS STS AssumeRole requires a source access key and secret access key",
+            ));
+        }
+        if credential.session_token.as_ref().is_some_and(|token| {
+            token.trim().is_empty() || http::HeaderValue::try_from(token.as_str()).is_err()
+        }) {
+            return Err(Error::credential_invalid(
+                "AWS STS AssumeRole source session token is invalid",
+            ));
+        }
+        if !credential.is_valid_at(required_until) {
+            return Err(Error::credential_invalid(
+                "AWS source credential expires before the STS AssumeRole request can complete",
+            ));
+        }
+        Ok(())
+    }
+
+    #[cfg(test)]
+    fn with_time(mut self, time: Timestamp) -> Self {
+        self.time = Some(time);
+        self
+    }
+}
+
+impl GrantCredential for AssumeRoleGranter {
+    type Credential = Credential;
+
+    fn required_valid_until(
+        &self,
+        _credential: &Self::Credential,
+        _expires_in: Option<Duration>,
+    ) -> Timestamp {
+        self.now() + STS_REQUEST_HEADROOM
+    }
+
+    async fn grant_credential(
+        &self,
+        ctx: &Context,
+        credential: &Self::Credential,
+        expires_in: Option<Duration>,
+    ) -> Result<Self::Credential> {
+        let duration_seconds = Self::duration_seconds(expires_in)?;
+        let endpoint = regional_sts_endpoint(&self.region, &self.grant)?;
+        let operation = AssumeRoleOperation::new(endpoint, &self.grant, duration_seconds)?;
+        let required_until = self.required_valid_until(credential, expires_in);
+        self.validate_source(credential, required_until)?;
+
+        let request = operation.build_request()?;
+        let (mut parts, body) = request.into_parts();
+        self.request_signer()
+            .sign_request(ctx, &mut parts, Some(credential), None)
+            .await
+            .map_err(|err| {
+                Error::new(err.kind(), "failed to sign AWS STS AssumeRole request")
+                    .set_retryable(err.is_retryable())
+            })?;
+        operation
+            .send(ctx, http::Request::from_parts(parts, body))
+            .await
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::VecDeque;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+    use std::sync::{Arc, Mutex};
+
+    use aws_credential_types::Credentials as AwsCredentials;
+    use aws_sigv4::http_request::{
+        PayloadChecksumKind, PercentEncodingMode, SignableBody, SignableRequest, SigningSettings,
+    };
+    use aws_sigv4::sign::v4;
+    use bytes::Bytes;
+    use http::HeaderMap;
+    use http::header::{AUTHORIZATION, CONTENT_TYPE, HeaderName};
+    use reqsign_core::hash::hex_sha256;
+    use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer};
+
+    use super::*;
+    use crate::AssumeRoleCredentialProvider;
+
+    const SOURCE_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
+    const SOURCE_SECRET_KEY: &str = "source-secret-key";
+    const SOURCE_SESSION_TOKEN: &str = "source-session-token";
+    const RETURNED_SECRET_KEY: &str = "returned-secret-key";
+    const RETURNED_SESSION_TOKEN: &str = "returned-session-token";
+    const ROLE_ARN: &str = "arn:aws:iam::123456789012:role/customer-reader";
+    const REGION: &str = "us-east-1";
+    const EXPIRATION: &str = "2099-11-09T13:34:41Z";
+
+    fn signing_time() -> Timestamp {
+        "2026-07-30T08:00:00Z"
+            .parse()
+            .expect("signing time must parse")
+    }
+
+    fn source_credential(session_token: Option<&str>) -> Credential {
+        Credential {
+            access_key_id: SOURCE_ACCESS_KEY.to_string(),
+            secret_access_key: SOURCE_SECRET_KEY.to_string(),
+            session_token: session_token.map(str::to_owned),
+            expires_in: Some(EXPIRATION.parse().expect("expiration must parse")),
+        }
+    }
+
+    fn valid_grant() -> AssumeRoleGrant {
+        AssumeRoleGrant::new(ROLE_ARN, "customer-a")
+    }
+
+    fn sign_with_aws_sdk(
+        mut request: http::Request<Bytes>,
+        session_token: Option<&str>,
+    ) -> http::Request<Bytes> {
+        let mut settings = SigningSettings::default();
+        settings.percent_encoding_mode = PercentEncodingMode::Double;
+        settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
+        let identity = AwsCredentials::new(
+            SOURCE_ACCESS_KEY,
+            SOURCE_SECRET_KEY,
+            session_token.map(str::to_owned),
+            None,
+            "reqsign-assume-role-test",
+        )
+        .into();
+        let signing = v4::SigningParams::builder()
+            .identity(&identity)
+            .region(REGION)
+            .name("sts")
+            .time(signing_time().as_system_time())
+            .settings(settings)
+            .build()
+            .expect("AWS SDK signing parameters must build");
+        let output = aws_sigv4::http_request::sign(
+            SignableRequest::new(
+                request.method().as_str(),
+                request.uri().to_string(),
+                request.headers().iter().map(|(name, value)| {
+                    (
+                        name.as_str(),
+                        std::str::from_utf8(value.as_bytes())
+                            .expect("request headers must contain text"),
+                    )
+                }),
+                SignableBody::Bytes(request.body()),
+            )
+            .expect("request must be signable"),
+            &signing.into(),
+        )
+        .expect("AWS SDK must sign the request");
+        let (instructions, _) = output.into_parts();
+        instructions.apply_to_request_http1x(&mut request);
+        request
+    }
+
+    fn success_response(access_key_id: &str) -> http::Response<Bytes> {
+        http::Response::builder()
+            .status(http::StatusCode::OK)
+            .body(Bytes::from(format!(
+                "<AssumeRoleResponse>\
+                    <AssumeRoleResult>\
+                        <Credentials>\
+                            <AccessKeyId>{access_key_id}</AccessKeyId>\
+                            <SecretAccessKey>{RETURNED_SECRET_KEY}</SecretAccessKey>\
+                            <SessionToken>{RETURNED_SESSION_TOKEN}</SessionToken>\
+                            <Expiration>{EXPIRATION}</Expiration>\
+                        </Credentials>\
+                    </AssumeRoleResult>\
+                </AssumeRoleResponse>"
+            )))
+            .expect("response must build")
+    }
+
+    #[derive(Clone)]
+    struct CapturedRequest {
+        method: http::Method,
+        uri: http::Uri,
+        headers: HeaderMap,
+        body: Bytes,
+    }
+
+    impl Debug for CapturedRequest {
+        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+            f.debug_struct("CapturedRequest")
+                .field("method", &self.method)
+                .field("uri", &"REDACTED")
+                .field("headers", &"REDACTED")
+                .field("body", &"REDACTED")
+                .finish()
+        }
+    }
+
+    #[derive(Clone)]
+    struct MockHttpSend {
+        calls: Arc<AtomicUsize>,
+        requests: Arc<Mutex<Vec<CapturedRequest>>>,
+        responses: Arc<Mutex<VecDeque<http::Response<Bytes>>>>,
+    }
+
+    impl Debug for MockHttpSend {
+        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+            f.debug_struct("MockHttpSend").finish_non_exhaustive()
+        }
+    }
+
+    impl MockHttpSend {
+        fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
+            Self {
+                calls: Arc::new(AtomicUsize::new(0)),
+                requests: Arc::new(Mutex::new(Vec::new())),
+                responses: Arc::new(Mutex::new(responses.into_iter().collect())),
+            }
+        }
+
+        fn calls(&self) -> usize {
+            self.calls.load(Ordering::SeqCst)
+        }
+
+        fn requests(&self) -> Vec<CapturedRequest> {
+            self.requests.lock().expect("lock poisoned").clone()
+        }
+    }
+
+    impl HttpSend for MockHttpSend {
+        async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+            self.calls.fetch_add(1, Ordering::SeqCst);
+            let (parts, body) = request.into_parts();
+            self.requests
+                .lock()
+                .expect("lock poisoned")
+                .push(CapturedRequest {
+                    method: parts.method,
+                    uri: parts.uri,
+                    headers: parts.headers,
+                    body,
+                });
+            self.responses
+                .lock()
+                .expect("lock poisoned")
+                .pop_front()
+                .ok_or_else(|| Error::unexpected("mock response queue is empty"))
+        }
+    }
+
+    #[derive(Clone)]
+    struct FixedCredentialProvider {
+        credential: Credential,
+        calls: Arc<AtomicUsize>,
+    }
+
+    impl Debug for FixedCredentialProvider {
+        fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+            f.debug_struct("FixedCredentialProvider")
+                .finish_non_exhaustive()
+        }
+    }
+
+    impl FixedCredentialProvider {
+        fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
+            let calls = Arc::new(AtomicUsize::new(0));
+            (
+                Self {
+                    credential,
+                    calls: calls.clone(),
+                },
+                calls,
+            )
+        }
+    }
+
+    impl ProvideCredential for FixedCredentialProvider {
+        type Credential = Credential;
+
+        async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
+            self.calls.fetch_add(1, Ordering::SeqCst);
+            Ok(Some(self.credential.clone()))
+        }
+    }
+
+    #[derive(Debug)]
+    struct SecretTransportError;
+
+    impl HttpSend for SecretTransportError {
+        async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+            Err(
+                Error::unexpected("transport captured source-secret-key and policy-secret")
+                    .set_retryable(true),
+            )
+        }
+    }
+
+    #[derive(Debug)]
+    struct NonRetryableTransportError;
+
+    impl HttpSend for NonRetryableTransportError {
+        async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+            Err(Error::unexpected("temporary transport failure"))
+        }
+    }
+
+    #[tokio::test]
+    async fn signs_exact_wire_with_explicit_source_and_never_caches_output() {
+        let http = MockHttpSend::new([
+            success_response("ASIAFIRSTOUTPUT001"),
+            success_response("ASIASECONDOUTPUT01"),
+        ]);
+        let context = Context::new().with_http_send(http.clone());
+        let (provider, provider_calls) =
+            FixedCredentialProvider::new(source_credential(Some(SOURCE_SESSION_TOKEN)));
+        let operation =
+            AssumeRoleGranter::new(REGION, valid_grant().with_external_id("tenant/123"))
+                .with_time(signing_time());
+        let granter = Granter::new(context, provider, operation);
+
+        let first = granter
+            .grant(Some(Duration::from_secs(3_600)))
+            .await
+            .expect("first grant must succeed");
+        let second = granter
+            .grant(Some(Duration::from_secs(3_600)))
+            .await
+            .expect("second grant must execute again");
+
+        assert_eq!(first.access_key_id, "ASIAFIRSTOUTPUT001");
+        assert_eq!(second.access_key_id, "ASIASECONDOUTPUT01");
+        assert_ne!(first.access_key_id, SOURCE_ACCESS_KEY);
+        assert_ne!(first.secret_access_key, SOURCE_SECRET_KEY);
+        assert_ne!(first.session_token.as_deref(), Some(SOURCE_SESSION_TOKEN));
+        assert_eq!(
+            first.expires_in,
+            Some(EXPIRATION.parse().expect("expiration must parse"))
+        );
+        assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
+        assert_eq!(http.calls(), 2);
+
+        let requests = http.requests();
+        assert_eq!(requests.len(), 2);
+        assert_eq!(requests[0].method, http::Method::GET);
+        assert!(requests[0].body.is_empty());
+        assert_eq!(
+            requests[0].uri.to_string(),
+            "https://sts.us-east-1.amazonaws.com/?Action=AssumeRole&RoleArn=arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Fcustomer-reader&Version=2011-06-15&RoleSessionName=customer-a&ExternalId=tenant%2F123&DurationSeconds=3600"
+        );
+        assert_eq!(
+            requests[0]
+                .headers
+                .get(CONTENT_TYPE)
+                .expect("content type must be set"),
+            "application/x-www-form-urlencoded"
+        );
+        assert_eq!(
+            requests[0]
+                .headers
+                .get("x-amz-date")
+                .expect("signing date must be set"),
+            "20260730T080000Z"
+        );
+        assert_eq!(
+            requests[0]
+                .headers
+                .get("x-amz-security-token")
+                .expect("source session token must be signed"),
+            SOURCE_SESSION_TOKEN
+        );
+        assert_eq!(
+            requests[0]
+                .headers
+                .get(HeaderName::from_static("x-amz-content-sha256"))
+                .expect("payload hash must be set"),
+            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+        );
+        assert_eq!(
+            crate::EMPTY_STRING_SHA256,
+            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+        );
+        assert_eq!(
+            requests[0]
+                .headers
+                .get(AUTHORIZATION)
+                .expect("authorization must be set")
+                .to_str()
+                .expect("authorization must be text"),
+            "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260730/us-east-1/sts/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=9561cc1cbe7212bd5763162afbaf215cd24c153eb27ac2718ebee4b12ff82a5f"
+        );
+        let sdk_request = sign_with_aws_sdk(
+            AssumeRoleOperation::new(
+                "sts.us-east-1.amazonaws.com",
+                &valid_grant().with_external_id("tenant/123"),
+                Some(3_600),
+            )
+            .expect("operation must build")
+            .build_request()
+            .expect("request must build"),
+            Some(SOURCE_SESSION_TOKEN),
+        );
+        assert_eq!(
+            requests[0]
+                .headers
+                .get(AUTHORIZATION)
+                .expect("reqsign authorization must be set"),
+            sdk_request
+                .headers()
+                .get(AUTHORIZATION)
+                .expect("AWS SDK authorization must be set")
+        );
+        assert_eq!(requests[0].uri, requests[1].uri);
+        assert_eq!(requests[0].headers, requests[1].headers);
+    }
+
+    #[tokio::test]
+    async fn signs_large_grant_as_post_with_an_independent_oracle() {
+        let grant = valid_grant().with_tags(
+            (0..8)
+                .map(|index| (format!("Tag{index}"), "x".repeat(256)))
+                .collect(),
+        );
+        let sdk_request = sign_with_aws_sdk(
+            AssumeRoleOperation::new("sts.us-east-1.amazonaws.com", &grant, Some(3_600))
+                .expect("operation must build")
+                .build_request()
+                .expect("request must build"),
+            Some(SOURCE_SESSION_TOKEN),
+        );
+        let http = MockHttpSend::new([success_response("ASIAPOSTOUTPUT0001")]);
+        let context = Context::new().with_http_send(http.clone());
+        let operation = AssumeRoleGranter::new(REGION, grant).with_time(signing_time());
+
+        operation
+            .grant_credential(
+                &context,
+                &source_credential(Some(SOURCE_SESSION_TOKEN)),
+                Some(Duration::from_secs(3_600)),
+            )
+            .await
+            .expect("large AssumeRole grant must succeed");
+
+        let requests = http.requests();
+        assert_eq!(requests.len(), 1);
+        let request = &requests[0];
+        assert_eq!(request.method, http::Method::POST);
+        assert_eq!(request.uri, "https://sts.us-east-1.amazonaws.com/");
+        assert!(request.uri.query().is_none());
+        assert!(request.body.len() > 2_048);
+        assert!(request.body.starts_with(b"Action=AssumeRole&"));
+        assert_eq!(
+            request
+                .headers
+                .get("x-amz-content-sha256")
+                .expect("payload hash must be set")
+                .to_str()
+                .expect("payload hash must be text"),
+            hex_sha256(&request.body)
+        );
+        assert_eq!(
+            request
+                .headers
+                .get(AUTHORIZATION)
+                .expect("reqsign authorization must be set"),
+            sdk_request
+                .headers()
+                .get(AUTHORIZATION)
+                .expect("AWS SDK authorization must be set")
+        );
+        assert_eq!(
+            request
+                .headers
+                .get("x-amz-content-sha256")
+                .expect("reqsign payload hash must be set"),
+            sdk_request
+                .headers()
+                .get("x-amz-content-sha256")
+                .expect("AWS SDK payload hash must be set")
+        );
+    }
+
+    #[tokio::test]
+    async fn supports_long_lived_and_temporary_source_credentials() {
+        let http = MockHttpSend::new([
+            success_response("ASIALONGTERMSOURCE1"),
+            success_response("ASIATEMPSOURCE00001"),
+        ]);
+        let context = Context::new().with_http_send(http.clone());
+        let operation = AssumeRoleGranter::new(REGION, valid_grant()).with_time(signing_time());
+        let mut long_lived_source = source_credential(None);
+        long_lived_source.expires_in = None;
+
+        operation
+            .grant_credential(&context, &long_lived_source, None)
+            .await
+            .expect("long-lived source must be supported");
+        operation
+            .grant_credential(
+                &context,
+                &source_credential(Some(SOURCE_SESSION_TOKEN)),
+                None,
+            )
+            .await
+            .expect("temporary source must be supported");
+
+        let requests = http.requests();
+        assert_eq!(requests.len(), 2);
+        assert!(
+            !requests[0]
+                .uri
+                .query()
+                .expect("AssumeRole query must be present")
+                .contains("DurationSeconds"),
+            "None must use the AWS default session duration"
+        );
+        assert!(
+            requests[0].headers.get("x-amz-security-token").is_none(),
+            "long-lived sources must not add a session token"
+        );
+        assert_eq!(
+            requests[1]
+                .headers
+                .get("x-amz-security-token")
+                .expect("temporary source token must be set"),
+            SOURCE_SESSION_TOKEN
+        );
+        for request in requests {
+            assert!(
+                request
+                    .headers
+                    .get(AUTHORIZATION)
+                    .expect("authorization must be set")
+                    .to_str()
+                    .expect("authorization must be text")
+                    .contains(SOURCE_ACCESS_KEY)
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn validates_typed_grant_region_and_lifetime_before_sts_io() {
+        let http = MockHttpSend::new(Vec::<http::Response<Bytes>>::new());
+        let context = Context::new().with_http_send(http.clone());
+        let source = source_credential(None);
+        let valid_policy_arn = "arn:aws:iam::123456789012:policy/customer-reader".to_string();
+
+        let invalid_operations = vec![
+            AssumeRoleGranter::new(REGION, AssumeRoleGrant::new("not-an-arn", "customer-a")),
+            AssumeRoleGranter::new(REGION, AssumeRoleGrant::new(ROLE_ARN, "x")),
+            AssumeRoleGranter::new(REGION, valid_grant().with_external_id("contains space")),
+            AssumeRoleGranter::new(REGION, valid_grant().with_policy("not-json")),
+            AssumeRoleGranter::new(
+                REGION,
+                valid_grant().with_policy(r#"{"Statement":"policy-secret-😀"}"#),
+            ),
+            AssumeRoleGranter::new(
+                REGION,
+                valid_grant().with_policy_arns(vec![valid_policy_arn.clone(); 11]),
+            ),
+            AssumeRoleGranter::new(
+                REGION,
+                valid_grant().with_policy_arns(vec!["arn:aws:s3:::not-an-iam-policy".to_string()]),
+            ),
+            AssumeRoleGranter::new(
+                REGION,
+                valid_grant().with_policy_arns(vec![
+                    "arn:aws:iam::210987654321:policy/customer-reader".to_string(),
+                ]),
+            ),
+            AssumeRoleGranter::new(
+                REGION,
+                valid_grant().with_tags(vec![
+                    ("Project".to_string(), "one".to_string()),
+                    ("project".to_string(), "two".to_string()),
+                ]),
+            ),
+            AssumeRoleGranter::new(
+                REGION,
+                valid_grant().with_mfa("arn:aws:iam::123456789012:mfa/customer-a", "12345"),
+            ),
+            AssumeRoleGranter::new("us east 1", valid_grant()),
+            AssumeRoleGranter::new(
+                REGION,
+                AssumeRoleGrant::new(
+                    "arn:aws-cn:iam::123456789012:role/customer-reader",
+                    "customer-a",
+                ),
+            ),
+        ];
+
+        for operation in invalid_operations {
+            let error = operation
+                .grant_credential(&context, &source, None)
+                .await
+                .expect_err("invalid typed grant must be rejected");
+            assert!(
+                matches!(
+                    error.kind(),
+                    ErrorKind::RequestInvalid | ErrorKind::ConfigInvalid
+                ),
+                "unexpected error: {error:?}"
+            );
+        }
+
+        let operation = AssumeRoleGranter::new(REGION, valid_grant()).with_time(signing_time());
+        for duration in [899, 43_201] {
+            let error = operation
+                .grant_credential(&context, &source, Some(Duration::from_secs(duration)))
+                .await
+                .expect_err("invalid duration must be rejected");
+            assert_eq!(error.kind(), ErrorKind::RequestInvalid);
+        }
+        assert_eq!(http.calls(), 0);
+    }
+
+    #[tokio::test]
+    async fn rejects_invalid_source_variants_before_sts_io() {
+        let http = MockHttpSend::new(Vec::<http::Response<Bytes>>::new());
+        let context = Context::new().with_http_send(http.clone());
+        let operation = AssumeRoleGranter::new(REGION, valid_grant()).with_time(signing_time());
+        let invalid_sources = [
+            Credential::default(),
+            Credential {
+                access_key_id: SOURCE_ACCESS_KEY.to_string(),
+                ..Default::default()
+            },
+            source_credential(Some("")),
+            source_credential(Some("prefix\nsuffix")),
+            Credential {
+                expires_in: Some(signing_time() + Duration::from_secs(10)),
+                ..source_credential(None)
+            },
+        ];
+
+        for source in invalid_sources {
+            let error = operation
+                .grant_credential(&context, &source, None)
+                .await
+                .expect_err("invalid source must be rejected");
+            assert_eq!(error.kind(), ErrorKind::CredentialInvalid);
+            let debug = format!("{error:?}");
+            assert!(!debug.contains(SOURCE_SECRET_KEY));
+            assert!(!debug.contains(SOURCE_SESSION_TOKEN));
+        }
+        assert_eq!(http.calls(), 0);
+    }
+
+    #[tokio::test]
+    async fn rejects_expired_and_malformed_outputs_after_sts_io() {
+        let expired = http::Response::builder()
+            .status(http::StatusCode::OK)
+            .body(Bytes::from_static(
+                br#"<AssumeRoleResponse><AssumeRoleResult><Credentials>
+                    <AccessKeyId>ASIAEXPIREDOUTPUT1</AccessKeyId>
+                    <SecretAccessKey>expired-secret</SecretAccessKey>
+                    <SessionToken>expired-token</SessionToken>
+                    <Expiration>2020-01-01T00:00:00Z</Expiration>
+                </Credentials></AssumeRoleResult></AssumeRoleResponse>"#,
+            ))
+            .expect("response must build");
+        let malformed = http::Response::builder()
+            .status(http::StatusCode::OK)
+            .body(Bytes::from_static(
+                br#"<AssumeRoleResponse><AssumeRoleResult><Credentials>
+                    <AccessKeyId>ASIAMALFORMEDOUT01</AccessKeyId>
+                    <SecretAccessKey>malformed-secret</SecretAccessKey>
+                    <SessionToken></SessionToken>
+                    <Expiration>2035-01-01T00:00:00Z</Expiration>
+                </Credentials></AssumeRoleResult></AssumeRoleResponse>"#,
+            ))
+            .expect("response must build");
+        let malformed_access_key = http::Response::builder()
+            .status(http::StatusCode::OK)
+            .body(Bytes::from_static(
+                br#"<AssumeRoleResponse><AssumeRoleResult><Credentials>
+                    <AccessKeyId>ASIAINVALID@OUTPUT1</AccessKeyId>
+                    <SecretAccessKey>malformed-secret</SecretAccessKey>
+                    <SessionToken>malformed-token</SessionToken>
+                    <Expiration>2035-01-01T00:00:00Z</Expiration>
+                </Credentials></AssumeRoleResult></AssumeRoleResponse>"#,
+            ))
+            .expect("response must build");
+        let malformed_session_token = http::Response::builder()
+            .status(http::StatusCode::OK)
+            .body(Bytes::from_static(
+                br#"<AssumeRoleResponse><AssumeRoleResult><Credentials>
+                    <AccessKeyId>ASIAMALFORMEDOUT02</AccessKeyId>
+                    <SecretAccessKey>malformed-secret</SecretAccessKey>
+                    <SessionToken>prefix&#10;suffix</SessionToken>
+                    <Expiration>2035-01-01T00:00:00Z</Expiration>
+                </Credentials></AssumeRoleResult></AssumeRoleResponse>"#,
+            ))
+            .expect("response must build");
+        let http = MockHttpSend::new([
+            expired,
+            malformed,
+            malformed_access_key,
+            malformed_session_token,
+        ]);
+        let context = Context::new().with_http_send(http.clone());
+        let operation = AssumeRoleGranter::new(REGION, valid_grant()).with_time(signing_time());
+
+        let expired_error = operation
+            .grant_credential(&context, &source_credential(None), None)
+            .await
+            .expect_err("expired output must be rejected");
+        assert_eq!(expired_error.kind(), ErrorKind::CredentialInvalid);
+        let malformed_error = operation
+            .grant_credential(&context, &source_credential(None), None)
+            .await
+            .expect_err("malformed output must be rejected");
+        assert_eq!(malformed_error.kind(), ErrorKind::Unexpected);
+        for _ in 0..2 {
+            let error = operation
+                .grant_credential(&context, &source_credential(None), None)
+                .await
+                .expect_err("unusable output must be rejected");
+            assert_eq!(error.kind(), ErrorKind::Unexpected);
+        }
+        assert_eq!(http.calls(), 4);
+    }
+
+    #[tokio::test]
+    async fn fixed_provider_preserves_global_endpoint_and_request_defaults() {
+        let http = MockHttpSend::new([success_response("ASIAPROVIDERDEFAULT1")]);
+        let context = Context::new().with_http_send(http.clone());
+        let (source, _) = FixedCredentialProvider::new(source_credential(None));
+        let provider = AssumeRoleCredentialProvider::new(
+            ROLE_ARN.to_string(),
+            Signer::new(
+                context.clone(),
+                source,
+                RequestSigner::new("sts", REGION).with_time(signing_time()),
+            ),
+        );
+
+        provider
+            .provide_credential(&context)
+            .await
+            .expect("fixed provider must succeed")
+            .expect("fixed provider must return a credential");
+
+        let requests = http.requests();
+        assert_eq!(requests.len(), 1);
+        assert_eq!(
+            requests[0].uri.to_string(),
+            "https://sts.amazonaws.com/?Action=AssumeRole&RoleArn=arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Fcustomer-reader&Version=2011-06-15&RoleSessionName=reqsign&DurationSeconds=3600"
+        );
+    }
+
+    #[tokio::test]
+    async fn fixed_provider_accepts_valid_iam_role_paths_and_preserves_retryability() {
+        let success_http = MockHttpSend::new([success_response("ASIAVALIDROLEPATH01")]);
+        let success_context = Context::new().with_http_send(success_http);
+        let (source, _) = FixedCredentialProvider::new(source_credential(None));
+        let provider = AssumeRoleCredentialProvider::new(
+            "arn:aws:iam::123456789012:role/team!prod/Reader".to_string(),
+            Signer::new(
+                success_context.clone(),
+                source,
+                RequestSigner::new("sts", REGION).with_time(signing_time()),
+            ),
+        );
+        provider
+            .provide_credential(&success_context)
+            .await
+            .expect("valid role path must be accepted")
+            .expect("provider must return a credential");
+
+        let (source, _) = FixedCredentialProvider::new(source_credential(None));
+        let provider = AssumeRoleCredentialProvider::new(
+            ROLE_ARN.to_string(),
+            Signer::new(
+                Context::new(),
+                source,
+                RequestSigner::new("sts", REGION).with_time(signing_time()),
+            ),
+        );
+        let error = provider
+            .provide_credential(&Context::new().with_http_send(NonRetryableTransportError))
+            .await
+            .expect_err("transport must fail");
+        assert_eq!(error.kind(), ErrorKind::Unexpected);
+        assert!(error.is_retryable());
+    }
+
+    #[tokio::test]
+    async fn fixed_provider_and_explicit_granter_share_wire_and_result_path() {
+        let grant = valid_grant()
+            .with_external_id("tenant/123")
+            .with_policy(
+                r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::customer-a/*"}]}"#,
+            )
+            .with_policy_arns(vec![
+                "arn:aws:iam::123456789012:policy/customer-reader".to_string(),
+            ])
+            .with_tags(vec![("Project".to_string(), "Customer A".to_string())])
+            .with_mfa(
+                "arn:aws:iam::123456789012:mfa/customer-a",
+                "123456",
+            );
+        let http = MockHttpSend::new([
+            success_response("ASIAGRANTEROUTPUT01"),
+            success_response("ASIAPROVIDEROUTPUT1"),
+        ]);
+        let context = Context::new().with_http_send(http.clone());
+        let operation = AssumeRoleGranter::new(REGION, grant.clone()).with_time(signing_time());
+        let granted = operation
+            .grant_credential(
+                &context,
+                &source_credential(Some(SOURCE_SESSION_TOKEN)),
+                Some(Duration::from_secs(3_600)),
+            )
+            .await
+            .expect("explicit grant must succeed");
+
+        let (provider_source, _) =
+            FixedCredentialProvider::new(source_credential(Some(SOURCE_SESSION_TOKEN)));
+        let sts_signer = Signer::new(
+            context.clone(),
+            provider_source,
+            RequestSigner::new("sts", REGION).with_time(signing_time()),
+        );
+        let provider = AssumeRoleCredentialProvider::new(ROLE_ARN.to_string(), sts_signer)
+            .with_role_session_name("customer-a".to_string())
+            .with_external_id("tenant/123".to_string())
+            .with_duration_seconds(3_600)
+            .with_policy(
+                r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::customer-a/*"}]}"#.to_string(),
+            )
+            .with_policy_arns(vec![
+                "arn:aws:iam::123456789012:policy/customer-reader".to_string(),
+            ])
+            .with_tags(vec![("Project".to_string(), "Customer A".to_string())])
+            .with_mfa_serial("arn:aws:iam::123456789012:mfa/customer-a".to_string())
+            .with_mfa_code("123456".to_string())
+            .with_region(REGION.to_string())
+            .with_regional_sts_endpoint();
+        let provided = provider
+            .provide_credential(&context)
+            .await
+            .expect("fixed provider must succeed")
+            .expect("fixed provider must return a credential");
+
+        assert_eq!(granted.secret_access_key, provided.secret_access_key);
+        assert_eq!(granted.session_token, provided.session_token);
+        assert_eq!(granted.expires_in, provided.expires_in);
+        let requests = http.requests();
+        assert_eq!(requests.len(), 2);
+        assert_eq!(requests[0].method, requests[1].method);
+        assert_eq!(requests[0].uri, requests[1].uri);
+        assert_eq!(requests[0].headers, requests[1].headers);
+        assert_eq!(requests[0].body, requests[1].body);
+    }
+
+    #[tokio::test]
+    async fn redacts_debug_transport_sts_errors_and_raw_responses() {
+        let grant = valid_grant()
+            .with_external_id("external-secret")
+            .with_policy(r#"{"Statement":"policy-secret"}"#)
+            .with_mfa("arn:aws:iam::123456789012:mfa/customer-a", "654321");
+        let operation = AssumeRoleGranter::new(REGION, grant.clone()).with_time(signing_time());
+        let source = source_credential(Some(SOURCE_SESSION_TOKEN));
+
+        for debug in [
+            format!("{grant:?}"),
+            format!("{operation:?}"),
+            format!("{source:?}"),
+        ] {
+            for secret in [
+                "external-secret",
+                "policy-secret",
+                "654321",
+                SOURCE_ACCESS_KEY,
+                SOURCE_SECRET_KEY,
+                SOURCE_SESSION_TOKEN,
+            ] {
+                assert!(!debug.contains(secret), "{secret} leaked through Debug");
+            }
+        }
+
+        let transport_context = Context::new().with_http_send(SecretTransportError);
+        let transport_error = operation
+            .grant_credential(&transport_context, &source, None)
+            .await
+            .expect_err("transport must fail");
+        let transport_debug = format!("{transport_error:?}");
+        assert!(!transport_debug.contains(SOURCE_SECRET_KEY));
+        assert!(!transport_debug.contains("policy-secret"));
+        assert!(transport_error.is_retryable());
+
+        let known_sts_error_response = http::Response::builder()
+            .status(http::StatusCode::FORBIDDEN)
+            .header("x-amzn-requestid", "response-request-id-secret")
+            .body(Bytes::from_static(
+                br#"<ErrorResponse><Error><Code>AccessDenied</Code>
+                    <Message>policy-secret raw-response-secret returned-session-token</Message>
+                </Error></ErrorResponse>"#,
+            ))
+            .expect("response must build");
+        let unknown_sts_error_response = http::Response::builder()
+            .status(http::StatusCode::BAD_REQUEST)
+            .body(Bytes::from_static(
+                br#"<ErrorResponse><Error><Code>raw-response-secret</Code>
+                    <Message>policy-secret returned-session-token</Message>
+                </Error></ErrorResponse>"#,
+            ))
+            .expect("response must build");
+        let malformed_success = http::Response::builder()
+            .status(http::StatusCode::OK)
+            .body(Bytes::from_static(
+                b"raw-response-secret returned-session-token",
+            ))
+            .expect("response must build");
+        let http = MockHttpSend::new([
+            known_sts_error_response,
+            unknown_sts_error_response,
+            malformed_success,
+        ]);
+        let context = Context::new().with_http_send(http);
+        for _ in 0..3 {
+            let error = operation
+                .grant_credential(&context, &source, None)
+                .await
+                .expect_err("response must fail");
+            let debug = format!("{error:?}");
+            assert!(!debug.contains("policy-secret"));
+            assert!(!debug.contains("raw-response-secret"));
+            assert!(!debug.contains("returned-session-token"));
+            assert!(!debug.contains("response-request-id-secret"));
+        }
+
+        let (provider_source, _) = FixedCredentialProvider::new(source);
+        let provider = AssumeRoleCredentialProvider::new(
+            ROLE_ARN.to_string(),
+            Signer::new(
+                Context::new(),
+                provider_source,
+                RequestSigner::new("sts", REGION),
+            ),
+        )
+        .with_policy(r#"{"Statement":"provider-policy-secret"}"#.to_string())
+        .with_mfa_code("111111".to_string());
+        let provider_debug = format!("{provider:?}");
+        assert!(!provider_debug.contains("provider-policy-secret"));
+        assert!(!provider_debug.contains("111111"));
+    }
+}
diff --git a/services/aws-v4/src/lib.rs b/services/aws-v4/src/lib.rs
index 27fd63a..50bf383 100644
--- a/services/aws-v4/src/lib.rs
+++ b/services/aws-v4/src/lib.rs
@@ -151,6 +151,8 @@
 //! - [S3 signing example](examples/s3_sign.rs)
 //! - [DynamoDB signing example](examples/dynamodb_sign.rs)
 
+mod assume_role;
+pub use assume_role::AssumeRoleGranter;
 mod sign_request;
 pub use sign_request::RequestSigner;
 mod s3_access_grants;
@@ -165,7 +167,7 @@ pub use provide_credential::{
 };
 pub use reqsign_aws_core::constants;
 pub use reqsign_aws_core::{
-    AssumeRoleCredentialProvider, AssumeRoleWithWebIdentityCredentialProvider,
+    AssumeRoleCredentialProvider, AssumeRoleGrant, AssumeRoleWithWebIdentityCredentialProvider,
     CognitoIdentityCredentialProvider, Credential, DefaultCredentialProvider,
     DefaultCredentialProviderBuilder, ECSCredentialProvider, EnvCredentialProvider,
     IMDSv2CredentialProvider, ProfileCredentialProvider, StaticCredentialProvider,
diff --git a/services/aws-v4/tests/README.md b/services/aws-v4/tests/README.md
index 8b1e1c7..4d65297 100644
--- a/services/aws-v4/tests/README.md
+++ b/services/aws-v4/tests/README.md
@@ -9,7 +9,7 @@ This directory contains integration tests for the AWS V4 signing implementation,
 Tests for various AWS credential providers:
 - `env.rs` - EnvCredentialProvider (uses AWS environment variables)
 - `profile.rs` - ProfileCredentialProvider (uses AWS config/credentials files)
-- `assume_role.rs` - AssumeRoleCredentialProvider
+- `assume_role.rs` - AssumeRoleCredentialProvider and explicit AssumeRoleGranter
 - `assume_role_with_web_identity.rs` - AssumeRoleWithWebIdentityCredentialProvider
 - `cognito.rs` - CognitoIdentityCredentialProvider
 - `ecs.rs` - ECSCredentialProvider
@@ -106,4 +106,4 @@ The GitHub Actions workflow automatically:
 - Tests gracefully skip when required environment variables are not set
 - Each test outputs clear messages about what is being tested
 - Use `RUST_LOG=DEBUG` for detailed logging
-- Tests are designed to be independent and can run in parallel
\ No newline at end of file
+- Tests are designed to be independent and can run in parallel
diff --git a/services/aws-v4/tests/credential_providers/assume_role.rs b/services/aws-v4/tests/credential_providers/assume_role.rs
index 687f109..0187017 100644
--- a/services/aws-v4/tests/credential_providers/assume_role.rs
+++ b/services/aws-v4/tests/credential_providers/assume_role.rs
@@ -17,9 +17,13 @@
 
 use super::create_test_context;
 use log::info;
-use reqsign_aws_v4::{AssumeRoleCredentialProvider, DefaultCredentialProvider, RequestSigner};
-use reqsign_core::{ProvideCredential, Signer};
+use reqsign_aws_v4::{
+    AssumeRoleCredentialProvider, AssumeRoleGrant, AssumeRoleGranter, DefaultCredentialProvider,
+    RequestSigner,
+};
+use reqsign_core::{Granter, ProvideCredential, Signer};
 use std::env;
+use std::time::Duration;
 
 #[tokio::test]
 async fn test_assume_role_credential_provider() {
@@ -60,3 +64,37 @@ async fn test_assume_role_credential_provider() {
         "AssumeRole should return session token"
     );
 }
+
+#[tokio::test]
+async fn test_assume_role_granter() {
+    if env::var("REQSIGN_AWS_V4_TEST_ASSUME_ROLE").unwrap_or_default() != "on" {
+        info!("REQSIGN_AWS_V4_TEST_ASSUME_ROLE not set, skipping");
+        return;
+    }
+
+    let role_arn = env::var("REQSIGN_AWS_V4_ASSUME_ROLE_ARN")
+        .expect("REQSIGN_AWS_V4_ASSUME_ROLE_ARN must be set for assume_role test");
+    let region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
+    let context = create_test_context();
+    let grant = AssumeRoleGrant::new(role_arn, "reqsign-granter");
+    let granter = Granter::new(
+        context,
+        DefaultCredentialProvider::new(),
+        AssumeRoleGranter::new(region, grant),
+    );
+
+    let credential = granter
+        .grant(Some(Duration::from_secs(3_600)))
+        .await
+        .expect("AssumeRole grant should succeed");
+    assert!(!credential.access_key_id.is_empty());
+    assert!(!credential.secret_access_key.is_empty());
+    assert!(
+        credential.session_token.is_some(),
+        "AssumeRole grant should return a session token"
+    );
+    assert!(
+        credential.expires_in.is_some(),
+        "AssumeRole grant should preserve expiration"
+    );
+}