Skip to main content

reqsign_aws_core/provide_credential/
profile.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;
19#[cfg(not(target_arch = "wasm32"))]
20use crate::constants::*;
21#[cfg(not(target_arch = "wasm32"))]
22use ini::Ini;
23#[cfg(not(target_arch = "wasm32"))]
24use log::debug;
25#[cfg(not(target_arch = "wasm32"))]
26use reqsign_core::Error;
27use reqsign_core::{Context, ProvideCredential, Result};
28
29/// ProfileCredentialProvider loads AWS credentials from configuration files.
30///
31/// This provider loads credentials from:
32/// - `~/.aws/credentials` (or the path specified by `AWS_SHARED_CREDENTIALS_FILE`)
33/// - `~/.aws/config` (or the path specified by `AWS_CONFIG_FILE`)
34///
35/// The profile to use is determined by:
36/// 1. The profile specified via `with_profile()`
37/// 2. The `AWS_PROFILE` environment variable
38/// 3. Default to "default"
39#[derive(Debug, Clone)]
40pub struct ProfileCredentialProvider {
41    profile: Option<String>,
42    config_file: Option<String>,
43    credentials_file: Option<String>,
44}
45
46impl Default for ProfileCredentialProvider {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl ProfileCredentialProvider {
53    /// Create a new ProfileCredentialProvider with default settings.
54    pub fn new() -> Self {
55        Self {
56            profile: None,
57            config_file: None,
58            credentials_file: None,
59        }
60    }
61
62    /// Set the profile name to use.
63    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
64        self.profile = Some(profile.into());
65        self
66    }
67
68    /// Set the path to the config file.
69    pub fn with_config_file(mut self, path: impl Into<String>) -> Self {
70        self.config_file = Some(path.into());
71        self
72    }
73
74    /// Set the path to the credentials file.
75    pub fn with_credentials_file(mut self, path: impl Into<String>) -> Self {
76        self.credentials_file = Some(path.into());
77        self
78    }
79
80    #[cfg(not(target_arch = "wasm32"))]
81    async fn load_from_credentials_file(
82        &self,
83        ctx: &Context,
84        profile: &str,
85    ) -> Result<Option<Credential>> {
86        let path = if let Some(path) = &self.credentials_file {
87            path.clone()
88        } else if let Some(path) = ctx.env_var(AWS_SHARED_CREDENTIALS_FILE) {
89            path
90        } else {
91            "~/.aws/credentials".to_string()
92        };
93
94        let expanded_path = if path.starts_with("~/") {
95            match ctx.expand_home_dir(&path) {
96                Some(expanded) => expanded,
97                None => {
98                    debug!("failed to expand homedir for path: {path}");
99                    return Ok(None);
100                }
101            }
102        } else {
103            path.clone()
104        };
105
106        let content = match ctx.file_read(&expanded_path).await {
107            Ok(content) => content,
108            Err(err) => {
109                debug!("failed to read credentials file {expanded_path}: {err:?}");
110                return Ok(None);
111            }
112        };
113
114        let conf = Ini::load_from_str(&String::from_utf8_lossy(&content)).map_err(|e| {
115            Error::config_invalid("failed to parse credentials file").with_source(e)
116        })?;
117
118        let props = match conf.section(Some(profile)) {
119            Some(props) => props,
120            None => {
121                debug!("profile {profile} not found in credentials file");
122                return Ok(None);
123            }
124        };
125
126        let access_key_id = props.get("aws_access_key_id");
127        let secret_access_key = props.get("aws_secret_access_key");
128
129        match (access_key_id, secret_access_key) {
130            (Some(ak), Some(sk)) => Ok(Some(Credential {
131                access_key_id: ak.to_string(),
132                secret_access_key: sk.to_string(),
133                session_token: props.get("aws_session_token").map(|s| s.to_string()),
134                expires_in: None,
135            })),
136            _ => Ok(None),
137        }
138    }
139
140    #[cfg(not(target_arch = "wasm32"))]
141    async fn load_from_config_file(
142        &self,
143        ctx: &Context,
144        profile: &str,
145    ) -> Result<Option<Credential>> {
146        let path = if let Some(path) = &self.config_file {
147            path.clone()
148        } else if let Some(path) = ctx.env_var(AWS_CONFIG_FILE) {
149            path
150        } else {
151            "~/.aws/config".to_string()
152        };
153
154        let expanded_path = if path.starts_with("~/") {
155            match ctx.expand_home_dir(&path) {
156                Some(expanded) => expanded,
157                None => {
158                    debug!("failed to expand homedir for path: {path}");
159                    return Ok(None);
160                }
161            }
162        } else {
163            path.clone()
164        };
165
166        let content = match ctx.file_read(&expanded_path).await {
167            Ok(content) => content,
168            Err(err) => {
169                debug!("failed to read config file {expanded_path}: {err:?}");
170                return Ok(None);
171            }
172        };
173
174        let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
175            .map_err(|e| Error::config_invalid("failed to parse config file").with_source(e))?;
176
177        let section = match profile {
178            "default" => "default".to_string(),
179            x => format!("profile {x}"),
180        };
181
182        let props = match conf.section(Some(&section)) {
183            Some(props) => props,
184            None => {
185                debug!("section {profile} not found in config file");
186                return Ok(None);
187            }
188        };
189
190        let access_key_id = props.get("aws_access_key_id");
191        let secret_access_key = props.get("aws_secret_access_key");
192
193        match (access_key_id, secret_access_key) {
194            (Some(ak), Some(sk)) => Ok(Some(Credential {
195                access_key_id: ak.to_string(),
196                secret_access_key: sk.to_string(),
197                session_token: props.get("aws_session_token").map(|s| s.to_string()),
198                expires_in: None,
199            })),
200            _ => Ok(None),
201        }
202    }
203}
204impl ProvideCredential for ProfileCredentialProvider {
205    type Credential = Credential;
206
207    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
208        #[cfg(target_arch = "wasm32")]
209        {
210            let _ = ctx;
211            Ok(None)
212        }
213
214        #[cfg(not(target_arch = "wasm32"))]
215        {
216            let profile = self
217                .profile
218                .clone()
219                .or_else(|| ctx.env_var(AWS_PROFILE))
220                .unwrap_or_else(|| "default".to_string());
221
222            // Try credentials file first
223            if let Some(cred) = self.load_from_credentials_file(ctx, &profile).await? {
224                return Ok(Some(cred));
225            }
226
227            // Then try config file
228            self.load_from_config_file(ctx, &profile).await
229        }
230    }
231}
232
233#[cfg(test)]
234#[cfg(not(target_arch = "wasm32"))]
235mod tests {
236    use super::*;
237    use pretty_assertions::assert_eq;
238    use reqsign_core::StaticEnv;
239    use reqsign_file_read_tokio::TokioFileRead;
240    use reqsign_http_send_reqwest::ReqwestHttpSend;
241    use std::collections::HashMap;
242    use std::fs::File;
243    use std::io::Write;
244    use tempfile::tempdir;
245
246    #[tokio::test]
247    async fn test_profile_from_credentials_file() -> anyhow::Result<()> {
248        let _ = env_logger::builder().is_test(true).try_init();
249
250        let tmp_dir = tempdir()?;
251        let file_path = tmp_dir.path().join("credentials");
252        let mut tmp_file = File::create(&file_path)?;
253        writeln!(tmp_file, "[default]")?;
254        writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
255        writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
256        writeln!(tmp_file, "aws_session_token = DEFAULTSESSIONTOKEN")?;
257        writeln!(tmp_file)?;
258        writeln!(tmp_file, "[profile1]")?;
259        writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
260        writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
261        writeln!(tmp_file, "aws_session_token = PROFILE1SESSIONTOKEN")?;
262
263        let context = Context::new()
264            .with_file_read(TokioFileRead)
265            .with_http_send(ReqwestHttpSend::default())
266            .with_env(StaticEnv {
267                home_dir: None,
268                envs: HashMap::new(),
269            });
270
271        // Test the final default fallback without an explicit profile or AWS_PROFILE
272        let provider =
273            ProfileCredentialProvider::new().with_credentials_file(file_path.to_str().unwrap());
274        let cred = provider.provide_credential(&context).await?;
275        assert!(cred.is_some());
276        let cred = cred.unwrap();
277        assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
278        assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
279        assert_eq!(cred.session_token, Some("DEFAULTSESSIONTOKEN".to_string()));
280
281        // Test specific profile
282        let provider = ProfileCredentialProvider::new()
283            .with_profile("profile1")
284            .with_credentials_file(file_path.to_str().unwrap());
285        let cred = provider.provide_credential(&context).await?;
286        assert!(cred.is_some());
287        let cred = cred.unwrap();
288        assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
289        assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
290        assert_eq!(cred.session_token, Some("PROFILE1SESSIONTOKEN".to_string()));
291
292        Ok(())
293    }
294
295    #[tokio::test]
296    async fn test_profile_from_config_file() -> anyhow::Result<()> {
297        let _ = env_logger::builder().is_test(true).try_init();
298
299        let tmp_dir = tempdir()?;
300        let file_path = tmp_dir.path().join("config");
301        let mut tmp_file = File::create(&file_path)?;
302        writeln!(tmp_file, "[default]")?;
303        writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
304        writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
305        writeln!(tmp_file)?;
306        writeln!(tmp_file, "[profile profile1]")?;
307        writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
308        writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
309
310        let context = Context::new()
311            .with_file_read(TokioFileRead)
312            .with_http_send(ReqwestHttpSend::default())
313            .with_env(StaticEnv {
314                home_dir: None,
315                envs: HashMap::new(),
316            });
317
318        // Test default profile
319        let provider =
320            ProfileCredentialProvider::new().with_config_file(file_path.to_str().unwrap());
321        let cred = provider.provide_credential(&context).await?;
322        assert!(cred.is_some());
323        let cred = cred.unwrap();
324        assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
325        assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
326        assert!(cred.session_token.is_none());
327
328        // Test specific profile
329        let provider = ProfileCredentialProvider::new()
330            .with_profile("profile1")
331            .with_config_file(file_path.to_str().unwrap());
332        let cred = provider.provide_credential(&context).await?;
333        assert!(cred.is_some());
334        let cred = cred.unwrap();
335        assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
336        assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
337        assert!(cred.session_token.is_none());
338
339        Ok(())
340    }
341
342    #[tokio::test]
343    async fn test_profile_env_fallback() -> anyhow::Result<()> {
344        let _ = env_logger::builder().is_test(true).try_init();
345
346        let tmp_dir = tempdir()?;
347        let file_path = tmp_dir.path().join("credentials");
348        let mut tmp_file = File::create(&file_path)?;
349        writeln!(tmp_file, "[default]")?;
350        writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
351        writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
352        writeln!(tmp_file)?;
353        writeln!(tmp_file, "[profile1]")?;
354        writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
355        writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
356
357        let context = Context::new()
358            .with_file_read(TokioFileRead)
359            .with_http_send(ReqwestHttpSend::default())
360            .with_env(StaticEnv {
361                home_dir: None,
362                envs: HashMap::from([(AWS_PROFILE.to_string(), "profile1".to_string())]),
363            });
364
365        let provider =
366            ProfileCredentialProvider::new().with_credentials_file(file_path.to_str().unwrap());
367        let cred = provider.provide_credential(&context).await?;
368        assert!(cred.is_some());
369        let cred = cred.unwrap();
370        assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
371        assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
372
373        Ok(())
374    }
375
376    #[tokio::test]
377    async fn test_explicit_profile_overrides_env() -> anyhow::Result<()> {
378        let _ = env_logger::builder().is_test(true).try_init();
379
380        let tmp_dir = tempdir()?;
381        let file_path = tmp_dir.path().join("credentials");
382        let mut tmp_file = File::create(&file_path)?;
383        writeln!(tmp_file, "[default]")?;
384        writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
385        writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
386        writeln!(tmp_file)?;
387        writeln!(tmp_file, "[profile1]")?;
388        writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
389        writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
390
391        let context = Context::new()
392            .with_file_read(TokioFileRead)
393            .with_http_send(ReqwestHttpSend::default())
394            .with_env(StaticEnv {
395                home_dir: None,
396                envs: HashMap::from([(AWS_PROFILE.to_string(), "profile1".to_string())]),
397            });
398
399        let provider = ProfileCredentialProvider::new()
400            .with_profile("default")
401            .with_credentials_file(file_path.to_str().unwrap());
402        let cred = provider.provide_credential(&context).await?;
403        assert!(cred.is_some());
404        let cred = cred.unwrap();
405        assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
406        assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
407
408        Ok(())
409    }
410
411    #[tokio::test]
412    async fn test_profile_missing_credentials() -> anyhow::Result<()> {
413        let context = Context::new()
414            .with_file_read(TokioFileRead)
415            .with_http_send(ReqwestHttpSend::default());
416
417        let provider = ProfileCredentialProvider::new()
418            .with_credentials_file("/non/existent/path")
419            .with_config_file("/non/existent/path");
420        let cred = provider.provide_credential(&context).await?;
421        assert!(cred.is_none());
422
423        Ok(())
424    }
425}