Skip to main content

reqsign_aws_core/provide_credential/
cognito.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::{Method, Request, StatusCode};
20use log::debug;
21use reqsign_core::time::Timestamp;
22use reqsign_core::{Context, Error, ProvideCredential, Result};
23use serde::Deserialize;
24use serde_json::json;
25
26/// Cognito Identity Credentials Provider
27///
28/// This provider fetches temporary AWS credentials using Amazon Cognito Identity.
29/// It's typically used for mobile and web applications that need temporary AWS access.
30///
31/// # Requirements
32/// - A Cognito Identity Pool ID
33/// - Optional: Identity token from a supported identity provider (Facebook, Google, etc.)
34///
35/// # Environment Variables
36/// The provider supports the following environment variables:
37/// - `AWS_COGNITO_IDENTITY_POOL_ID`: The Cognito Identity Pool ID
38/// - `AWS_COGNITO_IDENTITY_ID`: A specific identity ID (if already known)
39/// - `AWS_REGION` or `AWS_DEFAULT_REGION`: The AWS region
40/// - `AWS_COGNITO_ENDPOINT`: Custom endpoint (for testing)
41///
42/// # Usage
43/// ```rust,no_run
44/// use reqsign_aws_core::CognitoIdentityCredentialProvider;
45///
46/// let provider = CognitoIdentityCredentialProvider::new()
47///     .with_identity_pool_id("us-east-1:12345678-1234-1234-1234-123456789012")
48///     .with_region("us-east-1");
49/// ```
50#[derive(Debug, Clone)]
51pub struct CognitoIdentityCredentialProvider {
52    identity_pool_id: Option<String>,
53    region: Option<String>,
54    identity_id: Option<String>,
55    logins: Option<std::collections::HashMap<String, String>>,
56}
57
58impl Default for CognitoIdentityCredentialProvider {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl CognitoIdentityCredentialProvider {
65    /// Create a new Cognito Identity credential provider
66    pub fn new() -> Self {
67        Self {
68            identity_pool_id: None,
69            region: None,
70            identity_id: None,
71            logins: None,
72        }
73    }
74
75    /// Set the Cognito Identity Pool ID
76    pub fn with_identity_pool_id(mut self, pool_id: impl Into<String>) -> Self {
77        self.identity_pool_id = Some(pool_id.into());
78        self
79    }
80
81    /// Set the AWS region
82    pub fn with_region(mut self, region: impl Into<String>) -> Self {
83        self.region = Some(region.into());
84        self
85    }
86
87    /// Set a specific identity ID (if already known)
88    pub fn with_identity_id(mut self, identity_id: impl Into<String>) -> Self {
89        self.identity_id = Some(identity_id.into());
90        self
91    }
92
93    /// Add login tokens from identity providers
94    pub fn with_logins(mut self, logins: std::collections::HashMap<String, String>) -> Self {
95        self.logins = Some(logins);
96        self
97    }
98
99    /// Get or create an identity ID
100    async fn get_identity_id(&self, ctx: &Context) -> Result<String> {
101        // Check for explicit identity ID or from environment
102        if let Some(id) = &self.identity_id {
103            return Ok(id.clone());
104        }
105        if let Some(id) = ctx.env_var("AWS_COGNITO_IDENTITY_ID") {
106            return Ok(id);
107        }
108
109        let pool_id = self
110            .identity_pool_id
111            .clone()
112            .or_else(|| ctx.env_var("AWS_COGNITO_IDENTITY_POOL_ID"))
113            .ok_or_else(|| Error::config_invalid("identity_pool_id is required".to_string()))?;
114
115        let region = self
116            .region
117            .clone()
118            .or_else(|| ctx.env_var("AWS_REGION"))
119            .or_else(|| ctx.env_var("AWS_DEFAULT_REGION"))
120            .ok_or_else(|| Error::config_invalid("region is required".to_string()))?;
121
122        // Allow endpoint override for testing
123        let endpoint = ctx
124            .env_var("AWS_COGNITO_ENDPOINT")
125            .unwrap_or_else(|| format!("https://cognito-identity.{region}.amazonaws.com/"));
126
127        let body = if let Some(logins) = &self.logins {
128            json!({
129                "IdentityPoolId": pool_id,
130                "Logins": logins
131            })
132        } else {
133            json!({
134                "IdentityPoolId": pool_id
135            })
136        };
137
138        let req = Request::builder()
139            .method(Method::POST)
140            .uri(&endpoint)
141            .header("x-amz-target", "AWSCognitoIdentityService.GetId")
142            .header("content-type", "application/x-amz-json-1.1")
143            .body(bytes::Bytes::from(serde_json::to_vec(&body).map_err(
144                |e| Error::unexpected(format!("failed to serialize request body: {e}")),
145            )?))
146            .map_err(|e| Error::unexpected(format!("failed to build request: {e}")))?;
147
148        let resp = ctx
149            .http_send(req)
150            .await
151            .map_err(|e| Error::unexpected(format!("failed to get identity ID: {e}")))?;
152
153        if resp.status() != StatusCode::OK {
154            return Err(Error::unexpected(format!(
155                "Cognito GetId returned status: {}",
156                resp.status()
157            )));
158        }
159
160        let body = resp.into_body();
161        let result: GetIdResponse = serde_json::from_slice(&body)
162            .map_err(|e| Error::unexpected(format!("failed to parse GetId response: {e}")))?;
163
164        Ok(result.identity_id)
165    }
166
167    /// Get credentials for an identity
168    async fn get_credentials_for_identity(
169        &self,
170        ctx: &Context,
171        identity_id: &str,
172    ) -> Result<Credential> {
173        let region = self
174            .region
175            .clone()
176            .or_else(|| ctx.env_var("AWS_REGION"))
177            .or_else(|| ctx.env_var("AWS_DEFAULT_REGION"))
178            .ok_or_else(|| Error::config_invalid("region is required".to_string()))?;
179
180        // Allow endpoint override for testing
181        let endpoint = ctx
182            .env_var("AWS_COGNITO_ENDPOINT")
183            .unwrap_or_else(|| format!("https://cognito-identity.{region}.amazonaws.com/"));
184
185        let body = if let Some(logins) = &self.logins {
186            json!({
187                "IdentityId": identity_id,
188                "Logins": logins
189            })
190        } else {
191            json!({
192                "IdentityId": identity_id
193            })
194        };
195
196        let req = Request::builder()
197            .method(Method::POST)
198            .uri(&endpoint)
199            .header(
200                "x-amz-target",
201                "AWSCognitoIdentityService.GetCredentialsForIdentity",
202            )
203            .header("content-type", "application/x-amz-json-1.1")
204            .body(bytes::Bytes::from(serde_json::to_vec(&body).map_err(
205                |e| Error::unexpected(format!("failed to serialize request body: {e}")),
206            )?))
207            .map_err(|e| Error::unexpected(format!("failed to build request: {e}")))?;
208
209        let resp = ctx
210            .http_send(req)
211            .await
212            .map_err(|e| Error::unexpected(format!("failed to get credentials: {e}")))?;
213
214        if resp.status() != StatusCode::OK {
215            return Err(Error::unexpected(format!(
216                "Cognito GetCredentialsForIdentity returned status: {}",
217                resp.status()
218            )));
219        }
220
221        let body = resp.into_body();
222        let result: GetCredentialsResponse = serde_json::from_slice(&body)
223            .map_err(|e| Error::unexpected(format!("failed to parse credentials response: {e}")))?;
224
225        let creds = result.credentials;
226        let expires_in = Timestamp::from_second(creds.expiration)
227            .map_err(|e| Error::unexpected(format!("invalid expiration date: {e}")))?;
228
229        Ok(Credential {
230            access_key_id: creds.access_key_id,
231            secret_access_key: creds.secret_key,
232            session_token: Some(creds.session_token),
233            expires_in: Some(expires_in),
234        })
235    }
236}
237
238#[derive(Debug, Deserialize)]
239#[serde(rename_all = "PascalCase")]
240struct GetIdResponse {
241    identity_id: String,
242}
243
244#[derive(Debug, Deserialize)]
245#[serde(rename_all = "PascalCase")]
246struct GetCredentialsResponse {
247    credentials: CognitoCredentials,
248    #[allow(dead_code)]
249    identity_id: String,
250}
251
252#[derive(Debug, Deserialize)]
253#[serde(rename_all = "PascalCase")]
254struct CognitoCredentials {
255    access_key_id: String,
256    secret_key: String,
257    session_token: String,
258    expiration: i64,
259}
260impl ProvideCredential for CognitoIdentityCredentialProvider {
261    type Credential = Credential;
262
263    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
264        // Check if identity pool ID is available from config or environment
265        let has_pool_id = self.identity_pool_id.is_some()
266            || ctx.env_var("AWS_COGNITO_IDENTITY_POOL_ID").is_some();
267
268        if !has_pool_id {
269            debug!("Cognito Identity: no identity pool ID configured");
270            return Ok(None);
271        }
272
273        // Get or create identity ID
274        let identity_id = self.get_identity_id(ctx).await?;
275        debug!("Cognito Identity: using identity ID: {identity_id}");
276
277        // Get credentials for the identity
278        let creds = self.get_credentials_for_identity(ctx, &identity_id).await?;
279
280        Ok(Some(creds))
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use reqsign_file_read_tokio::TokioFileRead;
288    use reqsign_http_send_reqwest::ReqwestHttpSend;
289
290    #[tokio::test]
291    async fn test_cognito_provider_no_config() {
292        let ctx = Context::new()
293            .with_file_read(TokioFileRead)
294            .with_http_send(ReqwestHttpSend::default());
295        let provider = CognitoIdentityCredentialProvider::new();
296        let result = provider.provide_credential(&ctx).await.unwrap();
297        assert!(result.is_none());
298    }
299
300    #[test]
301    fn test_cognito_provider_builder() {
302        let provider = CognitoIdentityCredentialProvider::new()
303            .with_identity_pool_id("us-east-1:12345678-1234-1234-1234-123456789012")
304            .with_region("us-east-1");
305
306        assert_eq!(
307            provider.identity_pool_id,
308            Some("us-east-1:12345678-1234-1234-1234-123456789012".to_string())
309        );
310        assert_eq!(provider.region, Some("us-east-1".to_string()));
311    }
312}