Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add aws external account subject token #172

Merged
merged 10 commits into from
Dec 2, 2024
6 changes: 5 additions & 1 deletion gcloud-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ default = ["tls-roots"]
tls-roots = ["tonic/tls-roots", "reqwest/rustls-tls-native-roots"]
tls-webpki-roots = ["tonic/tls-webpki-roots", "reqwest/rustls-tls-webpki-roots"]
rest = ["serde_with"]
external-account-aws = ["aws-config", "aws-sigv4", "aws-credential-types", "percent-encoding"]
abdolence marked this conversation as resolved.
Show resolved Hide resolved

# Google API features
ccc-hosted-marketplace-v2 = []
Expand Down Expand Up @@ -451,7 +452,10 @@ once_cell = "1.19"
reqwest = { version=">=0.12.7", features=["multipart", "json", "gzip", "stream"], default-features = false }
bytes = { version = "1"}
serde_with = { version = "3", optional = true }

aws-config = { version = "1.1", features = ["behavior-version-latest"], optional = true }
aws-sigv4 = { version = "1.2" , optional = true }
aws-credential-types = { version = "1.2.1", optional = true }
percent-encoding = { version = "2.3", optional = true }
[dev-dependencies]
cargo-audit = "0.21"

Expand Down
2 changes: 1 addition & 1 deletion gcloud-sdk/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub enum ErrorKind {
CredentialsJson(serde_json::Error),
/// An error reading credentials file.
CredentialsFile(std::io::Error),
/// An error parsing data from token response.
/// An error from json serialization and deserialization.
TokenJson(serde_json::Error),
/// Invalid token error.
TokenData,
Expand Down
224 changes: 222 additions & 2 deletions gcloud-sdk/src/token_source/ext_creds_source/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use tracing::*;
pub enum ExternalCredentialSource {
UrlBased(ExternalCredentialUrl),
FileBased(ExternalCredentialFile),
// AWS external source implementation example https://github.com/googleapis/google-auth-library-nodejs/blob/4bbd13fbf9081e004209d0ffc336648cff0c529e/src/auth/awsclient.ts
#[cfg(feature = "external-account-aws")]
Aws(Aws),
}

#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
Expand All @@ -25,6 +26,26 @@ pub struct ExternalCredentialFile {
format: Option<ExternalCredentialUrlFormat>,
}

/// https://google.aip.dev/auth/4117#determining-the-subject-token-in-aws
#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Aws {
/// This defines the regional AWS GetCallerIdentity action URL. This URL should be used
/// to determine the AWS account ID and its roles.
pub regional_cred_verification_url: String,
/// This is the environment identifier, of format `aws{version}`.
pub environment_id: String,
/// This URL should be used to determine the current AWS region needed for the signed
/// request construction when the region environment variables are not present.
pub region_url: Option<String>,
/// This AWS metadata server URL should be used to retrieve the access key, secret key
/// and security token needed to sign the GetCallerIdentity request.
pub url: Option<String>,
/// Presence of this URL enforces the auth libraries to fetch a Session Token from AWS.
/// This field is required for EC2 instances using IMDSv2. This Session Token would
/// later be used while making calls to the metadata endpoint.
pub imdsv2_session_token_url: Option<String>,
}

#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type")]
#[serde(rename_all = "camelCase")]
Expand All @@ -42,11 +63,35 @@ pub async fn subject_token(
client: &reqwest::Client,
external_account: &ExternalAccount,
) -> crate::error::Result<SecretValue> {
match external_account.credential_source {
match &external_account.credential_source {
ExternalCredentialSource::UrlBased(ref url_creds) => {
subject_token_url(client, url_creds).await
}
ExternalCredentialSource::FileBased(ref url_creds) => subject_token_file(url_creds).await,
#[cfg(feature = "external-account-aws")]
ExternalCredentialSource::Aws(Aws {
regional_cred_verification_url,
environment_id,
..
abdolence marked this conversation as resolved.
Show resolved Hide resolved
}) => {
if environment_id.starts_with("aws") {
if environment_id != "aws1" {
return Err(crate::error::ErrorKind::ExternalCredsSourceError(
"unsupported aws version".to_string(),
)
.into());
}
};
let (credentials, region) = aws::get_aws_props().await?;
aws::subject_token_aws(
regional_cred_verification_url.as_str(),
credentials,
region,
std::time::SystemTime::now(),
&external_account.audience,
)
.await
}
}
}

Expand Down Expand Up @@ -139,3 +184,178 @@ fn subject_token_from_json(
.into()
})
}

