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 expiration = parse_expiration(&creds.expiration)?;
227        let expires_in = Timestamp::from_second(expiration)
228            .map_err(|e| Error::unexpected(format!("invalid expiration date: {e}")))?;
229
230        Ok(Credential {
231            access_key_id: creds.access_key_id,
232            secret_access_key: creds.secret_key,
233            session_token: Some(creds.session_token),
234            expires_in: Some(expires_in),
235        })
236    }
237}
238
239#[derive(Debug, Deserialize)]
240#[serde(rename_all = "PascalCase")]
241struct GetIdResponse {
242    identity_id: String,
243}
244
245#[derive(Debug, Deserialize)]
246#[serde(rename_all = "PascalCase")]
247struct GetCredentialsResponse {
248    credentials: CognitoCredentials,
249    #[allow(dead_code)]
250    identity_id: String,
251}
252
253#[derive(Debug, Deserialize)]
254#[serde(rename_all = "PascalCase")]
255struct CognitoCredentials {
256    access_key_id: String,
257    secret_key: String,
258    session_token: String,
259    expiration: serde_json::Number,
260}
261
262fn parse_expiration(expiration: &serde_json::Number) -> Result<i64> {
263    if let Some(expiration) = expiration.as_i64() {
264        return Ok(expiration);
265    }
266
267    let expiration = expiration
268        .as_f64()
269        .ok_or_else(|| Error::unexpected("Cognito credential expiration is not a JSON number"))?;
270    if !expiration.is_finite()
271        || expiration.fract() != 0.0
272        || expiration < i64::MIN as f64
273        || expiration >= -(i64::MIN as f64)
274    {
275        return Err(Error::unexpected(
276            "Cognito credential expiration must be a whole number of Unix seconds",
277        ));
278    }
279    Ok(expiration as i64)
280}
281impl ProvideCredential for CognitoIdentityCredentialProvider {
282    type Credential = Credential;
283
284    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
285        // Check if identity pool ID is available from config or environment
286        let has_pool_id = self.identity_pool_id.is_some()
287            || ctx.env_var("AWS_COGNITO_IDENTITY_POOL_ID").is_some();
288
289        if !has_pool_id {
290            debug!("Cognito Identity: no identity pool ID configured");
291            return Ok(None);
292        }
293
294        // Get or create identity ID
295        let identity_id = self.get_identity_id(ctx).await?;
296        debug!("Cognito Identity: using identity ID: {identity_id}");
297
298        // Get credentials for the identity
299        let creds = self.get_credentials_for_identity(ctx, &identity_id).await?;
300
301        Ok(Some(creds))
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use reqsign_file_read_tokio::TokioFileRead;
309    use reqsign_http_send_reqwest::ReqwestHttpSend;
310
311    #[tokio::test]
312    async fn test_cognito_provider_no_config() {
313        let ctx = Context::new()
314            .with_file_read(TokioFileRead)
315            .with_http_send(ReqwestHttpSend::default());
316        let provider = CognitoIdentityCredentialProvider::new();
317        let result = provider.provide_credential(&ctx).await.unwrap();
318        assert!(result.is_none());
319    }
320
321    #[test]
322    fn test_cognito_provider_builder() {
323        let provider = CognitoIdentityCredentialProvider::new()
324            .with_identity_pool_id("us-east-1:12345678-1234-1234-1234-123456789012")
325            .with_region("us-east-1");
326
327        assert_eq!(
328            provider.identity_pool_id,
329            Some("us-east-1:12345678-1234-1234-1234-123456789012".to_string())
330        );
331        assert_eq!(provider.region, Some("us-east-1".to_string()));
332    }
333
334    #[test]
335    fn test_parse_cognito_expiration() {
336        let integer = serde_json::from_str("1786124131").unwrap();
337        assert_eq!(parse_expiration(&integer).unwrap(), 1_786_124_131);
338
339        let whole_float = serde_json::from_str("1786124131.0").unwrap();
340        assert_eq!(parse_expiration(&whole_float).unwrap(), 1_786_124_131);
341
342        let fractional = serde_json::from_str("1786124131.5").unwrap();
343        assert!(parse_expiration(&fractional).is_err());
344    }
345}