You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@reqsign.apache.org by xu...@apache.org on 2026/10/02 17:32:55 UTC

(reqsign) 01/01: feat(aws): refresh named SSO session access tokens

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

Xuanwo pushed a commit to branch xuanwo/sso-token-refresh
in repository https://gitbox.apache.org/repos/asf/reqsign.git

commit 3457f3ed812e98e463cf9e2007f5df7984094b75
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Sat Oct 3 01:32:45 2026 +0800

    feat(aws): refresh named SSO session access tokens
---
 core/src/api.rs                                    | 102 +++-
 services/aws-core/Cargo.toml                       |   1 +
 .../aws-core/src/provide_credential/default.rs     |   7 +-
 services/aws-core/src/provide_credential/sso.rs    | 440 +++++++++++-----
 .../aws-core/src/provide_credential/sso/tests.rs   | 560 +++++++++++++++++++++
 5 files changed, 968 insertions(+), 142 deletions(-)

diff --git a/core/src/api.rs b/core/src/api.rs
index eb4b5d0..61ef2f2 100644
--- a/core/src/api.rs
+++ b/core/src/api.rs
@@ -373,7 +373,8 @@ where
 ///
 /// This is a generic implementation that can be used by any service to chain multiple
 /// credential providers together. The chain will try each provider in order until one
-/// returns credentials or all providers have been exhausted.
+/// returns credentials or all providers have been exhausted. Errors are skipped
+/// unless a provider was added with [`Self::push_with_error_propagation`].
 ///
 /// # Example
 ///
@@ -405,7 +406,12 @@ where
 /// # }
 /// ```
 pub struct ProvideCredentialChain<C> {
-    providers: Vec<Box<dyn ProvideCredentialDyn<Credential = C>>>,
+    providers: Vec<CredentialProviderEntry<C>>,
+}
+
+struct CredentialProviderEntry<C> {
+    provider: Box<dyn ProvideCredentialDyn<Credential = C>>,
+    propagate_error: bool,
 }
 
 impl<C> ProvideCredentialChain<C>
@@ -421,7 +427,27 @@ where
 
     /// Add a credential provider to the chain.
     pub fn push(mut self, provider: impl ProvideCredential<Credential = C> + 'static) -> Self {
-        self.providers.push(Box::new(provider));
+        self.providers.push(CredentialProviderEntry {
+            provider: Box::new(provider),
+            propagate_error: false,
+        });
+        self
+    }
+
+    /// Add a provider whose errors stop credential resolution.
+    ///
+    /// `Ok(None)` still tries the next provider. Use this when a configured
+    /// identity must not silently fall back to another identity on failure.
+    /// Providers added with [`Self::push`], [`Self::push_front`], or
+    /// [`Self::from_vec`] retain their fallback-on-error behavior.
+    pub fn push_with_error_propagation(
+        mut self,
+        provider: impl ProvideCredential<Credential = C> + 'static,
+    ) -> Self {
+        self.providers.push(CredentialProviderEntry {
+            provider: Box::new(provider),
+            propagate_error: true,
+        });
         self
     }
 
@@ -432,13 +458,27 @@ where
         mut self,
         provider: impl ProvideCredential<Credential = C> + 'static,
     ) -> Self {
-        self.providers.insert(0, Box::new(provider));
+        self.providers.insert(
+            0,
+            CredentialProviderEntry {
+                provider: Box::new(provider),
+                propagate_error: false,
+            },
+        );
         self
     }
 
     /// Create a credential provider chain from a vector of providers.
     pub fn from_vec(providers: Vec<Box<dyn ProvideCredentialDyn<Credential = C>>>) -> Self {
-        Self { providers }
+        Self {
+            providers: providers
+                .into_iter()
+                .map(|provider| CredentialProviderEntry {
+                    provider,
+                    propagate_error: false,
+                })
+                .collect(),
+        }
     }
 
     /// Get the number of providers in the chain.
@@ -479,7 +519,8 @@ where
     type Credential = C;
 
     async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
