gcloud_sdk/token_source/ext_creds_source/
mod.rs1use crate::token_source::credentials::ExternalAccount;
2use secret_vault_value::SecretValue;
3use std::collections::HashMap;
4
5use tracing::*;
6
7#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8#[serde(untagged)]
9pub enum ExternalCredentialSource {
10 #[cfg(feature = "external-account-aws")]
11 Aws(Aws),
12 UrlBased(ExternalCredentialUrl),
13 FileBased(ExternalCredentialFile),
14}
15
16#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct ExternalCredentialUrl {
18 url: String,
19 headers: Option<HashMap<String, SecretValue>>,
20 format: Option<ExternalCredentialUrlFormat>,
21}
22
23#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub struct ExternalCredentialFile {
25 file: String,
26 format: Option<ExternalCredentialUrlFormat>,
27}
28
29#[cfg(feature = "external-account-aws")]
31#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub struct Aws {
33 pub regional_cred_verification_url: String,
36 pub environment_id: String,
38 pub region_url: Option<String>,
41 pub url: Option<String>,
44 pub imdsv2_session_token_url: Option<String>,
48}
49
50#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51#[serde(tag = "type")]
52#[serde(rename_all = "camelCase")]
53pub enum ExternalCredentialUrlFormat {
54 Json(ExternalCredentialUrlFormatJson),
55 Text,
56}
57
58#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub struct ExternalCredentialUrlFormatJson {
60 pub subject_token_field_name: String,
61}
62
63pub async fn subject_token(
64 client: &reqwest::Client,
65 external_account: &ExternalAccount,
66) -> crate::error::Result<SecretValue> {
67 match &external_account.credential_source {
68 ExternalCredentialSource::UrlBased(ref url_creds) => {
69 subject_token_url(client, url_creds).await
70 }
71 ExternalCredentialSource::FileBased(ref url_creds) => subject_token_file(url_creds).await,
72 #[cfg(feature = "external-account-aws")]
73 ExternalCredentialSource::Aws(Aws {
74 regional_cred_verification_url,
75 environment_id,
76 ..
77 }) => {
78 debug!(
79 "Using external credentials AWS source. Regional URL: {}",
80 regional_cred_verification_url
81 );
82 if environment_id.starts_with("aws") {
83 if environment_id != "aws1" {
84 return Err(crate::error::ErrorKind::ExternalCredsSourceError(
85 "unsupported aws version".to_string(),
86 )
87 .into());
88 }
89 };
90 let (credentials, region) = aws::get_aws_props().await?;
91 aws::subject_token_aws(
92 regional_cred_verification_url.as_str(),
93 credentials,
94 region,
95 std::time::SystemTime::now(),
96 &external_account.audience,
97 )
98 .await
99 }
100 }
101}
102
103pub async fn subject_token_url(
104 client: &reqwest::Client,
105 url_creds: &ExternalCredentialUrl,
106) -> crate::error::Result<SecretValue> {
107 debug!(
108 "Using external credentials URL source {}. Format: {:?}",
109 &url_creds.url, &url_creds.format
110 );
111 let mut request = client.get(url_creds.url.as_str());
112
113 if let Some(headers) = &url_creds.headers {
114 for (header_name, header_value) in headers {
115 request = request.header(header_name, header_value.as_sensitive_str());
116 }
117 }
118
119 let response = request.send().await?;
120
121 if response.status().is_success() {
122 match &url_creds.format {
123 None | Some(ExternalCredentialUrlFormat::Text) => Ok(response.text().await?.into()),
124 Some(ExternalCredentialUrlFormat::Json(json_settings)) => {
125 let json: serde_json::Value = response.json().await?;
126 subject_token_from_json(&json, &json_settings.subject_token_field_name)
127 }
128 }
129 } else {
130 let status = response.status();
131 let err_body = response.text().await?;
132 let err_text = format!(
133 "Unable to receive subject using external credential url: {}. HTTP: {} {}",
134 &url_creds.url, status, err_body
135 );
136 Err(crate::error::ErrorKind::ExternalCredsSourceError(err_text).into())
137 }
138}
139
140pub async fn subject_token_file(
141 url_creds: &ExternalCredentialFile,
142) -> crate::error::Result<SecretValue> {
143 debug!(
144 "Using external credentials file source {}. Format: {:?}",
145 &url_creds.file, &url_creds.format
146 );
147 let file_content: String = std::fs::read_to_string(url_creds.file.as_str()).map_err(|e| {
148 crate::error::ErrorKind::ExternalCredsSourceError(format!(
149 "External file is not readable: {}",
150 e
151 ))
152 })?;
153 match &url_creds.format {
154 None | Some(ExternalCredentialUrlFormat::Text) => Ok(file_content.into()),
155 Some(ExternalCredentialUrlFormat::Json(json_settings)) => {
156 let json: serde_json::Value =
157 serde_json::from_str(file_content.as_str()).map_err(|e| {
158 crate::error::ErrorKind::ExternalCredsSourceError(format!(
159 "External file JSON format error: {}",
160 e
161 ))
162 })?;
163 subject_token_from_json(&json, &json_settings.subject_token_field_name)
164 }
165 }
166}
167
168fn subject_token_from_json(
169 json: &serde_json::Value,
170 subject_token_field_name: &str,
171) -> crate::error::Result<SecretValue> {
172 let json_object = json.as_object().ok_or_else(|| {
173 crate::error::ErrorKind::ExternalCredsSourceError(format!(
174 "External subject JSON format is not object: {}",
175 json
176 ))
177 })?;
178 let subject_json_value = json_object.get(subject_token_field_name).ok_or_else(|| {
179 crate::error::ErrorKind::ExternalCredsSourceError(format!(
180 "External subject JSON format doesn't contain required field: {}",
181 subject_token_field_name
182 ))
183 })?;
184 subject_json_value.as_str().map(Into::into).ok_or_else(|| {
185 crate::error::ErrorKind::ExternalCredsSourceError(format!(
186 "External subject JSON field must have string type: {}",
187 subject_token_field_name
188 ))
189 .into()
190 })
191}
192
193#[cfg(feature = "external-account-aws")]
194mod aws {
195 use crate::error::Error;
196 use crate::error::ErrorKind;
197 use aws_config::Region;
198 use aws_credential_types::provider::ProvideCredentials;
199 use aws_credential_types::Credentials;
200 use aws_sigv4::http_request::{
201 SignableBody, SignatureLocation, SigningParams, SigningSettings,
202 };
203 use aws_sigv4::sign::v4::SigningParams as V4SigningParams;
204 use hyper::http::{Method, Request};
205 use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
206 use secret_vault_value::SecretValue;
207 use serde::Serialize;
208 use std::time::SystemTime;
209
210 pub async fn subject_token_aws(
211 regional_cred_verification_url: &str,
212 credentials: Credentials,
213 region: Region,
214 sign_at: SystemTime,
215 audience: &str,
216 ) -> crate::error::Result<SecretValue> {
217 let identity = credentials.into();
218 let signature_time = sign_at;
219
220 let mut signing_settings = SigningSettings::default();
221 signing_settings.signature_location = SignatureLocation::Headers;
222 let v4_signing_params = V4SigningParams::builder()
223 .name("sts")
224 .identity(&identity)
225 .region(region.as_ref())
226 .time(signature_time)
227 .settings(signing_settings)
228 .build()
229 .map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?;
230 let params = SigningParams::V4(v4_signing_params);
231
232 let regional_cred_verification_url =
233 regional_cred_verification_url.replace("{region}", region.as_ref());
234 let subject_token_url = regional_cred_verification_url;
235 let url = url::Url::parse(&subject_token_url)
236 .map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?;
237 let method = Method::POST;
238 let mut headers = vec![("x-goog-cloud-target-resource", audience)];
239 if let Some(host) = url.host_str() {
240 headers.push(("Host", host))
241 }
242 let mut req = Request::builder().uri(url.to_string()).method(&method);
243 for header in &headers {
244 req = req.header(header.0, header.1);
245 }
246 let mut request = req
247 .body(())
248 .map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?;
249
250 let signable_request = aws_sigv4::http_request::SignableRequest::new(
251 method.as_str(),
252 &subject_token_url,
253 headers.into_iter(),
254 SignableBody::empty(),
255 )
256 .map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?;
257 let (instruction, _) = aws_sigv4::http_request::sign(signable_request, ¶ms)
258 .map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?
259 .into_parts();
260 instruction.apply_to_request_http1x(&mut request);
261 let payload = AWSRequest {
262 url: subject_token_url.to_string(),
263 method: method.to_string(),
264 headers: request
265 .headers()
266 .into_iter()
267 .flat_map(|(k, v)| {
268 v.to_str()
269 .ok()
270 .map(|v| AWSRequestHeader::new(k.to_string(), v.to_string()))
271 })
272 .collect(),
273 };
274 let payload =
275 serde_json::to_string(&payload).map_err(|e| Error::from(ErrorKind::TokenJson(e)))?;
276 let sts_token = utf8_percent_encode(&payload, NON_ALPHANUMERIC).to_string();
277
278 Ok(sts_token.into())
279 }
280
281 pub async fn get_aws_props() -> crate::error::Result<(Credentials, Region)> {
282 let region_provider =
283 aws_config::default_provider::region::DefaultRegionChain::builder().build();
284 let region = region_provider.region().await.ok_or_else(|| {
285 Error::from(ErrorKind::ExternalCredsSourceError(
286 "region not found".to_string(),
287 ))
288 })?;
289 let credentials_provider =
290 aws_config::default_provider::credentials::DefaultCredentialsChain::builder()
291 .build()
292 .await;
293 let credentials: Credentials = credentials_provider
294 .provide_credentials()
295 .await
296 .map_err(|e| Error::from(ErrorKind::ExternalCredsSourceError(e.to_string())))?
297 .into();
298 Ok((credentials, region))
299 }
300
301 #[derive(Debug, Serialize)]
302 struct AWSRequest {
303 url: String,
304 method: String,
305 headers: Vec<AWSRequestHeader>,
306 }
307
308 #[derive(Debug, Serialize, Clone)]
309 struct AWSRequestHeader {
310 key: String,
311 value: String,
312 }
313
314 impl AWSRequestHeader {
315 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
316 Self {
317 key: key.into(),
318 value: value.into(),
319 }
320 }
321 }
322
323 #[cfg(test)]
324 mod tests {
325 use std::time::SystemTime;
326
327 use aws_config::Region;
328 use aws_credential_types::Credentials;
329 use chrono::NaiveDateTime;
330
331 use super::subject_token_aws;
332 #[tokio::test]
333 async fn sanity_check_subject_token() {
334 let credentials = Credentials::new(
337 "AccessKeyId",
338 "SecretAccessKey",
339 Some("SecurityToken".to_string()),
340 None,
341 "test",
342 );
343 let sign_at: SystemTime =
344 NaiveDateTime::parse_from_str("2022-12-31 00:00:00", "%Y-%m-%d %H:%M:%S")
345 .unwrap()
346 .and_utc()
347 .into();
348 let region = Region::from_static("ap-northeast-1b");
349 let audience = "//iam.googleapis.com/projects/myprojectnumber/locations/global/workloadIdentityPools/aws-test/providers/aws-test";
350 let regional_cred_verification_url =
351 "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15";
352 let result = subject_token_aws(
353 regional_cred_verification_url,
354 credentials,
355 region.clone(),
356 sign_at,
357 audience,
358 )
359 .await;
360 assert_eq!(
361 result.unwrap().sensitive_value_to_str().unwrap(),
362 "%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"
363 );
364 }
365 }
366}