Skip to main content

reqsign_google/provide_credential/
default.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use log::debug;
19
20use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
21
22use crate::constants::GOOGLE_APPLICATION_CREDENTIALS;
23use crate::credential::Credential;
24
25use super::{parse::parse_credential_bytes, vm_metadata::VmMetadataCredentialProvider};
26
27/// Default credential provider for Google Cloud Storage (GCS).
28///
29/// Resolution order follows ADC (Application Default Credentials):
30/// 1. Env var `GOOGLE_APPLICATION_CREDENTIALS`
31/// 2. Well-known location (`~/.config/gcloud/application_default_credentials.json`)
32/// 3. VM metadata service (GCE / Cloud Functions / App Engine)
33#[derive(Debug)]
34pub struct DefaultCredentialProvider {
35    chain: ProvideCredentialChain<Credential>,
36}
37
38impl Default for DefaultCredentialProvider {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl DefaultCredentialProvider {
45    /// Create a builder to configure the default ADC chain for GCS.
46    pub fn builder() -> DefaultCredentialProviderBuilder {
47        DefaultCredentialProviderBuilder::default()
48    }
49
50    /// Create a new DefaultCredentialProvider with the default chain:
51    /// env ADC -> well-known ADC -> VM metadata
52    pub fn new() -> Self {
53        Self::builder().build()
54    }
55
56    /// Create with a custom credential chain.
57    pub fn with_chain(chain: ProvideCredentialChain<Credential>) -> Self {
58        Self { chain }
59    }
60
61    /// Add a credential provider to the front of the default chain.
62    pub fn push_front(
63        mut self,
64        provider: impl ProvideCredential<Credential = Credential> + 'static,
65    ) -> Self {
66        self.chain = self.chain.push_front(provider);
67        self
68    }
69}
70impl ProvideCredential for DefaultCredentialProvider {
71    type Credential = Credential;
72
73    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
74        self.chain.provide_credential(ctx).await
75    }
76}
77
78#[derive(Default, Clone, Debug)]
79pub struct EnvCredentialProvider {
80    scope: Option<String>,
81}
82
83impl EnvCredentialProvider {
84    /// Create a new env ADC credential provider.
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Set the OAuth2 scope to request when exchanging ADC credentials.
90    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
91        self.scope = Some(scope.into());
92        self
93    }
94}
95impl ProvideCredential for EnvCredentialProvider {
96    type Credential = Credential;
97
98    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
99        let path = match ctx.env_var(GOOGLE_APPLICATION_CREDENTIALS) {
100            Some(path) if !path.is_empty() => path,
101            _ => return Ok(None),
102        };
103
104        debug!("trying to load credential from env GOOGLE_APPLICATION_CREDENTIALS: {path}");
105
106        let content = ctx.file_read(&path).await?;
107        parse_credential_bytes(ctx, &content, self.scope.clone()).await
108    }
109}
110
111#[derive(Default, Clone, Debug)]
112pub struct WellKnownCredentialProvider {
113    scope: Option<String>,
114}
115
116impl WellKnownCredentialProvider {
117    /// Create a new well-known ADC credential provider.
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Set the OAuth2 scope to request when exchanging ADC credentials.
123    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
124        self.scope = Some(scope.into());
125        self
126    }
127}
128impl ProvideCredential for WellKnownCredentialProvider {
129    type Credential = Credential;
130
131    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
132        let config_dir = if let Some(v) = ctx.env_var("APPDATA") {
133            v
134        } else if let Some(v) = ctx.env_var("XDG_CONFIG_HOME") {
135            v
136        } else if let Some(v) = ctx.env_var("HOME") {
137            format!("{v}/.config")
138        } else {
139            return Ok(None);
140        };
141
142        let path = format!("{config_dir}/gcloud/application_default_credentials.json");
143        debug!("trying to load credential from well-known location: {path}");
144
145        let content = match ctx.file_read(&path).await {
146            Ok(v) => v,
147            Err(_) => return Ok(None),
148        };
149
150        match parse_credential_bytes(ctx, &content, self.scope.clone()).await {
151            Ok(v) => Ok(v),
152            Err(_) => Ok(None),
153        }
154    }
155}
156
157/// Builder for `DefaultCredentialProvider`.
158pub struct DefaultCredentialProviderBuilder {
159    env: Option<EnvCredentialProvider>,
160    well_known: Option<WellKnownCredentialProvider>,
161    vm_metadata: Option<VmMetadataCredentialProvider>,
162}
163
164impl Default for DefaultCredentialProviderBuilder {
165    fn default() -> Self {
166        Self {
167            env: Some(EnvCredentialProvider::new()),
168            well_known: Some(WellKnownCredentialProvider::new()),
169            vm_metadata: Some(VmMetadataCredentialProvider::new()),
170        }
171    }
172}
173
174impl DefaultCredentialProviderBuilder {
175    /// Create a new builder with default state.
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Set the env ADC provider slot.
181    pub fn env(mut self, provider: EnvCredentialProvider) -> Self {
182        self.env = Some(provider);
183        self
184    }
185
186    /// Remove the env ADC provider slot.
187    pub fn no_env(mut self) -> Self {
188        self.env = None;
189        self
190    }
191
192    /// Set the well-known ADC provider slot.
193    pub fn well_known(mut self, provider: WellKnownCredentialProvider) -> Self {
194        self.well_known = Some(provider);
195        self
196    }
197
198    /// Remove the well-known ADC provider slot.
199    pub fn no_well_known(mut self) -> Self {
200        self.well_known = None;
201        self
202    }
203
204    /// Set the VM metadata provider slot.
205    pub fn vm_metadata(mut self, provider: VmMetadataCredentialProvider) -> Self {
206        self.vm_metadata = Some(provider);
207        self
208    }
209
210    /// Remove the VM metadata provider slot.
211    pub fn no_vm_metadata(mut self) -> Self {
212        self.vm_metadata = None;
213        self
214    }
215
216    /// Build the `DefaultCredentialProvider` with the configured options.
217    pub fn build(self) -> DefaultCredentialProvider {
218        let mut chain = ProvideCredentialChain::new();
219
220        if let Some(p) = self.env {
221            chain = chain.push(p);
222        }
223
224        if let Some(p) = self.well_known {
225            chain = chain.push(p);
226        }
227
228        if let Some(p) = self.vm_metadata {
229            chain = chain.push(p);
230        }
231
232        DefaultCredentialProvider::with_chain(chain)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use bytes::Bytes;
240    use reqsign_core::{Context, FileRead, HttpSend, StaticEnv};
241    use std::collections::HashMap;
242    use std::env;
243    use std::sync::{Arc, Mutex};
244
245    #[derive(Clone, Debug, Default)]
246    struct MockHttpSend {
247        uris: Arc<Mutex<Vec<String>>>,
248    }
249
250    impl HttpSend for MockHttpSend {
251        async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
252            self.uris.lock().unwrap().push(req.uri().to_string());
253
254            Ok(http::Response::builder()
255                .status(http::StatusCode::OK)
256                .body(
257                    br#"{"access_token":"test-access-token","expires_in":3600}"#
258                        .as_slice()
259                        .into(),
260                )
261                .expect("response must build"))
262        }
263    }
264
265    #[derive(Clone, Debug, Default)]
266    struct MockFileRead {
267        files: Arc<HashMap<String, Vec<u8>>>,
268        paths: Arc<Mutex<Vec<String>>>,
269    }
270
271    impl MockFileRead {
272        fn new(files: HashMap<String, Vec<u8>>) -> Self {
273            Self {
274                files: Arc::new(files),
275                paths: Arc::new(Mutex::new(Vec::new())),
276            }
277        }
278    }
279
280    impl FileRead for MockFileRead {
281        async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
282            self.paths.lock().unwrap().push(path.to_string());
283            self.files.get(path).cloned().ok_or_else(|| {
284                reqsign_core::Error::config_invalid(format!("file not found: {path}"))
285            })
286        }
287    }
288
289    #[tokio::test]
290    async fn test_default_provider_env() {
291        let envs = HashMap::from([(
292            GOOGLE_APPLICATION_CREDENTIALS.to_string(),
293            format!(
294                "{}/testdata/test_credential.json",
295                env::current_dir()
296                    .expect("current_dir must exist")
297                    .to_string_lossy()
298            ),
299        )]);
300
301        let ctx = Context::new()
302            .with_file_read(reqsign_file_read_tokio::TokioFileRead)
303            .with_http_send(reqsign_http_send_reqwest::ReqwestHttpSend::default())
304            .with_env(StaticEnv {
305                home_dir: None,
306                envs,
307            });
308
309        let provider = DefaultCredentialProvider::new();
310        let cred = provider
311            .provide_credential(&ctx)
312            .await
313            .expect("load must succeed");
314        assert!(cred.is_some());
315
316        let cred = cred.unwrap();
317        assert!(cred.has_service_account());
318        let sa = cred.service_account.as_ref().unwrap();
319        assert_eq!("test-234@test.iam.gserviceaccount.com", &sa.client_email);
320    }
321
322    #[tokio::test]
323    async fn test_default_provider_builder_default_chain() {
324        let provider = DefaultCredentialProvider::builder().build();
325
326        // Even without valid credentials, this should not panic
327        let ctx = Context::new()
328            .with_file_read(reqsign_file_read_tokio::TokioFileRead)
329            .with_http_send(reqsign_http_send_reqwest::ReqwestHttpSend::default());
330        let _ = provider.provide_credential(&ctx).await;
331    }
332
333    #[tokio::test]
334    async fn test_default_provider_no_env_removes_provider() -> Result<()> {
335        let env_path = "/tmp/google-env-adc.json";
336        let file_read = MockFileRead::new(HashMap::from([(
337            env_path.to_string(),
338            include_bytes!(concat!(
339                env!("CARGO_MANIFEST_DIR"),
340                "/testdata/test_credential.json"
341            ))
342            .to_vec(),
343        )]));
344        let ctx = Context::new()
345            .with_file_read(file_read.clone())
346            .with_env(StaticEnv {
347                home_dir: None,
348                envs: HashMap::from([(
349                    GOOGLE_APPLICATION_CREDENTIALS.to_string(),
350                    env_path.to_string(),
351                )]),
352            });
353
354        let provider = DefaultCredentialProvider::builder()
355            .no_env()
356            .no_well_known()
357            .no_vm_metadata()
358            .build();
359
360        assert!(provider.provide_credential(&ctx).await?.is_none());
361        assert!(file_read.paths.lock().unwrap().is_empty());
362
363        Ok(())
364    }
365
366    #[tokio::test]
367    async fn test_default_provider_no_well_known_removes_provider() -> Result<()> {
368        let home = "/tmp/google-home";
369        let well_known_path = format!("{home}/.config/gcloud/application_default_credentials.json");
370        let file_read = MockFileRead::new(HashMap::from([(
371            well_known_path.clone(),
372            include_bytes!(concat!(
373                env!("CARGO_MANIFEST_DIR"),
374                "/testdata/test_credential.json"
375            ))
376            .to_vec(),
377        )]));
378        let ctx = Context::new()
379            .with_file_read(file_read.clone())
380            .with_env(StaticEnv {
381                home_dir: None,
382                envs: HashMap::from([("HOME".to_string(), home.to_string())]),
383            });
384
385        let provider = DefaultCredentialProvider::builder()
386            .no_env()
387            .no_well_known()
388            .no_vm_metadata()
389            .build();
390
391        assert!(provider.provide_credential(&ctx).await?.is_none());
392        assert!(file_read.paths.lock().unwrap().is_empty());
393
394        Ok(())
395    }
396
397    #[tokio::test]
398    async fn test_default_provider_no_vm_metadata_removes_provider() -> Result<()> {
399        let http = MockHttpSend::default();
400        let ctx = Context::new().with_http_send(http.clone());
401
402        let provider = DefaultCredentialProvider::builder()
403            .no_env()
404            .no_well_known()
405            .no_vm_metadata()
406            .build();
407
408        assert!(provider.provide_credential(&ctx).await?.is_none());
409        assert!(http.uris.lock().unwrap().is_empty());
410
411        Ok(())
412    }
413
414    #[tokio::test]
415    async fn test_default_provider_custom_vm_metadata_service_account() -> Result<()> {
416        let http = MockHttpSend::default();
417        let ctx = Context::new().with_http_send(http.clone());
418
419        let provider = DefaultCredentialProvider::builder()
420            .no_env()
421            .no_well_known()
422            .vm_metadata(
423                VmMetadataCredentialProvider::new()
424                    .with_endpoint("127.0.0.1:8080")
425                    .with_service_account("custom@test-project.iam.gserviceaccount.com"),
426            )
427            .build();
428
429        let cred = provider
430            .provide_credential(&ctx)
431            .await?
432            .expect("credential must exist");
433
434        assert!(cred.has_token());
435        assert_eq!(
436            http.uris.lock().unwrap().as_slice(),
437            &["http://127.0.0.1:8080/computeMetadata/v1/instance/service-accounts/custom@test-project.iam.gserviceaccount.com/token?scopes=https://www.googleapis.com/auth/cloud-platform".to_string()]
438        );
439
440        Ok(())
441    }
442}