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/05 05:42:17 UTC

(reqsign) branch main updated: feat(aws): refresh named SSO session access tokens (#903)

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

Xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/reqsign.git


The following commit(s) were added to refs/heads/main by this push:
     new f0752d1  feat(aws): refresh named SSO session access tokens (#903)
f0752d1 is described below

commit f0752d1cfc33ff441cd45d8aa847ff2c5e53a9bb
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Mon Oct 5 13:42:11 2026 +0800

    feat(aws): refresh named SSO session access tokens (#903)
    
    Closes #898. Builds on the shared AWS configuration loader introduced in
    #902.
    
    Named SSO sessions renew expired access tokens through
    `Context::http_send` using cached OIDC registration and refresh
    material. Configuration is resolved through the shared AWS loader from
    #902, including literal command values. This includes named-session
    resolution and cache selection from #856, without implementing
    assume-role profile graphs.
    
    Each internal `SsoSession` owns its token state and refresh lock.
    Provider clones share sessions, but a blocked refresh does not block
    another session. Disk JSON is converted into typed in-memory state with
    `Timestamp` expirations. A successful refresh replaces the complete
    token state before requesting role credentials, preserving rotated
    refresh tokens across role-exchange failures. Rejected grants require
    login; throttling (including HTTP 400 `slow_down`) and transient
    failures remain retryable without exposing response descriptions or
    secrets.
    
    The AWS default provider executes SSO between its preceding and
    following provider chains and propagates SSO errors directly. Absent SSO
    configuration continues resolution. Core APIs are unchanged, as are
    custom `ProvideCredentialChain` behavior, `with_chain`, slot removal,
    and `push_front` precedence.
    
    Refreshed material remains in memory for the provider and its clones.
    The AWS CLI cache is never written, independent providers/processes do
    not coordinate, and restarting requires usable on-disk refresh material.
    Once an in-memory token expires, a newer CLI login cache can replace it.
    Legacy inline configuration remains non-refreshable and no interactive
    login is started.
    
    Tests use synthetic files and HTTP responses, a controlled clock, and
    gated concurrent calls. Coverage includes rotation and subsequent
    refresh, independent-session progress, same-session coordination, failed
    role exchanges, default-chain boundaries, and the reported non-SSO
    configuration reproducer. No developer SSO cache or real SSO account is
    required.
---
 services/aws-core/Cargo.toml                       |   1 +
 services/aws-core/src/config.rs                    |  54 +-
 .../aws-core/src/provide_credential/default.rs     |  49 +-
 services/aws-core/src/provide_credential/sso.rs    | 279 ++++----
 .../aws-core/src/provide_credential/sso/session.rs | 305 ++++++++
 .../aws-core/src/provide_credential/sso/tests.rs   | 774 +++++++++++++++++++++
 6 files changed, 1273 insertions(+), 189 deletions(-)

diff --git a/services/aws-core/Cargo.toml b/services/aws-core/Cargo.toml
index 15a081d..9d1a96d 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 }
 futures = { workspace = true }
 futures-timer = { version = "3", features = ["wasm-bindgen"] }
diff --git a/services/aws-core/src/config.rs b/services/aws-core/src/config.rs
index 289f76f..66ed017 100644
--- a/services/aws-core/src/config.rs
+++ b/services/aws-core/src/config.rs
@@ -99,24 +99,27 @@ impl SharedConfig {
             .or_else(|| ctx.env_var("AWS_DEFAULT_REGION"))
     }
 
-    pub(crate) async fn load_config_file(&self, ctx: &Context) -> Result<Ini> {
-        read_ini(
+    pub(crate) fn config_file_path(&self, ctx: &Context) -> Option<String> {
+        file_path(
             ctx,
             self.config_file.as_deref(),
             "AWS_CONFIG_FILE",
             "~/.aws/config",
         )
-        .await
+    }
+
+    pub(crate) async fn load_config_file(&self, ctx: &Context) -> Result<Ini> {
+        read_ini(ctx, self.config_file_path(ctx)).await
     }
 
     pub(crate) async fn load_credentials_file(&self, ctx: &Context) -> Result<Ini> {
-        read_ini(
+        let path = file_path(
             ctx,
             self.credentials_file.as_deref(),
             "AWS_SHARED_CREDENTIALS_FILE",
             "~/.aws/credentials",
-        )
-        .await
+        );
+        read_ini(ctx, path).await
     }
 
     /// Read and merge the selected profile. This never contacts IMDS, even when enabled.
@@ -221,12 +224,18 @@ pub(crate) fn config_section(profile: &str) -> String {
     }
 }
 
-async fn read_ini(ctx: &Context, explicit: Option<&str>, env: &str, default: &str) -> Result<Ini> {
-    let path = explicit
-        .map(str::to_owned)
-        .or_else(|| ctx.env_var(env))
-        .unwrap_or_else(|| default.into());
-    let Some(path) = ctx.expand_home_dir(&path) else {
+fn aws_parse_options() -> ParseOption {
+    // Preserve literal commands and keep nested settings distinct from profile properties.
+    ParseOption {
+        enabled_quote: false,
+        enabled_escape: false,
+        enabled_indented_mutiline_value: true,
+        ..Default::default()
+    }
+}
+
+async fn read_ini(ctx: &Context, path: Option<String>) -> Result<Ini> {
+    let Some(path) = path else {
         return Ok(Ini::new());
     };
     let Ok(content) = ctx.file_read(&path).await else {
@@ -234,15 +243,14 @@ async fn read_ini(ctx: &Context, explicit: Option<&str>, env: &str, default: &st
     };
     let content = std::str::from_utf8(&content)
         .map_err(|_| Error::config_invalid("AWS shared configuration is not UTF-8"))?;
-    // Commands contain literal quotes/backslashes; nested settings belong to their parent value.
-    Ini::load_from_str_opt(
-        content,
-        ParseOption {
-            enabled_quote: false,
-            enabled_escape: false,
-            enabled_indented_mutiline_value: true,
-            ..Default::default()
-        },
-    )
-    .map_err(|_| Error::config_invalid("failed to parse AWS shared configuration"))
+    Ini::load_from_str_opt(content, aws_parse_options())
+        .map_err(|_| Error::config_invalid("failed to parse AWS shared configuration"))
+}
+
+fn file_path(ctx: &Context, explicit: Option<&str>, env: &str, default: &str) -> Option<String> {
+    let path = explicit
+        .map(str::to_owned)
+        .or_else(|| ctx.env_var(env))
+        .unwrap_or_else(|| default.into());
+    ctx.expand_home_dir(&path)
 }
diff --git a/services/aws-core/src/provide_credential/default.rs b/services/aws-core/src/provide_credential/default.rs
index 7a90516..0e5b525 100644
--- a/services/aws-core/src/provide_credential/default.rs
+++ b/services/aws-core/src/provide_credential/default.rs
@@ -35,9 +35,15 @@ 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>,
+    #[cfg(not(target_arch = "wasm32"))]
+    sso: Option<SSOCredentialProvider>,
+    fallback: ProvideCredentialChain<Credential>,
 }
 
 impl Default for DefaultCredentialProvider {
@@ -59,7 +65,12 @@ impl DefaultCredentialProvider {
 
     /// Create with a custom credential chain.
     pub fn with_chain(chain: ProvideCredentialChain<Credential>) -> Self {
-        Self { chain }
+        Self {
+            chain,
+            #[cfg(not(target_arch = "wasm32"))]
+            sso: None,
+            fallback: ProvideCredentialChain::new(),
+        }
     }
 
     /// Add a credential provider to the front of the default chain.
@@ -250,40 +261,50 @@ impl DefaultCredentialProviderBuilder {
             chain = chain.push(p);
         }
 
-        #[cfg(not(target_arch = "wasm32"))]
-        {
-            if let Some(p) = self.sso {
-                chain = chain.push(p);
-            }
-        }
+        let mut fallback = ProvideCredentialChain::new();
 
         if let Some(p) = self.web_identity {
-            chain = chain.push(p);
+            fallback = fallback.push(p);
         }
 
         #[cfg(not(target_arch = "wasm32"))]
         {
             if let Some(p) = self.process {
-                chain = chain.push(p);
+                fallback = fallback.push(p);
             }
         }
 
         if let Some(p) = self.ecs {
-            chain = chain.push(p);
+            fallback = fallback.push(p);
         }
 
         if let Some(p) = self.imds {
-            chain = chain.push(p);
+            fallback = fallback.push(p);
         }
 
-        DefaultCredentialProvider::with_chain(chain)
+        DefaultCredentialProvider {
+            chain,
+            #[cfg(not(target_arch = "wasm32"))]
+            sso: self.sso,
+            fallback,
+        }
     }
 }
 impl ProvideCredential for DefaultCredentialProvider {
     type Credential = Credential;
 
     async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
-        self.chain.provide_credential(ctx).await
+        if let Some(credential) = self.chain.provide_credential(ctx).await? {
+            return Ok(Some(credential));
+        }
+        // SSO selects an identity. Its failure must not switch to a later source.
+        #[cfg(not(target_arch = "wasm32"))]
+        if let Some(sso) = &self.sso {
+            if let Some(credential) = sso.provide_credential(ctx).await? {
+                return Ok(Some(credential));
+            }
+        }
+        self.fallback.provide_credential(ctx).await
     }
 }
 
@@ -641,7 +662,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 da9fceb..627c048 100644
--- a/services/aws-core/src/provide_credential/sso.rs
+++ b/services/aws-core/src/provide_credential/sso.rs
@@ -16,17 +16,21 @@
 // under the License.
 
 use crate::Credential;
+use asyncband::mutex::Mutex;
 use http::{Method, Request, StatusCode};
-use log::{debug, warn};
 use reqsign_core::time::Timestamp;
 use reqsign_core::{Context, Error, ProvideCredential, Result};
 use serde::Deserialize;
 
+mod session;
+use session::{SessionKey, SsoSession, login_required};
+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
@@ -38,11 +42,28 @@ 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.
+/// The AWS default provider propagates SSO errors. A custom credential chain
+/// retains its own error policy.
 #[derive(Debug, Clone)]
 pub struct SSOCredentialProvider {
     profile: Option<String>,
@@ -51,6 +72,7 @@ pub struct SSOCredentialProvider {
     sso_role_name: Option<String>,
     sso_start_url: Option<String>,
     sso_endpoint: Option<String>, // Allow custom endpoint for testing
+    sessions: Arc<Mutex<HashMap<SessionKey, Arc<SsoSession>>>>,
 }
 
 impl Default for SSOCredentialProvider {
@@ -69,6 +91,7 @@ impl SSOCredentialProvider {
             sso_role_name: None,
             sso_start_url: None,
             sso_endpoint: None,
+            sessions: Arc::new(Mutex::new(HashMap::new())),
         }
     }
 
@@ -108,7 +131,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,
@@ -116,94 +139,113 @@ 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
         let mut shared = crate::SharedConfig::new();
         if let Some(profile) = &self.profile {
             shared = shared.with_profile(profile);
         }
-        let profile_name = shared.profile_name(ctx);
-        self.load_from_config_file(ctx, &profile_name).await
+        self.load_from_config_file(ctx, &shared).await
     }
 
-    async fn load_from_config_file(&self, ctx: &Context, profile: &str) -> Result<SSOConfig> {
-        let conf = crate::SharedConfig::new().load_config_file(ctx).await?;
-        let profile_section = crate::config::config_section(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 sso_start_url = section.get(AWS_SSO_START_URL).ok_or_else(|| {
-            Error::config_invalid(format!("missing {AWS_SSO_START_URL} in profile"))
-        })?;
+    async fn load_from_config_file(
+        &self,
+        ctx: &Context,
+        shared: &crate::SharedConfig,
+    ) -> Result<Option<SSOConfig>> {
+        let conf = shared.load_config_file(ctx).await?;
+        let profile_section = crate::config::config_section(&shared.profile_name(ctx));
+        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);
+        }
 
-        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(),
-        })
+        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: shared.config_file_path(ctx),
+        }))
     }
 
