reqsign_aws_core/provide_credential/
ecs.rs1use crate::Credential;
19use http::{HeaderValue, Method, Request, StatusCode};
20use log::debug;
21use reqsign_core::{Context, Error, ProvideCredential, Result};
22use serde::Deserialize;
23
24const AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: &str = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI";
25const AWS_CONTAINER_CREDENTIALS_FULL_URI: &str = "AWS_CONTAINER_CREDENTIALS_FULL_URI";
26const AWS_CONTAINER_AUTHORIZATION_TOKEN: &str = "AWS_CONTAINER_AUTHORIZATION_TOKEN";
27const AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE: &str = "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE";
28const ECS_METADATA_ENDPOINT: &str = "http://169.254.170.2";
29const ECS_CONTAINER_METADATA_URI: &str = "ECS_CONTAINER_METADATA_URI";
30
31#[derive(Debug, Clone)]
77pub struct ECSCredentialProvider {
78 endpoint: Option<String>,
79 auth_token: Option<String>,
80 auth_token_file: Option<String>,
81 relative_uri: Option<String>,
82 metadata_uri_override: Option<String>,
83}
84
85impl Default for ECSCredentialProvider {
86 fn default() -> Self {
87 Self::new()
88 }
89}
90
91impl ECSCredentialProvider {
92 pub fn new() -> Self {
94 Self {
95 endpoint: None,
96 auth_token: None,
97 auth_token_file: None,
98 relative_uri: None,
99 metadata_uri_override: None,
100 }
101 }
102
103 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
105 self.endpoint = Some(endpoint.into());
106 self
107 }
108
109 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
111 self.auth_token = Some(token.into());
112 self
113 }
114
115 pub fn with_auth_token_file(mut self, file_path: impl Into<String>) -> Self {
117 self.auth_token_file = Some(file_path.into());
118 self
119 }
120
121 pub fn with_relative_uri(mut self, uri: impl Into<String>) -> Self {
124 self.relative_uri = Some(uri.into());
125 self
126 }
127
128 pub fn with_metadata_uri_override(mut self, uri: impl Into<String>) -> Self {
131 self.metadata_uri_override = Some(uri.into());
132 self
133 }
134
135 async fn load_auth_token(&self, ctx: &Context) -> Result<Option<String>> {
136 if let Some(token) = &self.auth_token {
138 return Ok(Some(token.clone()));
139 }
140
141 if let Some(token_file) = &self.auth_token_file {
143 let token = ctx.file_read(token_file).await.map_err(|e| {
144 Error::config_invalid("failed to read ECS auth token file")
145 .with_source(e)
146 .with_context(format!("file: {token_file}"))
147 })?;
148 return Ok(Some(String::from_utf8_lossy(&token).trim().to_string()));
149 }
150
151 if let Some(token) = ctx.env_var(AWS_CONTAINER_AUTHORIZATION_TOKEN) {
153 return Ok(Some(token));
154 }
155
156 if let Some(token_file) = ctx.env_var(AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE) {
158 let token = ctx.file_read(&token_file).await.map_err(|e| {
159 Error::config_invalid("failed to read ECS auth token file")
160 .with_source(e)
161 .with_context(format!("file: {token_file}"))
162 })?;
163 return Ok(Some(String::from_utf8_lossy(&token).trim().to_string()));
164 }
165
166 Ok(None)
167 }
168
169 fn get_endpoint(&self, ctx: &Context) -> Result<String> {
170 if let Some(endpoint) = &self.endpoint {
172 return Ok(endpoint.clone());
173 }
174
175 if let Some(relative_uri) = &self.relative_uri {
177 let base_endpoint = self
178 .metadata_uri_override
179 .as_deref()
180 .unwrap_or(ECS_METADATA_ENDPOINT);
181 return Ok(format!("{base_endpoint}{relative_uri}"));
182 }
183
184 if let Some(full_uri) = ctx.env_var(AWS_CONTAINER_CREDENTIALS_FULL_URI) {
187 return Ok(full_uri);
188 }
189
190 if let Some(relative_uri) = ctx.env_var(AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
192 let base_endpoint = match &self.metadata_uri_override {
194 Some(override_uri) => override_uri.clone(),
195 None => ctx
196 .env_var(ECS_CONTAINER_METADATA_URI)
197 .unwrap_or_else(|| ECS_METADATA_ENDPOINT.to_string()),
198 };
199 return Ok(format!("{base_endpoint}{relative_uri}"));
200 }
201
202 Err(Error::config_invalid(
203 "ECS container credentials endpoint not configured"
204 )
205 .with_context("hint: use with_relative_uri(), with_endpoint(), or set AWS_CONTAINER_CREDENTIALS_RELATIVE_URI/AWS_CONTAINER_CREDENTIALS_FULL_URI")
206 .with_context("note: are you running on ECS or Fargate?"))
207 }
208}
209
210#[derive(Debug, Deserialize)]
211#[serde(rename_all = "PascalCase")]
212struct ECSCredentialResponse {
213 access_key_id: String,
214 secret_access_key: String,
215 token: String,
216 expiration: String,
217}
218impl ProvideCredential for ECSCredentialProvider {
219 type Credential = Credential;
220
221 async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
222 let endpoint = match self.get_endpoint(ctx) {
223 Ok(ep) => ep,
224 Err(_) => {
225 debug!("ECS credential provider: no container credentials endpoint found");
226 return Ok(None);
227 }
228 };
229
230 debug!("ECS credential provider: fetching credentials from {endpoint}");
231
232 let mut req = Request::builder()
233 .method(Method::GET)
234 .uri(&endpoint)
235 .body(bytes::Bytes::new())
236 .map_err(|e| {
237 Error::request_invalid("failed to build ECS credentials request")
238 .with_source(e)
239 .with_context(format!("endpoint: {endpoint}"))
240 })?;
241
242 if let Some(token) = self.load_auth_token(ctx).await? {
244 req.headers_mut().insert(
245 "Authorization",
246 HeaderValue::from_str(&token).map_err(|e| {
247 Error::config_invalid("invalid ECS authorization token")
248 .with_source(e)
249 .with_context("token_source: environment or file")
250 })?,
251 );
252 }
253
254 let resp = ctx.http_send(req).await.map_err(|e| {
255 Error::unexpected("failed to fetch ECS credentials")
256 .with_source(e)
257 .with_context(format!("endpoint: {endpoint}"))
258 .with_context("hint: check if running on ECS/Fargate with proper IAM role")
259 .set_retryable(true)
260 })?;
261
262 if resp.status() != StatusCode::OK {
263 let status = resp.status();
264 let body = String::from_utf8_lossy(resp.body());
265
266 let error = match status.as_u16() {
267 401 | 403 => Error::permission_denied(format!(
268 "ECS task not authorized to fetch credentials: {body}"
269 ))
270 .with_context("hint: check if task has proper IAM role attached"),
271 404 => Error::config_invalid("ECS credentials endpoint not found")
272 .with_context(format!("endpoint: {endpoint}"))
273 .with_context("hint: verify the container credentials URI"),
274 500..=599 => Error::unexpected(format!("ECS metadata service error: {body}"))
275 .set_retryable(true),
276 _ => Error::unexpected(format!(
277 "ECS metadata endpoint returned unexpected status {status}: {body}"
278 )),
279 };
280
281 return Err(error
282 .with_context(format!("http_status: {status}"))
283 .with_context(format!("endpoint: {endpoint}")));
284 }
285
286 let body = resp.into_body();
287 let creds: ECSCredentialResponse = serde_json::from_slice(&body).map_err(|e| {
288 Error::unexpected("failed to parse ECS credentials response")
289 .with_source(e)
290 .with_context(format!("response_length: {}", body.len()))
291 .with_context(format!("endpoint: {endpoint}"))
292 })?;
293
294 let expires_in = creds.expiration.parse().map_err(|e| {
295 Error::unexpected("failed to parse ECS credential expiration")
296 .with_source(e)
297 .with_context(format!("expiration_value: {}", creds.expiration))
298 })?;
299
300 Ok(Some(Credential {
301 access_key_id: creds.access_key_id,
302 secret_access_key: creds.secret_access_key,
303 session_token: Some(creds.token),
304 expires_in: Some(expires_in),
305 }))
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use reqsign_core::StaticEnv;
313 use reqsign_file_read_tokio::TokioFileRead;
314 use reqsign_http_send_reqwest::ReqwestHttpSend;
315 use std::collections::HashMap;
316
317 #[tokio::test]
318 async fn test_ecs_provider_no_env() {
319 let ctx = Context::new()
320 .with_file_read(TokioFileRead)
321 .with_http_send(ReqwestHttpSend::default());
322 let ctx = ctx.with_env(StaticEnv {
323 home_dir: None,
324 envs: HashMap::new(),
325 });
326
327 let provider = ECSCredentialProvider::new();
328 let result = provider.provide_credential(&ctx).await.unwrap();
329 assert!(result.is_none());
330 }
331
332 #[tokio::test]
333 async fn test_get_endpoint_relative_uri() {
334 let ctx = Context::new()
335 .with_file_read(TokioFileRead)
336 .with_http_send(ReqwestHttpSend::default());
337 let ctx = ctx.with_env(StaticEnv {
338 home_dir: None,
339 envs: HashMap::from_iter([(
340 AWS_CONTAINER_CREDENTIALS_RELATIVE_URI.to_string(),
341 "/v2/credentials/task-role".to_string(),
342 )]),
343 });
344
345 let provider = ECSCredentialProvider::new();
346 let endpoint = provider.get_endpoint(&ctx).unwrap();
347 assert_eq!(endpoint, "http://169.254.170.2/v2/credentials/task-role");
348 }
349
350 #[tokio::test]
351 async fn test_get_endpoint_relative_uri_with_custom_base() {
352 let ctx = Context::new()
353 .with_file_read(TokioFileRead)
354 .with_http_send(ReqwestHttpSend::default());
355 let ctx = ctx.with_env(StaticEnv {
356 home_dir: None,
357 envs: HashMap::from_iter([
358 (
359 AWS_CONTAINER_CREDENTIALS_RELATIVE_URI.to_string(),
360 "/creds".to_string(),
361 ),
362 (
363 ECS_CONTAINER_METADATA_URI.to_string(),
364 "http://localhost:51679".to_string(),
365 ),
366 ]),
367 });
368
369 let provider = ECSCredentialProvider::new();
370 let endpoint = provider.get_endpoint(&ctx).unwrap();
371 assert_eq!(endpoint, "http://localhost:51679/creds");
372 }
373
374 #[tokio::test]
375 async fn test_get_endpoint_full_uri() {
376 let ctx = Context::new()
377 .with_file_read(TokioFileRead)
378 .with_http_send(ReqwestHttpSend::default());
379 let ctx = ctx.with_env(StaticEnv {
380 home_dir: None,
381 envs: HashMap::from_iter([(
382 AWS_CONTAINER_CREDENTIALS_FULL_URI.to_string(),
383 "http://localhost:8080/credentials".to_string(),
384 )]),
385 });
386
387 let provider = ECSCredentialProvider::new();
388 let endpoint = provider.get_endpoint(&ctx).unwrap();
389 assert_eq!(endpoint, "http://localhost:8080/credentials");
390 }
391
392 #[tokio::test]
393 async fn test_custom_endpoint() {
394 let ctx = Context::new()
395 .with_file_read(TokioFileRead)
396 .with_http_send(ReqwestHttpSend::default());
397 let provider = ECSCredentialProvider::new().with_endpoint("http://custom-endpoint/creds");
398
399 let endpoint = provider.get_endpoint(&ctx).unwrap();
400 assert_eq!(endpoint, "http://custom-endpoint/creds");
401 }
402
403 #[tokio::test]
404 async fn test_configured_relative_uri() {
405 let ctx = Context::new()
406 .with_file_read(TokioFileRead)
407 .with_http_send(ReqwestHttpSend::default())
408 .with_env(StaticEnv {
409 home_dir: None,
410 envs: HashMap::new(),
411 });
412
413 let provider = ECSCredentialProvider::new().with_relative_uri("/v2/credentials/task-role");
414
415 let endpoint = provider.get_endpoint(&ctx).unwrap();
416 assert_eq!(endpoint, "http://169.254.170.2/v2/credentials/task-role");
417 }
418
419 #[tokio::test]
420 async fn test_configured_relative_uri_with_custom_base() {
421 let ctx = Context::new()
422 .with_file_read(TokioFileRead)
423 .with_http_send(ReqwestHttpSend::default())
424 .with_env(StaticEnv {
425 home_dir: None,
426 envs: HashMap::new(),
427 });
428
429 let provider = ECSCredentialProvider::new()
430 .with_relative_uri("/creds")
431 .with_metadata_uri_override("http://localhost:51679");
432
433 let endpoint = provider.get_endpoint(&ctx).unwrap();
434 assert_eq!(endpoint, "http://localhost:51679/creds");
435 }
436
437 #[tokio::test]
438 async fn test_configured_values_override_env() {
439 let ctx = Context::new()
440 .with_file_read(TokioFileRead)
441 .with_http_send(ReqwestHttpSend::default())
442 .with_env(StaticEnv {
443 home_dir: None,
444 envs: HashMap::from_iter([
445 (
446 AWS_CONTAINER_CREDENTIALS_FULL_URI.to_string(),
447 "http://env-endpoint/creds".to_string(),
448 ),
449 (
450 AWS_CONTAINER_CREDENTIALS_RELATIVE_URI.to_string(),
451 "/env-relative".to_string(),
452 ),
453 ]),
454 });
455
456 let provider =
457 ECSCredentialProvider::new().with_endpoint("http://configured-endpoint/creds");
458
459 let endpoint = provider.get_endpoint(&ctx).unwrap();
460 assert_eq!(endpoint, "http://configured-endpoint/creds");
462 }
463
464 #[tokio::test]
465 async fn test_priority_order() {
466 let ctx = Context::new()
467 .with_file_read(TokioFileRead)
468 .with_http_send(ReqwestHttpSend::default())
469 .with_env(StaticEnv {
470 home_dir: None,
471 envs: HashMap::from_iter([(
472 AWS_CONTAINER_CREDENTIALS_FULL_URI.to_string(),
473 "http://env-full-uri/creds".to_string(),
474 )]),
475 });
476
477 let provider = ECSCredentialProvider::new()
479 .with_endpoint("http://custom/creds")
480 .with_relative_uri("/relative");
481
482 let endpoint = provider.get_endpoint(&ctx).unwrap();
483 assert_eq!(endpoint, "http://custom/creds");
484
485 let provider = ECSCredentialProvider::new().with_relative_uri("/relative");
487
488 let endpoint = provider.get_endpoint(&ctx).unwrap();
489 assert_eq!(endpoint, "http://169.254.170.2/relative");
490 }
491
492 #[tokio::test]
493 async fn test_configured_auth_token() {
494 let ctx = Context::new()
495 .with_file_read(TokioFileRead)
496 .with_http_send(ReqwestHttpSend::default())
497 .with_env(StaticEnv {
498 home_dir: None,
499 envs: HashMap::from_iter([(
500 AWS_CONTAINER_AUTHORIZATION_TOKEN.to_string(),
501 "env-token".to_string(),
502 )]),
503 });
504
505 let provider = ECSCredentialProvider::new().with_auth_token("configured-token");
506
507 let token = provider.load_auth_token(&ctx).await.unwrap();
508 assert_eq!(token, Some("configured-token".to_string()));
510 }
511
512 #[tokio::test]
513 async fn test_configured_auth_token_file() {
514 use std::io::Write;
515 use tempfile::NamedTempFile;
516
517 let mut temp_file = NamedTempFile::new().unwrap();
518 writeln!(temp_file, "file-token").unwrap();
519 let temp_path = temp_file.path().to_str().unwrap();
520
521 let ctx = Context::new()
522 .with_file_read(TokioFileRead)
523 .with_http_send(ReqwestHttpSend::default())
524 .with_env(StaticEnv {
525 home_dir: None,
526 envs: HashMap::from_iter([
527 (
528 AWS_CONTAINER_AUTHORIZATION_TOKEN.to_string(),
529 "env-token".to_string(),
530 ),
531 (
532 AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE.to_string(),
533 "env-file.txt".to_string(),
534 ),
535 ]),
536 });
537
538 let provider = ECSCredentialProvider::new().with_auth_token_file(temp_path);
539
540 let token = provider.load_auth_token(&ctx).await.unwrap();
541 assert_eq!(token, Some("file-token".to_string()));
543 }
544
545 #[tokio::test]
546 async fn test_auth_token_priority() {
547 use std::io::Write;
548 use tempfile::NamedTempFile;
549
550 let mut temp_file = NamedTempFile::new().unwrap();
551 writeln!(temp_file, "file-token").unwrap();
552 let temp_path = temp_file.path().to_str().unwrap();
553
554 let ctx = Context::new()
555 .with_file_read(TokioFileRead)
556 .with_http_send(ReqwestHttpSend::default())
557 .with_env(StaticEnv {
558 home_dir: None,
559 envs: HashMap::new(),
560 });
561
562 let provider = ECSCredentialProvider::new()
564 .with_auth_token("direct-token")
565 .with_auth_token_file(temp_path);
566
567 let token = provider.load_auth_token(&ctx).await.unwrap();
568 assert_eq!(token, Some("direct-token".to_string()));
569
570 let provider = ECSCredentialProvider::new().with_auth_token_file(temp_path);
572
573 let token = provider.load_auth_token(&ctx).await.unwrap();
574 assert_eq!(token, Some("file-token".to_string()));
575 }
576}