1use super::error::AwsError;
4
5#[derive(Clone)]
8pub struct AwsConfig {
9 pub region: String,
10 pub endpoint: String,
13}
14
15impl AwsConfig {
16 pub fn from_env() -> Result<Self, AwsError> {
18 let region = std::env::var("AWS_DEFAULT_REGION")
19 .or_else(|_| std::env::var("AWS_REGION"))
20 .map_err(|_| AwsError::Config("AWS_DEFAULT_REGION or AWS_REGION is not set".into()))?;
21 let endpoint = format!("https://secretsmanager.{region}.amazonaws.com");
22 Ok(Self { region, endpoint })
23 }
24
25 pub fn with_endpoint(region: impl Into<String>, endpoint: impl Into<String>) -> Self {
27 let region = region.into();
28 let endpoint = endpoint.into();
29 Self { region, endpoint }
30 }
31}
32
33#[derive(Clone)]
35pub struct AwsCredentials {
36 pub access_key_id: String,
37 pub secret_access_key: String,
38 pub session_token: Option<String>,
39}
40
41impl AwsCredentials {
42 pub fn from_env_or_imds() -> Result<Self, AwsError> {
47 if let (Ok(key), Ok(secret)) = (
49 std::env::var("AWS_ACCESS_KEY_ID"),
50 std::env::var("AWS_SECRET_ACCESS_KEY"),
51 ) {
52 return Ok(Self {
53 access_key_id: key,
54 secret_access_key: secret,
55 session_token: std::env::var("AWS_SESSION_TOKEN").ok(),
56 });
57 }
58
59 if let Ok(relative) = std::env::var("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") {
61 return fetch_ecs_credentials(&relative);
62 }
63
64 fetch_imdsv2_credentials()
66 }
67}
68
69fn http_agent() -> ureq::Agent {
70 ureq::AgentBuilder::new()
71 .timeout_connect(std::time::Duration::from_secs(5))
72 .timeout(std::time::Duration::from_secs(10))
73 .build()
74}
75
76fn fetch_ecs_credentials(relative_uri: &str) -> Result<AwsCredentials, AwsError> {
77 let url = format!("http://169.254.170.2{relative_uri}");
78 let agent = http_agent();
79 let resp: serde_json::Value = agent
80 .get(&url)
81 .call()
82 .map_err(|e| AwsError::Auth(format!("ECS credentials request failed: {e}")))?
83 .into_json()
84 .map_err(|e| AwsError::Transport(e.to_string()))?;
85 parse_credentials_response(&resp)
86}
87
88fn fetch_imdsv2_credentials() -> Result<AwsCredentials, AwsError> {
89 let agent = http_agent();
90
91 let imds_token: String = agent
93 .put("http://169.254.169.254/latest/api/token")
94 .set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
95 .call()
96 .map_err(|e| {
97 AwsError::Auth(format!(
98 "IMDSv2 unreachable and no AWS credentials set \
99 (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY): {e}"
100 ))
101 })?
102 .into_string()
103 .map_err(|e| AwsError::Transport(e.to_string()))?;
104 let imds_token = imds_token.trim();
105
106 let role_name: String = agent
108 .get("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
109 .set("X-aws-ec2-metadata-token", imds_token)
110 .call()
111 .map_err(|e| AwsError::Auth(format!("IMDS role name request failed: {e}")))?
112 .into_string()
113 .map_err(|e| AwsError::Transport(e.to_string()))?;
114 let role_name = role_name.trim();
115
116 let resp: serde_json::Value = agent
118 .get(&format!(
119 "http://169.254.169.254/latest/meta-data/iam/security-credentials/{role_name}"
120 ))
121 .set("X-aws-ec2-metadata-token", imds_token)
122 .call()
123 .map_err(|e| AwsError::Auth(format!("IMDS credentials request failed: {e}")))?
124 .into_json()
125 .map_err(|e| AwsError::Transport(e.to_string()))?;
126
127 parse_credentials_response(&resp)
128}
129
130fn parse_credentials_response(resp: &serde_json::Value) -> Result<AwsCredentials, AwsError> {
131 let access_key_id = resp["AccessKeyId"]
132 .as_str()
133 .ok_or_else(|| AwsError::Auth("credentials response missing 'AccessKeyId'".into()))?
134 .to_string();
135 let secret_access_key = resp["SecretAccessKey"]
136 .as_str()
137 .ok_or_else(|| AwsError::Auth("credentials response missing 'SecretAccessKey'".into()))?
138 .to_string();
139 let session_token = resp["Token"].as_str().map(|s| s.to_string());
140 Ok(AwsCredentials {
141 access_key_id,
142 secret_access_key,
143 session_token,
144 })
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn from_env_missing_region_returns_config_error() {
153 let result = temp_env::with_vars(
154 [
155 ("AWS_DEFAULT_REGION", None::<&str>),
156 ("AWS_REGION", None::<&str>),
157 ],
158 AwsConfig::from_env,
159 );
160 assert!(matches!(result, Err(AwsError::Config(_))));
161 }
162
163 #[test]
164 fn from_env_uses_aws_default_region() {
165 let result =
166 temp_env::with_var("AWS_DEFAULT_REGION", Some("us-east-1"), AwsConfig::from_env);
167 let cfg = result.unwrap();
168 assert_eq!(cfg.region, "us-east-1");
169 assert_eq!(
170 cfg.endpoint,
171 "https://secretsmanager.us-east-1.amazonaws.com"
172 );
173 }
174
175 #[test]
176 fn from_env_falls_back_to_aws_region() {
177 let result = temp_env::with_vars(
178 [
179 ("AWS_DEFAULT_REGION", None::<&str>),
180 ("AWS_REGION", Some("eu-west-1")),
181 ],
182 AwsConfig::from_env,
183 );
184 let cfg = result.unwrap();
185 assert_eq!(cfg.region, "eu-west-1");
186 }
187
188 #[test]
189 fn static_credentials_from_env() {
190 let creds = temp_env::with_vars(
191 [
192 ("AWS_ACCESS_KEY_ID", Some("AKIAIOSFODNN7EXAMPLE")),
193 ("AWS_SECRET_ACCESS_KEY", Some("secret")),
194 ("AWS_SESSION_TOKEN", Some("tok")),
195 ],
196 AwsCredentials::from_env_or_imds,
197 )
198 .unwrap();
199 assert_eq!(creds.access_key_id, "AKIAIOSFODNN7EXAMPLE");
200 assert_eq!(creds.secret_access_key, "secret");
201 assert_eq!(creds.session_token.as_deref(), Some("tok"));
202 }
203
204 #[test]
205 fn static_credentials_no_session_token() {
206 let creds = temp_env::with_vars(
207 [
208 ("AWS_ACCESS_KEY_ID", Some("AKIAIOSFODNN7EXAMPLE")),
209 ("AWS_SECRET_ACCESS_KEY", Some("secret")),
210 ("AWS_SESSION_TOKEN", None::<&str>),
211 ],
212 AwsCredentials::from_env_or_imds,
213 )
214 .unwrap();
215 assert!(creds.session_token.is_none());
216 }
217
218 #[test]
219 fn parse_credentials_response_success() {
220 let resp = serde_json::json!({
221 "AccessKeyId": "ASIA...",
222 "SecretAccessKey": "wJalrXUtn",
223 "Token": "session-tok"
224 });
225 let creds = parse_credentials_response(&resp).unwrap();
226 assert_eq!(creds.access_key_id, "ASIA...");
227 assert_eq!(creds.session_token.as_deref(), Some("session-tok"));
228 }
229
230 #[test]
231 fn parse_credentials_response_missing_key_returns_auth_error() {
232 let resp = serde_json::json!({"SecretAccessKey": "secret"});
233 assert!(matches!(
234 parse_credentials_response(&resp),
235 Err(AwsError::Auth(_))
236 ));
237 }
238}