-    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);
-                }
-
-                Ok(Some(token))
-            }
-            Err(_) => {
-                debug!("SSO token cache not found");
-                Ok(None)
-            }
+        config: &SSOConfig,
+        now: impl Fn() -> Timestamp + Send + Sync,
+    ) -> Result<String> {
+        let key = SessionKey::from_config(ctx, config)?;
+        if config.session_name.is_none() {
+            return session::legacy_access_token(ctx, &key.path, now()).await;
         }
+        let session = {
+            let mut sessions = self.sessions.lock().await;
+            sessions
+                .entry(key.clone())
+                .or_insert_with(|| Arc::new(SsoSession::new(key)))
+                .clone()
+        };
+        session.access_token(ctx, now).await
+    }
+
+    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(
@@ -232,17 +274,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!(
@@ -253,7 +298,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)
@@ -274,14 +319,8 @@ struct SSOConfig {
     sso_region: String,
     sso_role_name: String,
     sso_start_url: String,
-}
-
-#[derive(Debug, Deserialize)]
-struct CachedToken {
-    #[serde(rename = "accessToken")]
-    access_token: String,
-    #[serde(rename = "expiresAt")]
-    expires_at: String,
+    session_name: Option<String>,
+    config_path: Option<String>,
 }
 
 #[derive(Debug, Deserialize)]
@@ -302,73 +341,9 @@ 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
     }
 }
 
