Skip to main content

reqsign_aws_core/provide_credential/
ecs.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::{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/// ECS Task Role Credentials Provider
32///
33/// This provider fetches IAM credentials from the ECS Task IAM Roles endpoint.
34/// It supports both relative URI (ECS) and full URI (Fargate) modes.
35///
36/// # Important Note
37/// This provider fetches IAM **credentials**, not task metadata. The credentials
38/// endpoint is separate from the task metadata endpoints (v2/v3/v4). While metadata
39/// endpoints provide information about the task and container, the credentials
40/// endpoint provides IAM role credentials for authentication.
41///
42/// # Configuration
43///
44/// Configuration values can be provided directly via builder methods or through environment
45/// variables. Direct configuration takes precedence over environment variables.
46///
47/// ## Builder Methods
48/// - [`ECSCredentialProvider::with_relative_uri`]: Set the relative URI for ECS environments
49/// - [`ECSCredentialProvider::with_endpoint`]: Set a complete custom endpoint URL (for Fargate or custom setups)
50/// - [`ECSCredentialProvider::with_auth_token`]: Set the authorization token directly
51/// - [`ECSCredentialProvider::with_auth_token_file`]: Set the path to the authorization token file
52/// - [`ECSCredentialProvider::with_metadata_uri_override`]: Override the base metadata endpoint for relative URIs
53///
54/// ## Environment Variables (Fallback)
55/// - `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`: Relative URI to fetch credentials (ECS)
56/// - `AWS_CONTAINER_CREDENTIALS_FULL_URI`: Full URI to fetch credentials (Fargate)
57/// - `AWS_CONTAINER_AUTHORIZATION_TOKEN`: Authorization token for the request
58/// - `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE`: File containing the authorization token
59/// - `ECS_CONTAINER_METADATA_URI`: Override the default base endpoint (for testing)
60///
61/// # Examples
62///
63/// ```rust,no_run
64/// use reqsign_aws_core::ECSCredentialProvider;
65///
66/// // Configure for ECS with relative URI
67/// let provider = ECSCredentialProvider::new()
68///     .with_relative_uri("/v2/credentials/task-role")
69///     .with_auth_token("my-auth-token");
70///
71/// // Configure for Fargate with full endpoint
72/// let provider = ECSCredentialProvider::new()
73///     .with_endpoint("http://169.254.170.2/v2/credentials/task-role")
74///     .with_auth_token_file("/tmp/auth-token");
75/// ```
76#[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    /// Create a new ECS credential provider
93    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    /// Create with custom endpoint
104    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
105        self.endpoint = Some(endpoint.into());
106        self
107    }
108
109    /// Create with custom auth token
110    pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
111        self.auth_token = Some(token.into());
112        self
113    }
114
115    /// Create with custom auth token file path
116    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    /// Create with container credentials relative URI
122    /// This is used in ECS environments where the base metadata URI is known
123    pub fn with_relative_uri(mut self, uri: impl Into<String>) -> Self {
124        self.relative_uri = Some(uri.into());
125        self
126    }
127
128    /// Override the metadata URI base endpoint (typically for testing)
129    /// Defaults to http://169.254.170.2 if not specified
130    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 auth token is already set, use it
137        if let Some(token) = &self.auth_token {
138            return Ok(Some(token.clone()));
139        }
140
141        // Try to get token from configured file first
142        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        // Try to get token from environment
152        if let Some(token) = ctx.env_var(AWS_CONTAINER_AUTHORIZATION_TOKEN) {
153            return Ok(Some(token));
154        }
155
156        // Try to get token from environment file
157        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        // Use custom endpoint if provided (highest priority)
171        if let Some(endpoint) = &self.endpoint {
172            return Ok(endpoint.clone());
173        }
174
175        // Try configured relative URI (ECS)
176        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        // Fall back to environment variables
185        // Try full URI from environment (Fargate)
186        if let Some(full_uri) = ctx.env_var(AWS_CONTAINER_CREDENTIALS_FULL_URI) {
187            return Ok(full_uri);
188        }
189
190        // Try relative URI from environment (ECS)
191        if let Some(relative_uri) = ctx.env_var(AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
192            // Allow override of metadata endpoint for testing
193            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        // Add authorization token if available
243        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        // Configured value should override environment
461        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        // Test priority: custom endpoint > relative URI > env
478        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        // Test without custom endpoint
486        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        // Configured token should override environment
509        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        // Configured file should override environment
542        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        // Test priority: direct token > configured file > env token > env file
563        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        // Test without direct token
571        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}