You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/03/18 09:40:40 UTC

(opendal-reqsign) branch xuanwo/google-file-token-providers created (now 97dd64a)

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

xuanwo pushed a change to branch xuanwo/google-file-token-providers
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git


      at 97dd64a  Merge remote-tracking branch 'origin/main' into xuanwo/google-file-token-providers

This branch includes the following new commits:

     new f32dab1  feat(google): add explicit file and token providers
     new 97dd64a  Merge remote-tracking branch 'origin/main' into xuanwo/google-file-token-providers

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(opendal-reqsign) 02/02: Merge remote-tracking branch 'origin/main' into xuanwo/google-file-token-providers

Posted by xu...@apache.org.
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 97dd64a4d21c48883edcd790a11ba4b06b3dd393
Merge: f32dab1 561a93a
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Wed Mar 18 17:40:24 2026 +0800

    Merge remote-tracking branch 'origin/main' into xuanwo/google-file-token-providers
    
    # Conflicts:
    #       services/google/src/lib.rs

 .github/workflows/aws_v4.yml                       | 30 ++++----
 .github/workflows/azure_storage.yml                | 30 ++++----
 .github/workflows/google.yml                       | 30 ++++----
 .github/workflows/tencent_cos.yml                  |  2 +-
 Cargo.toml                                         |  1 -
 services/google/Cargo.toml                         |  1 -
 services/google/src/lib.rs                         |  4 +-
 services/google/src/provide_credential/default.rs  | 54 +++++++++++++-
 .../google/src/provide_credential/vm_metadata.rs   | 82 +++++++++++++++++++++-
 services/google/src/sign_request.rs                |  3 +-
 services/oracle/src/lib.rs                         |  6 +-
 services/oracle/src/provide_credential/default.rs  | 50 ++++++++++++-
 services/tencent-cos/src/lib.rs                    |  2 +-
 .../volcengine-tos/src/provide_credential/mod.rs   |  2 +-
 14 files changed, 236 insertions(+), 61 deletions(-)

diff --cc services/google/src/lib.rs
index db75cf8,79785bd..b9d1cae
--- a/services/google/src/lib.rs
+++ b/services/google/src/lib.rs
@@@ -27,6 -27,6 +27,6 @@@ pub use sign_request::RequestSigner
  
  mod provide_credential;
  pub use provide_credential::{
-     DefaultCredentialProvider, FileCredentialProvider, StaticCredentialProvider,
-     TokenCredentialProvider, VmMetadataCredentialProvider,
 -    DefaultCredentialProvider, DefaultCredentialProviderBuilder, StaticCredentialProvider,
 -    VmMetadataCredentialProvider,
++    DefaultCredentialProvider, DefaultCredentialProviderBuilder, FileCredentialProvider,
++    StaticCredentialProvider, TokenCredentialProvider, VmMetadataCredentialProvider,
  };


(opendal-reqsign) 01/02: feat(google): add explicit file and token providers

Posted by xu...@apache.org.
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 <gi...@xuanwo.io>
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!("test-234@test.iam.gserviceaccount.com", 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());
+}