yup_oauth2/
external_account.rs

1//! This module provides a token source (`GetToken`) that obtains tokens using workload identity federation
2//! for use by software (i.e., non-human actors) to get access to Google services.
3//!
4//! Resources:
5//! - [Workload identity federation](https://cloud.google.com/iam/docs/workload-identity-federation)
6//! - [External Account Credentials (Workload Identity Federation)](https://google.aip.dev/auth/4117)
7//!
8use crate::client::SendRequest;
9use crate::error::Error;
10use crate::types::TokenInfo;
11use http::header;
12use http_body_util::BodyExt;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use thiserror::Error;
16use url::form_urlencoded;
17
18/// JSON schema of external account secret.
19///
20/// You can use `helpers::read_external_account_secret()` to read a JSON file
21/// into a `ExternalAccountSecret`.
22#[derive(Serialize, Deserialize, Debug, Clone)]
23pub struct ExternalAccountSecret {
24    /// audience
25    pub audience: String,
26    /// subject_token_type
27    pub subject_token_type: String,
28    /// service_account_impersonation_url
29    pub service_account_impersonation_url: Option<String>,
30    /// token_url
31    pub token_url: String,
32    // TODO: support service_account_impersonation.
33    /// credential_source
34    pub credential_source: CredentialSource,
35    #[serde(rename = "type")]
36    /// key_type
37    pub key_type: String,
38}
39
40/// JSON schema of credential source.
41#[derive(Serialize, Deserialize, Debug, Clone)]
42#[serde(untagged)]
43pub enum CredentialSource {
44    /// file-sourced credentials
45    File {
46        /// File name of a file containing a subject token.
47        file: String,
48    },
49
50    /// [Microsoft Azure and URL-sourced credentials](https://google.aip.dev/auth/4117#determining-the-subject-token-in-microsoft-azure-and-url-sourced-credentials)
51    Url {
52        /// This defines the local metadata server to retrieve the external credentials from. For
53        /// Azure, this should be the Azure Instance Metadata Service (IMDS) URL used to retrieve
54        /// the Azure AD access token.
55        url: String,
56        /// This defines the headers to append to the GET request to credential_source.url.
57        headers: Option<HashMap<String, String>>,
58        /// See struct documentation.
59        format: UrlCredentialSourceFormat,
60    },
61    // TODO: executable-sourced credentials
62}
63
64/// JSON schema of URL-sourced credentials' format.
65/// This indicates the format of the URL response. This can be either "text" or "json". The default should be "text".
66#[derive(Serialize, Deserialize, Debug, Clone)]
67#[serde(tag = "type")]
68pub enum UrlCredentialSourceFormat {
69    /// Response is text.
70    #[serde(rename = "text")]
71    Text,
72    /// Response is JSON.
73    #[serde(rename = "json")]
74    Json {
75        /// Required for JSON URL responses. This indicates the JSON field name where the subject_token should be stored.
76        subject_token_field_name: String,
77    },
78}
79
80#[derive(Debug, Error)]
81/// Errors that can happen when parsing a Credential source
82pub enum CredentialSourceError {
83    /// Parsing credential text source failed
84    #[error("Failed to parse credential text source: {0}")]
85    CredentialSourceTextInvalid(std::string::FromUtf8Error),
86    /// Failed to parse JSON
87    #[error("JSON credential source is invalid: {0}")]
88    JsonInvalid(#[source] serde_json::Error),
89    /// JSON is missing this field
90    #[error("JSON credential source is missing field {0}")]
91    MissingJsonField(String),
92    /// This field of JSON is invalid
93    #[error("JSON credential source could not convert field {0} to string")]
94    InvalidJsonField(String),
95}
96
97/// An ExternalAccountFlow can fetch OAuth tokens using an external account secret.
98pub struct ExternalAccountFlow {
99    pub(crate) secret: ExternalAccountSecret,
100}
101
102impl ExternalAccountFlow {
103    /// Send a request for a new Bearer token to the OAuth provider.
104    pub(crate) async fn token<T>(
105        &self,
106        hyper_client: &impl SendRequest,
107        scopes: &[T],
108    ) -> Result<TokenInfo, Error>
109    where
110        T: AsRef<str>,
111    {
112        let subject_token = match &self.secret.credential_source {
113            CredentialSource::File { file } => tokio::fs::read_to_string(file).await?,
114            CredentialSource::Url {
115                url,
116                headers,
117                format,
118            } => {
119                let request = headers
120                    .iter()
121                    .flatten()
122                    .fold(hyper::Request::get(url), |builder, (name, value)| {
123                        builder.header(name, value)
124                    })
125                    .body(String::new())
126                    .unwrap();
127
128                log::debug!("requesting credential from url: {:?}", request);
129                let (head, body) = hyper_client.request(request).await?.into_parts();
130                let body = body.collect().await?.to_bytes();
131                log::debug!("received response; head: {:?}, body: {:?}", head, body);
132
133                match format {
134                    UrlCredentialSourceFormat::Text => {
135                        String::from_utf8(body.to_vec()).map_err(|e| {
136                            Error::CredentialSourceError(
137                                CredentialSourceError::CredentialSourceTextInvalid(e),
138                            )
139                        })?
140                    }
141                    UrlCredentialSourceFormat::Json {
142                        subject_token_field_name,
143                    } => serde_json::from_slice::<HashMap<String, serde_json::Value>>(&body)
144                        .map_err(|e| {
145                            Error::CredentialSourceError(CredentialSourceError::JsonInvalid(e))
146                        })?
147                        .remove(subject_token_field_name)
148                        .ok_or_else(|| {
149                            Error::CredentialSourceError(CredentialSourceError::MissingJsonField(
150                                subject_token_field_name.to_owned(),
151                            ))
152                        })?
153                        .as_str()
154                        .ok_or_else(|| {
155                            Error::CredentialSourceError(CredentialSourceError::InvalidJsonField(
156                                subject_token_field_name.to_owned(),
157                            ))
158                        })?
159                        .to_string(),
160                }
161            }
162        };
163
164        let req = form_urlencoded::Serializer::new(String::new())
165            .extend_pairs(&[
166                ("audience", self.secret.audience.as_str()),
167                (
168                    "grant_type",
169                    "urn:ietf:params:oauth:grant-type:token-exchange",
170                ),
171                (
172                    "requested_token_type",
173                    "urn:ietf:params:oauth:token-type:access_token",
174                ),
175                (
176                    "subject_token_type",
177                    self.secret.subject_token_type.as_str(),
178                ),
179                ("subject_token", subject_token.as_str()),
180                (
181                    "scope",
182                    if self.secret.service_account_impersonation_url.is_some() {
183                        "https://www.googleapis.com/auth/cloud-platform".to_owned()
184                    } else {
185                        crate::helper::join(scopes, " ")
186                    }
187                    .as_str(),
188                ),
189            ])
190            .finish();
191
192        let request = http::Request::post(&self.secret.token_url)
193            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
194            .body(req)
195            .unwrap();
196
197        log::debug!("requesting token from external account: {:?}", request);
198        let (head, body) = hyper_client.request(request).await?.into_parts();
199        let body = body.collect().await?.to_bytes();
200        log::debug!("received response; head: {:?}, body: {:?}", head, body);
201
202        let token_info = TokenInfo::from_json(&body)?;
203
204        if let Some(service_account_impersonation_url) =
205            &self.secret.service_account_impersonation_url
206        {
207            crate::service_account_impersonator::token_impl(
208                hyper_client,
209                service_account_impersonation_url,
210                true,
211                &token_info.access_token.ok_or(Error::MissingAccessToken)?,
212                scopes,
213            )
214            .await
215        } else {
216            Ok(token_info)
217        }
218    }
219}