awscreds/
credentials.rs

1#![allow(dead_code)]
2
3use crate::error::CredentialsError;
4use ini::Ini;
5use log::debug;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::env;
9use std::ops::Deref;
10use std::path::Path;
11use std::sync::atomic::AtomicU32;
12use std::sync::atomic::Ordering;
13use std::time::Duration;
14use time::OffsetDateTime;
15use url::Url;
16
17/// AWS access credentials: access key, secret key, and optional token.
18///
19/// # Example
20///
21/// Loads from the standard AWS credentials file with the given profile name,
22/// defaults to "default".
23///
24/// ```no_run
25/// # // Do not execute this as it would cause unit tests to attempt to access
26/// # // real user credentials.
27/// use awscreds::Credentials;
28///
29/// // Load credentials from `[default]` profile
30/// let credentials = Credentials::default();
31///
32/// // Also loads credentials from `[default]` profile
33/// let credentials = Credentials::new(None, None, None, None, None);
34///
35/// // Load credentials from `[my-profile]` profile
36/// let credentials = Credentials::new(None, None, None, None, Some("my-profile".into()));
37///
38/// // Use anonymous credentials for public objects
39/// let credentials = Credentials::anonymous();
40/// ```
41///
42/// Credentials may also be initialized directly or by the following environment variables:
43///
44///   - `AWS_ACCESS_KEY_ID`,
45///   - `AWS_SECRET_ACCESS_KEY`
46///   - `AWS_SESSION_TOKEN`
47///
48/// The order of preference is arguments, then environment, and finally AWS
49/// credentials file.
50///
51/// ```
52/// use awscreds::Credentials;
53///
54/// // Load credentials directly
55/// let access_key = "AKIAIOSFODNN7EXAMPLE";
56/// let secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
57/// let credentials = Credentials::new(Some(access_key), Some(secret_key), None, None, None);
58///
59/// // Load credentials from the environment
60/// use std::env;
61/// env::set_var("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE");
62/// env::set_var("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
63/// let credentials = Credentials::new(None, None, None, None, None);
64/// ```
65#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
66pub struct Credentials {
67    /// AWS public access key.
68    pub access_key: Option<String>,
69    /// AWS secret key.
70    pub secret_key: Option<String>,
71    /// Temporary token issued by AWS service.
72    pub security_token: Option<String>,
73    pub session_token: Option<String>,
74    pub expiration: Option<Rfc3339OffsetDateTime>,
75}
76
77#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
78#[repr(transparent)]
79pub struct Rfc3339OffsetDateTime(#[serde(with = "time::serde::rfc3339")] pub time::OffsetDateTime);
80
81impl From<time::OffsetDateTime> for Rfc3339OffsetDateTime {
82    fn from(v: time::OffsetDateTime) -> Self {
83        Self(v)
84    }
85}
86
87impl From<Rfc3339OffsetDateTime> for time::OffsetDateTime {
88    fn from(v: Rfc3339OffsetDateTime) -> Self {
89        v.0
90    }
91}
92
93impl Deref for Rfc3339OffsetDateTime {
94    type Target = time::OffsetDateTime;
95
96    fn deref(&self) -> &Self::Target {
97        &self.0
98    }
99}
100
101#[derive(Deserialize, Debug)]
102#[serde(rename_all = "PascalCase")]
103pub struct AssumeRoleWithWebIdentityResponse {
104    pub assume_role_with_web_identity_result: AssumeRoleWithWebIdentityResult,
105    pub response_metadata: ResponseMetadata,
106}
107
108#[derive(Deserialize, Debug)]
109#[serde(rename_all = "PascalCase")]
110pub struct AssumeRoleWithWebIdentityResult {
111    pub subject_from_web_identity_token: String,
112    pub audience: String,
113    pub assumed_role_user: AssumedRoleUser,
114    pub credentials: StsResponseCredentials,
115    pub provider: String,
116}
117
118#[derive(Deserialize, Debug)]
119#[serde(rename_all = "PascalCase")]
120pub struct StsResponseCredentials {
121    pub session_token: String,
122    pub secret_access_key: String,
123    pub expiration: Rfc3339OffsetDateTime,
124    pub access_key_id: String,
125}
126
127#[derive(Deserialize, Debug)]
128#[serde(rename_all = "PascalCase")]
129pub struct AssumedRoleUser {
130    pub arn: String,
131    pub assumed_role_id: String,
132}
133
134#[derive(Deserialize, Debug)]
135#[serde(rename_all = "PascalCase")]
136pub struct ResponseMetadata {
137    pub request_id: String,
138}
139
140/// The global request timeout in milliseconds. 0 means no timeout.
141///
142/// Defaults to 30 seconds.
143static REQUEST_TIMEOUT_MS: AtomicU32 = AtomicU32::new(30_000);
144
145/// Sets the timeout for all credentials HTTP requests and returns the
146/// old timeout value, if any; this timeout applies after a 30-second
147/// connection timeout.
148///
149/// Short durations are bumped to one millisecond, and durations
150/// greater than 4 billion milliseconds (49 days) are rounded up to
151/// infinity (no timeout).
152/// The global default value is 30 seconds.
153#[cfg(feature = "http-credentials")]
154pub fn set_request_timeout(timeout: Option<Duration>) -> Option<Duration> {
155    use std::convert::TryInto;
156    let duration_ms = timeout
157        .as_ref()
158        .map(Duration::as_millis)
159        .unwrap_or(u128::MAX)
160        .max(1); // A 0 duration means infinity.
161
162    // Store that non-zero u128 value in an AtomicU32 by mapping large
163    // values to 0: `http_get` maps that to no (infinite) timeout.
164    let prev = REQUEST_TIMEOUT_MS.swap(duration_ms.try_into().unwrap_or(0), Ordering::Relaxed);
165
166    if prev == 0 {
167        None
168    } else {
169        Some(Duration::from_millis(prev as u64))
170    }
171}
172
173#[cfg(feature = "http-credentials")]
174fn apply_timeout(builder: attohttpc::RequestBuilder) -> attohttpc::RequestBuilder {
175    let timeout_ms = REQUEST_TIMEOUT_MS.load(Ordering::Relaxed);
176    if timeout_ms > 0 {
177        return builder.timeout(Duration::from_millis(timeout_ms as u64));
178    }
179    builder
180}
181
182/// Sends a GET request to `url` with a request timeout if one was set.
183#[cfg(feature = "http-credentials")]
184fn http_get(url: &str) -> attohttpc::Result<attohttpc::Response> {
185    let builder = apply_timeout(attohttpc::get(url));
186
187    builder.send()
188}
189
190impl Credentials {
191    pub fn refresh(&mut self) -> Result<(), CredentialsError> {
192        if let Some(expiration) = self.expiration {
193            if expiration.0 <= OffsetDateTime::now_utc() {
194                debug!("Refreshing credentials!");
195                let refreshed = Credentials::default()?;
196                *self = refreshed
197            }
198        }
199        Ok(())
200    }
201
202    #[cfg(feature = "http-credentials")]
203    pub fn from_sts_env(session_name: &str) -> Result<Credentials, CredentialsError> {
204        let role_arn = env::var("AWS_ROLE_ARN")?;
205        let web_identity_token_file = env::var("AWS_WEB_IDENTITY_TOKEN_FILE")?;
206        let web_identity_token = std::fs::read_to_string(web_identity_token_file)?;
207        Credentials::from_sts(&role_arn, session_name, &web_identity_token)
208    }
209
210    #[cfg(feature = "http-credentials")]
211    pub fn from_sts(
212        role_arn: &str,
213        session_name: &str,
214        web_identity_token: &str,
215    ) -> Result<Credentials, CredentialsError> {
216        let url = Url::parse_with_params(
217            "https://sts.amazonaws.com/",
218            &[
219                ("Action", "AssumeRoleWithWebIdentity"),
220                ("RoleSessionName", session_name),
221                ("RoleArn", role_arn),
222                ("WebIdentityToken", web_identity_token),
223                ("Version", "2011-06-15"),
224            ],
225        )?;
226        let response = http_get(url.as_str())?;
227        let serde_response =
228            quick_xml::de::from_str::<AssumeRoleWithWebIdentityResponse>(&response.text()?)?;
229        // assert!(quick_xml::de::from_str::<AssumeRoleWithWebIdentityResponse>(&response.text()?).unwrap());
230
231        Ok(Credentials {
232            access_key: Some(
233                serde_response
234                    .assume_role_with_web_identity_result
235                    .credentials
236                    .access_key_id,
237            ),
238            secret_key: Some(
239                serde_response
240                    .assume_role_with_web_identity_result
241                    .credentials
242                    .secret_access_key,
243            ),
244            security_token: None,
245            session_token: Some(
246                serde_response
247                    .assume_role_with_web_identity_result
248                    .credentials
249                    .session_token,
250            ),
251            expiration: Some(
252                serde_response
253                    .assume_role_with_web_identity_result
254                    .credentials
255                    .expiration,
256            ),
257        })
258    }
259
260    #[allow(clippy::should_implement_trait)]
261    pub fn default() -> Result<Credentials, CredentialsError> {
262        Credentials::new(None, None, None, None, None)
263    }
264
265    pub fn anonymous() -> Result<Credentials, CredentialsError> {
266        Ok(Credentials {
267            access_key: None,
268            secret_key: None,
269            security_token: None,
270            session_token: None,
271            expiration: None,
272        })
273    }
274
275    /// Initialize Credentials directly with key ID, secret key, and optional
276    /// token.
277    pub fn new(
278        access_key: Option<&str>,
279        secret_key: Option<&str>,
280        security_token: Option<&str>,
281        session_token: Option<&str>,
282        profile: Option<&str>,
283    ) -> Result<Credentials, CredentialsError> {
284        if access_key.is_some() {
285            return Ok(Credentials {
286                access_key: access_key.map(|s| s.to_string()),
287                secret_key: secret_key.map(|s| s.to_string()),
288                security_token: security_token.map(|s| s.to_string()),
289                session_token: session_token.map(|s| s.to_string()),
290                expiration: None,
291            });
292        }
293
294        let credentials = Credentials::from_env().or_else(|_| Credentials::from_profile(profile));
295
296        #[cfg(feature = "http-credentials")]
297        let credentials = credentials
298            .or_else(|_| Credentials::from_sts_env("aws-creds"))
299            .or_else(|_| Credentials::from_container_credentials_provider())
300            .or_else(|_| Credentials::from_instance_metadata_v2(false))
301            .or_else(|_| Credentials::from_instance_metadata(false));
302
303        credentials.map_err(|_| CredentialsError::NoCredentials)
304    }
305
306    pub fn from_env_specific(
307        access_key_var: Option<&str>,
308        secret_key_var: Option<&str>,
309        security_token_var: Option<&str>,
310        session_token_var: Option<&str>,
311    ) -> Result<Credentials, CredentialsError> {
312        let access_key = from_env_with_default(access_key_var, "AWS_ACCESS_KEY_ID")?;
313        let secret_key = from_env_with_default(secret_key_var, "AWS_SECRET_ACCESS_KEY")?;
314
315        let security_token = from_env_with_default(security_token_var, "AWS_SECURITY_TOKEN").ok();
316        let session_token = from_env_with_default(session_token_var, "AWS_SESSION_TOKEN").ok();
317        Ok(Credentials {
318            access_key: Some(access_key),
319            secret_key: Some(secret_key),
320            security_token,
321            session_token,
322            expiration: None,
323        })
324    }
325
326    pub fn from_env() -> Result<Credentials, CredentialsError> {
327        Credentials::from_env_specific(None, None, None, None)
328    }
329
330    #[cfg(feature = "http-credentials")]
331    pub fn from_container_credentials_provider() -> Result<Credentials, CredentialsError> {
332        let Ok(credentials_path) = env::var("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") else {
333            return Err(CredentialsError::NotContainer);
334        };
335
336        let resp: CredentialsFromInstanceMetadata = apply_timeout(attohttpc::get(format!(
337            "http://169.254.170.2{}",
338            credentials_path
339        )))
340        .send()?
341        .json()?;
342
343        Ok(Credentials {
344            access_key: Some(resp.access_key_id),
345            secret_key: Some(resp.secret_access_key),
346            security_token: Some(resp.token),
347            expiration: Some(resp.expiration),
348            session_token: None,
349        })
350    }
351
352    #[cfg(feature = "http-credentials")]
353    pub fn from_instance_metadata(not_ec2: bool) -> Result<Credentials, CredentialsError> {
354        if !not_ec2 && !is_ec2() {
355            return Err(CredentialsError::NotEc2);
356        }
357
358        let role = apply_timeout(attohttpc::get(
359            "http://169.254.169.254/latest/meta-data/iam/security-credentials",
360        ))
361        .send()?
362        .text()?;
363
364        let resp: CredentialsFromInstanceMetadata = apply_timeout(attohttpc::get(format!(
365            "http://169.254.169.254/latest/meta-data/iam/security-credentials/{}",
366            role
367        )))
368        .send()?
369        .json()?;
370
371        Ok(Credentials {
372            access_key: Some(resp.access_key_id),
373            secret_key: Some(resp.secret_access_key),
374            security_token: Some(resp.token),
375            expiration: Some(resp.expiration),
376            session_token: None,
377        })
378    }
379
380    #[cfg(feature = "http-credentials")]
381    pub fn from_instance_metadata_v2(not_ec2: bool) -> Result<Credentials, CredentialsError> {
382        if !not_ec2 && !is_ec2() {
383            return Err(CredentialsError::NotEc2);
384        }
385
386        let token = apply_timeout(attohttpc::put("http://169.254.169.254/latest/api/token"))
387            .header("X-aws-ec2-metadata-token-ttl-seconds", "21600")
388            .send()?;
389        if !token.is_success() {
390            return Err(CredentialsError::UnexpectedStatusCode(
391                token.status().as_u16(),
392            ));
393        }
394        let token = token.text()?;
395
396        let role = apply_timeout(attohttpc::get(
397            "http://169.254.169.254/latest/meta-data/iam/security-credentials",
398        ))
399        .header("X-aws-ec2-metadata-token", &token)
400        .send()?
401        .text()?;
402
403        let resp: CredentialsFromInstanceMetadata = apply_timeout(attohttpc::get(format!(
404            "http://169.254.169.254/latest/meta-data/iam/security-credentials/{}",
405            role
406        )))
407        .header("X-aws-ec2-metadata-token", &token)
408        .send()?
409        .json()?;
410
411        Ok(Credentials {
412            access_key: Some(resp.access_key_id),
413            secret_key: Some(resp.secret_access_key),
414            security_token: Some(resp.token),
415            expiration: Some(resp.expiration),
416            session_token: None,
417        })
418    }
419
420    /// Load credentials from a specific credentials file.
421    ///
422    /// This method allows loading AWS credentials from a custom file location,
423    /// which is useful when credentials are stored in a non-standard location.
424    ///
425    /// # Arguments
426    ///
427    /// * `file` - Path to the credentials file
428    /// * `section` - Optional profile name to load (defaults to "default")
429    ///
430    /// # Example
431    ///
432    /// ```no_run
433    /// use awscreds::Credentials;
434    ///
435    /// let credentials = Credentials::from_credentials_file(
436    ///     "/custom/path/credentials",
437    ///     Some("production")
438    /// ).unwrap();
439    /// ```
440    pub fn from_credentials_file<P: AsRef<Path>>(
441        file: P,
442        section: Option<&str>,
443    ) -> Result<Credentials, CredentialsError> {
444        let conf = Ini::load_from_file(file.as_ref())?;
445        let section = section.unwrap_or("default");
446        let data = conf
447            .section(Some(section))
448            .ok_or(CredentialsError::ConfigNotFound)?;
449        let access_key = data
450            .get("aws_access_key_id")
451            .map(|s| s.to_string())
452            .ok_or(CredentialsError::ConfigMissingAccessKeyId)?;
453        let secret_key = data
454            .get("aws_secret_access_key")
455            .map(|s| s.to_string())
456            .ok_or(CredentialsError::ConfigMissingSecretKey)?;
457        let credentials = Credentials {
458            access_key: Some(access_key),
459            secret_key: Some(secret_key),
460            security_token: data.get("aws_security_token").map(|s| s.to_string()),
461            session_token: data.get("aws_session_token").map(|s| s.to_string()),
462            expiration: None,
463        };
464        Ok(credentials)
465    }
466
467    pub fn from_profile(section: Option<&str>) -> Result<Credentials, CredentialsError> {
468        // Check for AWS_SHARED_CREDENTIALS_FILE environment variable first
469        let profile = if let Ok(path) = env::var("AWS_SHARED_CREDENTIALS_FILE") {
470            path
471        } else {
472            let home_dir = home::home_dir().ok_or(CredentialsError::HomeDir)?;
473            format!("{}/.aws/credentials", home_dir.display())
474        };
475        Credentials::from_credentials_file(&profile, section)
476    }
477}
478
479fn from_env_with_default(var: Option<&str>, default: &str) -> Result<String, CredentialsError> {
480    let val = var.unwrap_or(default);
481    env::var(val)
482        .or_else(|_e| env::var(val))
483        .map_err(|_| CredentialsError::MissingEnvVar(val.to_string(), default.to_string()))
484}
485
486fn is_ec2() -> bool {
487    if let Ok(uuid) = std::fs::read_to_string("/sys/hypervisor/uuid") {
488        if uuid.starts_with("ec2") {
489            return true;
490        }
491    }
492    if let Ok(vendor) = std::fs::read_to_string("/sys/class/dmi/id/board_vendor") {
493        if vendor.starts_with("Amazon EC2") {
494            return true;
495        }
496    }
497    false
498}
499
500#[derive(Deserialize)]
501#[serde(rename_all = "PascalCase")]
502struct CredentialsFromInstanceMetadata {
503    access_key_id: String,
504    secret_access_key: String,
505    token: String,
506    expiration: Rfc3339OffsetDateTime, // TODO fix #163
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use std::io::Write;
513    use tempfile::NamedTempFile;
514
515    fn create_test_credentials_file(content: &str) -> NamedTempFile {
516        let mut file = NamedTempFile::new().unwrap();
517        file.write_all(content.as_bytes()).unwrap();
518        file.flush().unwrap();
519        file
520    }
521
522    #[test]
523    fn test_from_credentials_file_custom_location() {
524        let content = r#"[default]
525aws_access_key_id = AKIAIOSFODNN7EXAMPLE
526aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
527
528[production]
529aws_access_key_id = PROD_KEY
530aws_secret_access_key = PROD_SECRET
531aws_session_token = PROD_SESSION_TOKEN
532"#;
533        let file = create_test_credentials_file(content);
534
535        // Test default section
536        let creds = Credentials::from_credentials_file(file.path(), None).unwrap();
537        assert_eq!(creds.access_key.unwrap(), "AKIAIOSFODNN7EXAMPLE");
538
539        // Test custom section
540        let creds = Credentials::from_credentials_file(file.path(), Some("production")).unwrap();
541        assert_eq!(creds.access_key.unwrap(), "PROD_KEY");
542        assert_eq!(creds.session_token.unwrap(), "PROD_SESSION_TOKEN");
543    }
544
545    #[test]
546    fn test_from_profile_respects_env_var() {
547        let content = r#"[default]
548aws_access_key_id = ENV_KEY
549aws_secret_access_key = ENV_SECRET
550"#;
551        let file = create_test_credentials_file(content);
552
553        // Set the environment variable
554        env::set_var("AWS_SHARED_CREDENTIALS_FILE", file.path());
555
556        let creds = Credentials::from_profile(None).unwrap();
557        assert_eq!(creds.access_key.unwrap(), "ENV_KEY");
558
559        // Clean up
560        env::remove_var("AWS_SHARED_CREDENTIALS_FILE");
561    }
562
563    #[test]
564    fn test_from_credentials_file_errors() {
565        // Test missing file
566        let result = Credentials::from_credentials_file("/nonexistent/path", None);
567        assert!(result.is_err());
568
569        // Test missing section
570        let content = r#"[default]
571aws_access_key_id = KEY
572aws_secret_access_key = SECRET
573"#;
574        let file = create_test_credentials_file(content);
575        let result = Credentials::from_credentials_file(file.path(), Some("nonexistent"));
576        assert!(matches!(
577            result.unwrap_err(),
578            CredentialsError::ConfigNotFound
579        ));
580    }
581}
582
583#[cfg(test)]
584#[test]
585fn test_instance_metadata_creds_deserialization() {
586    // As documented here:
587    // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials
588    serde_json::from_str::<CredentialsFromInstanceMetadata>(
589        r#"
590        {
591            "Code" : "Success",
592            "LastUpdated" : "2012-04-26T16:39:16Z",
593            "Type" : "AWS-HMAC",
594            "AccessKeyId" : "ASIAIOSFODNN7EXAMPLE",
595            "SecretAccessKey" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
596            "Token" : "token",
597            "Expiration" : "2017-05-17T15:09:54Z"
598        }
599    "#,
600    )
601    .unwrap();
602}
603
604#[cfg(test)]
605#[ignore]
606#[test]
607fn test_credentials_refresh() {
608    let mut c = Credentials::default().expect("Could not generate credentials");
609    let e = Rfc3339OffsetDateTime(OffsetDateTime::now_utc());
610    c.expiration = Some(e);
611    std::thread::sleep(std::time::Duration::from_secs(3));
612    c.refresh().expect("Could not refresh");
613    assert!(c.expiration.is_none())
614}