reqsign_aws_core/provide_credential/
sso.rs1use crate::Credential;
19use http::{Method, Request, StatusCode};
20use ini::Ini;
21use log::{debug, warn};
22use reqsign_core::time::Timestamp;
23use reqsign_core::{Context, Error, ProvideCredential, Result};
24use serde::Deserialize;
25
26const AWS_SSO_ACCOUNT_ID: &str = "sso_account_id";
27const AWS_SSO_REGION: &str = "sso_region";
28const AWS_SSO_ROLE_NAME: &str = "sso_role_name";
29const AWS_SSO_START_URL: &str = "sso_start_url";
30#[allow(dead_code)]
31const AWS_SSO_SESSION_NAME: &str = "sso_session";
32
33#[derive(Debug, Clone)]
48pub struct SSOCredentialProvider {
49 profile: Option<String>,
50 sso_account_id: Option<String>,
51 sso_region: Option<String>,
52 sso_role_name: Option<String>,
53 sso_start_url: Option<String>,
54 sso_endpoint: Option<String>, }
56
57impl Default for SSOCredentialProvider {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl SSOCredentialProvider {
64 pub fn new() -> Self {
66 Self {
67 profile: None,
68 sso_account_id: None,
69 sso_region: None,
70 sso_role_name: None,
71 sso_start_url: None,
72 sso_endpoint: None,
73 }
74 }
75
76 pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
78 self.profile = Some(profile.into());
79 self
80 }
81
82 pub fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
84 self.sso_account_id = Some(account_id.into());
85 self
86 }
87
88 pub fn with_region(mut self, region: impl Into<String>) -> Self {
90 self.sso_region = Some(region.into());
91 self
92 }
93
94 pub fn with_role_name(mut self, role_name: impl Into<String>) -> Self {
96 self.sso_role_name = Some(role_name.into());
97 self
98 }
99
100 pub fn with_start_url(mut self, start_url: impl Into<String>) -> Self {
102 self.sso_start_url = Some(start_url.into());
103 self
104 }
105
106 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
108 self.sso_endpoint = Some(endpoint.into());
109 self
110 }
111
112 async fn load_sso_config(&self, ctx: &Context) -> Result<SSOConfig> {
113 if let (Some(account_id), Some(region), Some(role_name), Some(start_url)) = (
115 &self.sso_account_id,
116 &self.sso_region,
117 &self.sso_role_name,
118 &self.sso_start_url,
119 ) {
120 return Ok(SSOConfig {
121 sso_account_id: account_id.clone(),
122 sso_region: region.clone(),
123 sso_role_name: role_name.clone(),
124 sso_start_url: start_url.clone(),
125 });
126 }
127
128 let profile_name = self
131 .profile
132 .clone()
133 .or_else(|| ctx.env_var("AWS_PROFILE"))
134 .unwrap_or_else(|| "default".to_string());
135 self.load_from_config_file(ctx, &profile_name).await
136 }
137
138 async fn load_from_config_file(&self, ctx: &Context, profile: &str) -> Result<SSOConfig> {
139 let config_path = ctx
141 .env_var("AWS_CONFIG_FILE")
142 .unwrap_or_else(|| "~/.aws/config".to_string());
143
144 let expanded_path = if config_path.starts_with("~/") {
145 match ctx.expand_home_dir(&config_path) {
146 Some(expanded) => expanded,
147 None => return Err(Error::config_invalid("failed to expand home directory")),
148 }
149 } else {
150 config_path
151 };
152
153 let content = ctx.file_read(&expanded_path).await.map_err(|_| {
154 Error::config_invalid(format!("failed to read config file: {expanded_path}"))
155 })?;
156
157 let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
158 .map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
159
160 let profile_section = if profile == "default" {
161 profile.to_string()
162 } else {
163 format!("profile {profile}")
164 };
165
166 let section = conf.section(Some(profile_section)).ok_or_else(|| {
167 Error::config_invalid(format!("profile '{profile}' not found in config"))
168 })?;
169
170 let sso_account_id = section.get(AWS_SSO_ACCOUNT_ID).ok_or_else(|| {
172 Error::config_invalid(format!("missing {AWS_SSO_ACCOUNT_ID} in profile"))
173 })?;
174
175 let sso_region = section
176 .get(AWS_SSO_REGION)
177 .ok_or_else(|| Error::config_invalid(format!("missing {AWS_SSO_REGION} in profile")))?;
178
179 let sso_role_name = section.get(AWS_SSO_ROLE_NAME).ok_or_else(|| {
180 Error::config_invalid(format!("missing {AWS_SSO_ROLE_NAME} in profile"))
181 })?;
182
183 let sso_start_url = section.get(AWS_SSO_START_URL).ok_or_else(|| {
184 Error::config_invalid(format!("missing {AWS_SSO_START_URL} in profile"))
185 })?;
186
187 Ok(SSOConfig {
188 sso_account_id: sso_account_id.to_string(),
189 sso_region: sso_region.to_string(),
190 sso_role_name: sso_role_name.to_string(),
191 sso_start_url: sso_start_url.to_string(),
192 })
193 }
194
195 async fn find_cached_token(
196 &self,
197 ctx: &Context,
198 start_url: &str,
199 ) -> Result<Option<CachedToken>> {
200 let home_dir = ctx
202 .home_dir()
203 .ok_or_else(|| Error::config_invalid("HOME directory not found".to_string()))?;
204
205 let cache_dir = home_dir.join(".aws").join("sso").join("cache");
206
207 let cache_key = hex_sha1(start_url.as_bytes());
209 let cache_file = cache_dir.join(format!("{cache_key}.json"));
210
211 debug!("looking for SSO token cache at: {cache_file:?}");
212
213 match ctx.file_read(&cache_file.to_string_lossy()).await {
214 Ok(content) => {
215 let token: CachedToken = serde_json::from_slice(&content).map_err(|e| {
216 Error::unexpected(format!("failed to parse SSO token cache: {e}"))
217 })?;
218
219 let expires_at = token.expires_at.parse::<Timestamp>()?;
221 if expires_at <= Timestamp::now() {
222 warn!("SSO token is expired");
223 return Ok(None);
224 }
225
226 Ok(Some(token))
227 }
228 Err(_) => {
229 debug!("SSO token cache not found");
230 Ok(None)
231 }
232 }
233 }
234
235 async fn get_role_credentials(
236 &self,
237 ctx: &Context,
238 config: &SSOConfig,
239 access_token: &str,
240 ) -> Result<Credential> {
241 let endpoint = self
243 .sso_endpoint
244 .clone()
245 .or_else(|| ctx.env_var("AWS_SSO_ENDPOINT"))
246 .unwrap_or_else(|| {
247 format!(
248 "https://portal.sso.{}.amazonaws.com/federation/credentials",
249 config.sso_region
250 )
251 });
252
253 let params = serde_urlencoded::to_string([
254 ("role_name", &config.sso_role_name),
255 ("account_id", &config.sso_account_id),
256 ])
257 .map_err(|e| Error::unexpected(format!("failed to encode query params: {e}")))?;
258
259 let url = format!("{endpoint}?{params}");
260
261 let req = Request::builder()
262 .method(Method::GET)
263 .uri(&url)
264 .header("x-amz-sso_bearer_token", access_token)
265 .body(bytes::Bytes::new())
266 .map_err(|e| Error::unexpected(format!("failed to build request: {e}")))?;
267
268 let resp = ctx
269 .http_send(req)
270 .await
271 .map_err(|e| Error::unexpected(format!("failed to fetch SSO credentials: {e}")))?;
272
273 if resp.status() != StatusCode::OK {
274 return Err(Error::unexpected(format!(
275 "SSO endpoint returned status: {}",
276 resp.status()
277 )));
278 }
279
280 let body = resp.into_body();
281 let creds: SSOCredentialResponse = serde_json::from_slice(&body)
282 .map_err(|e| Error::unexpected(format!("failed to parse SSO credentials: {e}")))?;
283
284 let role_creds = creds.role_credentials;
285 let expires_in = Timestamp::from_millisecond(role_creds.expiration)
286 .map_err(|e| Error::unexpected(format!("invalid expiration timestamp: {e}")))?;
287
288 Ok(Credential {
289 access_key_id: role_creds.access_key_id,
290 secret_access_key: role_creds.secret_access_key,
291 session_token: Some(role_creds.session_token),
292 expires_in: Some(expires_in),
293 })
294 }
295}
296
297#[derive(Debug)]
298struct SSOConfig {
299 sso_account_id: String,
300 sso_region: String,
301 sso_role_name: String,
302 sso_start_url: String,
303}
304
305#[derive(Debug, Deserialize)]
306struct CachedToken {
307 #[serde(rename = "accessToken")]
308 access_token: String,
309 #[serde(rename = "expiresAt")]
310 expires_at: String,
311}
312
313#[derive(Debug, Deserialize)]
314#[serde(rename_all = "camelCase")]
315struct SSOCredentialResponse {
316 role_credentials: RoleCredentials,
317}
318
319#[derive(Debug, Deserialize)]
320#[serde(rename_all = "camelCase")]
321struct RoleCredentials {
322 access_key_id: String,
323 secret_access_key: String,
324 session_token: String,
325 expiration: i64,
326}
327impl ProvideCredential for SSOCredentialProvider {
328 type Credential = Credential;
329
330 async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
331 let config = match self.load_sso_config(ctx).await {
332 Ok(c) => c,
333 Err(_) => {
334 debug!("SSO configuration not found");
335 return Ok(None);
336 }
337 };
338
339 debug!(
340 "SSO config loaded: account={}, role={}",
341 config.sso_account_id, config.sso_role_name
342 );
343
344 let token = self
346 .find_cached_token(ctx, &config.sso_start_url)
347 .await?
348 .ok_or_else(|| {
349 Error::config_invalid(
350 "No valid SSO token found. Please run 'aws sso login' first".to_string(),
351 )
352 })?;
353
354 let creds = self
356 .get_role_credentials(ctx, &config, &token.access_token)
357 .await?;
358
359 Ok(Some(creds))
360 }
361}
362
363fn hex_sha1(data: &[u8]) -> String {
365 use sha1::{Digest, Sha1};
366 let mut hasher = Sha1::new();
367 hasher.update(data);
368 hex::encode(hasher.finalize())
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use reqsign_core::StaticEnv;
375 use reqsign_file_read_tokio::TokioFileRead;
376 use reqsign_http_send_reqwest::ReqwestHttpSend;
377 use std::collections::HashMap;
378
379 #[tokio::test]
380 async fn test_sso_provider_no_config() {
381 let ctx = Context::new()
382 .with_file_read(TokioFileRead)
383 .with_http_send(ReqwestHttpSend::default());
384 let ctx = ctx.with_env(StaticEnv {
385 home_dir: Some(std::path::PathBuf::from("/home/test")),
386 envs: HashMap::new(),
387 });
388
389 let provider = SSOCredentialProvider::new();
390 let result = provider.provide_credential(&ctx).await.unwrap();
391 assert!(result.is_none());
392 }
393
394 #[test]
395 fn test_sha1_hash() {
396 let url = "https://my-sso-portal.awsapps.com/start";
397 let hash = hex_sha1(url.as_bytes());
398 assert_eq!(hash.len(), 40); }
400}