Skip to main content

reqsign_aws_core/provide_credential/
sso.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use 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/// SSO Credentials Provider
34///
35/// This provider fetches credentials from AWS SSO (IAM Identity Center).
36/// It reads cached SSO tokens from ~/.aws/sso/cache/ and exchanges them for temporary credentials.
37///
38/// # Configuration
39/// SSO configuration is typically stored in ~/.aws/config under a profile:
40/// ```ini
41/// [profile my-sso-profile]
42/// sso_start_url = https://my-sso-portal.awsapps.com/start
43/// sso_region = us-east-1
44/// sso_account_id = 123456789012
45/// sso_role_name = MyRole
46/// ```
47#[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>, // Allow custom endpoint for testing
55}
56
57impl Default for SSOCredentialProvider {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl SSOCredentialProvider {
64    /// Create a new SSO credential provider
65    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    /// Set the profile name to use
77    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
78        self.profile = Some(profile.into());
79        self
80    }
81
82    /// Set SSO account ID
83    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    /// Set SSO region
89    pub fn with_region(mut self, region: impl Into<String>) -> Self {
90        self.sso_region = Some(region.into());
91        self
92    }
93
94    /// Set SSO role name
95    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    /// Set SSO start URL
101    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    /// Set custom SSO endpoint (for testing)
107    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 all fields are provided directly, use them
114        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        // Otherwise, load from config file
129        // Priority: 1. self.profile, 2. AWS_PROFILE env var, 3. "default"
130        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        // Load AWS config file
140        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        // Check if this profile has SSO configuration
171        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        // Get home directory and build cache path
201        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        // Generate cache file name (SHA1 hash of start URL)
208        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                // Check if token is expired
220                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        // Allow endpoint override for testing
242        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        // Find cached SSO token
345        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        // Exchange token for role credentials
355        let creds = self
356            .get_role_credentials(ctx, &config, &token.access_token)
357            .await?;
358
359        Ok(Some(creds))
360    }
361}
362
363// Simple SHA1 implementation for cache key generation
364fn 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); // SHA1 produces 40 hex characters
399    }
400}