This is an automated email from the ASF dual-hosted git repository. xuanwo pushed a commit to branch xuanwo/google-file-token-providers in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
commit f32dab1c9ffd2ccf3b106138522cf302fed50a01 Author: Xuanwo <[email protected]> AuthorDate: Wed Mar 18 17:39:03 2026 +0800 feat(google): add explicit file and token providers --- services/google/src/lib.rs | 3 +- 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(+), 84 deletions(-) diff --git a/services/google/src/lib.rs b/services/google/src/lib.rs index 2b3cd06..db75cf8 100644 --- a/services/google/src/lib.rs +++ b/services/google/src/lib.rs @@ -27,5 +27,6 @@ pub use sign_request::RequestSigner; mod provide_credential; pub use provide_credential::{ - DefaultCredentialProvider, StaticCredentialProvider, VmMetadataCredentialProvider, + DefaultCredentialProvider, FileCredentialProvider, StaticCredentialProvider, + TokenCredentialProvider, VmMetadataCredentialProvider, }; diff --git a/services/google/src/provide_credential/default.rs b/services/google/src/provide_credential/default.rs index cf5d570..230bb15 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()); +}
