Skip to main content

reqsign/google/
credential.rs

1pub mod external_account;
2pub mod impersonated_service_account;
3pub mod service_account;
4
5#[cfg(not(target_arch = "wasm32"))]
6use std::env;
7use std::sync::Arc;
8use std::sync::Mutex;
9
10use anyhow::anyhow;
11use anyhow::Result;
12use log::debug;
13
14pub use self::external_account::ExternalAccount;
15use self::impersonated_service_account::ImpersonatedServiceAccount;
16pub use self::service_account::ServiceAccount;
17use super::constants::GOOGLE_APPLICATION_CREDENTIALS;
18use crate::hash::base64_decode;
19
20#[derive(Clone, serde::Deserialize)]
21#[cfg_attr(test, derive(Debug))]
22#[serde(rename_all = "snake_case")]
23#[allow(clippy::enum_variant_names)]
24pub enum CredentialType {
25    ImpersonatedServiceAccount,
26    ExternalAccount,
27    ServiceAccount,
28}
29
30/// A Google API credential file.
31#[derive(Clone, Default)]
32#[cfg_attr(test, derive(Debug))]
33pub struct Credential {
34    pub(crate) service_account: Option<ServiceAccount>,
35    pub(crate) impersonated_service_account: Option<ImpersonatedServiceAccount>,
36    pub(crate) external_account: Option<ExternalAccount>,
37}
38
39impl Credential {
40    /// Deserialize credential file
41    pub fn from_slice(v: &[u8]) -> Result<Credential> {
42        let service_account = serde_json::from_slice(v).ok();
43        let impersonated_service_account = serde_json::from_slice(v).ok();
44        let external_account = serde_json::from_slice(v).ok();
45
46        let cred = Credential {
47            service_account,
48            impersonated_service_account,
49            external_account,
50        };
51
52        if cred.service_account.is_none()
53            && cred.impersonated_service_account.is_none()
54            && cred.external_account.is_none()
55        {
56            return Err(anyhow!("Couldn't deserialize credential file"));
57        }
58
59        Ok(cred)
60    }
61}
62
63/// CredentialLoader will load credential from different methods.
64#[derive(Default)]
65#[cfg_attr(test, derive(Debug))]
66pub struct CredentialLoader {
67    path: Option<String>,
68    content: Option<String>,
69    disable_env: bool,
70    disable_well_known_location: bool,
71
72    credential: Arc<Mutex<Option<Credential>>>,
73}
74
75impl CredentialLoader {
76    /// Disable load from env.
77    pub fn with_disable_env(mut self) -> Self {
78        self.disable_env = true;
79        self
80    }
81
82    /// Disable load from well known location.
83    pub fn with_disable_well_known_location(mut self) -> Self {
84        self.disable_well_known_location = true;
85        self
86    }
87
88    /// Set credential path.
89    pub fn with_path(mut self, path: &str) -> Self {
90        self.path = Some(path.to_string());
91        self
92    }
93
94    /// Set credential content.
95    pub fn with_content(mut self, content: &str) -> Self {
96        self.content = Some(content.to_string());
97        self
98    }
99
100    /// Load credential from pre-configured methods.
101    pub fn load(&self) -> Result<Option<Credential>> {
102        // Return cached credential if it has been loaded at least once.
103        if let Some(cred) = self.credential.lock().expect("lock poisoned").clone() {
104            return Ok(Some(cred));
105        }
106
107        let cred = if let Some(cred) = self.load_inner()? {
108            cred
109        } else {
110            return Ok(None);
111        };
112
113        let mut lock = self.credential.lock().expect("lock poisoned");
114        *lock = Some(cred.clone());
115
116        Ok(Some(cred))
117    }
118
119    fn load_inner(&self) -> Result<Option<Credential>> {
120        if let Ok(Some(cred)) = self.load_via_content() {
121            return Ok(Some(cred));
122        }
123
124        #[cfg(not(target_arch = "wasm32"))]
125        if let Ok(Some(cred)) = self.load_via_path() {
126            return Ok(Some(cred));
127        }
128
129        #[cfg(not(target_arch = "wasm32"))]
130        if let Ok(Some(cred)) = self.load_via_env() {
131            return Ok(Some(cred));
132        }
133
134        #[cfg(not(target_arch = "wasm32"))]
135        if let Ok(Some(cred)) = self.load_via_well_known_location() {
136            return Ok(Some(cred));
137        }
138
139        Ok(None)
140    }
141
142    #[cfg(not(target_arch = "wasm32"))]
143    fn load_via_path(&self) -> Result<Option<Credential>> {
144        let path = if let Some(path) = &self.path {
145            path
146        } else {
147            return Ok(None);
148        };
149
150        Ok(Some(Self::load_file(path)?))
151    }
152
153    /// Build credential loader from given base64 content.
154    fn load_via_content(&self) -> Result<Option<Credential>> {
155        let content = if let Some(content) = &self.content {
156            content
157        } else {
158            return Ok(None);
159        };
160
161        let decode_content = base64_decode(content)?;
162
163        let cred = Credential::from_slice(&decode_content).map_err(|err| {
164            debug!("load credential from content failed: {err:?}");
165            err
166        })?;
167        Ok(Some(cred))
168    }
169
170    /// Load from env GOOGLE_APPLICATION_CREDENTIALS.
171    #[cfg(not(target_arch = "wasm32"))]
172    fn load_via_env(&self) -> Result<Option<Credential>> {
173        if self.disable_env {
174            return Ok(None);
175        }
176
177        if let Ok(cred_path) = env::var(GOOGLE_APPLICATION_CREDENTIALS) {
178            let cred = Self::load_file(&cred_path)?;
179            Ok(Some(cred))
180        } else {
181            Ok(None)
182        }
183    }
184
185    /// Load from well known locations:
186    ///
187    /// - `$HOME/.config/gcloud/application_default_credentials.json`
188    /// - `%APPDATA%\gcloud\application_default_credentials.json`
189    #[cfg(not(target_arch = "wasm32"))]
190    fn load_via_well_known_location(&self) -> Result<Option<Credential>> {
191        if self.disable_well_known_location {
192            return Ok(None);
193        }
194
195        let config_dir = if let Ok(v) = env::var("APPDATA") {
196            v
197        } else if let Ok(v) = env::var("XDG_CONFIG_HOME") {
198            v
199        } else if let Ok(v) = env::var("HOME") {
200            format!("{v}/.config")
201        } else {
202            // User's env doesn't have a config dir.
203            return Ok(None);
204        };
205
206        let cred = Self::load_file(&format!(
207            "{config_dir}/gcloud/application_default_credentials.json"
208        ))?;
209        Ok(Some(cred))
210    }
211
212    /// Build credential loader from given path.
213    fn load_file(path: &str) -> Result<Credential> {
214        let content = std::fs::read(path).map_err(|err| {
215            debug!("load credential failed at reading file: {err:?}");
216            err
217        })?;
218
219        let account = Credential::from_slice(&content).map_err(|err| {
220            debug!("load credential failed at serde_json: {err:?}");
221            err
222        })?;
223
224        Ok(account)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use log::warn;
231
232    use super::external_account::CredentialSource;
233    use super::external_account::FormatType;
234    use super::*;
235
236    #[test]
237    fn loader_returns_service_account() {
238        temp_env::with_vars(
239            vec![(
240                GOOGLE_APPLICATION_CREDENTIALS,
241                Some(format!(
242                    "{}/testdata/services/google/test_credential.json",
243                    env::current_dir()
244                        .expect("current_dir must exist")
245                        .to_string_lossy()
246                )),
247            )],
248            || {
249                let cred_loader = CredentialLoader::default();
250
251                let cred = cred_loader
252                    .load()
253                    .expect("credential must exist")
254                    .unwrap()
255                    .service_account
256                    .expect("couldn't deserialize service account");
257
258                assert_eq!("test-234@test.iam.gserviceaccount.com", &cred.client_email);
259                assert_eq!(
260                    "-----BEGIN RSA PRIVATE KEY-----
261MIICXAIBAAKBgQDOy4jaJIcVlffi5ENtlNhJ0tsI1zt21BI3DMGtPq7n3Ymow24w
262BV2Z73l4dsqwRo2QVSwnCQ2bVtM2DgckMNDShfWfKe3LRcl96nnn51AtAYIfRnc+
263ogstzxZi4J64f7IR3KIAFxJnzo+a6FS6MmsYMAs8/Oj68fRmCD0AbAs5ZwIDAQAB
264AoGAVpPkMeBFJgZph/alPEWq4A2FYogp/y/+iEmw9IVf2PdpYNyhTz2P2JjoNEUX
265ywFe12SxXY5uwfBx8RmiZ8aARkIBWs7q9Sz6f/4fdCHAuu3GAv5hmMO4dLQsGcKl
266XAQW4QxZM5/x5IXlDh4KdcUP65P0ZNS3deqDlsq/vVfY9EECQQD9I/6KNmlSrbnf
267Fa/5ybF+IV8mOkEfkslQT4a9pWbA1FF53Vk4e7B+Faow3uUGHYs/HUwrd3vIVP84
268S+4Jeuc3AkEA0SGF5l3BrWWTok1Wr/UE+oPOUp2L4AV6kH8co11ZyxSQkRloLdMd
269bNzNXShuhwgvNjvgkseNSeQPJKxFRn73UQJACacMtrJ6c6eiNcp66lhxhzC4kxmX
270kB+lw4U0yxh6gZHXBYGWPFwjD7u9wJ1POFt6Cs8QL3wf4TS0gq4KhpwEIwJACIA8
271WSjmfo3qemZ6Z5ymHyjMcj9FOE4AtW71Uw6wX7juR3eo7HPwdkRjdK34EDUc9i9o
2726Y6DB8Xld7ApALyYgQJBAPTMFpKpCRNvYH5VrdObid5+T7OwDrJFHGWdbDGiT++O
273V08rl535r74rMilnQ37X1/zaKBYyxpfhnd2XXgoCgTM=
274-----END RSA PRIVATE KEY-----
275",
276                    &cred.private_key
277                );
278            },
279        );
280    }
281
282    #[test]
283    fn loader_returns_impersonated_service_account() {
284        temp_env::with_vars(
285            vec![(
286                GOOGLE_APPLICATION_CREDENTIALS,
287                Some(format!(
288                    "{}/testdata/services/google/test_impersonated_service_account.json",
289                    env::current_dir()
290                        .expect("current_dir must exist")
291                        .to_string_lossy()
292                )),
293            )],
294            || {
295                let cred_loader = CredentialLoader::default();
296
297                let cred = cred_loader
298                    .load()
299                    .expect("credential must exist")
300                    .unwrap()
301                    .impersonated_service_account
302                    .expect("couldn't deserialize impersonated service account");
303
304                assert_eq!("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/example-01-iam@example-01.iam.gserviceaccount.com:generateAccessToken", &cred.service_account_impersonation_url);
305                assert_eq!("placeholder_client_id", &cred.source_credentials.client_id);
306                assert_eq!(
307                    "placeholder_client_secret",
308                    &cred.source_credentials.client_secret
309                );
310                assert_eq!(
311                    "placeholder_refresh_token",
312                    &cred.source_credentials.refresh_token
313                );
314            },
315        );
316    }
317
318    #[test]
319    fn loader_returns_external_account() {
320        temp_env::with_vars(
321            vec![(
322                GOOGLE_APPLICATION_CREDENTIALS,
323                Some(format!(
324                    "{}/testdata/services/google/test_external_account.json",
325                    env::current_dir()
326                        .expect("current_dir must exist")
327                        .to_string_lossy()
328                )),
329            )],
330            || {
331                let cred_loader = CredentialLoader::default();
332
333                let cred = cred_loader
334                    .load()
335                    .expect("credential must exist")
336                    .unwrap()
337                    .external_account
338                    .expect("couldn't deserialize external account");
339
340                assert_eq!(
341                    "//iam.googleapis.com/projects/000000000000/locations/global/workloadIdentityPools/reqsign/providers/reqsign-provider",
342                    &cred.audience
343                );
344                assert_eq!(
345                    "urn:ietf:params:oauth:token-type:jwt",
346                    &cred.subject_token_type
347                );
348                assert_eq!(
349                    "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-234@test.iam.gserviceaccount.com:generateAccessToken",
350                    &cred.service_account_impersonation_url.unwrap()
351                );
352                assert_eq!("https://sts.googleapis.com/v1/token", &cred.token_url);
353
354                let CredentialSource::UrlSourced(source) = cred.credential_source else {
355                    panic!("expected URL credential source");
356                };
357
358                assert_eq!("http://localhost:5000/token", &source.url);
359                assert!(matches!(&source.format, FormatType::Json { .. }));
360            },
361        );
362    }
363
364    #[test]
365    fn loader_returns_external_account_from_github_oidc() {
366        let path = if let Ok(path) = env::var("REQSIGN_GOOGLE_CREDENTIAL_PATH") {
367            path
368        } else {
369            warn!("REQSIGN_GOOGLE_CREDENTIAL_PATH is not set, ignore");
370            return;
371        };
372
373        let cred_loader = CredentialLoader::default().with_path(&path);
374
375        let cred: ExternalAccount = cred_loader
376            .load()
377            .expect("credential must exist")
378            .unwrap()
379            .external_account
380            .expect("couldn't deserialize external account from Github OIDC");
381
382        assert_eq!(
383            "urn:ietf:params:oauth:token-type:jwt",
384            &cred.subject_token_type
385        );
386
387        assert_eq!("https://sts.googleapis.com/v1/token", &cred.token_url);
388    }
389}