-// Simple SHA1 implementation for cache key generation
-fn hex_sha1(data: &[u8]) -> String {
-    use sha1::{Digest, Sha1};
-    let mut hasher = Sha1::new();
-    hasher.update(data);
-    hex::encode(hasher.finalize())
-}
-
 #[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/session.rs b/services/aws-core/src/provide_credential/sso/session.rs
new file mode 100644
index 0000000..965e51f
--- /dev/null
+++ b/services/aws-core/src/provide_credential/sso/session.rs
@@ -0,0 +1,305 @@
+// 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.
+
+//! Session-owned access tokens and OIDC refresh state. Disk cache records are
+//! inputs only; refreshed material remains in memory for the session lifetime.
+
+use asyncband::mutex::Mutex;
+use http::{Method, Request, StatusCode};
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, Result};
+use serde::Deserialize;
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(super) struct SessionKey {
+    pub(super) path: String,
+    config_path: Option<String>,
+    session_name: Option<String>,
+    start_url: String,
+    region: String,
+}
+
+impl SessionKey {
+    pub fn from_config(ctx: &Context, config: &super::SSOConfig) -> Result<Self> {
+        let home = ctx
+            .home_dir()
+            .ok_or_else(|| Error::config_invalid("HOME directory not found"))?;
+        let name = config
+            .session_name
+            .as_deref()
+            .unwrap_or(&config.sso_start_url);
+        let path = home
+            .join(".aws")
+            .join("sso")
+            .join("cache")
+            .join(format!("{}.json", hex_sha1(name.as_bytes())));
+        Ok(SessionKey {
+            path: path.to_string_lossy().into_owned(),
+            config_path: config.config_path.clone(),
+            session_name: config.session_name.clone(),
+            start_url: config.sso_start_url.clone(),
+            region: config.sso_region.clone(),
+        })
+    }
+}
+
+#[derive(Debug)]
+pub(super) struct SsoSession {
+    key: SessionKey,
+    token: Mutex<Option<SessionToken>>,
+}
+
+impl SsoSession {
+    pub fn new(key: SessionKey) -> Self {
+        Self {
+            key,
+            token: Mutex::new(None),
+        }
+    }
+
+    pub async fn access_token(
+        &self,
+        ctx: &Context,
+        now: impl Fn() -> Timestamp + Send + Sync,
+    ) -> Result<String> {
+        // Only this session is locked across I/O. Clones share refresh state,
+        // while other sessions can load or refresh their tokens independently.
+        let mut state = self.token.lock().await;
+        if let Some(token) = state.as_ref() {
+            if token.is_valid_at(now()) {
+                return Ok(token.access_token.clone());
+            }
+        }
+        if let Some(disk) = SessionToken::read(ctx, &self.key.path).await? {
+            if state
+                .as_ref()
+                .is_none_or(|current| disk.expires_at > current.expires_at)
+            {
+                *state = Some(disk);
+            }
+        }
+        let token = state
+            .as_mut()
+            .ok_or_else(|| login_required("SSO token cache not found"))?;
+        if !token.is_valid_at(now()) {
+            // Replace only on success. Retain rotated material even if a later
+            // role exchange fails, and preserve current state on refresh errors.
+            *token = token.refresh_token(ctx, &self.key.region, &now).await?;
+        }
+        if !token.is_valid_at(now()) {
+            return Err(login_required("refreshed SSO access token has expired"));
+        }
+        Ok(token.access_token.clone())
+    }
+}
+
+pub(super) async fn legacy_access_token(
+    ctx: &Context,
+    path: &str,
+    now: Timestamp,
+) -> Result<String> {
+    let token = SessionToken::read(ctx, path)
+        .await?
+        .ok_or_else(|| login_required("SSO token cache not found"))?;
+    if !token.is_valid_at(now) {
+        return Err(login_required("legacy SSO token has expired"));
+    }
+    Ok(token.access_token)
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct CachedTokenFile {
+    access_token: String,
+    expires_at: String,
+    refresh_token: Option<String>,
+    client_id: Option<String>,
+    client_secret: Option<String>,
+    registration_expires_at: Option<String>,
+}
+
+impl CachedTokenFile {
+    fn into_token(self) -> Result<SessionToken> {
+        let expires_at = self
+            .expires_at
+            .parse()
+            .map_err(|_| login_required("invalid SSO token expiration"))?;
+        // Invalid refresh material must not prevent use of a valid access token.
+        let refresh = (|| {
+            Some(RefreshMaterial {
+                token: self.refresh_token.filter(|s| !s.is_empty())?,
+                client_id: self.client_id.filter(|s| !s.is_empty())?,
+                client_secret: self.client_secret.filter(|s| !s.is_empty())?,
+                expires_at: self.registration_expires_at?.parse().ok()?,
+            })
+        })();
+        Ok(SessionToken {
+            access_token: self.access_token,
+            expires_at,
+            refresh,
+        })
+    }
+}
+
+struct SessionToken {
+    access_token: String,
+    expires_at: Timestamp,
+    refresh: Option<RefreshMaterial>,
+}
+
+impl std::fmt::Debug for SessionToken {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("SessionToken").finish_non_exhaustive()
+    }
+}
+
+#[derive(Clone)]
+struct RefreshMaterial {
+    token: String,
+    client_id: String,
+    client_secret: String,
+    expires_at: Timestamp,
+}
+
+impl SessionToken {
+    fn is_valid_at(&self, now: Timestamp) -> bool {
+        !self.access_token.is_empty() && self.expires_at > now
+    }
+
+    async fn read(ctx: &Context, path: &str) -> Result<Option<Self>> {
+        let content = match ctx.file_read(path).await {
+            Ok(content) => content,
+            Err(_) => return Ok(None),
+        };
+        serde_json::from_slice::<CachedTokenFile>(&content)
+            .map_err(|_| login_required("invalid SSO token cache"))?
+            .into_token()
+            .map(Some)
+    }
+
+    async fn refresh_token(
+        &self,
+        ctx: &Context,
+        region: &str,
+        now: impl Fn() -> Timestamp + Send + Sync,
+    ) -> Result<Self> {
+        let material = self
+            .refresh
+            .as_ref()
+            .ok_or_else(|| login_required("SSO refresh material is missing or invalid"))?;
+        let started = now();
+        if material.expires_at <= started {
+            return Err(login_required("SSO client registration has expired"));
+        }
+        let body = serde_json::json!({
+            "grantType": "refresh_token",
+            "clientId": material.client_id,
+            "clientSecret": material.client_secret,
+            "refreshToken": material.token,
+        });
+        let req = Request::builder()
+            .method(Method::POST)
+            .uri(format!("https://oidc.{}.amazonaws.com/token", 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();
+            // CreateToken also reports throttling as HTTP 400. Only recognize
+            // the allowlisted code; never retain or expose the error description.
+            let slow_down = status == StatusCode::BAD_REQUEST
+                && serde_json::from_slice::<RefreshError>(response.body())
+                    .is_ok_and(|error| matches!(error.error, RefreshErrorCode::SlowDown));
+            if status == StatusCode::TOO_MANY_REQUESTS || slow_down {
+                return Err(Error::rate_limited("SSO token refresh was throttled"));
+            }
+            if status.is_server_error() {
+                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"))?;
+        let mut material = material.clone();
+        if let Some(token) = refreshed.refresh_token {
+            material.token = token;
+        }
+        Ok(Self {
+            access_token: refreshed.access_token,
+            expires_at,
+            refresh: Some(material),
+        })
+    }
+}
+
+#[derive(Deserialize)]
+struct RefreshError {
+    error: RefreshErrorCode,
+}
+
+#[derive(Deserialize)]
+enum RefreshErrorCode {
+    #[serde(rename = "slow_down")]
+    SlowDown,
+    #[serde(other)]
+    Other,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct RefreshResponse {
+    access_token: String,
+    expires_in: i64,
+    refresh_token: Option<String>,
+}
+
+pub(super) fn login_required(reason: &str) -> Error {
+    Error::config_invalid(format!(
+        "{reason}. Please run 'aws sso login' for the selected profile"
+    ))
+}
+
+// Simple SHA1 implementation for cache key generation
+pub(super) fn hex_sha1(data: &[u8]) -> String {
+    use sha1::{Digest, Sha1};
+    let mut hasher = Sha1::new();
+    hasher.update(data);
+    hex::encode(hasher.finalize())
+}
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..3a05e66
--- /dev/null
+++ b/services/aws-core/src/provide_credential/sso/tests.rs
@@ -0,0 +1,774 @@
+// 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::session::hex_sha1;
+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"})),
+        (
+            400,
+            json!({"error": "client-secret", "error_description": "new-secret"}),
+        ),
+        (
+            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"
+    );
+}
+
+#[derive(Debug, Default)]
+struct ProcessCommand(std::sync::atomic::AtomicUsize);
+
+impl reqsign_core::CommandExecute for ProcessCommand {
+    async fn command_execute(
+        &self,
+        program: &str,
+        args: &[&str],
+    ) -> Result<reqsign_core::CommandOutput> {
+        assert_eq!(program, "helper");
+        assert!(args.is_empty());
+        self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+        Ok(reqsign_core::CommandOutput {
+            status: 0,
+            stdout:
+                br#"{"Version":1,"AccessKeyId":"process-key","SecretAccessKey":"process-secret"}"#
+                    .to_vec(),
+            stderr: vec![],
+        })
+    }
+}
+
+#[tokio::test]
+async fn default_chain_preserves_literal_config_and_selected_sso_errors() {
+    for command in [
+        r"helper --certificate C:\x509\certificate.pem",
+        r#"helper --argument "unterminated"#,
+    ] {
+        for selected_sso in [false, true] {
+            let (ctx, files, http) = fixture(cached("2099-01-01T00:00:00Z"));
+            let sso = if selected_sso {
+                "sso_session = missing\n"
+            } else {
+                ""
+            };
+            files.put("/config", format!(
+                "[default]\nregion = us-east-1\n{sso}\n[profile unselected]\ncredential_process = {command}\n"
+            ));
+            let process = Arc::new(ProcessCommand::default());
+            let ctx = ctx.with_command_execute(process.clone());
+            let provider = crate::DefaultCredentialProvider::builder()
+                .no_env()
+                .no_profile()
+                .no_web_identity()
+                .no_ecs()
+                .no_imds()
+                .process(crate::ProcessCredentialProvider::new().with_command("helper"))
+                .with_profile("default")
+                .build();
+            let result = provider.provide_credential(&ctx).await;
+            if selected_sso {
+                let error = result.unwrap_err();
+                assert_eq!(error.kind(), reqsign_core::ErrorKind::ConfigInvalid);
+                assert_eq!(error.to_string(), "referenced SSO session not found");
+                assert_eq!(process.0.load(std::sync::atomic::Ordering::SeqCst), 0);
+            } else {
+                assert_eq!(result.unwrap().unwrap().access_key_id, "process-key");
+                assert_eq!(process.0.load(std::sync::atomic::Ordering::SeqCst), 1);
+            }
+            assert_eq!(http.count(), 0);
+        }
+    }
+}
+
+#[tokio::test]
+async fn refresh_throttling_preserves_material_for_retry() {
+    for status in [400, 429] {
+        let (ctx, _, http) = fixture(cached("2025-01-01T00:00:00Z"));
+        http.reply(
+            status,
+            json!({
+                "error": "slow_down", "error_description": "client-secret original-refresh-secret"
+            }),
+        );
+        let provider = SSOCredentialProvider::new();
+        let error = provider
+            .provide_with_clock(&ctx, timestamp)
+            .await
+            .unwrap_err();
+        assert_eq!(error.kind(), reqsign_core::ErrorKind::RateLimited);
+        assert!(error.is_retryable());
+        assert!(!error.to_string().contains("aws sso login"));
+        for secret in ["client-secret", "original-refresh-secret"] {
+            assert!(!format!("{error:?}").contains(secret));
+        }
+        assert_eq!(http.count(), 1);
+        http.refresh("new-access", Some("rotated-refresh"));
+        http.role();
+        provider.provide_with_clock(&ctx, timestamp).await.unwrap();
+        let requests = http.requests.lock().unwrap();
+        assert_eq!(requests.len(), 3);
+        assert_eq!(requests[0].body(), requests[1].body());
+        assert_eq!(
+            requests[2].headers()["x-amz-sso_bearer_token"],
+            "new-access"
+        );
+    }
+}
+
+#[tokio::test]
+async fn another_session_can_finish_while_refresh_is_blocked() {
+    let files = Arc::new(Files::default());
+    files.put(
+        "/config",
+        format!(
+            "{}\n{}",
+            config("one"),
+            config("two").replace("[profile test]", "[profile second]")
+        ),
+    );
+    files.token("/test", "one", cached("2025-01-01T00:00:00Z"));
+    files.token("/test", "two", cached("2025-01-01T00:00:00Z"));
+    let blocked_http = Arc::new(Http {
+        refresh_gate: Some(Notify::new()),
+        ..Http::default()
+    });
+    blocked_http.refresh("one-access", None);
+    blocked_http.role();
+    let independent_http = Arc::new(Http::default());
+    independent_http.refresh("two-access", None);
+    independent_http.role();
+    let ctx = context(&files, &blocked_http, "/test", "/config");
+    let second_ctx = context(&files, &independent_http, "/test", "/config");
+    let provider = SSOCredentialProvider::new();
+    let second_provider = provider.clone().with_profile("second");
+    let mut blocked = pin!(provider.provide_with_clock(&ctx, timestamp));
+    let mut independent = pin!(second_provider.provide_with_clock(&second_ctx, timestamp));
+    poll_fn(|cx| {
+        assert!(blocked.as_mut().poll(cx).is_pending());
+        let Poll::Ready(result) = independent.as_mut().poll(cx) else {
+            panic!("another session must not wait for the blocked refresh");
+        };
+        assert_eq!(result.unwrap().unwrap().access_key_id, "role-key");
+        Poll::Ready(())
+    })
+    .await;
+    assert_eq!(blocked_http.count(), 1);
+    assert_eq!(independent_http.count(), 2);
+    blocked_http.refresh_gate.as_ref().unwrap().notify_one();
+    assert!(blocked.await.unwrap().is_some());
+}
+
+#[derive(Debug)]
+struct FailedProvider;
+
+impl ProvideCredential for FailedProvider {
+    type Credential = Credential;
+
+    async fn provide_credential(&self, _: &Context) -> Result<Option<Credential>> {
+        Err(Error::config_invalid("custom provider failed"))
+    }
+}
+
+#[tokio::test]
+async fn default_provider_keeps_slot_removal_precedence_and_custom_chain_policy() {
+    let (ctx, files, _) = fixture(cached("2099-01-01T00:00:00Z"));
+    files.put("/config", "[profile test]\nsso_session = missing\n");
+    let process = Arc::new(ProcessCommand::default());
+    let ctx = ctx.with_command_execute(process.clone());
+    let builder = || {
+        crate::DefaultCredentialProvider::builder()
+            .no_env()
+            .no_profile()
+            .no_web_identity()
+            .no_ecs()
+            .no_imds()
+            .process(crate::ProcessCredentialProvider::new().with_command("helper"))
+    };
+    assert!(builder().build().provide_credential(&ctx).await.is_err());
+    assert_eq!(process.0.load(std::sync::atomic::Ordering::SeqCst), 0);
+    assert_eq!(
+        builder()
+            .no_sso()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap()
+            .access_key_id,
+        "process-key"
+    );
+    let first = builder()
+        .build()
+        .push_front(crate::StaticCredentialProvider::new("first-key", "secret"));
+    assert_eq!(
+        first
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap()
+            .access_key_id,
+        "first-key"
+    );
+    let chain = reqsign_core::ProvideCredentialChain::new()
+        .push(FailedProvider)
+        .push(crate::StaticCredentialProvider::new("custom-key", "secret"));
+    let custom = crate::DefaultCredentialProvider::with_chain(chain);
+    assert_eq!(
+        custom
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap()
+            .access_key_id,
+        "custom-key"
+    );
+    assert_eq!(process.0.load(std::sync::atomic::Ordering::SeqCst), 1);
+}


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