-        for provider in &self.providers {
+        for entry in &self.providers {
+            let provider = &entry.provider;
             log::debug!("Trying credential provider: {provider:?}");
 
             match provider.provide_credential_dyn(ctx).await {
@@ -492,6 +533,9 @@ where
                     continue;
                 }
                 Err(e) => {
+                    if entry.propagate_error {
+                        return Err(e);
+                    }
                     log::warn!("Error loading credential from provider {provider:?}: {e:?}");
                     // Continue to next provider on error
                     continue;
@@ -507,6 +551,52 @@ where
 mod tests {
     use super::*;
 
+    #[derive(Debug)]
+    struct ChainResult(Result<Option<u8>>);
+
+    impl ProvideCredential for ChainResult {
+        type Credential = u8;
+
+        async fn provide_credential(&self, _: &Context) -> Result<Option<u8>> {
+            match &self.0 {
+                Ok(value) => Ok(*value),
+                Err(_) => Err(crate::Error::credential_invalid(
+                    "selected identity expired",
+                )),
+            }
+        }
+    }
+
+    #[test]
+    fn chain_error_propagation_is_opt_in_and_absence_still_falls_through() {
+        futures::executor::block_on(async {
+            let failure = || ChainResult(Err(crate::Error::credential_invalid("expired")));
+            let fallback = || ChainResult(Ok(Some(42)));
+            let ctx = Context::new();
+            let ordinary = ProvideCredentialChain::new()
+                .push(failure())
+                .push(fallback());
+            assert_eq!(ordinary.provide_credential(&ctx).await.unwrap(), Some(42));
+
+            let selected = ProvideCredentialChain::new()
+                .push_with_error_propagation(failure())
+                .push(fallback());
+            let error = selected.provide_credential(&ctx).await.unwrap_err();
+            assert_eq!(error.kind(), crate::ErrorKind::CredentialInvalid);
+            assert_eq!(error.to_string(), "selected identity expired");
+
+            let absent = ProvideCredentialChain::new()
+                .push_with_error_propagation(ChainResult(Ok(None)))
+                .push(fallback());
+            assert_eq!(absent.provide_credential(&ctx).await.unwrap(), Some(42));
+
+            let present = ProvideCredentialChain::new()
+                .push_with_error_propagation(fallback())
+                .push_with_error_propagation(failure());
+            assert_eq!(present.provide_credential(&ctx).await.unwrap(), Some(42));
+        });
+    }
+
     #[derive(Clone, Debug)]
     struct ExactCredential {
         valid_at: Timestamp,
diff --git a/services/aws-core/Cargo.toml b/services/aws-core/Cargo.toml
index 4ce2422..e521876 100644
--- a/services/aws-core/Cargo.toml
+++ b/services/aws-core/Cargo.toml
@@ -27,6 +27,7 @@ repository.workspace = true
 rust-version.workspace = true
 
 [dependencies]
+asyncband = { workspace = true }
 bytes = { workspace = true }
 form_urlencoded = { workspace = true }
 hex = { workspace = true }
diff --git a/services/aws-core/src/provide_credential/default.rs b/services/aws-core/src/provide_credential/default.rs
index 7a90516..7e94e19 100644
--- a/services/aws-core/src/provide_credential/default.rs
+++ b/services/aws-core/src/provide_credential/default.rs
@@ -35,6 +35,9 @@ use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
 /// 5. Process credentials
 /// 6. ECS (IAM Roles for Tasks) & Container credentials
 /// 7. EC2 IMDSv2
+///
+/// Once SSO configuration is selected, SSO errors stop resolution to avoid
+/// silently switching identities. An absent SSO configuration continues the chain.
 #[derive(Debug)]
 pub struct DefaultCredentialProvider {
     chain: ProvideCredentialChain<Credential>,
@@ -253,7 +256,7 @@ impl DefaultCredentialProviderBuilder {
         #[cfg(not(target_arch = "wasm32"))]
         {
             if let Some(p) = self.sso {
-                chain = chain.push(p);
+                chain = chain.push_with_error_propagation(p);
             }
         }
 
@@ -641,7 +644,7 @@ mod tests {
             .expect_err("selected SSO profile must be loaded before cache lookup");
         assert_eq!(ErrorKind::ConfigInvalid, error.kind());
         assert_eq!(
-            "No valid SSO token found. Please run 'aws sso login' first",
+            "SSO token cache not found. Please run 'aws sso login' for the selected profile",
             error.to_string()
         );
 
diff --git a/services/aws-core/src/provide_credential/sso.rs b/services/aws-core/src/provide_credential/sso.rs
index 9fb6f28..6b2edee 100644
--- a/services/aws-core/src/provide_credential/sso.rs
+++ b/services/aws-core/src/provide_credential/sso.rs
@@ -16,18 +16,19 @@
 // under the License.
 
 use crate::Credential;
+use asyncband::mutex::Mutex;
 use http::{Method, Request, StatusCode};
 use ini::Ini;
-use log::{debug, warn};
 use reqsign_core::time::Timestamp;
 use reqsign_core::{Context, Error, ProvideCredential, Result};
 use serde::Deserialize;
+use std::collections::HashMap;
+use std::sync::Arc;
 
 const AWS_SSO_ACCOUNT_ID: &str = "sso_account_id";
 const AWS_SSO_REGION: &str = "sso_region";
 const AWS_SSO_ROLE_NAME: &str = "sso_role_name";
 const AWS_SSO_START_URL: &str = "sso_start_url";
-#[allow(dead_code)]
 const AWS_SSO_SESSION_NAME: &str = "sso_session";
 
 /// SSO Credentials Provider
@@ -39,11 +40,29 @@ const AWS_SSO_SESSION_NAME: &str = "sso_session";
 /// SSO configuration is typically stored in ~/.aws/config under a profile:
 /// ```ini
 /// [profile my-sso-profile]
-/// sso_start_url = https://my-sso-portal.awsapps.com/start
-/// sso_region = us-east-1
+/// sso_session = my-session
 /// sso_account_id = 123456789012
 /// sso_role_name = MyRole
+///
+/// [sso-session my-session]
+/// sso_start_url = https://my-sso-portal.awsapps.com/start
+/// sso_region = us-east-1
 /// ```
+///
+/// Named sessions refresh expired access tokens using cached OIDC registration
+/// and refresh tokens. Refreshed material is kept in memory for this provider
+/// and its clones; independently constructed providers do not coordinate refreshes.
+/// The AWS CLI cache is read through `Context` and is never written. On restart,
+/// the on-disk registration and refresh token must still be usable. When an
+/// in-memory token expires, a newer disk token (for example after `aws sso login`)
+/// replaces it. Recreate the provider to discard its in-memory state immediately.
+///
+/// Legacy inline `sso_start_url` / `sso_region` profiles and configuration supplied
+/// directly through the setters remain non-refreshable. This provider never
+/// starts interactive login and does not resolve assume-role profile chains.
+/// When assembling a custom credential chain, use
+/// [`reqsign_core::ProvideCredentialChain::push_with_error_propagation`] to keep
+/// SSO failures from falling back to an unrelated identity.
 #[derive(Debug, Clone)]
 pub struct SSOCredentialProvider {
     profile: Option<String>,
@@ -52,6 +71,7 @@ pub struct SSOCredentialProvider {
     sso_role_name: Option<String>,
     sso_start_url: Option<String>,
     sso_endpoint: Option<String>, // Allow custom endpoint for testing
+    tokens: Arc<Mutex<HashMap<TokenKey, CachedToken>>>,
 }
 
 impl Default for SSOCredentialProvider {
@@ -70,6 +90,7 @@ impl SSOCredentialProvider {
             sso_role_name: None,
             sso_start_url: None,
             sso_endpoint: None,
+            tokens: Arc::new(Mutex::new(HashMap::new())),
         }
     }
 
@@ -109,7 +130,7 @@ impl SSOCredentialProvider {
         self
     }
 
-    async fn load_sso_config(&self, ctx: &Context) -> Result<SSOConfig> {
+    async fn load_sso_config(&self, ctx: &Context) -> Result<Option<SSOConfig>> {
         // If all fields are provided directly, use them
         if let (Some(account_id), Some(region), Some(role_name), Some(start_url)) = (
             &self.sso_account_id,
@@ -117,12 +138,14 @@ impl SSOCredentialProvider {
             &self.sso_role_name,
             &self.sso_start_url,
         ) {
-            return Ok(SSOConfig {
+            return Ok(Some(SSOConfig {
                 sso_account_id: account_id.clone(),
                 sso_region: region.clone(),
                 sso_role_name: role_name.clone(),
                 sso_start_url: start_url.clone(),
-            });
+                session_name: None,
+                config_path: None,
+            }));
         }
 
         // Otherwise, load from config file
@@ -135,7 +158,11 @@ impl SSOCredentialProvider {
         self.load_from_config_file(ctx, &profile_name).await
     }
 
-    async fn load_from_config_file(&self, ctx: &Context, profile: &str) -> Result<SSOConfig> {
+    async fn load_from_config_file(
+        &self,
+        ctx: &Context,
+        profile: &str,
+    ) -> Result<Option<SSOConfig>> {
         // Load AWS config file
         let config_path = ctx
             .env_var("AWS_CONFIG_FILE")
@@ -144,18 +171,19 @@ impl SSOCredentialProvider {
         let expanded_path = if config_path.starts_with("~/") {
             match ctx.expand_home_dir(&config_path) {
                 Some(expanded) => expanded,
-                None => return Err(Error::config_invalid("failed to expand home directory")),
+                None => return Ok(None),
             }
         } else {
             config_path
         };
 
-        let content = ctx.file_read(&expanded_path).await.map_err(|_| {
-            Error::config_invalid(format!("failed to read config file: {expanded_path}"))
-        })?;
+        let content = match ctx.file_read(&expanded_path).await {
+            Ok(content) => content,
+            Err(_) => return Ok(None),
+        };
 
         let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
-            .map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
+            .map_err(|_| Error::config_invalid("failed to parse AWS SSO config file"))?;
 
         let profile_section = if profile == "default" {
             profile.to_string()
@@ -163,73 +191,203 @@ impl SSOCredentialProvider {
             format!("profile {profile}")
         };
 
-        let section = conf.section(Some(profile_section)).ok_or_else(|| {
-            Error::config_invalid(format!("profile '{profile}' not found in config"))
-        })?;
-
-        // Check if this profile has SSO configuration
-        let sso_account_id = section.get(AWS_SSO_ACCOUNT_ID).ok_or_else(|| {
-            Error::config_invalid(format!("missing {AWS_SSO_ACCOUNT_ID} in profile"))
-        })?;
-
-        let sso_region = section
-            .get(AWS_SSO_REGION)
-            .ok_or_else(|| Error::config_invalid(format!("missing {AWS_SSO_REGION} in profile")))?;
-
-        let sso_role_name = section.get(AWS_SSO_ROLE_NAME).ok_or_else(|| {
-            Error::config_invalid(format!("missing {AWS_SSO_ROLE_NAME} in profile"))
-        })?;
+        let Some(section) = conf.section(Some(profile_section)) else {
+            return Ok(None);
+        };
+        if ![
+            AWS_SSO_ACCOUNT_ID,
+            AWS_SSO_REGION,
+            AWS_SSO_ROLE_NAME,
+            AWS_SSO_START_URL,
+            AWS_SSO_SESSION_NAME,
+        ]
+        .iter()
+        .any(|key| section.contains_key(key))
+        {
+            return Ok(None);
+        }
 
-        let sso_start_url = section.get(AWS_SSO_START_URL).ok_or_else(|| {
-            Error::config_invalid(format!("missing {AWS_SSO_START_URL} in profile"))
-        })?;
+        let required = |section: &ini::Properties, key: &str| -> Result<String> {
+            section
+                .get(key)
+                .filter(|v| !v.is_empty())
+                .map(str::to_owned)
+                .ok_or_else(|| Error::config_invalid(format!("missing {key} in SSO configuration")))
+        };
+        let session_name = section.get(AWS_SSO_SESSION_NAME).map(str::to_owned);
+        let session = match &session_name {
+            Some(name) => conf
+                .section(Some(format!("sso-session {name}")))
+                .ok_or_else(|| Error::config_invalid("referenced SSO session not found"))?,
+            None => section,
+        };
+        let region = required(session, AWS_SSO_REGION)?;
+        let start_url = required(session, AWS_SSO_START_URL)?;
+        if session_name.is_some() {
+            for (key, value) in [(AWS_SSO_REGION, &region), (AWS_SSO_START_URL, &start_url)] {
+                if section.get(key).is_some_and(|v| v != value) {
+                    return Err(Error::config_invalid(format!(
+                        "{key} conflicts with named SSO session"
+                    )));
+                }
+            }
+        }
+        Ok(Some(SSOConfig {
+            sso_account_id: required(section, AWS_SSO_ACCOUNT_ID)?,
+            sso_region: region,
+            sso_role_name: required(section, AWS_SSO_ROLE_NAME)?,
+            sso_start_url: start_url,
+            session_name,
+            config_path: Some(expanded_path),
+        }))
+    }
 
-        Ok(SSOConfig {
-            sso_account_id: sso_account_id.to_string(),
-            sso_region: sso_region.to_string(),
-            sso_role_name: sso_role_name.to_string(),
-            sso_start_url: sso_start_url.to_string(),
-        })
+    async fn read_cached_token(ctx: &Context, path: &str) -> Result<Option<CachedToken>> {
+        match ctx.file_read(path).await {
+            Ok(content) => serde_json::from_slice(&content)
+                .map(Some)
+                .map_err(|_| login_required("invalid SSO token cache")),
+            Err(_) => Ok(None),
+        }
     }
 
-    async fn find_cached_token(
+    async fn access_token(
         &self,
         ctx: &Context,
-        start_url: &str,
-    ) -> Result<Option<CachedToken>> {
-        // Get home directory and build cache path
-        let home_dir = ctx
-            .home_dir()
-            .ok_or_else(|| Error::config_invalid("HOME directory not found".to_string()))?;
-
-        let cache_dir = home_dir.join(".aws").join("sso").join("cache");
-
-        // Generate cache file name (SHA1 hash of start URL)
-        let cache_key = hex_sha1(start_url.as_bytes());
-        let cache_file = cache_dir.join(format!("{cache_key}.json"));
-
-        debug!("looking for SSO token cache at: {cache_file:?}");
-
-        match ctx.file_read(&cache_file.to_string_lossy()).await {
-            Ok(content) => {
-                let token: CachedToken = serde_json::from_slice(&content).map_err(|e| {
-                    Error::unexpected(format!("failed to parse SSO token cache: {e}"))
-                })?;
-
-                // Check if token is expired
-                let expires_at = token.expires_at.parse::<Timestamp>()?;
-                if expires_at <= Timestamp::now() {
-                    warn!("SSO token is expired");
-                    return Ok(None);
-                }
+        config: &SSOConfig,
+        now: impl Fn() -> Timestamp + Send + Sync,
+    ) -> Result<String> {
+        let key = config.token_key(ctx)?;
+        if config.session_name.is_none() {
+            let token = Self::read_cached_token(ctx, &key.path)
+                .await?
+                .ok_or_else(|| login_required("SSO token cache not found"))?;
+            return if token.is_valid_at(now())? {
+                Ok(token.access_token)
+            } else {
+                Err(login_required("legacy SSO token has expired"))
+            };
+        }
 
-                Ok(Some(token))
+        // Hold the provider lock across refresh so clones cannot exchange the
+        // same refresh token concurrently, including when the service rotates it.
+        let mut tokens = self.tokens.lock().await;
+        if let Some(token) = tokens.get(&key) {
+            if token.is_valid_at(now())? {
+                return Ok(token.access_token.clone());
             }
-            Err(_) => {
-                debug!("SSO token cache not found");
-                Ok(None)
+        }
+        if let Some(disk) = Self::read_cached_token(ctx, &key.path).await? {
+            let replace = match tokens.get(&key) {
+                Some(current) => disk.expiration()? > current.expiration()?,
+                None => true,
+            };
+            if replace {
+                tokens.insert(key.clone(), disk);
             }
         }
+        let token = tokens
+            .get_mut(&key)
+            .ok_or_else(|| login_required("SSO token cache not found"))?;
+        if !token.is_valid_at(now())? {
+            self.refresh_token(ctx, config, token, &now).await?;
+        }
+        if !token.is_valid_at(now())? {
+            return Err(login_required("refreshed SSO access token has expired"));
+        }
+        Ok(token.access_token.clone())
+    }
+
+    async fn refresh_token(
+        &self,
+        ctx: &Context,
+        config: &SSOConfig,
+        token: &mut CachedToken,
+        now: impl Fn() -> Timestamp + Send + Sync,
+    ) -> Result<()> {
+        let required = |value: &Option<String>| {
+            value
+                .as_ref()
+                .filter(|v| !v.is_empty())
+                .cloned()
+                .ok_or_else(|| login_required("SSO refresh material is missing"))
+        };
+        let registration_expiry = required(&token.registration_expires_at)?
+            .parse::<Timestamp>()
+            .map_err(|_| login_required("invalid SSO registration expiration"))?;
+        let started = now();
+        if registration_expiry <= started {
+            return Err(login_required("SSO client registration has expired"));
+        }
+        let body = serde_json::json!({
+            "grantType": "refresh_token",
+            "clientId": required(&token.client_id)?,
+            "clientSecret": required(&token.client_secret)?,
+            "refreshToken": required(&token.refresh_token)?,
+        });
+        let req = Request::builder()
+            .method(Method::POST)
+            .uri(format!(
+                "https://oidc.{}.amazonaws.com/token",
+                config.sso_region
+            ))
+            .header(http::header::CONTENT_TYPE, "application/json")
+            .body(bytes::Bytes::from(body.to_string()))
+            .map_err(|_| Error::config_invalid("failed to build SSO refresh request"))?;
+        // HTTP adapters and server error bodies can contain the submitted secrets.
+        let response = ctx.http_send(req).await.map_err(|_| {
+            Error::unexpected("SSO token refresh transport failed").set_retryable(true)
+        })?;
+        if response.status() != StatusCode::OK {
+            let status = response.status();
+            if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
+                return Err(
+                    Error::unexpected(format!("SSO token refresh returned {status}"))
+                        .set_retryable(true),
+                );
+            }
+            return Err(login_required(&format!(
+                "SSO token refresh rejected ({status})"
+            )));
+        }
+        let refreshed: RefreshResponse = serde_json::from_slice(response.body())
+            .map_err(|_| login_required("invalid SSO token refresh response"))?;
+        if refreshed.access_token.is_empty()
+            || refreshed.expires_in <= 0
+            || refreshed
+                .refresh_token
+                .as_ref()
+                .is_some_and(|v| v.is_empty())
+        {
+            return Err(login_required("invalid SSO token refresh response"));
+        }
+        let expires_at = started
+            .as_second()
+            .checked_add(refreshed.expires_in)
+            .and_then(|seconds| Timestamp::from_second(seconds).ok())
+            .ok_or_else(|| login_required("invalid SSO token expiration"))?;
+        // Commit the complete response before attempting GetRoleCredentials. Its
+        // failure must not lose a rotated refresh token or extend token lifetime.
+        token.access_token = refreshed.access_token;
+        token.expires_at = expires_at.to_string();
+        if let Some(refresh_token) = refreshed.refresh_token {
+            token.refresh_token = Some(refresh_token);
+        }
+        Ok(())
+    }
+
+    async fn provide_with_clock(
+        &self,
+        ctx: &Context,
+        now: impl Fn() -> Timestamp + Send + Sync,
+    ) -> Result<Option<Credential>> {
+        let Some(config) = self.load_sso_config(ctx).await? else {
+            return Ok(None);
+        };
+        let token = self.access_token(ctx, &config, now).await?;
+        self.get_role_credentials(ctx, &config, &token)
+            .await
+            .map(Some)
     }
 
     async fn get_role_credentials(
@@ -258,17 +416,20 @@ impl SSOCredentialProvider {
 
         let url = format!("{endpoint}?{params}");
 
+        let mut bearer = http::HeaderValue::from_str(access_token)
+            .map_err(|_| login_required("invalid SSO access token"))?;
+        bearer.set_sensitive(true);
         let req = Request::builder()
             .method(Method::GET)
             .uri(&url)
-            .header("x-amz-sso_bearer_token", access_token)
+            .header("x-amz-sso_bearer_token", bearer)
             .body(bytes::Bytes::new())
             .map_err(|e| Error::unexpected(format!("failed to build request: {e}")))?;
 
         let resp = ctx
             .http_send(req)
             .await
-            .map_err(|e| Error::unexpected(format!("failed to fetch SSO credentials: {e}")))?;
+            .map_err(|_| Error::unexpected("failed to fetch SSO credentials"))?;
 
         if resp.status() != StatusCode::OK {
             return Err(Error::unexpected(format!(
@@ -279,7 +440,7 @@ impl SSOCredentialProvider {
 
         let body = resp.into_body();
         let creds: SSOCredentialResponse = serde_json::from_slice(&body)
-            .map_err(|e| Error::unexpected(format!("failed to parse SSO credentials: {e}")))?;
+            .map_err(|_| Error::unexpected("failed to parse SSO credentials"))?;
 
         let role_creds = creds.role_credentials;
         let expires_in = Timestamp::from_millisecond(role_creds.expiration)
@@ -300,14 +461,81 @@ struct SSOConfig {
     sso_region: String,
     sso_role_name: String,
     sso_start_url: String,
+    session_name: Option<String>,
+    config_path: Option<String>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct TokenKey {
+    path: String,
+    config_path: Option<String>,
+    session_name: Option<String>,
+    start_url: String,
+    region: String,
+}
+
+impl SSOConfig {
+    fn token_key(&self, ctx: &Context) -> Result<TokenKey> {
+        let home = ctx
+            .home_dir()
+            .ok_or_else(|| Error::config_invalid("HOME directory not found"))?;
+        let name = self.session_name.as_deref().unwrap_or(&self.sso_start_url);
+        let path = home
+            .join(".aws")
+            .join("sso")
+            .join("cache")
+            .join(format!("{}.json", hex_sha1(name.as_bytes())));
+        Ok(TokenKey {
+            path: path.to_string_lossy().into_owned(),
+            config_path: self.config_path.clone(),
+            session_name: self.session_name.clone(),
+            start_url: self.sso_start_url.clone(),
+            region: self.sso_region.clone(),
+        })
+    }
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
 struct CachedToken {
-    #[serde(rename = "accessToken")]
     access_token: String,
-    #[serde(rename = "expiresAt")]
     expires_at: String,
+    refresh_token: Option<String>,
+    client_id: Option<String>,
+    client_secret: Option<String>,
+    registration_expires_at: Option<String>,
+}
+
+impl std::fmt::Debug for CachedToken {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("CachedToken").finish_non_exhaustive()
+    }
+}
+
+impl CachedToken {
+    fn expiration(&self) -> Result<Timestamp> {
+        self.expires_at
+            .parse()
+            .map_err(|_| login_required("invalid SSO token expiration"))
+    }
+
+    fn is_valid_at(&self, now: Timestamp) -> Result<bool> {
+        Ok(!self.access_token.is_empty() && self.expiration()? > now)
+    }
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct RefreshResponse {
+    access_token: String,
+    expires_in: i64,
+    refresh_token: Option<String>,
+}
+
+fn login_required(reason: &str) -> Error {
+    Error::config_invalid(format!(
+        "{reason}. Please run 'aws sso login' for the selected profile"
+    ))
 }
 
 #[derive(Debug, Deserialize)]
@@ -328,35 +556,7 @@ impl ProvideCredential for SSOCredentialProvider {
     type Credential = Credential;
 
     async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
-        let config = match self.load_sso_config(ctx).await {
-            Ok(c) => c,
-            Err(_) => {
-                debug!("SSO configuration not found");
-                return Ok(None);
-            }
-        };
-
-        debug!(
-            "SSO config loaded: account={}, role={}",
-            config.sso_account_id, config.sso_role_name
-        );
-
-        // Find cached SSO token
-        let token = self
-            .find_cached_token(ctx, &config.sso_start_url)
-            .await?
-            .ok_or_else(|| {
-                Error::config_invalid(
-                    "No valid SSO token found. Please run 'aws sso login' first".to_string(),
-                )
-            })?;
-
-        // Exchange token for role credentials
-        let creds = self
-            .get_role_credentials(ctx, &config, &token.access_token)
-            .await?;
-
-        Ok(Some(creds))
+        self.provide_with_clock(ctx, Timestamp::now).await
     }
 }
 
@@ -369,32 +569,4 @@ fn hex_sha1(data: &[u8]) -> String {
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use reqsign_core::StaticEnv;
-    use reqsign_file_read_tokio::TokioFileRead;
-    use reqsign_http_send_reqwest::ReqwestHttpSend;
-    use std::collections::HashMap;
-
-    #[tokio::test]
-    async fn test_sso_provider_no_config() {
-        let ctx = Context::new()
-            .with_file_read(TokioFileRead)
-            .with_http_send(ReqwestHttpSend::default());
-        let ctx = ctx.with_env(StaticEnv {
-            home_dir: Some(std::path::PathBuf::from("/home/test")),
-            envs: HashMap::new(),
-        });
-
-        let provider = SSOCredentialProvider::new();
-        let result = provider.provide_credential(&ctx).await.unwrap();
-        assert!(result.is_none());
-    }
-
-    #[test]
-    fn test_sha1_hash() {
-        let url = "https://my-sso-portal.awsapps.com/start";
-        let hash = hex_sha1(url.as_bytes());
-        assert_eq!(hash.len(), 40); // SHA1 produces 40 hex characters
-    }
-}
+mod tests;
diff --git a/services/aws-core/src/provide_credential/sso/tests.rs b/services/aws-core/src/provide_credential/sso/tests.rs
new file mode 100644
index 0000000..d5ebcdf
--- /dev/null
+++ b/services/aws-core/src/provide_credential/sso/tests.rs
@@ -0,0 +1,560 @@
+// 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 super::*;
+use bytes::Bytes;
+use http::Response;
+use reqsign_core::{FileRead, HttpSend, StaticEnv};
+use serde_json::{Value, json};
+use std::collections::VecDeque;
+use std::future::{Future, poll_fn};
+use std::pin::pin;
+use std::sync::Mutex as StdMutex;
+use std::task::Poll;
+use tokio::sync::Notify;
+
+const START_URL: &str = "https://example.awsapps.com/start";
+
+#[derive(Debug, Default)]
+struct Files(StdMutex<HashMap<String, Vec<u8>>>);
+
+impl FileRead for Files {
+    async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
+        self.0
+            .lock()
+            .unwrap()
+            .get(path)
+            .cloned()
+            .ok_or_else(|| Error::unexpected("file not found"))
+    }
+}
+
+impl Files {
+    fn put(&self, path: &str, content: impl Into<Vec<u8>>) {
+        self.0
+            .lock()
+            .unwrap()
+            .insert(path.to_string(), content.into());
+    }
+
+    fn token(&self, home: &str, name: &str, value: Value) {
+        self.put(
+            &format!("{home}/.aws/sso/cache/{}.json", hex_sha1(name.as_bytes())),
+            value.to_string(),
+        );
+    }
+}
+
+#[derive(Debug, Default)]
+struct Http {
+    requests: StdMutex<Vec<Request<Bytes>>>,
+    responses: StdMutex<VecDeque<Result<Response<Bytes>>>>,
+    refresh_gate: Option<Notify>,
+}
+
+impl HttpSend for Http {
+    async fn http_send(&self, request: Request<Bytes>) -> Result<Response<Bytes>> {
+        let refreshing = request.method() == Method::POST;
+        self.requests.lock().unwrap().push(request);
+        if refreshing {
+            if let Some(gate) = &self.refresh_gate {
+                gate.notified().await;
+            }
+        }
+        self.responses
+            .lock()
+            .unwrap()
+            .pop_front()
+            .expect("unexpected HTTP request")
+    }
+}
+
+impl Http {
+    fn reply(&self, status: u16, value: Value) {
+        self.responses
+            .lock()
+            .unwrap()
+            .push_back(Ok(Response::builder()
+                .status(status)
+                .body(Bytes::from(value.to_string()))
+                .unwrap()));
+    }
+
+    fn role(&self) {
+        self.reply(
+            200,
+            json!({"roleCredentials": {
+                "accessKeyId": "role-key", "secretAccessKey": "role-secret",
+                "sessionToken": "role-token", "expiration": 2000000000000i64
+            }}),
+        );
+    }
+
+    fn refresh(&self, token: &str, refresh: Option<&str>) {
+        self.reply(
+            200,
+            json!({"accessToken": token, "expiresIn": 60, "refreshToken": refresh}),
+        );
+    }
+
+    fn count(&self) -> usize {
+        self.requests.lock().unwrap().len()
+    }
+}
+
+fn config(session: &str) -> String {
+    format!(
+        "[profile test]\nsso_session = {session}\nsso_account_id = 123456789012\nsso_role_name = Reader\n\n[sso-session {session}]\nsso_region = us-east-1\nsso_start_url = {START_URL}\n"
+    )
+}
+
+fn cached(expires: &str) -> Value {
+    json!({"accessToken": "cached-access-secret", "expiresAt": expires,
+        "refreshToken": "original-refresh-secret", "clientId": "client-id",
+        "clientSecret": "client-secret", "registrationExpiresAt": "2099-01-01T00:00:00Z"})
+}
+
+fn timestamp() -> Timestamp {
+    "2026-01-01T00:00:00Z".parse().unwrap()
+}
+
+fn context(files: &Arc<Files>, http: &Arc<Http>, home: &str, config_path: &str) -> Context {
+    Context::new()
+        .with_file_read(files.clone())
+        .with_http_send(http.clone())
+        .with_env(StaticEnv {
+            home_dir: Some(home.into()),
+            envs: HashMap::from([
+                ("AWS_CONFIG_FILE".into(), config_path.into()),
+                ("AWS_PROFILE".into(), "test".into()),
+            ]),
+        })
+}
+
+fn fixture(token: Value) -> (Context, Arc<Files>, Arc<Http>) {
+    let files = Arc::new(Files::default());
+    let http = Arc::new(Http::default());
+    files.put("/config", config("one"));
+    files.token("/test", "one", token);
+    (context(&files, &http, "/test", "/config"), files, http)
+}
+
+#[tokio::test]
+async fn unexpired_token_is_used_without_refresh_material() {
+    let (ctx, _, http) = fixture(json!({
+        "accessToken": "cached-access-secret", "expiresAt": "2099-01-01T00:00:00Z"
+    }));
+    http.role();
+    let credential = SSOCredentialProvider::new()
+        .provide_credential(&ctx)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(credential.access_key_id, "role-key");
+    assert_eq!(
+        credential.expires_in.unwrap(),
+        Timestamp::from_millisecond(2000000000000).unwrap()
+    );
+    let requests = http.requests.lock().unwrap();
+    assert_eq!(requests.len(), 1);
+    assert_eq!(requests[0].method(), Method::GET);
+    assert_eq!(
+        requests[0].uri().to_string(),
+        "https://portal.sso.us-east-1.amazonaws.com/federation/credentials?role_name=Reader&account_id=123456789012"
+    );
+    assert_eq!(
+        requests[0].headers()["x-amz-sso_bearer_token"],
+        "cached-access-secret"
+    );
+    assert!(requests[0].headers()["x-amz-sso_bearer_token"].is_sensitive());
+}
+
+#[tokio::test]
+async fn refresh_rotation_survives_role_failure_and_subsequent_refresh() {
+    let (ctx, files, http) = fixture(cached("2025-01-01T00:00:00Z"));
+    let disk_before = files.0.lock().unwrap().clone();
+    let provider = SSOCredentialProvider::new();
+    http.refresh("new-access-secret", Some("rotated-refresh-secret"));
+    http.reply(503, json!({"secret": "do-not-log"}));
+    assert!(provider.provide_with_clock(&ctx, timestamp).await.is_err());
+    http.role();
+    provider
+        .clone()
+        .provide_with_clock(&ctx, || timestamp() + std::time::Duration::from_secs(59))
+        .await
+        .unwrap();
+    http.refresh("next-access-secret", None);
+    http.role();
+    provider
+        .provide_with_clock(&ctx, || timestamp() + std::time::Duration::from_secs(60))
+        .await
+        .unwrap();
+    http.refresh("third-access-secret", None);
+    http.role();
+    provider
+        .provide_with_clock(&ctx, || timestamp() + std::time::Duration::from_secs(120))
+        .await
+        .unwrap();
+    let requests = http.requests.lock().unwrap();
+    assert_eq!(requests.len(), 7);
+    assert_eq!(
+        requests[0].uri(),
+        "https://oidc.us-east-1.amazonaws.com/token"
+    );
+    assert_eq!(
+        requests[0].headers()[http::header::CONTENT_TYPE],
+        "application/json"
+    );
+    let body: Value = serde_json::from_slice(requests[0].body()).unwrap();
+    assert_eq!(
+        body,
+        json!({"grantType": "refresh_token", "clientId": "client-id", "clientSecret": "client-secret", "refreshToken": "original-refresh-secret"})
+    );
+    for index in [3, 5] {
+        let body: Value = serde_json::from_slice(requests[index].body()).unwrap();
+        assert_eq!(body["refreshToken"], "rotated-refresh-secret");
+    }
+    for (index, token) in [
+        (1, "new-access-secret"),
+        (2, "new-access-secret"),
+        (4, "next-access-secret"),
+        (6, "third-access-secret"),
+    ] {
+        assert_eq!(requests[index].headers()["x-amz-sso_bearer_token"], token);
+    }
+    assert_eq!(*files.0.lock().unwrap(), disk_before);
+    let debug = format!("{provider:?}");
+    for secret in [
+        "cached-access-secret",
+        "new-access-secret",
+        "third-access-secret",
+        "rotated-refresh-secret",
+        "client-secret",
+    ] {
+        assert!(!debug.contains(secret));
+    }
+}
+
+#[tokio::test]
+async fn concurrent_clones_exchange_refresh_token_once() {
+    let files = Arc::new(Files::default());
+    files.put("/config", config("one"));
+    files.token("/test", "one", cached("2025-01-01T00:00:00Z"));
+    let http = Arc::new(Http {
+        refresh_gate: Some(Notify::new()),
+        ..Http::default()
+    });
+    http.refresh("new-token", Some("rotated"));
+    http.role();
+    http.role();
+    let ctx = context(&files, &http, "/test", "/config");
+    let provider = SSOCredentialProvider::new();
+    let clone = provider.clone();
+    let mut first = pin!(provider.provide_with_clock(&ctx, timestamp));
+    let mut second = pin!(clone.provide_with_clock(&ctx, timestamp));
+    poll_fn(|cx| {
+        assert!(first.as_mut().poll(cx).is_pending());
+        assert!(second.as_mut().poll(cx).is_pending());
+        Poll::Ready(())
+    })
+    .await;
+    assert_eq!(http.count(), 1);
+    http.refresh_gate.as_ref().unwrap().notify_one();
+    let (a, b) = tokio::join!(first, second);
+    assert!(a.unwrap().is_some());
+    assert!(b.unwrap().is_some());
+    assert_eq!(http.count(), 3);
+}
+
+#[tokio::test]
+async fn missing_or_expired_refresh_material_requires_login_without_http() {
+    for field in [
+        "refreshToken",
+        "clientId",
+        "clientSecret",
+        "registrationExpiresAt",
+    ] {
+        for value in [Value::Null, json!("")] {
+            let mut token = cached("2025-01-01T00:00:00Z");
+            token[field] = value;
+            let (ctx, _, http) = fixture(token);
+            let error = SSOCredentialProvider::new()
+                .provide_with_clock(&ctx, timestamp)
+                .await
+                .unwrap_err();
+            assert!(
+                error
+                    .to_string()
+                    .contains("SSO refresh material is missing")
+            );
+            assert!(error.to_string().contains("aws sso login"));
+            assert_eq!(http.count(), 0);
+        }
+    }
+    for expiry in [
+        "2026-01-01T00:00:00Z",
+        "2025-01-01T00:00:00Z",
+        "invalid-client-secret",
+    ] {
+        let mut token = cached("2025-01-01T00:00:00Z");
+        token["registrationExpiresAt"] = json!(expiry);
+        let (ctx, _, http) = fixture(token);
+        let error = SSOCredentialProvider::new()
+            .provide_with_clock(&ctx, timestamp)
+            .await
+            .unwrap_err();
+        assert!(error.to_string().contains("aws sso login"));
+        assert!(!format!("{error:?}").contains("invalid-client-secret"));
+        assert_eq!(http.count(), 0);
+    }
+}
+
+#[tokio::test]
+async fn rejected_or_malformed_refresh_never_uses_expired_token_or_leaks_secrets() {
+    for (status, body) in [
+        (
+            400,
+            json!({"error": "invalid_grant", "error_description": "client-secret"}),
+        ),
+        (401, json!({"error": "invalid_client"})),
+        (
+            200,
+            json!({"accessToken": "new-secret", "expiresIn": "client-secret"}),
+        ),
+        (200, json!({"accessToken": "new-secret", "expiresIn": 0})),
+        (200, json!({"accessToken": "new-secret", "expiresIn": -1})),
+        (
+            200,
+            json!({"accessToken": "new-secret", "expiresIn": i64::MAX}),
+        ),
+        (200, json!({"accessToken": "", "expiresIn": 60})),
+    ] {
+        let (ctx, _, http) = fixture(cached("2025-01-01T00:00:00Z"));
+        http.reply(status, body);
+        let error = SSOCredentialProvider::new()
+            .provide_with_clock(&ctx, timestamp)
+            .await
+            .unwrap_err();
+        assert!(error.to_string().contains("aws sso login"));
+        assert!(!format!("{error:?}").contains("client-secret"));
+        assert!(!format!("{error:?}").contains("new-secret"));
+        assert_eq!(http.count(), 1);
+    }
+}
+
+#[tokio::test]
+async fn transport_failure_can_retry_without_losing_refresh_material() {
+    let (ctx, _, http) = fixture(cached("2025-01-01T00:00:00Z"));
+    http.responses
+        .lock()
+        .unwrap()
+        .push_back(Err(Error::unexpected("client-secret")));
+    let provider = SSOCredentialProvider::new();
+    let error = provider
+        .provide_with_clock(&ctx, timestamp)
+        .await
+        .unwrap_err();
+    assert!(!format!("{error:?}").contains("client-secret"));
+    http.refresh("new-token", None);
+    http.role();
+    provider.provide_with_clock(&ctx, timestamp).await.unwrap();
+    assert_eq!(http.count(), 3);
+}
+
+#[tokio::test]
+async fn sessions_with_same_start_url_have_separate_cache_and_refresh_state() {
+    let (ctx, files, http) = fixture(cached("2025-01-01T00:00:00Z"));
+    let mut other = cached("2025-01-01T00:00:00Z");
+    other["refreshToken"] = json!("session-two-refresh");
+    files.token("/test", "two", other);
+    let provider = SSOCredentialProvider::new();
+    http.refresh("session-one-access", None);
+    http.role();
+    provider.provide_with_clock(&ctx, timestamp).await.unwrap();
+    files.put("/config", config("two"));
+    http.refresh("session-two-access", None);
+    http.role();
+    provider.provide_with_clock(&ctx, timestamp).await.unwrap();
+    files.put("/config", config("one"));
+    http.role();
+    provider.provide_with_clock(&ctx, timestamp).await.unwrap();
+    let requests = http.requests.lock().unwrap();
+    let body: Value = serde_json::from_slice(requests[2].body()).unwrap();
+    assert_eq!(body["refreshToken"], "session-two-refresh");
+    assert_eq!(
+        requests[3].headers()["x-amz-sso_bearer_token"],
+        "session-two-access"
+    );
+    assert_eq!(
+        requests[4].headers()["x-amz-sso_bearer_token"],
+        "session-one-access"
+    );
+    assert_eq!(requests.len(), 5);
+}
+
+#[tokio::test]
+async fn cache_paths_and_config_sources_isolate_clones() {
+    let (ctx, files, http) = fixture(cached("2099-01-01T00:00:00Z"));
+    let provider = SSOCredentialProvider::new();
+    http.role();
+    provider.provide_with_clock(&ctx, timestamp).await.unwrap();
+    let mut other = cached("2099-01-01T00:00:00Z");
+    other["accessToken"] = json!("other-home");
+    files.token("/other", "one", other);
+    http.role();
+    provider
+        .clone()
+        .provide_with_clock(&context(&files, &http, "/other", "/config"), timestamp)
+        .await
+        .unwrap();
+    files.put("/other-config", config("one"));
+    let mut other = cached("2099-01-01T00:00:00Z");
+    other["accessToken"] = json!("other-config");
+    files.token("/test", "one", other);
+    http.role();
+    provider
+        .provide_with_clock(&context(&files, &http, "/test", "/other-config"), timestamp)
+        .await
+        .unwrap();
+    let requests = http.requests.lock().unwrap();
+    assert_eq!(
+        requests[1].headers()["x-amz-sso_bearer_token"],
+        "other-home"
+    );
+    assert_eq!(
+        requests[2].headers()["x-amz-sso_bearer_token"],
+        "other-config"
+    );
+}
+
+#[tokio::test]
+async fn legacy_inline_config_uses_url_cache_and_never_refreshes() {
+    let (ctx, files, http) = fixture(cached("2099-01-01T00:00:00Z"));
+    files.put("/config", format!("[profile test]\nsso_start_url = {START_URL}\nsso_region = us-east-1\nsso_account_id = 123\nsso_role_name = Reader\n"));
+    files.token("/test", START_URL, cached("2026-01-01T00:00:01Z"));
+    let provider = SSOCredentialProvider::new();
+    http.role();
+    provider.provide_with_clock(&ctx, timestamp).await.unwrap();
+    let error = provider
+        .provide_with_clock(&ctx, || timestamp() + std::time::Duration::from_secs(1))
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("legacy SSO token has expired"));
+    assert_eq!(http.count(), 1);
+}
+
+#[tokio::test]
+async fn named_session_never_falls_back_to_url_cache_or_another_provider() {
+    let (ctx, files, http) = fixture(cached("2025-01-01T00:00:00Z"));
+    files.token("/test", START_URL, cached("2099-01-01T00:00:00Z"));
+    http.reply(400, json!({"error": "invalid_grant"}));
+    let provider = crate::DefaultCredentialProvider::builder()
+        .no_env()
+        .no_profile()
+        .no_web_identity()
+        .no_process()
+        .no_imds()
+        .with_profile("test")
+        .build();
+    // The default chain must preserve the SSO error instead of returning None.
+    let error = provider.provide_credential(&ctx).await.unwrap_err();
+    assert!(error.to_string().contains("SSO token refresh rejected"));
+    assert_eq!(http.count(), 1);
+    http.refresh("default-chain-access", None);
+    http.role();
+    assert_eq!(
+        provider
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap()
+            .access_key_id,
+        "role-key"
+    );
+    assert_eq!(http.count(), 3);
+}
+
+#[tokio::test]
+async fn missing_named_cache_does_not_use_legacy_url_cache() {
+    let (ctx, files, http) = fixture(cached("2099-01-01T00:00:00Z"));
+    files.put("/config", config("missing"));
+    files.token("/test", START_URL, cached("2099-01-01T00:00:00Z"));
+    let error = SSOCredentialProvider::new()
+        .provide_credential(&ctx)
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("SSO token cache not found"));
+    assert!(error.to_string().contains("aws sso login"));
+    assert_eq!(http.count(), 0);
+}
+
+#[tokio::test]
+async fn named_session_config_errors_are_not_absence() {
+    for contents in [
+        "[profile test]\nsso_session = missing\nsso_account_id = 123\nsso_role_name = Reader\n"
+            .to_string(),
+        config("one").replace("sso_account_id = 123456789012", ""),
+        config("one").replace("sso_region = us-east-1", ""),
+        config("one").replace(
+            "sso_session = one",
+            "sso_session = one\nsso_region = us-west-2",
+        ),
+    ] {
+        let (ctx, files, http) = fixture(cached("2099-01-01T00:00:00Z"));
+        files.put("/config", contents);
+        assert!(
+            SSOCredentialProvider::new()
+                .provide_credential(&ctx)
+                .await
+                .is_err()
+        );
+        assert_eq!(http.count(), 0);
+    }
+    let (ctx, files, _) = fixture(cached("2099-01-01T00:00:00Z"));
+    files.put("/config", "[profile test]\nregion = us-east-1\n");
+    assert!(
+        SSOCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .is_none()
+    );
+}
+
+#[tokio::test]
+async fn newer_login_cache_replaces_expired_in_memory_token() {
+    let (ctx, files, http) = fixture(cached("2025-01-01T00:00:00Z"));
+    let provider = SSOCredentialProvider::new();
+    http.refresh("refreshed-access", None);
+    http.role();
+    provider.provide_with_clock(&ctx, timestamp).await.unwrap();
+    files.token(
+        "/test",
+        "one",
+        json!({"accessToken": "new-login", "expiresAt": "2099-01-01T00:00:00Z"}),
+    );
+    http.role();
+    provider
+        .provide_with_clock(&ctx, || timestamp() + std::time::Duration::from_secs(60))
+        .await
+        .unwrap();
+    assert_eq!(http.count(), 3);
+    assert_eq!(
+        http.requests.lock().unwrap()[2].headers()["x-amz-sso_bearer_token"],
+        "new-login"
+    );
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@reqsign.apache.org
For additional commands, e-mail: commits-help@reqsign.apache.org