This is an automated email from the ASF dual-hosted git repository.

xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git


The following commit(s) were added to refs/heads/main by this push:
     new 35f3ede  feat(google): add explicit file and token providers (#707)
35f3ede is described below

commit 35f3ede60f498d6c0234a2ce27c23e8100a9c738
Author: Xuanwo <[email protected]>
AuthorDate: Wed Mar 18 18:37:13 2026 +0800

    feat(google): add explicit file and token providers (#707)
    
    This PR adds built-in Google credential providers for explicit
    `credential_path` and raw access token inputs so downstream integrations
    no longer need local adapters. It keeps the existing core caching model
    unchanged while factoring JSON credential parsing into a shared helper
    reused by default, static, and file-based providers.
    
    This also adds targeted tests for file-path credentials and token
    providers, including token-path loading and expiration handling. Related
    issues: #694, #696.
---
 services/google/src/lib.rs                         |   4 +-
 services/google/src/provide_credential/default.rs  |  46 +--------
 services/google/src/provide_credential/file.rs     |  58 +++++++++++
 services/google/src/provide_credential/mod.rs      |   8 ++
 services/google/src/provide_credential/parse.rs    |  64 ++++++++++++
 .../src/provide_credential/static_provider.rs      |  48 ++-------
 services/google/src/provide_credential/token.rs    | 113 +++++++++++++++++++++
 services/google/tests/credential_providers/file.rs |  64 ++++++++++++
 services/google/tests/credential_providers/mod.rs  |   2 +
 .../google/tests/credential_providers/token.rs     |  92 +++++++++++++++++
 10 files changed, 414 insertions(+), 85 deletions(-)

diff --git a/services/google/src/lib.rs b/services/google/src/lib.rs
index 79785bd..b9d1cae 100644
--- a/services/google/src/lib.rs
+++ b/services/google/src/lib.rs
@@ -27,6 +27,6 @@ pub use sign_request::RequestSigner;
 
 mod provide_credential;
 pub use provide_credential::{
-    DefaultCredentialProvider, DefaultCredentialProviderBuilder, 
StaticCredentialProvider,
-    VmMetadataCredentialProvider,
+    DefaultCredentialProvider, DefaultCredentialProviderBuilder, 
FileCredentialProvider,
+    StaticCredentialProvider, TokenCredentialProvider, 
VmMetadataCredentialProvider,
 };
diff --git a/services/google/src/provide_credential/default.rs 
b/services/google/src/provide_credential/default.rs
index 70c0b0a..576eca4 100644
--- a/services/google/src/provide_credential/default.rs
+++ b/services/google/src/provide_credential/default.rs
@@ -19,15 +19,10 @@ use log::debug;
 
 use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
 
-use crate::constants::{DEFAULT_SCOPE, GOOGLE_APPLICATION_CREDENTIALS, 
GOOGLE_SCOPE};
-use crate::credential::{Credential, CredentialFile};
+use crate::constants::GOOGLE_APPLICATION_CREDENTIALS;
+use crate::credential::Credential;
 
-use super::{
-    authorized_user::AuthorizedUserCredentialProvider,
-    external_account::ExternalAccountCredentialProvider,
-    impersonated_service_account::ImpersonatedServiceAccountCredentialProvider,
-    vm_metadata::VmMetadataCredentialProvider,
-};
+use super::{parse::parse_credential_bytes, 
vm_metadata::VmMetadataCredentialProvider};
 
 /// Default credential provider for Google Cloud Storage (GCS).
 ///
@@ -207,41 +202,6 @@ impl ProvideCredential for WellKnownAdcCredentialProvider {
     }
 }
 
-async fn parse_credential_bytes(
-    ctx: &Context,
-    content: &[u8],
-    scope_override: Option<String>,
-) -> Result<Option<Credential>> {
-    let cred_file = CredentialFile::from_slice(content)?;
-
-    let scope = scope_override
-        .or_else(|| ctx.env_var(GOOGLE_SCOPE))
-        .unwrap_or_else(|| DEFAULT_SCOPE.to_string());
-
-    match cred_file {
-        CredentialFile::ServiceAccount(sa) => {
-            debug!("loaded service account credential");
-            Ok(Some(Credential::with_service_account(sa)))
-        }
-        CredentialFile::ExternalAccount(ea) => {
-            debug!("loaded external account credential, exchanging for token");
-            let provider = 
ExternalAccountCredentialProvider::new(ea).with_scope(&scope);
-            provider.provide_credential(ctx).await
-        }
-        CredentialFile::ImpersonatedServiceAccount(isa) => {
-            debug!("loaded impersonated service account credential, exchanging 
for token");
-            let provider =
-                
ImpersonatedServiceAccountCredentialProvider::new(isa).with_scope(&scope);
-            provider.provide_credential(ctx).await
-        }
-        CredentialFile::AuthorizedUser(au) => {
-            debug!("loaded authorized user credential, exchanging for token");
-            let provider = AuthorizedUserCredentialProvider::new(au);
-            provider.provide_credential(ctx).await
-        }
-    }
-}
-
 /// Builder for `DefaultCredentialProvider`.
 ///
 /// Use `configure_vm_metadata` to customize VM metadata behavior and
diff --git a/services/google/src/provide_credential/file.rs 
b/services/google/src/provide_credential/file.rs
new file mode 100644
index 0000000..f779e21
--- /dev/null
+++ b/services/google/src/provide_credential/file.rs
@@ -0,0 +1,58 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use log::debug;
+
+use reqsign_core::{Context, ProvideCredential, Result};
+
+use crate::credential::Credential;
+
+use super::parse::parse_credential_bytes;
+
+/// FileCredentialProvider loads Google credentials from an explicit 
credential file path.
+#[derive(Debug, Clone)]
+pub struct FileCredentialProvider {
+    path: String,
+    scope: Option<String>,
+}
+
+impl FileCredentialProvider {
+    /// Create a new FileCredentialProvider from a credential file path.
+    pub fn new(path: impl Into<String>) -> Self {
+        Self {
+            path: path.into(),
+            scope: None,
+        }
+    }
+
+    /// Set the OAuth2 scope.
+    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
+        self.scope = Some(scope.into());
+        self
+    }
+}
+
+impl ProvideCredential for FileCredentialProvider {
+    type Credential = Credential;
+
+    async fn provide_credential(&self, ctx: &Context) -> 
Result<Option<Self::Credential>> {
+        debug!("loading credential from file path: {}", self.path);
+
+        let content = ctx.file_read(&self.path).await?;
+        parse_credential_bytes(ctx, &content, self.scope.clone()).await
+    }
+}
diff --git a/services/google/src/provide_credential/mod.rs 
b/services/google/src/provide_credential/mod.rs
index d181afe..f61c487 100644
--- a/services/google/src/provide_credential/mod.rs
+++ b/services/google/src/provide_credential/mod.rs
@@ -19,12 +19,20 @@ mod default;
 #[allow(unused_imports)]
 pub use default::{DefaultCredentialProvider, DefaultCredentialProviderBuilder};
 
+mod file;
+pub use file::FileCredentialProvider;
+
+mod parse;
+
 mod vm_metadata;
 pub use vm_metadata::VmMetadataCredentialProvider;
 
 mod static_provider;
 pub use static_provider::StaticCredentialProvider;
 
+mod token;
+pub use token::TokenCredentialProvider;
+
 // Internal providers - not exported
 mod authorized_user;
 mod external_account;
diff --git a/services/google/src/provide_credential/parse.rs 
b/services/google/src/provide_credential/parse.rs
new file mode 100644
index 0000000..f120629
--- /dev/null
+++ b/services/google/src/provide_credential/parse.rs
@@ -0,0 +1,64 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use log::debug;
+
+use reqsign_core::{Context, ProvideCredential, Result};
+
+use crate::constants::{DEFAULT_SCOPE, GOOGLE_SCOPE};
+use crate::credential::{Credential, CredentialFile};
+
+use super::{
+    authorized_user::AuthorizedUserCredentialProvider,
+    external_account::ExternalAccountCredentialProvider,
+    impersonated_service_account::ImpersonatedServiceAccountCredentialProvider,
+};
+
+pub(super) async fn parse_credential_bytes(
+    ctx: &Context,
+    content: &[u8],
+    scope_override: Option<String>,
+) -> Result<Option<Credential>> {
+    let cred_file = CredentialFile::from_slice(content)?;
+
+    let scope = scope_override
+        .or_else(|| ctx.env_var(GOOGLE_SCOPE))
+        .unwrap_or_else(|| DEFAULT_SCOPE.to_string());
+
+    match cred_file {
+        CredentialFile::ServiceAccount(sa) => {
+            debug!("loaded service account credential");
+            Ok(Some(Credential::with_service_account(sa)))
+        }
+        CredentialFile::ExternalAccount(ea) => {
+            debug!("loaded external account credential, exchanging for token");
+            let provider = 
ExternalAccountCredentialProvider::new(ea).with_scope(&scope);
+            provider.provide_credential(ctx).await
+        }
+        CredentialFile::ImpersonatedServiceAccount(isa) => {
+            debug!("loaded impersonated service account credential, exchanging 
for token");
+            let provider =
+                
ImpersonatedServiceAccountCredentialProvider::new(isa).with_scope(&scope);
+            provider.provide_credential(ctx).await
+        }
+        CredentialFile::AuthorizedUser(au) => {
+            debug!("loaded authorized user credential, exchanging for token");
+            let provider = AuthorizedUserCredentialProvider::new(au);
+            provider.provide_credential(ctx).await
+        }
+    }
+}
diff --git a/services/google/src/provide_credential/static_provider.rs 
b/services/google/src/provide_credential/static_provider.rs
index f7ea3e7..fc241b1 100644
--- a/services/google/src/provide_credential/static_provider.rs
+++ b/services/google/src/provide_credential/static_provider.rs
@@ -19,13 +19,9 @@ use log::debug;
 
 use reqsign_core::{Context, ProvideCredential, Result, hash::base64_decode};
 
-use crate::credential::{Credential, CredentialFile};
+use crate::credential::Credential;
 
-use super::{
-    authorized_user::AuthorizedUserCredentialProvider,
-    external_account::ExternalAccountCredentialProvider,
-    impersonated_service_account::ImpersonatedServiceAccountCredentialProvider,
-};
+use super::parse::parse_credential_bytes;
 
 /// StaticCredentialProvider loads credentials from a JSON string provided at 
construction time.
 #[derive(Debug, Clone)]
@@ -70,40 +66,12 @@ impl ProvideCredential for StaticCredentialProvider {
     async fn provide_credential(&self, ctx: &Context) -> 
Result<Option<Self::Credential>> {
         debug!("loading credential from static content");
 
-        let cred_file = 
CredentialFile::from_slice(self.content.as_bytes()).map_err(|err| {
-            debug!("failed to parse credential from content: {err:?}");
-            err
-        })?;
-
-        // Get scope from instance or environment
-        let scope = self
-            .scope
-            .clone()
-            .or_else(|| ctx.env_var(crate::constants::GOOGLE_SCOPE))
-            .unwrap_or_else(|| crate::constants::DEFAULT_SCOPE.to_string());
-
-        match cred_file {
-            CredentialFile::ServiceAccount(sa) => {
-                debug!("loaded service account credential");
-                Ok(Some(Credential::with_service_account(sa)))
-            }
-            CredentialFile::ExternalAccount(ea) => {
-                debug!("loaded external account credential, exchanging for 
token");
-                let provider = 
ExternalAccountCredentialProvider::new(ea).with_scope(&scope);
-                provider.provide_credential(ctx).await
-            }
-            CredentialFile::ImpersonatedServiceAccount(isa) => {
-                debug!("loaded impersonated service account credential, 
exchanging for token");
-                let provider =
-                    
ImpersonatedServiceAccountCredentialProvider::new(isa).with_scope(&scope);
-                provider.provide_credential(ctx).await
-            }
-            CredentialFile::AuthorizedUser(au) => {
-                debug!("loaded authorized user credential, exchanging for 
token");
-                let provider = AuthorizedUserCredentialProvider::new(au);
-                provider.provide_credential(ctx).await
-            }
-        }
+        parse_credential_bytes(ctx, self.content.as_bytes(), 
self.scope.clone())
+            .await
+            .map_err(|err| {
+                debug!("failed to parse credential from content: {err:?}");
+                err
+            })
     }
 }
 
diff --git a/services/google/src/provide_credential/token.rs 
b/services/google/src/provide_credential/token.rs
new file mode 100644
index 0000000..104c07a
--- /dev/null
+++ b/services/google/src/provide_credential/token.rs
@@ -0,0 +1,113 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use log::debug;
+use std::time::Duration;
+
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, ProvideCredential, Result};
+
+use crate::credential::{Credential, Token};
+
+#[derive(Debug, Clone)]
+enum TokenSource {
+    Inline(String),
+    Path(String),
+}
+
+#[derive(Debug, Clone, Copy)]
+enum Expiration {
+    At(Timestamp),
+    In(Duration),
+}
+
+/// TokenCredentialProvider loads a raw OAuth access token from memory or a 
file path.
+#[derive(Debug, Clone)]
+pub struct TokenCredentialProvider {
+    source: TokenSource,
+    expiration: Option<Expiration>,
+}
+
+impl TokenCredentialProvider {
+    /// Create a new TokenCredentialProvider from an access token string.
+    pub fn new(access_token: impl Into<String>) -> Self {
+        Self {
+            source: TokenSource::Inline(access_token.into()),
+            expiration: None,
+        }
+    }
+
+    /// Create a new TokenCredentialProvider from a token file path.
+    pub fn from_path(path: impl Into<String>) -> Self {
+        Self {
+            source: TokenSource::Path(path.into()),
+            expiration: None,
+        }
+    }
+
+    /// Set an absolute expiration time for the token.
+    pub fn with_expires_at(mut self, expires_at: Timestamp) -> Self {
+        self.expiration = Some(Expiration::At(expires_at));
+        self
+    }
+
+    /// Set a relative expiration duration for the token.
+    ///
+    /// The expiration is evaluated when credentials are loaded.
+    pub fn with_expires_in(mut self, expires_in: Duration) -> Self {
+        self.expiration = Some(Expiration::In(expires_in));
+        self
+    }
+
+    fn build_token(&self, access_token: String) -> Result<Credential> {
+        let access_token = access_token.trim().to_string();
+        if access_token.is_empty() {
+            return Err(Error::credential_invalid("access token is empty"));
+        }
+
+        let expires_at = self.expiration.map(|expiration| match expiration {
+            Expiration::At(ts) => ts,
+            Expiration::In(duration) => Timestamp::now() + duration,
+        });
+
+        Ok(Credential::with_token(Token {
+            access_token,
+            expires_at,
+        }))
+    }
+}
+
+impl ProvideCredential for TokenCredentialProvider {
+    type Credential = Credential;
+
+    async fn provide_credential(&self, ctx: &Context) -> 
Result<Option<Self::Credential>> {
+        let access_token = match &self.source {
+            TokenSource::Inline(access_token) => {
+                debug!("loading access token from static content");
+                access_token.clone()
+            }
+            TokenSource::Path(path) => {
+                debug!("loading access token from file path: {path}");
+                let content = ctx.file_read(path).await?;
+                String::from_utf8(content)
+                    .map_err(|e| Error::unexpected("invalid UTF-8 in token 
file").with_source(e))?
+            }
+        };
+
+        self.build_token(access_token).map(Some)
+    }
+}
diff --git a/services/google/tests/credential_providers/file.rs 
b/services/google/tests/credential_providers/file.rs
new file mode 100644
index 0000000..205b52e
--- /dev/null
+++ b/services/google/tests/credential_providers/file.rs
@@ -0,0 +1,64 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::create_test_context;
+use reqsign_core::{ProvideCredential, Result};
+use reqsign_google::FileCredentialProvider;
+use std::env;
+
+#[tokio::test]
+async fn test_file_credential_provider() -> Result<()> {
+    let path = format!(
+        "{}/testdata/test_credential.json",
+        env::current_dir()
+            .expect("current_dir must exist")
+            .to_string_lossy()
+    );
+
+    let ctx = create_test_context();
+    let provider = FileCredentialProvider::new(path);
+
+    let credential = provider
+        .provide_credential(&ctx)
+        .await?
+        .expect("credential must be provided");
+
+    assert!(credential.has_service_account());
+    let sa = credential.service_account.as_ref().unwrap();
+    assert_eq!("[email protected]", sa.client_email);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn test_file_credential_provider_missing_file() {
+    let path = format!(
+        "{}/testdata/does-not-exist.json",
+        env::current_dir()
+            .expect("current_dir must exist")
+            .to_string_lossy()
+    );
+
+    let ctx = create_test_context();
+    let provider = FileCredentialProvider::new(path);
+
+    let err = provider
+        .provide_credential(&ctx)
+        .await
+        .expect_err("missing file must fail");
+    assert_eq!(reqsign_core::ErrorKind::Unexpected, err.kind());
+}
diff --git a/services/google/tests/credential_providers/mod.rs 
b/services/google/tests/credential_providers/mod.rs
index 58b7b60..f2409a8 100644
--- a/services/google/tests/credential_providers/mod.rs
+++ b/services/google/tests/credential_providers/mod.rs
@@ -18,8 +18,10 @@
 mod authorized_user;
 mod default;
 mod external_account;
+mod file;
 mod impersonated_service_account;
 mod static_provider;
+mod token;
 mod vm_metadata;
 
 use reqsign_core::{Context, OsEnv, StaticEnv};
diff --git a/services/google/tests/credential_providers/token.rs 
b/services/google/tests/credential_providers/token.rs
new file mode 100644
index 0000000..70790dd
--- /dev/null
+++ b/services/google/tests/credential_providers/token.rs
@@ -0,0 +1,92 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::create_test_context;
+use reqsign_core::time::Timestamp;
+use reqsign_core::{ErrorKind, ProvideCredential, Result};
+use reqsign_google::TokenCredentialProvider;
+use std::fs;
+use std::path::PathBuf;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+fn temp_token_file() -> PathBuf {
+    let nanos = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("system time must be after unix epoch")
+        .as_nanos();
+
+    std::env::temp_dir().join(format!("reqsign-google-token-{nanos}.txt"))
+}
+
+#[tokio::test]
+async fn test_token_credential_provider_inline() -> Result<()> {
+    let ctx = create_test_context();
+    let expires_at = Timestamp::now() + Duration::from_secs(3600);
+    let provider = 
TokenCredentialProvider::new("test-access-token").with_expires_at(expires_at);
+
+    let credential = provider
+        .provide_credential(&ctx)
+        .await?
+        .expect("credential must be provided");
+
+    assert!(credential.has_token());
+    assert!(credential.has_valid_token());
+
+    let token = credential.token.as_ref().unwrap();
+    assert_eq!("test-access-token", token.access_token);
+    assert_eq!(Some(expires_at), token.expires_at);
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn test_token_credential_provider_from_path() -> Result<()> {
+    let path = temp_token_file();
+    fs::write(&path, "test-access-token\n").expect("token file must be 
written");
+
+    let ctx = create_test_context();
+    let provider = TokenCredentialProvider::from_path(path.to_string_lossy())
+        .with_expires_in(Duration::from_secs(3600));
+
+    let credential = provider
+        .provide_credential(&ctx)
+        .await?
+        .expect("credential must be provided");
+
+    fs::remove_file(&path).expect("token file must be removed");
+
+    assert!(credential.has_token());
+    assert!(credential.has_valid_token());
+
+    let token = credential.token.as_ref().unwrap();
+    assert_eq!("test-access-token", token.access_token);
+    assert!(token.expires_at.is_some());
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn test_token_credential_provider_empty_token() {
+    let ctx = create_test_context();
+    let provider = TokenCredentialProvider::new("   ");
+
+    let err = provider
+        .provide_credential(&ctx)
+        .await
+        .expect_err("empty token must fail");
+    assert_eq!(ErrorKind::CredentialInvalid, err.kind());
+}

Reply via email to