use crate::Credential;
use http::{Method, Request, StatusCode};
use ini::Ini;
use log::{debug, warn};
use reqsign_core::time::Timestamp;
use reqsign_core::{Context, Error, ProvideCredential, Result};
use serde::Deserialize;
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";
#[derive(Debug, Clone)]
pub struct SSOCredentialProvider {
profile: Option<String>,
sso_account_id: Option<String>,
sso_region: Option<String>,
sso_role_name: Option<String>,
sso_start_url: Option<String>,
sso_endpoint: Option<String>, }
impl Default for SSOCredentialProvider {
fn default() -> Self {
Self::new()
}
}
impl SSOCredentialProvider {
pub fn new() -> Self {
Self {
profile: None,
sso_account_id: None,
sso_region: None,
sso_role_name: None,
sso_start_url: None,
sso_endpoint: None,
}
}
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
self.sso_account_id = Some(account_id.into());
self
}
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.sso_region = Some(region.into());
self
}
pub fn with_role_name(mut self, role_name: impl Into<String>) -> Self {
self.sso_role_name = Some(role_name.into());
self
}
pub fn with_start_url(mut self, start_url: impl Into<String>) -> Self {
self.sso_start_url = Some(start_url.into());
self
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.sso_endpoint = Some(endpoint.into());
self
}
async fn load_sso_config(&self, ctx: &Context) -> Result<SSOConfig> {
if let (Some(account_id), Some(region), Some(role_name), Some(start_url)) = (
&self.sso_account_id,
&self.sso_region,
&self.sso_role_name,
&self.sso_start_url,
) {
return Ok(SSOConfig {
sso_account_id: account_id.clone(),
sso_region: region.clone(),
sso_role_name: role_name.clone(),
sso_start_url: start_url.clone(),
});
}
let profile_name = self
.profile
.clone()
.or_else(|| ctx.env_var("AWS_PROFILE"))
.unwrap_or_else(|| "default".to_string());
self.load_from_config_file(ctx, &profile_name).await
}
async fn load_from_config_file(&self, ctx: &Context, profile: &str) -> Result<SSOConfig> {
let config_path = ctx
.env_var("AWS_CONFIG_FILE")
.unwrap_or_else(|| "~/.aws/config".to_string());
let expanded_path = if config_path.starts_with("~/") {
match ctx.expand_home_dir(&config_path) {
Some(expanded) => expanded,
None => return Err(Error::config_invalid("failed to expand home directory")),
}
} else {
config_path
};
let content = ctx.file_read(&expanded_path).await.map_err(|_| {
Error::config_invalid(format!("failed to read config file: {expanded_path}"))
})?;
let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
.map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
let profile_section = if profile == "default" {
profile.to_string()
} else {
format!("profile {profile}")
};
let section = conf.section(Some(profile_section)).ok_or_else(|| {
Error::config_invalid(format!("profile '{profile}' not found in config"))
})?;
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"))
})?;
Ok(SSOConfig {
sso_account_id: sso_account_id.to_string(),
sso_region: sso_region.to_string(),
sso_role_name: sso_role_name.to_string(),
sso_start_url: sso_start_url.to_string(),
})
}
async fn find_cached_token(
&self,
ctx: &Context,
start_url: &str,
) -> Result<Option<CachedToken>> {
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");
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}"))
})?;
let expires_at = token.expires_at.parse::<Timestamp>()?;
if expires_at <= Timestamp::now() {
warn!("SSO token is expired");
return Ok(None);
}
Ok(Some(token))
}
Err(_) => {
debug!("SSO token cache not found");
Ok(None)
}
}
}
async fn get_role_credentials(
&self,
ctx: &Context,
config: &SSOConfig,
access_token: &str,
) -> Result<Credential> {
let endpoint = self
.sso_endpoint
.clone()
.or_else(|| ctx.env_var("AWS_SSO_ENDPOINT"))
.unwrap_or_else(|| {
format!(
"https://portal.sso.{}.amazonaws.com/federation/credentials",
config.sso_region
)
});
let params = serde_urlencoded::to_string([
("role_name", &config.sso_role_name),
("account_id", &config.sso_account_id),
])
.map_err(|e| Error::unexpected(format!("failed to encode query params: {e}")))?;
let url = format!("{endpoint}?{params}");
let req = Request::builder()
.method(Method::GET)
.uri(&url)
.header("x-amz-sso_bearer_token", access_token)
.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}")))?;
if resp.status() != StatusCode::OK {
return Err(Error::unexpected(format!(
"SSO endpoint returned status: {}",
resp.status()
)));
}
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}")))?;
let role_creds = creds.role_credentials;
let expires_in = Timestamp::from_millisecond(role_creds.expiration)
.map_err(|e| Error::unexpected(format!("invalid expiration timestamp: {e}")))?;
Ok(Credential {
access_key_id: role_creds.access_key_id,
secret_access_key: role_creds.secret_access_key,
session_token: Some(role_creds.session_token),
expires_in: Some(expires_in),
})
}
}
#[derive(Debug)]
struct SSOConfig {
sso_account_id: String,
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,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SSOCredentialResponse {
role_credentials: RoleCredentials,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RoleCredentials {
access_key_id: String,
secret_access_key: String,
session_token: String,
expiration: i64,
}
impl ProvideCredential for SSOCredentialProvider {
type Credential = Credential;
async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
let config = match self.load_sso_config(ctx).await {
Ok(c) => c,
Err(_) => {
debug!("SSO configuration not found");
return Ok(None);
}
};
debug!(
"SSO config loaded: account={}, role={}",
config.sso_account_id, config.sso_role_name
);
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(),
)
})?;
let creds = self
.get_role_credentials(ctx, &config, &token.access_token)
.await?;
Ok(Some(creds))
}
}
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); }
}