reqsign_aws_core/provide_credential/
cognito.rs1use 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#[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 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 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 pub fn with_region(mut self, region: impl Into<String>) -> Self {
83 self.region = Some(region.into());
84 self
85 }
86
87 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 pub fn with_logins(mut self, logins: std::collections::HashMap<String, String>) -> Self {
95 self.logins = Some(logins);
96 self
97 }
98
99 async fn get_identity_id(&self, ctx: &Context) -> Result<String> {
101 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 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 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 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 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 let identity_id = self.get_identity_id(ctx).await?;
296 debug!("Cognito Identity: using identity ID: {identity_id}");
297
298 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}