Skip to main content

gcloud_sdk/token_source/ext_creds_source/
mod.rs

1use 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/// https://google.aip.dev/auth/4117#determining-the-subject-token-in-aws
30#[cfg(feature = "external-account-aws")]
31#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub struct Aws {
33    /// This defines the regional AWS GetCallerIdentity action URL. This URL should be used
34    ///  to determine the AWS account ID and its roles.
35    pub regional_cred_verification_url: String,
36    /// This is the environment identifier, of format `aws{version}`.
37    pub environment_id: String,
38    /// This URL should be used to determine the current AWS region needed for the signed
39    /// request construction when the region environment variables are not present.
40    pub region_url: Option<String>,
41    /// This AWS metadata server URL should be used to retrieve the access key, secret key
42    /// and security token needed to sign the GetCallerIdentity request.
43    pub url: Option<String>,
44    /// Presence of this URL enforces the auth libraries to fetch a Session Token from AWS.
45    /// This field is required for EC2 instances using IMDSv2. This Session Token would
46    /// later be used while making calls to the metadata endpoint.
47    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, &params)
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            // This test uses the following implementation for reference
335            // https://github.com/yoshidan/google-cloud-rust/blob/8d09d6156dfb29965cd20539375896f16b3f739d/foundation/auth/src/token_source/external_account_source/aws_subject_token_source.rs#L381
336            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}