use crate::Credential;
use http::{Method, Request, StatusCode};
use log::debug;
use reqsign_core::time::Timestamp;
use reqsign_core::{Context, Error, ProvideCredential, Result};
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Clone)]
pub struct CognitoIdentityCredentialProvider {
identity_pool_id: Option<String>,
region: Option<String>,
identity_id: Option<String>,
logins: Option<std::collections::HashMap<String, String>>,
}
impl Default for CognitoIdentityCredentialProvider {
fn default() -> Self {
Self::new()
}
}
impl CognitoIdentityCredentialProvider {
pub fn new() -> Self {
Self {
identity_pool_id: None,
region: None,
identity_id: None,
logins: None,
}
}
pub fn with_identity_pool_id(mut self, pool_id: impl Into<String>) -> Self {
self.identity_pool_id = Some(pool_id.into());
self
}
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
pub fn with_identity_id(mut self, identity_id: impl Into<String>) -> Self {
self.identity_id = Some(identity_id.into());
self
}
pub fn with_logins(mut self, logins: std::collections::HashMap<String, String>) -> Self {
self.logins = Some(logins);
self
}
async fn get_identity_id(&self, ctx: &Context) -> Result<String> {
if let Some(id) = &self.identity_id {
return Ok(id.clone());
}
if let Some(id) = ctx.env_var("AWS_COGNITO_IDENTITY_ID") {
return Ok(id);
}
let pool_id = self
.identity_pool_id
.clone()
.or_else(|| ctx.env_var("AWS_COGNITO_IDENTITY_POOL_ID"))
.ok_or_else(|| Error::config_invalid("identity_pool_id is required".to_string()))?;
let region = self
.region
.clone()
.or_else(|| ctx.env_var("AWS_REGION"))
.or_else(|| ctx.env_var("AWS_DEFAULT_REGION"))
.ok_or_else(|| Error::config_invalid("region is required".to_string()))?;
let endpoint = ctx
.env_var("AWS_COGNITO_ENDPOINT")
.unwrap_or_else(|| format!("https://cognito-identity.{region}.amazonaws.com/"));
let body = if let Some(logins) = &self.logins {
json!({
"IdentityPoolId": pool_id,
"Logins": logins
})
} else {
json!({
"IdentityPoolId": pool_id
})
};
let req = Request::builder()
.method(Method::POST)
.uri(&endpoint)
.header("x-amz-target", "AWSCognitoIdentityService.GetId")
.header("content-type", "application/x-amz-json-1.1")
.body(bytes::Bytes::from(serde_json::to_vec(&body).map_err(
|e| Error::unexpected(format!("failed to serialize request body: {e}")),
)?))
.map_err(|e| Error::unexpected(format!("failed to build request: {e}")))?;
let resp = ctx
.http_send(req)
.await
.map_err(|e| Error::unexpected(format!("failed to get identity ID: {e}")))?;
if resp.status() != StatusCode::OK {
return Err(Error::unexpected(format!(
"Cognito GetId returned status: {}",
resp.status()
)));
}
let body = resp.into_body();
let result: GetIdResponse = serde_json::from_slice(&body)
.map_err(|e| Error::unexpected(format!("failed to parse GetId response: {e}")))?;
Ok(result.identity_id)
}
async fn get_credentials_for_identity(
&self,
ctx: &Context,
identity_id: &str,
) -> Result<Credential> {
let region = self
.region
.clone()
.or_else(|| ctx.env_var("AWS_REGION"))
.or_else(|| ctx.env_var("AWS_DEFAULT_REGION"))
.ok_or_else(|| Error::config_invalid("region is required".to_string()))?;
let endpoint = ctx
.env_var("AWS_COGNITO_ENDPOINT")
.unwrap_or_else(|| format!("https://cognito-identity.{region}.amazonaws.com/"));
let body = if let Some(logins) = &self.logins {
json!({
"IdentityId": identity_id,
"Logins": logins
})
} else {
json!({
"IdentityId": identity_id
})
};
let req = Request::builder()
.method(Method::POST)
.uri(&endpoint)
.header(
"x-amz-target",
"AWSCognitoIdentityService.GetCredentialsForIdentity",
)
.header("content-type", "application/x-amz-json-1.1")
.body(bytes::Bytes::from(serde_json::to_vec(&body).map_err(
|e| Error::unexpected(format!("failed to serialize request body: {e}")),
)?))
.map_err(|e| Error::unexpected(format!("failed to build request: {e}")))?;
let resp = ctx
.http_send(req)
.await
.map_err(|e| Error::unexpected(format!("failed to get credentials: {e}")))?;
if resp.status() != StatusCode::OK {
return Err(Error::unexpected(format!(
"Cognito GetCredentialsForIdentity returned status: {}",
resp.status()
)));
}
let body = resp.into_body();
let result: GetCredentialsResponse = serde_json::from_slice(&body)
.map_err(|e| Error::unexpected(format!("failed to parse credentials response: {e}")))?;
let creds = result.credentials;
let expiration = parse_expiration(&creds.expiration)?;
let expires_in = Timestamp::from_second(expiration)
.map_err(|e| Error::unexpected(format!("invalid expiration date: {e}")))?;
Ok(Credential {
access_key_id: creds.access_key_id,
secret_access_key: creds.secret_key,
session_token: Some(creds.session_token),
expires_in: Some(expires_in),
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct GetIdResponse {
identity_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct GetCredentialsResponse {
credentials: CognitoCredentials,
#[allow(dead_code)]
identity_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct CognitoCredentials {
access_key_id: String,
secret_key: String,
session_token: String,
expiration: serde_json::Number,
}
fn parse_expiration(expiration: &serde_json::Number) -> Result<i64> {
if let Some(expiration) = expiration.as_i64() {
return Ok(expiration);
}
let expiration = expiration
.as_f64()
.ok_or_else(|| Error::unexpected("Cognito credential expiration is not a JSON number"))?;
if !expiration.is_finite()
|| expiration.fract() != 0.0
|| expiration < i64::MIN as f64
|| expiration >= -(i64::MIN as f64)
{
return Err(Error::unexpected(
"Cognito credential expiration must be a whole number of Unix seconds",
));
}
Ok(expiration as i64)
}
impl ProvideCredential for CognitoIdentityCredentialProvider {
type Credential = Credential;
async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
let has_pool_id = self.identity_pool_id.is_some()
|| ctx.env_var("AWS_COGNITO_IDENTITY_POOL_ID").is_some();
if !has_pool_id {
debug!("Cognito Identity: no identity pool ID configured");
return Ok(None);
}
let identity_id = self.get_identity_id(ctx).await?;
debug!("Cognito Identity: using identity ID: {identity_id}");
let creds = self.get_credentials_for_identity(ctx, &identity_id).await?;
Ok(Some(creds))
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
#[tokio::test]
async fn test_cognito_provider_no_config() {
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default());
let provider = CognitoIdentityCredentialProvider::new();
let result = provider.provide_credential(&ctx).await.unwrap();
assert!(result.is_none());
}
#[test]
fn test_cognito_provider_builder() {
let provider = CognitoIdentityCredentialProvider::new()
.with_identity_pool_id("us-east-1:12345678-1234-1234-1234-123456789012")
.with_region("us-east-1");
assert_eq!(
provider.identity_pool_id,
Some("us-east-1:12345678-1234-1234-1234-123456789012".to_string())
);
assert_eq!(provider.region, Some("us-east-1".to_string()));
}
#[test]
fn test_parse_cognito_expiration() {
let integer = serde_json::from_str("1786124131").unwrap();
assert_eq!(parse_expiration(&integer).unwrap(), 1_786_124_131);
let whole_float = serde_json::from_str("1786124131.0").unwrap();
assert_eq!(parse_expiration(&whole_float).unwrap(), 1_786_124_131);
let fractional = serde_json::from_str("1786124131.5").unwrap();
assert!(parse_expiration(&fractional).is_err());
}
}