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());
+}