use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use thiserror::Error;
use tracing::{debug, trace};
#[derive(Debug, Error)]
pub enum CredentialsError {
#[error("SSO login required for profile '{profile}' (session: {sso_session}). Run 'aws sso login --profile {profile}'")]
SsoLoginRequired {
profile: String,
sso_session: String,
},
#[error("Console login required for profile '{profile}' (session: {login_session}). Run 'aws login --profile {profile}'")]
ConsoleLoginRequired {
profile: String,
login_session: String,
},
#[error("{0}")]
Other(#[from] anyhow::Error),
}
#[derive(Debug, Clone)]
pub struct Credentials {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: Option<String>,
}
struct CachedImdsCredentials {
credentials: Credentials,
expiration: Instant,
}
static IMDS_CACHE: OnceLock<std::sync::Mutex<Option<CachedImdsCredentials>>> = OnceLock::new();
static SSO_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedImdsCredentials>>> =
OnceLock::new();
static PROCESS_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedImdsCredentials>>> =
OnceLock::new();
static ASSUME_ROLE_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedImdsCredentials>>> =
OnceLock::new();
static ECS_CACHE: OnceLock<std::sync::Mutex<Option<CachedImdsCredentials>>> = OnceLock::new();
static CONSOLE_LOGIN_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedImdsCredentials>>> =
OnceLock::new();
const ECS_CREDENTIALS_ENDPOINT: &str = "http://169.254.170.2";
const IMDS_ENDPOINT: &str = "http://169.254.169.254";
const IMDS_TOKEN_TTL: u64 = 21600;
const IMDS_TIMEOUT: Duration = Duration::from_secs(2);
const CREDENTIAL_REFRESH_BUFFER: Duration = Duration::from_secs(300);
pub fn load_credentials(profile: &str) -> Result<Credentials> {
load_credentials_inner(profile).map_err(|e| match e {
CredentialsError::SsoLoginRequired {
profile,
sso_session,
} => {
anyhow!(
"SSO login required for profile '{}' (session: {}). Run 'aws sso login --profile {}'",
profile,
sso_session,
profile
)
}
CredentialsError::ConsoleLoginRequired {
profile,
login_session,
} => {
anyhow!(
"Console login required for profile '{}' (session: {}). Run 'aws login --profile {}'",
profile,
login_session,
profile
)
}
CredentialsError::Other(e) => e,
})
}
pub fn load_credentials_with_sso_check(profile: &str) -> Result<Credentials, CredentialsError> {
load_credentials_inner(profile)
}
fn load_credentials_inner(profile: &str) -> Result<Credentials, CredentialsError> {
if profile == "default" {
if let Ok(creds) = load_from_env() {
debug!("Loaded credentials from environment variables");
return Ok(creds);
}
}
if let Some(sso_config) = super::sso::get_sso_config(profile) {
debug!(
"SSO is configured for profile '{}', trying SSO first",
profile
);
match load_from_sso(profile) {
Ok(creds) => {
debug!("Loaded credentials from AWS SSO for profile '{}'", profile);
return Ok(creds);
}
Err(e) => {
debug!(
"SSO configured for profile '{}' but token unavailable: {}",
profile, e
);
return Err(CredentialsError::SsoLoginRequired {
profile: profile.to_string(),
sso_session: sso_config.sso_session,
});
}
}
}
if let Some(login_session) = get_login_session_config(profile) {
debug!(
"Console login session configured for profile '{}': {}",
profile, login_session
);
match load_from_console_login(profile, &login_session) {
Ok(creds) => {
debug!(
"Loaded credentials from console login cache for profile '{}'",
profile
);
return Ok(creds);
}
Err(e) => {
debug!(
"Console login configured for profile '{}' but credentials unavailable: {}",
profile, e
);
return Err(CredentialsError::ConsoleLoginRequired {
profile: profile.to_string(),
login_session,
});
}
}
}
if let Some(assume_role_config) = get_assume_role_config(profile) {
debug!(
"Role assumption configured for profile '{}', role_arn: {}",
profile, assume_role_config.role_arn
);
if let Ok(creds) = load_from_cli_cache(profile, &assume_role_config.role_arn) {
debug!(
"Loaded credentials from AWS CLI cache for profile '{}'",
profile
);
return Ok(creds);
}
match load_from_assume_role(profile, &assume_role_config) {
Ok(creds) => {
debug!(
"Loaded credentials via role assumption for profile '{}'",
profile
);
return Ok(creds);
}
Err(e) => {
debug!("Role assumption failed for profile '{}': {}", profile, e);
return Err(CredentialsError::Other(e));
}
}
}
if let Ok(creds) = load_from_credentials_file(profile) {
debug!(
"Loaded credentials from credentials file for profile '{}'",
profile
);
return Ok(creds);
}
if let Ok(creds) = load_from_config_file(profile) {
debug!(
"Loaded credentials from config file for profile '{}'",
profile
);
return Ok(creds);
}
if profile == "default" {
match load_from_imds() {
Ok(creds) => {
debug!("Loaded credentials from EC2 instance metadata (IMDSv2)");
return Ok(creds);
}
Err(e) => {
debug!("IMDSv2 credential loading failed: {}", e);
}
}
}
Err(CredentialsError::Other(anyhow!(
"No credentials found for profile '{}'. Run 'aws configure', 'aws sso login --profile {}', 'aws login --profile {}', or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY",
profile,
profile,
profile
)))
}
fn load_from_env() -> Result<Credentials> {
let access_key_id =
env::var("AWS_ACCESS_KEY_ID").map_err(|_| anyhow!("AWS_ACCESS_KEY_ID not set"))?;
let secret_access_key =
env::var("AWS_SECRET_ACCESS_KEY").map_err(|_| anyhow!("AWS_SECRET_ACCESS_KEY not set"))?;
let session_token = env::var("AWS_SESSION_TOKEN").ok();
Ok(Credentials {
access_key_id,
secret_access_key,
session_token,
})
}
pub fn aws_config_dir() -> Result<PathBuf> {
if let Ok(path) = env::var("AWS_CONFIG_FILE") {
if let Some(parent) = PathBuf::from(path).parent() {
return Ok(parent.to_path_buf());
}
}
dirs::home_dir()
.map(|h| h.join(".aws"))
.ok_or_else(|| anyhow!("Could not find home directory"))
}
pub fn get_aws_config_file_path() -> Result<PathBuf> {
if let Ok(path) = env::var("AWS_CONFIG_FILE") {
return Ok(PathBuf::from(path));
}
dirs::home_dir()
.map(|h| h.join(".aws").join("config"))
.ok_or_else(|| anyhow!("Could not find home directory"))
}
fn parse_ini_file(content: &str) -> HashMap<String, HashMap<String, String>> {
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut current_section = String::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
current_section = line[1..line.len() - 1].trim().to_string();
if current_section.starts_with("profile ") {
current_section = current_section["profile ".len()..].to_string();
}
sections.entry(current_section.clone()).or_default();
continue;
}
if let Some((key, value)) = line.split_once('=') {
if !current_section.is_empty() {
sections
.entry(current_section.clone())
.or_default()
.insert(key.trim().to_string(), value.trim().to_string());
}
}
}
sections
}
fn load_from_credentials_file(profile: &str) -> Result<Credentials> {
let creds_path = if let Ok(path) = env::var("AWS_SHARED_CREDENTIALS_FILE") {
PathBuf::from(path)
} else {
aws_config_dir()?.join("credentials")
};
let content =
fs::read_to_string(&creds_path).map_err(|_| anyhow!("Could not read {:?}", creds_path))?;
let sections = parse_ini_file(&content);
let section = sections
.get(profile)
.ok_or_else(|| anyhow!("Profile '{}' not found in credentials file", profile))?;
if let Some(command) = section.get("credential_process") {
debug!("Found credential_process for profile '{}'", profile);
return load_from_process(profile, command);
}
let access_key_id = section
.get("aws_access_key_id")
.ok_or_else(|| anyhow!("aws_access_key_id not found for profile '{}'", profile))?
.clone();
let secret_access_key = section
.get("aws_secret_access_key")
.ok_or_else(|| anyhow!("aws_secret_access_key not found for profile '{}'", profile))?
.clone();
let session_token = section.get("aws_session_token").cloned();
Ok(Credentials {
access_key_id,
secret_access_key,
session_token,
})
}
fn load_from_config_file(profile: &str) -> Result<Credentials> {
let config_path = get_aws_config_file_path()?;
let content = fs::read_to_string(&config_path)
.map_err(|_| anyhow!("Could not read {:?}", config_path))?;
let sections = parse_ini_file(&content);
let section = sections
.get(profile)
.ok_or_else(|| anyhow!("Profile '{}' not found in config file", profile))?;
if let Some(command) = section.get("credential_process") {
debug!("Found credential_process for profile '{}'", profile);
return load_from_process(profile, command);
}
if let (Some(access_key), Some(secret_key)) = (
section.get("aws_access_key_id"),
section.get("aws_secret_access_key"),
) {
return Ok(Credentials {
access_key_id: access_key.clone(),
secret_access_key: secret_key.clone(),
session_token: section.get("aws_session_token").cloned(),
});
}
Err(anyhow!(
"No direct credentials found in config for profile '{}'",
profile
))
}
fn load_from_sso(profile: &str) -> Result<Credentials> {
use super::sso;
let cache = SSO_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
if let Ok(guard) = cache.lock() {
if let Some(cached) = guard.get(profile) {
if cached.expiration > Instant::now() + CREDENTIAL_REFRESH_BUFFER {
trace!("Using cached SSO credentials for profile '{}'", profile);
return Ok(cached.credentials.clone());
}
}
}
let sso_config = sso::get_sso_config(profile)
.ok_or_else(|| anyhow!("Profile '{}' does not have SSO configured", profile))?;
let access_token = sso::read_cached_token(&sso_config).ok_or_else(|| {
anyhow!(
"SSO token not found or expired for profile '{}'. Interactive login required.",
profile
)
})?;
let credentials = sso::get_role_credentials(&sso_config, &access_token)?;
let expiration = Instant::now() + Duration::from_secs(3600); let cache = SSO_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
if let Ok(mut guard) = cache.lock() {
guard.insert(
profile.to_string(),
CachedImdsCredentials {
credentials: credentials.clone(),
expiration,
},
);
debug!("Cached SSO credentials for profile '{}'", profile);
}
Ok(credentials)
}
fn get_login_session_config(profile: &str) -> Option<String> {
let config_path = get_aws_config_file_path().ok()?;
let content = fs::read_to_string(&config_path).ok()?;
let sections = parse_ini_file(&content);
sections
.get(profile)
.and_then(|section| section.get("login_session").cloned())
}
fn get_login_cache_dir() -> Result<PathBuf> {
if let Ok(dir) = env::var("AWS_LOGIN_CACHE_DIRECTORY") {
return Ok(PathBuf::from(dir));
}
Ok(aws_config_dir()?.join("login").join("cache"))
}
fn load_from_console_login(profile: &str, login_session: &str) -> Result<Credentials> {
use sha2::{Digest, Sha256};
let cache = CONSOLE_LOGIN_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
if let Ok(guard) = cache.lock() {
if let Some(cached) = guard.get(profile) {
if cached.expiration > Instant::now() + CREDENTIAL_REFRESH_BUFFER {
trace!(
"Using cached console login credentials for profile '{}'",
profile
);
return Ok(cached.credentials.clone());
}
}
}
let cache_dir = get_login_cache_dir()?;
if !cache_dir.exists() {
return Err(anyhow!(
"Console login cache directory not found. Run 'aws login'"
));
}
let mut hasher = Sha256::new();
hasher.update(login_session.trim().as_bytes());
let hash = hasher.finalize();
let cache_filename = format!("{}.json", hex::encode(hash));
let cache_file = cache_dir.join(&cache_filename);
if !cache_file.exists() {
return Err(anyhow!(
"No login cache file found for profile '{}'. Run 'aws login --profile {}'",
profile,
profile
));
}
debug!("Reading login cache from {:?}", cache_file);
let content = fs::read_to_string(&cache_file)
.map_err(|e| anyhow!("Failed to read login cache: {}", e))?;
let cache_data: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| anyhow!("Failed to parse login cache: {}", e))?;
let access_token = cache_data
.get("accessToken")
.ok_or_else(|| anyhow!("accessToken not found in login cache"))?;
let access_key_id = access_token
.get("accessKeyId")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("accessKeyId not found in login cache"))?
.to_string();
let secret_access_key = access_token
.get("secretAccessKey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("secretAccessKey not found in login cache"))?
.to_string();
let session_token = access_token
.get("sessionToken")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expiration = if let Some(exp_str) = access_token.get("expiresAt").and_then(|v| v.as_str()) {
if let Ok(expiration_time) = chrono::DateTime::parse_from_rfc3339(exp_str) {
if expiration_time <= chrono::Utc::now() {
return Err(anyhow!(
"Console login credentials expired. Run 'aws login --profile {}'",
profile
));
}
trace!("Login credentials valid until: {}", expiration_time);
let now = chrono::Utc::now();
let duration_until_expiration = (expiration_time.with_timezone(&chrono::Utc) - now)
.to_std()
.unwrap_or(Duration::from_secs(3600));
Instant::now() + duration_until_expiration
} else {
Instant::now() + Duration::from_secs(3600)
}
} else {
Instant::now() + Duration::from_secs(3600)
};
let credentials = Credentials {
access_key_id,
secret_access_key,
session_token,
};
if let Ok(mut guard) = cache.lock() {
guard.insert(
profile.to_string(),
CachedImdsCredentials {
credentials: credentials.clone(),
expiration,
},
);
debug!(
"Cached console login credentials for profile '{}', expires in {:?}",
profile,
expiration - Instant::now()
);
}
Ok(credentials)
}
fn load_from_cli_cache(profile: &str, role_arn: &str) -> Result<Credentials> {
let cache_dir = aws_config_dir()?.join("cli").join("cache");
if !cache_dir.exists() {
return Err(anyhow!("AWS CLI cache directory not found"));
}
trace!(
"Searching AWS CLI cache for role_arn: {} (profile: {})",
role_arn,
profile
);
let entries = fs::read_dir(&cache_dir)
.map_err(|e| anyhow!("Failed to read CLI cache directory: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
if let Some(creds) = try_read_cli_cache_file(&path, role_arn) {
debug!("Found valid credentials in CLI cache: {:?}", path);
return Ok(creds);
}
}
Err(anyhow!(
"No valid cached credentials found for profile '{}'",
profile
))
}
fn try_read_cli_cache_file(path: &std::path::Path, role_arn: &str) -> Option<Credentials> {
let content = fs::read_to_string(path).ok()?;
let cache_data: serde_json::Value = serde_json::from_str(&content).ok()?;
let assumed_role_arn = cache_data
.get("AssumedRoleUser")
.and_then(|u| u.get("Arn"))
.and_then(|a| a.as_str())?;
let cache_parts: Vec<&str> = assumed_role_arn.split(':').collect();
let config_parts: Vec<&str> = role_arn.split(':').collect();
if cache_parts.get(4) != config_parts.get(4) {
return None;
}
let cache_role_name = assumed_role_arn.split('/').nth(1)?;
let config_role_name = role_arn.split('/').next_back()?;
if cache_role_name != config_role_name {
return None;
}
let creds = cache_data.get("Credentials")?;
let access_key_id = creds.get("AccessKeyId").and_then(|v| v.as_str())?;
let secret_access_key = creds.get("SecretAccessKey").and_then(|v| v.as_str())?;
let session_token = creds.get("SessionToken").and_then(|v| v.as_str());
if let Some(expiration_str) = creds.get("Expiration").and_then(|v| v.as_str()) {
if let Ok(expiration) = chrono::DateTime::parse_from_rfc3339(expiration_str) {
if expiration <= chrono::Utc::now() {
trace!("CLI cache credentials expired: {:?}", path);
return None;
}
trace!(
"CLI cache credentials valid until: {} (file: {:?})",
expiration,
path
);
}
}
Some(Credentials {
access_key_id: access_key_id.to_string(),
secret_access_key: secret_access_key.to_string(),
session_token: session_token.map(|s| s.to_string()),
})
}
fn load_from_process(profile: &str, command: &str) -> Result<Credentials> {
let cache = PROCESS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
if let Ok(guard) = cache.lock() {
if let Some(cached) = guard.get(profile) {
if cached.expiration > Instant::now() + CREDENTIAL_REFRESH_BUFFER {
trace!("Using cached process credentials for profile '{}'", profile);
return Ok(cached.credentials.clone());
}
}
}
let (credentials, expiration) = execute_credential_process(command)?;
let cache_expiration = expiration.unwrap_or_else(|| {
Instant::now() + Duration::from_secs(365 * 24 * 60 * 60) });
if let Ok(mut guard) = cache.lock() {
guard.insert(
profile.to_string(),
CachedImdsCredentials {
credentials: credentials.clone(),
expiration: cache_expiration,
},
);
if expiration.is_some() {
debug!(
"Cached temporary process credentials for profile '{}'",
profile
);
} else {
debug!(
"Cached long-term process credentials for profile '{}'",
profile
);
}
}
Ok(credentials)
}
fn execute_credential_process(command: &str) -> Result<(Credentials, Option<Instant>)> {
debug!("Executing credential_process: {}", command);
#[cfg(not(windows))]
let shell_cmd = Command::new("sh").arg("-c").arg(command).output();
#[cfg(windows)]
let shell_cmd = Command::new("cmd").arg("/C").arg(command).output();
let output = shell_cmd.map_err(|e| anyhow!("Failed to execute credential_process: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"credential_process failed with status {}: {}",
output.status,
stderr
));
}
let output_str = String::from_utf8(output.stdout)
.map_err(|e| anyhow!("Invalid UTF-8 output from credential_process: {}", e))?;
let json: serde_json::Value = serde_json::from_str(&output_str)
.map_err(|e| anyhow!("Failed to parse credential_process output: {}", e))?;
if let Some(version) = json.get("Version").and_then(|v| v.as_i64()) {
if version != 1 {
return Err(anyhow!(
"Unsupported credential_process version: {}",
version
));
}
}
let access_key_id = json
.get("AccessKeyId")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("AccessKeyId missing in credential_process output"))?
.to_string();
let secret_access_key = json
.get("SecretAccessKey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("SecretAccessKey missing in credential_process output"))?
.to_string();
let session_token = json
.get("SessionToken")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expiration = json
.get("Expiration")
.and_then(|v| v.as_str())
.and_then(parse_expiration);
Ok((
Credentials {
access_key_id,
secret_access_key,
session_token,
},
expiration,
))
}
#[allow(dead_code)]
pub fn get_profile_region(profile: &str) -> Option<String> {
if let Ok(region) = env::var("AWS_REGION") {
return Some(region);
}
if let Ok(region) = env::var("AWS_DEFAULT_REGION") {
return Some(region);
}
if let Ok(config_dir) = aws_config_dir() {
let config_path = config_dir.join("config");
if let Ok(content) = fs::read_to_string(&config_path) {
let sections = parse_ini_file(&content);
if let Some(section) = sections.get(profile) {
if let Some(region) = section.get("region") {
return Some(region.clone());
}
}
}
}
None
}
#[allow(dead_code)]
pub fn list_profiles() -> Vec<String> {
let mut profiles = Vec::new();
if let Ok(config_dir) = aws_config_dir() {
if let Ok(content) = fs::read_to_string(config_dir.join("credentials")) {
let sections = parse_ini_file(&content);
profiles.extend(sections.keys().cloned());
}
if let Ok(content) = fs::read_to_string(config_dir.join("config")) {
let sections = parse_ini_file(&content);
for key in sections.keys() {
if !profiles.contains(key) {
profiles.push(key.clone());
}
}
}
}
profiles.sort();
profiles
}
#[derive(Debug, Clone)]
pub struct AssumeRoleConfig {
pub role_arn: String,
pub source_profile: Option<String>,
pub credential_source: Option<CredentialSource>,
pub external_id: Option<String>,
pub role_session_name: Option<String>,
pub duration_seconds: Option<u32>,
pub region: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CredentialSource {
Environment,
Ec2InstanceMetadata,
EcsContainer,
}
fn get_assume_role_config(profile: &str) -> Option<AssumeRoleConfig> {
let config_path = if let Ok(path) = env::var("AWS_CONFIG_FILE") {
PathBuf::from(path)
} else {
aws_config_dir().ok()?.join("config")
};
let content = fs::read_to_string(&config_path).ok()?;
let sections = parse_ini_file(&content);
let section = sections.get(profile)?;
let role_arn = section.get("role_arn")?.clone();
let source_profile = section.get("source_profile").cloned();
let credential_source = section
.get("credential_source")
.and_then(|s| parse_credential_source(s));
match (&source_profile, &credential_source) {
(Some(_), Some(_)) => {
debug!(
"Profile '{}' has both source_profile and credential_source - invalid configuration",
profile
);
return None;
}
(None, None) => {
debug!(
"Profile '{}' has role_arn but neither source_profile nor credential_source",
profile
);
return None;
}
_ => {}
}
Some(AssumeRoleConfig {
role_arn,
source_profile,
credential_source,
external_id: section.get("external_id").cloned(),
role_session_name: section.get("role_session_name").cloned(),
duration_seconds: section.get("duration_seconds").and_then(|s| s.parse().ok()),
region: section.get("region").cloned(),
})
}
fn parse_credential_source(value: &str) -> Option<CredentialSource> {
match value {
"Environment" => Some(CredentialSource::Environment),
"Ec2InstanceMetadata" => Some(CredentialSource::Ec2InstanceMetadata),
"EcsContainer" => Some(CredentialSource::EcsContainer),
_ => {
debug!("Unknown credential_source value: {}", value);
None
}
}
}
fn load_from_assume_role(profile: &str, config: &AssumeRoleConfig) -> Result<Credentials> {
let cache = ASSUME_ROLE_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
if let Ok(guard) = cache.lock() {
if let Some(cached) = guard.get(profile) {
if cached.expiration > Instant::now() + CREDENTIAL_REFRESH_BUFFER {
trace!(
"Using cached assume role credentials for profile '{}'",
profile
);
return Ok(cached.credentials.clone());
}
}
}
let source_creds = if let Some(ref source_profile) = config.source_profile {
debug!(
"Loading source credentials from profile '{}'",
source_profile
);
load_credentials(source_profile).map_err(|e| {
anyhow!(
"Failed to load source credentials from profile '{}': {}",
source_profile,
e
)
})?
} else if let Some(ref credential_source) = config.credential_source {
debug!(
"Loading source credentials from credential_source: {:?}",
credential_source
);
load_from_credential_source(credential_source)?
} else {
return Err(anyhow!(
"Profile '{}' has role_arn but no source_profile or credential_source",
profile
));
};
let region = config
.region
.clone()
.or_else(|| {
config
.source_profile
.as_ref()
.and_then(|p| get_profile_region(p))
})
.unwrap_or_else(|| "us-east-1".to_string());
let (credentials, expiration) = call_sts_assume_role(config, &source_creds, ®ion)?;
if let Ok(mut guard) = cache.lock() {
guard.insert(
profile.to_string(),
CachedImdsCredentials {
credentials: credentials.clone(),
expiration,
},
);
debug!(
"Cached assume role credentials for profile '{}', expires in {:?}",
profile,
expiration - Instant::now()
);
}
Ok(credentials)
}
fn load_from_credential_source(source: &CredentialSource) -> Result<Credentials> {
match source {
CredentialSource::Environment => {
debug!("Loading credentials from Environment");
load_from_env()
}
CredentialSource::Ec2InstanceMetadata => {
debug!("Loading credentials from Ec2InstanceMetadata (IMDSv2)");
load_from_imds()
}
CredentialSource::EcsContainer => {
debug!("Loading credentials from EcsContainer");
load_from_ecs_container()
}
}
}
fn call_sts_assume_role(
config: &AssumeRoleConfig,
source_creds: &Credentials,
region: &str,
) -> Result<(Credentials, Instant)> {
use aws_sigv4::http_request::{sign, SignableBody, SignableRequest, SigningSettings};
use aws_sigv4::sign::v4::SigningParams;
use aws_smithy_runtime_api::client::identity::Identity;
use std::time::SystemTime;
let role_session_name = config
.role_session_name
.clone()
.unwrap_or_else(|| "orbit-session".to_string());
let duration_seconds = config.duration_seconds.unwrap_or(3600);
let sts_endpoint = env::var("ORBIT_STS_ENDPOINT")
.or_else(|_| env::var("AWS_ENDPOINT_URL"))
.unwrap_or_else(|_| format!("https://sts.{}.amazonaws.com", region));
let mut params = vec![
("Action", "AssumeRole"),
("Version", "2011-06-15"),
("RoleArn", &config.role_arn),
("RoleSessionName", &role_session_name),
];
let duration_str = duration_seconds.to_string();
params.push(("DurationSeconds", &duration_str));
if let Some(ref external_id) = config.external_id {
params.push(("ExternalId", external_id));
}
let query_string: String = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
let base = sts_endpoint.trim_end_matches('/');
let url = format!("{}/?{}", base, query_string);
debug!("Calling STS AssumeRole: {}", config.role_arn);
debug!("STS endpoint: {}", sts_endpoint);
trace!("STS URL: {}", url);
let parsed_url = url::Url::parse(&url)?;
let host = parsed_url
.host_str()
.ok_or_else(|| anyhow!("Invalid STS URL"))?;
let path_and_query = if let Some(query) = parsed_url.query() {
format!("{}?{}", parsed_url.path(), query)
} else {
parsed_url.path().to_string()
};
let headers = [("host".to_string(), host.to_string())];
let creds = aws_credential_types::Credentials::new(
&source_creds.access_key_id,
&source_creds.secret_access_key,
source_creds.session_token.clone(),
None,
"orbit",
);
let identity: Identity = creds.into();
let signing_params = SigningParams::builder()
.identity(&identity)
.region(region)
.name("sts")
.time(SystemTime::now())
.settings(SigningSettings::default())
.build()?
.into();
let signable_request = SignableRequest::new(
"POST",
&path_and_query,
headers.iter().map(|(k, v)| (k.as_str(), v.as_str())),
SignableBody::Bytes(&[]),
)?;
let (signing_instructions, _signature) = sign(signable_request, &signing_params)?.into_parts();
let client = super::tls::create_blocking_client_with_timeout(Duration::from_secs(30))?;
let mut request = client.post(&url);
for (name, value) in signing_instructions.headers() {
request = request.header(name.to_string(), value.to_string());
}
let response = request.send()?;
let status = response.status();
let text = response.text()?;
if !status.is_success() {
let error_msg = parse_sts_error(&text).unwrap_or_else(|| text.clone());
return Err(anyhow!("STS AssumeRole failed ({}): {}", status, error_msg));
}
parse_assume_role_response(&text)
}
fn parse_sts_error(xml: &str) -> Option<String> {
let code_start = xml.find("<Code>")? + 6;
let code_end = xml.find("</Code>")?;
let code = &xml[code_start..code_end];
let msg_start = xml.find("<Message>")? + 9;
let msg_end = xml.find("</Message>")?;
let message = &xml[msg_start..msg_end];
Some(format!("{}: {}", code, message))
}
fn parse_assume_role_response(xml: &str) -> Result<(Credentials, Instant)> {
let extract_value = |tag: &str| -> Option<String> {
let start_tag = format!("<{}>", tag);
let end_tag = format!("</{}>", tag);
let start = xml.find(&start_tag)? + start_tag.len();
let end = xml.find(&end_tag)?;
if start < end {
Some(xml[start..end].to_string())
} else {
None
}
};
let access_key_id = extract_value("AccessKeyId")
.ok_or_else(|| anyhow!("AccessKeyId not found in AssumeRole response"))?;
let secret_access_key = extract_value("SecretAccessKey")
.ok_or_else(|| anyhow!("SecretAccessKey not found in AssumeRole response"))?;
let session_token = extract_value("SessionToken")
.ok_or_else(|| anyhow!("SessionToken not found in AssumeRole response"))?;
let expiration_str = extract_value("Expiration")
.ok_or_else(|| anyhow!("Expiration not found in AssumeRole response"))?;
let expiration = parse_expiration(&expiration_str)
.unwrap_or_else(|| Instant::now() + Duration::from_secs(3600));
Ok((
Credentials {
access_key_id,
secret_access_key,
session_token: Some(session_token),
},
expiration,
))
}
fn load_from_imds() -> Result<Credentials> {
let cache = IMDS_CACHE.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(guard) = cache.lock() {
if let Some(ref cached) = *guard {
if cached.expiration > Instant::now() + CREDENTIAL_REFRESH_BUFFER {
trace!("Using cached IMDS credentials");
return Ok(cached.credentials.clone());
}
}
}
let creds = fetch_imds_credentials()?;
Ok(creds)
}
fn fetch_imds_credentials() -> Result<Credentials> {
let client = reqwest::blocking::Client::builder()
.timeout(IMDS_TIMEOUT)
.connect_timeout(IMDS_TIMEOUT)
.build()
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
trace!("Fetching IMDSv2 session token");
let token_url = format!("{}/latest/api/token", IMDS_ENDPOINT);
let token_response = client
.put(&token_url)
.header(
"X-aws-ec2-metadata-token-ttl-seconds",
IMDS_TOKEN_TTL.to_string(),
)
.send()
.map_err(|e| anyhow!("Failed to get IMDS token (not running on EC2?): {}", e))?;
if !token_response.status().is_success() {
return Err(anyhow!(
"IMDS token request failed with status: {}",
token_response.status()
));
}
let token = token_response
.text()
.map_err(|e| anyhow!("Failed to read IMDS token: {}", e))?;
trace!("Fetching IAM role name from IMDS");
let role_url = format!(
"{}/latest/meta-data/iam/security-credentials/",
IMDS_ENDPOINT
);
let role_response = client
.get(&role_url)
.header("X-aws-ec2-metadata-token", &token)
.send()
.map_err(|e| anyhow!("Failed to get IAM role: {}", e))?;
if !role_response.status().is_success() {
return Err(anyhow!(
"No IAM role attached to this EC2 instance (status: {})",
role_response.status()
));
}
let role_text = role_response
.text()
.map_err(|e| anyhow!("Failed to read IAM role name: {}", e))?;
let role_name = role_text
.lines()
.next()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow!("No IAM role attached to this EC2 instance"))?
.to_string();
debug!("Found IAM role: {}", role_name);
trace!("Fetching credentials for IAM role: {}", role_name);
let creds_url = format!(
"{}/latest/meta-data/iam/security-credentials/{}",
IMDS_ENDPOINT, role_name
);
let creds_response = client
.get(&creds_url)
.header("X-aws-ec2-metadata-token", &token)
.send()
.map_err(|e| anyhow!("Failed to get credentials: {}", e))?;
if !creds_response.status().is_success() {
return Err(anyhow!(
"Failed to get credentials for role '{}' (status: {})",
role_name,
creds_response.status()
));
}
let creds_json: serde_json::Value = creds_response
.json()
.map_err(|e| anyhow!("Failed to parse credentials JSON: {}", e))?;
let access_key_id = creds_json
.get("AccessKeyId")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("AccessKeyId not found in IMDS response"))?
.to_string();
let secret_access_key = creds_json
.get("SecretAccessKey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("SecretAccessKey not found in IMDS response"))?
.to_string();
let session_token = creds_json
.get("Token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expiration = if let Some(exp_str) = creds_json.get("Expiration").and_then(|v| v.as_str()) {
parse_expiration(exp_str).unwrap_or_else(|| {
Instant::now() + Duration::from_secs(3600)
})
} else {
Instant::now() + Duration::from_secs(3600)
};
let credentials = Credentials {
access_key_id,
secret_access_key,
session_token,
};
let cache = IMDS_CACHE.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(mut guard) = cache.lock() {
*guard = Some(CachedImdsCredentials {
credentials: credentials.clone(),
expiration,
});
debug!(
"Cached IMDS credentials, expires in {:?}",
expiration - Instant::now()
);
}
Ok(credentials)
}
fn parse_expiration(exp_str: &str) -> Option<Instant> {
use chrono::{DateTime, Utc};
let expiration_time: DateTime<Utc> = exp_str.parse().ok()?;
let now = Utc::now();
if expiration_time <= now {
return None;
}
let duration_until_expiration = (expiration_time - now).to_std().ok()?;
Some(Instant::now() + duration_until_expiration)
}
#[allow(dead_code)]
pub fn is_imds_available() -> bool {
let client = match reqwest::blocking::Client::builder()
.timeout(IMDS_TIMEOUT)
.connect_timeout(IMDS_TIMEOUT)
.build()
{
Ok(c) => c,
Err(_) => return false,
};
let token_url = format!("{}/latest/api/token", IMDS_ENDPOINT);
client
.put(&token_url)
.header("X-aws-ec2-metadata-token-ttl-seconds", "21600")
.send()
.map(|r| r.status().is_success())
.unwrap_or(false)
}
fn load_from_ecs_container() -> Result<Credentials> {
let cache = ECS_CACHE.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(guard) = cache.lock() {
if let Some(ref cached) = *guard {
if cached.expiration > Instant::now() + CREDENTIAL_REFRESH_BUFFER {
trace!("Using cached ECS container credentials");
return Ok(cached.credentials.clone());
}
}
}
let (credentials, expiration) = fetch_ecs_container_credentials()?;
if let Ok(mut guard) = cache.lock() {
*guard = Some(CachedImdsCredentials {
credentials: credentials.clone(),
expiration,
});
debug!(
"Cached ECS container credentials, expires in {:?}",
expiration - Instant::now()
);
}
Ok(credentials)
}
fn fetch_ecs_container_credentials() -> Result<(Credentials, Instant)> {
let (url, auth_token) = if let Ok(full_uri) = env::var("AWS_CONTAINER_CREDENTIALS_FULL_URI") {
let token = env::var("AWS_CONTAINER_AUTHORIZATION_TOKEN").ok();
debug!("Using ECS full URI: {}", full_uri);
(full_uri, token)
} else if let Ok(relative_uri) = env::var("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") {
let url = format!("{}{}", ECS_CREDENTIALS_ENDPOINT, relative_uri);
debug!("Using ECS relative URI: {}", url);
(url, None)
} else {
return Err(anyhow!(
"ECS container credentials not available: neither AWS_CONTAINER_CREDENTIALS_FULL_URI \
nor AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is set"
));
};
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.connect_timeout(Duration::from_secs(2))
.build()
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
let mut request = client.get(&url);
if let Some(ref token) = auth_token {
request = request.header("Authorization", token);
}
trace!("Fetching ECS container credentials from: {}", url);
let response = request
.send()
.map_err(|e| anyhow!("Failed to fetch ECS container credentials: {}", e))?;
if !response.status().is_success() {
return Err(anyhow!(
"ECS container credentials request failed with status: {}",
response.status()
));
}
let creds_json: serde_json::Value = response
.json()
.map_err(|e| anyhow!("Failed to parse ECS credentials JSON: {}", e))?;
let access_key_id = creds_json
.get("AccessKeyId")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("AccessKeyId not found in ECS credentials response"))?
.to_string();
let secret_access_key = creds_json
.get("SecretAccessKey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("SecretAccessKey not found in ECS credentials response"))?
.to_string();
let session_token = creds_json
.get("Token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expiration = if let Some(exp_str) = creds_json.get("Expiration").and_then(|v| v.as_str()) {
parse_expiration(exp_str).unwrap_or_else(|| Instant::now() + Duration::from_secs(3600))
} else {
Instant::now() + Duration::from_secs(3600)
};
debug!(
"Fetched ECS container credentials, expires in {:?}",
expiration - Instant::now()
);
Ok((
Credentials {
access_key_id,
secret_access_key,
session_token,
},
expiration,
))
}
#[cfg(test)]
mod tests {
use super::*;
const LOGIN_CACHE_DIR_ENV: &str = "AWS_LOGIN_CACHE_DIRECTORY";
struct LoginCacheDirVar {
_guard: std::sync::MutexGuard<'static, ()>,
original: Option<String>,
}
impl LoginCacheDirVar {
fn set(value: impl AsRef<std::ffi::OsStr>) -> Self {
let held = Self::acquire();
env::set_var(LOGIN_CACHE_DIR_ENV, value);
held
}
fn unset() -> Self {
let held = Self::acquire();
env::remove_var(LOGIN_CACHE_DIR_ENV);
held
}
fn acquire() -> Self {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let guard = LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
Self {
_guard: guard,
original: env::var(LOGIN_CACHE_DIR_ENV).ok(),
}
}
}
impl Drop for LoginCacheDirVar {
fn drop(&mut self) {
match &self.original {
Some(value) => env::set_var(LOGIN_CACHE_DIR_ENV, value),
None => env::remove_var(LOGIN_CACHE_DIR_ENV),
}
}
}
#[test]
fn test_sso_cache_is_profile_aware() {
let cache = SSO_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let creds_profile_a = Credentials {
access_key_id: "AKIAPROFILE_A_KEY".to_string(),
secret_access_key: "secret_a".to_string(),
session_token: Some("token_a".to_string()),
};
let creds_profile_b = Credentials {
access_key_id: "AKIAPROFILE_B_KEY".to_string(),
secret_access_key: "secret_b".to_string(),
session_token: Some("token_b".to_string()),
};
let expiration = Instant::now() + Duration::from_secs(3600);
{
let mut guard = cache.lock().unwrap();
guard.insert(
"profile-a".to_string(),
CachedImdsCredentials {
credentials: creds_profile_a.clone(),
expiration,
},
);
guard.insert(
"profile-b".to_string(),
CachedImdsCredentials {
credentials: creds_profile_b.clone(),
expiration,
},
);
}
{
let guard = cache.lock().unwrap();
let cached_a = guard.get("profile-a").unwrap();
assert_eq!(
cached_a.credentials.access_key_id, "AKIAPROFILE_A_KEY",
"Profile A should return Profile A's credentials"
);
}
{
let guard = cache.lock().unwrap();
let cached_b = guard.get("profile-b").unwrap();
assert_eq!(
cached_b.credentials.access_key_id, "AKIAPROFILE_B_KEY",
"Profile B should return Profile B's credentials, not Profile A's"
);
}
{
let guard = cache.lock().unwrap();
assert!(
guard.get("profile-c").is_none(),
"Non-existent profile should not return cached credentials"
);
}
}
#[test]
fn test_parse_ini_file() {
let content = r#"
[default]
aws_access_key_id = AKIADEFAULT
aws_secret_access_key = secret_default
[profile dev]
aws_access_key_id = AKIADEV
aws_secret_access_key = secret_dev
"#;
let sections = parse_ini_file(content);
assert!(sections.contains_key("default"));
assert!(sections.contains_key("dev"));
let default_section = sections.get("default").unwrap();
assert_eq!(
default_section.get("aws_access_key_id").unwrap(),
"AKIADEFAULT"
);
}
#[test]
fn test_credential_process_success() {
let json = r#"{"Version": 1, "AccessKeyId": "test_key", "SecretAccessKey": "test_secret", "SessionToken": "test_token", "Expiration": "2099-01-01T00:00:00Z"}"#;
#[cfg(not(windows))]
let cmd = format!("echo '{}'", json);
#[cfg(windows)]
let cmd = format!("echo {}", json.replace("\"", "\\\""));
let result = execute_credential_process(&cmd);
assert!(
result.is_ok(),
"credential_process failed: {:?}",
result.err()
);
let (creds, exp) = result.unwrap();
assert_eq!(creds.access_key_id, "test_key");
assert_eq!(creds.secret_access_key, "test_secret");
assert_eq!(creds.session_token, Some("test_token".to_string()));
assert!(exp.is_some());
}
#[test]
fn test_parse_assume_role_response() {
let xml = r#"
<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>ASIATEST123</AccessKeyId>
<SecretAccessKey>testsecret456</SecretAccessKey>
<SessionToken>testsessiontoken789</SessionToken>
<Expiration>2099-01-15T12:00:00Z</Expiration>
</Credentials>
<AssumedRoleUser>
<AssumedRoleId>AROATEST:orbit-session</AssumedRoleId>
<Arn>arn:aws:sts::123456789012:assumed-role/TestRole/orbit-session</Arn>
</AssumedRoleUser>
</AssumeRoleResult>
</AssumeRoleResponse>
"#;
let result = parse_assume_role_response(xml);
assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
let (creds, _exp) = result.unwrap();
assert_eq!(creds.access_key_id, "ASIATEST123");
assert_eq!(creds.secret_access_key, "testsecret456");
assert_eq!(creds.session_token, Some("testsessiontoken789".to_string()));
}
#[test]
fn test_parse_sts_error() {
let xml = r#"
<ErrorResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<Error>
<Code>AccessDenied</Code>
<Message>User is not authorized to perform sts:AssumeRole</Message>
</Error>
<RequestId>12345678-1234-1234-1234-123456789012</RequestId>
</ErrorResponse>
"#;
let result = parse_sts_error(xml);
assert!(result.is_some());
let error_msg = result.unwrap();
assert!(error_msg.contains("AccessDenied"));
assert!(error_msg.contains("not authorized"));
}
#[test]
fn test_assume_role_cache_is_profile_aware() {
let cache = ASSUME_ROLE_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let creds_dev = Credentials {
access_key_id: "ASIA_DEV_KEY".to_string(),
secret_access_key: "secret_dev".to_string(),
session_token: Some("token_dev".to_string()),
};
let creds_prod = Credentials {
access_key_id: "ASIA_PROD_KEY".to_string(),
secret_access_key: "secret_prod".to_string(),
session_token: Some("token_prod".to_string()),
};
let expiration = Instant::now() + Duration::from_secs(3600);
{
let mut guard = cache.lock().unwrap();
guard.insert(
"dev-role".to_string(),
CachedImdsCredentials {
credentials: creds_dev.clone(),
expiration,
},
);
guard.insert(
"prod-role".to_string(),
CachedImdsCredentials {
credentials: creds_prod.clone(),
expiration,
},
);
}
{
let guard = cache.lock().unwrap();
let cached_dev = guard.get("dev-role").unwrap();
assert_eq!(cached_dev.credentials.access_key_id, "ASIA_DEV_KEY");
let cached_prod = guard.get("prod-role").unwrap();
assert_eq!(cached_prod.credentials.access_key_id, "ASIA_PROD_KEY");
}
}
#[test]
fn test_parse_ini_file_with_role_arn() {
let content = r#"
[default]
aws_access_key_id = AKIADEFAULT
aws_secret_access_key = secret_default
region = us-east-1
[profile production]
role_arn = arn:aws:iam::123456789012:role/ProductionAccess
source_profile = default
region = us-west-2
external_id = my-external-id
[profile staging]
role_arn = arn:aws:iam::987654321098:role/StagingAccess
source_profile = default
role_session_name = my-custom-session
duration_seconds = 7200
"#;
let sections = parse_ini_file(content);
assert!(sections.contains_key("default"));
let default_section = sections.get("default").unwrap();
assert_eq!(
default_section.get("aws_access_key_id").unwrap(),
"AKIADEFAULT"
);
assert!(sections.contains_key("production"));
let prod_section = sections.get("production").unwrap();
assert_eq!(
prod_section.get("role_arn").unwrap(),
"arn:aws:iam::123456789012:role/ProductionAccess"
);
assert_eq!(prod_section.get("source_profile").unwrap(), "default");
assert_eq!(prod_section.get("external_id").unwrap(), "my-external-id");
assert!(sections.contains_key("staging"));
let staging_section = sections.get("staging").unwrap();
assert_eq!(
staging_section.get("role_arn").unwrap(),
"arn:aws:iam::987654321098:role/StagingAccess"
);
assert_eq!(
staging_section.get("role_session_name").unwrap(),
"my-custom-session"
);
assert_eq!(staging_section.get("duration_seconds").unwrap(), "7200");
}
#[test]
fn test_parse_credential_source() {
assert_eq!(
parse_credential_source("Environment"),
Some(CredentialSource::Environment)
);
assert_eq!(
parse_credential_source("Ec2InstanceMetadata"),
Some(CredentialSource::Ec2InstanceMetadata)
);
assert_eq!(
parse_credential_source("EcsContainer"),
Some(CredentialSource::EcsContainer)
);
assert_eq!(parse_credential_source("Invalid"), None);
assert_eq!(parse_credential_source("environment"), None); }
#[test]
fn test_parse_ini_file_with_credential_source() {
let content = r#"
[profile ecs-role]
role_arn = arn:aws:iam::123456789012:role/EcsRole
credential_source = EcsContainer
region = us-east-1
[profile ec2-role]
role_arn = arn:aws:iam::123456789012:role/Ec2Role
credential_source = Ec2InstanceMetadata
[profile env-role]
role_arn = arn:aws:iam::123456789012:role/EnvRole
credential_source = Environment
"#;
let sections = parse_ini_file(content);
assert!(sections.contains_key("ecs-role"));
let ecs_section = sections.get("ecs-role").unwrap();
assert_eq!(
ecs_section.get("role_arn").unwrap(),
"arn:aws:iam::123456789012:role/EcsRole"
);
assert_eq!(
ecs_section.get("credential_source").unwrap(),
"EcsContainer"
);
assert!(ecs_section.get("source_profile").is_none());
assert!(sections.contains_key("ec2-role"));
let ec2_section = sections.get("ec2-role").unwrap();
assert_eq!(
ec2_section.get("credential_source").unwrap(),
"Ec2InstanceMetadata"
);
assert!(sections.contains_key("env-role"));
let env_section = sections.get("env-role").unwrap();
assert_eq!(env_section.get("credential_source").unwrap(), "Environment");
}
#[test]
fn test_ecs_cache() {
let cache = ECS_CACHE.get_or_init(|| std::sync::Mutex::new(None));
let creds = Credentials {
access_key_id: "ASIA_ECS_KEY".to_string(),
secret_access_key: "secret_ecs".to_string(),
session_token: Some("token_ecs".to_string()),
};
let expiration = Instant::now() + Duration::from_secs(3600);
{
let mut guard = cache.lock().unwrap();
*guard = Some(CachedImdsCredentials {
credentials: creds.clone(),
expiration,
});
}
{
let guard = cache.lock().unwrap();
let cached = guard.as_ref().unwrap();
assert_eq!(cached.credentials.access_key_id, "ASIA_ECS_KEY");
}
}
#[test]
fn test_try_read_cli_cache_file_matching_role() {
use std::io::Write;
use tempfile::NamedTempFile;
let cache_json = r#"{
"Credentials": {
"AccessKeyId": "ASIATESTACCESSKEY",
"SecretAccessKey": "testsecretkey123",
"SessionToken": "testsessiontoken456",
"Expiration": "2099-01-01T00:00:00Z"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROATESTROLE:orbit-session",
"Arn": "arn:aws:sts::123456789012:assumed-role/TestRole/orbit-session"
}
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(cache_json.as_bytes()).unwrap();
let role_arn = "arn:aws:iam::123456789012:role/TestRole";
let result = try_read_cli_cache_file(temp_file.path(), role_arn);
assert!(result.is_some(), "Should find matching credentials");
let creds = result.unwrap();
assert_eq!(creds.access_key_id, "ASIATESTACCESSKEY");
assert_eq!(creds.secret_access_key, "testsecretkey123");
assert_eq!(creds.session_token, Some("testsessiontoken456".to_string()));
}
#[test]
fn test_try_read_cli_cache_file_non_matching_role() {
use std::io::Write;
use tempfile::NamedTempFile;
let cache_json = r#"{
"Credentials": {
"AccessKeyId": "ASIATESTACCESSKEY",
"SecretAccessKey": "testsecretkey123",
"SessionToken": "testsessiontoken456",
"Expiration": "2099-01-01T00:00:00Z"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROATESTROLE:orbit-session",
"Arn": "arn:aws:sts::123456789012:assumed-role/TestRole/orbit-session"
}
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(cache_json.as_bytes()).unwrap();
let role_arn = "arn:aws:iam::123456789012:role/DifferentRole";
let result = try_read_cli_cache_file(temp_file.path(), role_arn);
assert!(
result.is_none(),
"Should not find credentials for different role"
);
let role_arn = "arn:aws:iam::999999999999:role/TestRole";
let result = try_read_cli_cache_file(temp_file.path(), role_arn);
assert!(
result.is_none(),
"Should not find credentials for different account"
);
}
#[test]
fn test_try_read_cli_cache_file_expired() {
use std::io::Write;
use tempfile::NamedTempFile;
let cache_json = r#"{
"Credentials": {
"AccessKeyId": "ASIATESTACCESSKEY",
"SecretAccessKey": "testsecretkey123",
"SessionToken": "testsessiontoken456",
"Expiration": "2020-01-01T00:00:00Z"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROATESTROLE:orbit-session",
"Arn": "arn:aws:sts::123456789012:assumed-role/TestRole/orbit-session"
}
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(cache_json.as_bytes()).unwrap();
let role_arn = "arn:aws:iam::123456789012:role/TestRole";
let result = try_read_cli_cache_file(temp_file.path(), role_arn);
assert!(result.is_none(), "Should not return expired credentials");
}
#[test]
fn test_console_login_cache_is_profile_aware() {
let cache = CONSOLE_LOGIN_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let creds_profile_a = Credentials {
access_key_id: "ASIA_LOGIN_A_KEY".to_string(),
secret_access_key: "secret_login_a".to_string(),
session_token: Some("token_login_a".to_string()),
};
let creds_profile_b = Credentials {
access_key_id: "ASIA_LOGIN_B_KEY".to_string(),
secret_access_key: "secret_login_b".to_string(),
session_token: Some("token_login_b".to_string()),
};
let expiration = Instant::now() + Duration::from_secs(3600);
{
let mut guard = cache.lock().unwrap();
guard.insert(
"login-profile-a".to_string(),
CachedImdsCredentials {
credentials: creds_profile_a.clone(),
expiration,
},
);
guard.insert(
"login-profile-b".to_string(),
CachedImdsCredentials {
credentials: creds_profile_b.clone(),
expiration,
},
);
}
{
let guard = cache.lock().unwrap();
let cached_a = guard.get("login-profile-a").unwrap();
assert_eq!(
cached_a.credentials.access_key_id, "ASIA_LOGIN_A_KEY",
"Profile A should return Profile A's credentials"
);
}
{
let guard = cache.lock().unwrap();
let cached_b = guard.get("login-profile-b").unwrap();
assert_eq!(
cached_b.credentials.access_key_id, "ASIA_LOGIN_B_KEY",
"Profile B should return Profile B's credentials"
);
}
}
#[test]
fn test_parse_ini_file_with_login_session() {
let content = r#"
[default]
aws_access_key_id = AKIADEFAULT
aws_secret_access_key = secret_default
region = us-east-1
[profile console-login]
login_session = arn:aws:iam::123456789012:user/Admin
region = us-west-2
"#;
let sections = parse_ini_file(content);
assert!(sections.contains_key("console-login"));
let login_section = sections.get("console-login").unwrap();
assert_eq!(
login_section.get("login_session").unwrap(),
"arn:aws:iam::123456789012:user/Admin"
);
assert_eq!(login_section.get("region").unwrap(), "us-west-2");
}
#[test]
fn test_load_from_console_login_valid() {
use sha2::{Digest, Sha256};
use std::io::Write;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path();
let login_cache_json = r#"{
"accessToken": {
"accessKeyId": "ASIALOGINTESTACCESSKEY",
"secretAccessKey": "loginTestSecretKey123",
"sessionToken": "loginTestSessionToken456",
"accountId": "123456789012",
"expiresAt": "2099-01-01T00:00:00Z"
},
"tokenType": "aws_sigv4",
"refreshToken": "testRefreshToken",
"idToken": "testIdToken",
"clientId": "arn:aws:iam::123456789012:client/test"
}"#;
let login_session = "arn:aws:iam::123456789012:user/TestUser";
let mut hasher = Sha256::new();
hasher.update(login_session.trim().as_bytes());
let hash = hasher.finalize();
let cache_filename = format!("{}.json", hex::encode(hash));
let cache_file_path = cache_dir.join(&cache_filename);
let mut cache_file = std::fs::File::create(&cache_file_path).unwrap();
cache_file.write_all(login_cache_json.as_bytes()).unwrap();
let _cache_dir = LoginCacheDirVar::set(cache_dir);
let result = load_from_console_login("test-profile", login_session);
assert!(
result.is_ok(),
"Should load credentials: {:?}",
result.err()
);
let creds = result.unwrap();
assert_eq!(creds.access_key_id, "ASIALOGINTESTACCESSKEY");
assert_eq!(creds.secret_access_key, "loginTestSecretKey123");
assert_eq!(
creds.session_token,
Some("loginTestSessionToken456".to_string())
);
}
#[test]
fn test_load_from_console_login_expired() {
use sha2::{Digest, Sha256};
use std::io::Write;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path();
let login_cache_json = r#"{
"accessToken": {
"accessKeyId": "ASIAEXPIREDKEY",
"secretAccessKey": "expiredSecret",
"sessionToken": "expiredToken",
"accountId": "123456789012",
"expiresAt": "2020-01-01T00:00:00Z"
},
"tokenType": "aws_sigv4"
}"#;
let login_session = "arn:aws:iam::123456789012:user/ExpiredUser";
let mut hasher = Sha256::new();
hasher.update(login_session.trim().as_bytes());
let hash = hasher.finalize();
let cache_filename = format!("{}.json", hex::encode(hash));
let cache_file_path = cache_dir.join(&cache_filename);
let mut cache_file = std::fs::File::create(&cache_file_path).unwrap();
cache_file.write_all(login_cache_json.as_bytes()).unwrap();
let _cache_dir = LoginCacheDirVar::set(cache_dir);
let result = load_from_console_login("test-expired-profile", login_session);
assert!(result.is_err(), "Should fail for expired credentials");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("expired"),
"Error should mention expiration: {}",
err_msg
);
}
#[test]
fn test_get_login_cache_dir_default() {
let _cache_dir = LoginCacheDirVar::unset();
let result = get_login_cache_dir();
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.ends_with("login/cache") || path.to_string_lossy().contains("login"));
}
#[test]
fn test_get_login_cache_dir_env_override() {
let _cache_dir = LoginCacheDirVar::set("/custom/login/cache");
let result = get_login_cache_dir();
assert!(result.is_ok());
assert_eq!(result.unwrap(), PathBuf::from("/custom/login/cache"));
}
#[test]
fn test_credentials_error_sso_vs_console_login() {
let sso_error = CredentialsError::SsoLoginRequired {
profile: "sso-profile".to_string(),
sso_session: "my-sso-session".to_string(),
};
let console_error = CredentialsError::ConsoleLoginRequired {
profile: "console-profile".to_string(),
login_session: "arn:aws:iam::123456789012:user/Admin".to_string(),
};
let sso_msg = sso_error.to_string();
assert!(
sso_msg.contains("aws sso login"),
"SSO error should suggest 'aws sso login': {}",
sso_msg
);
assert!(
sso_msg.contains("sso-profile"),
"SSO error should contain profile name: {}",
sso_msg
);
assert!(
sso_msg.contains("my-sso-session"),
"SSO error should contain session name: {}",
sso_msg
);
let console_msg = console_error.to_string();
assert!(
console_msg.contains("aws login"),
"Console error should suggest 'aws login': {}",
console_msg
);
assert!(
!console_msg.contains("sso"),
"Console error should NOT mention 'sso': {}",
console_msg
);
assert!(
console_msg.contains("console-profile"),
"Console error should contain profile name: {}",
console_msg
);
assert!(
console_msg.contains("123456789012"),
"Console error should contain login session: {}",
console_msg
);
}
#[test]
fn test_credentials_error_matching() {
let test_cases: Vec<CredentialsError> = vec![
CredentialsError::SsoLoginRequired {
profile: "sso".to_string(),
sso_session: "session".to_string(),
},
CredentialsError::ConsoleLoginRequired {
profile: "console".to_string(),
login_session: "arn".to_string(),
},
CredentialsError::Other(anyhow!("generic error")),
];
for error in test_cases {
match &error {
CredentialsError::SsoLoginRequired { profile, .. } => {
assert_eq!(profile, "sso");
}
CredentialsError::ConsoleLoginRequired { profile, .. } => {
assert_eq!(profile, "console");
}
CredentialsError::Other(e) => {
assert!(e.to_string().contains("generic"));
}
}
}
}
}