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, ®ion), (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