#[cfg(feature = "external-account-aws")]
mod aws {
use crate::error::Error;
use crate::error::ErrorKind;
use aws_config::Region;
use aws_credential_types::provider::ProvideCredentials;
use aws_credential_types::Credentials;
use aws_sigv4::http_request::{
SignableBody, SignatureLocation, SigningParams, SigningSettings,
};
use aws_sigv4::sign::v4::SigningParams as V4SigningParams;
use hyper::http::{Method, Request};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use secret_vault_value::SecretValue;
use serde::Serialize;
use std::time::SystemTime;

pub async fn subject_token_aws(
regional_cred_verification_url: &str,
credentials: Credentials,
region: Region,
sign_at: SystemTime,
audience: &str,
) -> crate::error::Result<SecretValue> {
let identity = credentials.into();
let signature_time = sign_at;

let mut signing_settings = SigningSettings::default();
signing_settings.signature_location = SignatureLocation::Headers;
let v4_signing_params = V4SigningParams::builder()
.name("sts")
.identity(&identity)
.region(region.as_ref())
.time(signature_time)
.settings(signing_settings)
.build()
.map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?;
let params = SigningParams::V4(v4_signing_params);

let regional_cred_verification_url =
regional_cred_verification_url.replace("{region}", region.as_ref());
let subject_token_url = regional_cred_verification_url;
let url = url::Url::parse(&subject_token_url)
.map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?;
let method = Method::POST;
let mut headers = vec![("x-goog-cloud-target-resource", audience)];
if let Some(host) = url.host_str() {
headers.push(("Host", host))
}
let mut req = Request::builder().uri(url.to_string()).method(&method);
for header in &headers {
req = req.header(header.0, header.1);
}
let mut request = req
.body(())
.map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?;

let signable_request = aws_sigv4::http_request::SignableRequest::new(
method.as_str(),
&subject_token_url,
headers.into_iter(),
SignableBody::empty(),
)
.map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?;
let (instruction, _) = aws_sigv4::http_request::sign(signable_request, &params)
.map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?
.into_parts();
instruction.apply_to_request_http1x(&mut request);
let payload = AWSRequest {
url: subject_token_url.to_string(),
method: method.to_string(),
headers: request
.headers()
.into_iter()
.flat_map(|(k, v)| {
v.to_str()
.ok()
.map(|v| AWSRequestHeader::new(k.to_string(), v.to_string()))
})
.collect(),
};
let payload =
serde_json::to_string(&payload).map_err(|e| Error::from(ErrorKind::TokenJson(e)))?;
let sts_token = utf8_percent_encode(&payload, NON_ALPHANUMERIC).to_string();

Ok(sts_token.into())
}

pub async fn get_aws_props() -> crate::error::Result<(Credentials, Region)> {
let region_provider =
abdolence marked this conversation as resolved.
Show resolved Hide resolved
aws_config::default_provider::region::DefaultRegionChain::builder().build();
let region = region_provider.region().await.ok_or_else(|| {
Error::from(ErrorKind::ExternalCredsSourceError(
"region not found".to_string(),
))
})?;
let credentials_provider =
aws_config::default_provider::credentials::DefaultCredentialsChain::builder()
.build()
.await;
let credentials: Credentials = credentials_provider
.provide_credentials()
.await
.map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?
.into();
Ok((credentials, region))
}

#[derive(Debug, Serialize)]
struct AWSRequest {
url: String,
method: String,
headers: Vec<AWSRequestHeader>,
}

#[derive(Debug, Serialize, Clone)]
struct AWSRequestHeader {
key: String,
value: String,
}

impl AWSRequestHeader {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
}

#[cfg(test)]
mod tests {
use std::time::SystemTime;

use aws_config::Region;
use aws_credential_types::Credentials;
use chrono::NaiveDateTime;

use super::subject_token_aws;
#[tokio::test]
async fn sanity_check_subject_token() {
// This test uses the following implementation for reference
// https://github.com/yoshidan/google-cloud-rust/blob/8d09d6156dfb29965cd20539375896f16b3f739d/foundation/auth/src/token_source/external_account_source/aws_subject_token_source.rs#L381
abdolence marked this conversation as resolved.
Show resolved Hide resolved
let credentials = Credentials::new(
"AccessKeyId",
"SecretAccessKey",
Some("SecurityToken".to_string()),
None,
"test",
);
let sign_at: SystemTime =
NaiveDateTime::parse_from_str("2022-12-31 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap()
.and_utc()
.into();
let region = Region::from_static("ap-northeast-1b");
let audience = "//iam.googleapis.com/projects/myprojectnumber/locations/global/workloadIdentityPools/aws-test/providers/aws-test";
let regional_cred_verification_url =
"https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15";
let result = subject_token_aws(
regional_cred_verification_url,
credentials,
region.clone(),
sign_at,
audience,
)
.await;
assert_eq!(
result.unwrap().sensitive_value_to_str().unwrap(),
"%7B%22url%22%3A%22https%3A%2F%2Fsts%2Eap%2Dnortheast%2D1b%2Eamazonaws%2Ecom%3FAction%3DGetCallerIdentity%26Version%3D2011%2D06%2D15%22%2C%22method%22%3A%22POST%22%2C%22headers%22%3A%5B%7B%22key%22%3A%22x%2Dgoog%2Dcloud%2Dtarget%2Dresource%22%2C%22value%22%3A%22%2F%2Fiam%2Egoogleapis%2Ecom%2Fprojects%2Fmyprojectnumber%2Flocations%2Fglobal%2FworkloadIdentityPools%2Faws%2Dtest%2Fproviders%2Faws%2Dtest%22%7D%2C%7B%22key%22%3A%22host%22%2C%22value%22%3A%22sts%2Eap%2Dnortheast%2D1b%2Eamazonaws%2Ecom%22%7D%2C%7B%22key%22%3A%22x%2Damz%2Ddate%22%2C%22value%22%3A%2220221231T000000Z%22%7D%2C%7B%22key%22%3A%22authorization%22%2C%22value%22%3A%22AWS4%2DHMAC%2DSHA256%20Credential%3DAccessKeyId%2F20221231%2Fap%2Dnortheast%2D1b%2Fsts%2Faws4%5Frequest%2C%20SignedHeaders%3Dhost%3Bx%2Damz%2Ddate%3Bx%2Damz%2Dsecurity%2Dtoken%3Bx%2Dgoog%2Dcloud%2Dtarget%2Dresource%2C%20Signature%3D168a40df8b7c11fb0588a13cada1443e31e4736de702232f9a2177b26edda21c%22%7D%2C%7B%22key%22%3A%22x%2Damz%2Dsecurity%2Dtoken%22%2C%22value%22%3A%22SecurityToken%22%7D%5D%7D"
);
}
}
}