diff --git a/services/aws-core/Cargo.toml b/services/aws-core/Cargo.toml index 15a081db..9d1a96d5 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 289f76f4..66ed0171 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 { - read_ini( + pub(crate) fn config_file_path(&self, ctx: &Context) -> Option { + 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 { + read_ini(ctx, self.config_file_path(ctx)).await } pub(crate) async fn load_credentials_file(&self, ctx: &Context) -> Result { - 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 { - 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) -> Result { + 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 { + 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 7a905165..0e5b5252 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, + #[cfg(not(target_arch = "wasm32"))] + sso: Option, + fallback: ProvideCredentialChain, } impl Default for DefaultCredentialProvider { @@ -59,7 +65,12 @@ impl DefaultCredentialProvider { /// Create with a custom credential chain. pub fn with_chain(chain: ProvideCredentialChain) -> 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> { - 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 da9fceb0..627c048b 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, @@ -51,6 +72,7 @@ pub struct SSOCredentialProvider { sso_role_name: Option, sso_start_url: Option, sso_endpoint: Option, // Allow custom endpoint for testing + sessions: Arc>>>, } 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 { + async fn load_sso_config(&self, ctx: &Context) -> Result> { // 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 { - 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> { + 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 { + 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: shared.config_file_path(ctx), + })) } - async fn find_cached_token( + async fn access_token( &self, ctx: &Context, - start_url: &str, - ) -> Result> { - // 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::()?; - 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 { + 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> { + 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, + config_path: Option, } #[derive(Debug, Deserialize)] @@ -302,73 +341,9 @@ impl ProvideCredential for SSOCredentialProvider { type Credential = Credential; async fn provide_credential(&self, ctx: &Context) -> Result> { - 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 00000000..965e51fd --- /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, + session_name: Option, + start_url: String, + region: String, +} + +impl SessionKey { + pub fn from_config(ctx: &Context, config: &super::SSOConfig) -> Result { + 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>, +} + +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 { + // 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 { + 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, + client_id: Option, + client_secret: Option, + registration_expires_at: Option, +} + +impl CachedTokenFile { + fn into_token(self) -> Result { + 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, +} + +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> { + let content = match ctx.file_read(path).await { + Ok(content) => content, + Err(_) => return Ok(None), + }; + serde_json::from_slice::(&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 { + 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::(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, +} + +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 00000000..3a05e66a --- /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>>); + +impl FileRead for Files { + async fn file_read(&self, path: &str) -> Result> { + 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>) { + 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>>, + responses: StdMutex>>>, + refresh_gate: Option, +} + +impl HttpSend for Http { + async fn http_send(&self, request: Request) -> Result> { + 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, http: &Arc, 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, Arc) { + 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 { + 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> { + 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); +}