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/07/22 09:02:00 UTC
(opendal-reqsign) branch xuanwo/credential-validity-contract created (now 64ba819)
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a change to branch xuanwo/credential-validity-contract
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
at 64ba819 feat: define credential validity contracts
This branch includes the following new commits:
new 64ba819 feat: define credential validity contracts
The 1 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) 01/01: feat: define credential validity contracts
Posted by xu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch xuanwo/credential-validity-contract
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
commit 64ba819296b19ba6febc87092ed8c9da3c676ae9
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Wed Jul 22 17:01:42 2026 +0800
feat: define credential validity contracts
---
core/src/api.rs | 101 +++++++-
core/src/lib.rs | 6 +
core/src/signer.rs | 278 +++++++++++++++++++--
services/aliyun-oss/src/credential.rs | 47 +++-
.../src/provide_credential/assume_role.rs | 9 +-
services/aliyun-oss/src/sign_request.rs | 42 +++-
services/aws-v4/src/credential.rs | 43 +++-
services/aws-v4/src/sign_request.rs | 177 ++++++++++++-
services/azure-storage/src/credential.rs | 27 +-
services/azure-storage/src/sign_request.rs | 125 +++++++--
services/google/src/credential.rs | 48 +++-
services/google/src/sign_request.rs | 211 +++++++++++++++-
services/huaweicloud-obs/src/credential.rs | 21 +-
services/huaweicloud-obs/src/sign_request.rs | 31 ++-
services/oracle/src/credential.rs | 53 ++--
services/oracle/src/sign_request.rs | 16 +-
services/tencent-cos/src/credential.rs | 33 ++-
services/tencent-cos/src/sign_request.rs | 49 +++-
services/volcengine-tos/src/credential.rs | 17 ++
services/volcengine-tos/src/sign_request.rs | 21 +-
20 files changed, 1234 insertions(+), 121 deletions(-)
diff --git a/core/src/api.rs b/core/src/api.rs
index 56976a9..cb03aae 100644
--- a/core/src/api.rs
+++ b/core/src/api.rs
@@ -22,15 +22,20 @@ use std::future::Future;
use std::ops::Deref;
use std::time::Duration;
-/// SigningCredential is the trait used by signer as the signing credential.
+/// A credential that can distinguish cache freshness from exact usability.
+///
+/// Both checks must reject credentials that lack fields required for authentication.
pub trait SigningCredential: Clone + Debug + Send + Sync + Unpin + 'static {
- /// Check if the signing credential is valid.
+ /// Return whether a cached credential can be reused without refreshing it.
///
- /// Note: this will be removed in favor of `is_valid_at` in a future
- /// release.
+ /// Implementations may include a proactive refresh window in this check.
fn is_valid(&self) -> bool;
- /// Check if the signing credential will be valid at the given timestamp.
+ /// Return whether the credential is usable at this exact timestamp.
+ ///
+ /// Implementations with an expiration time should not add a refresh or
+ /// operation-specific buffer here. The default preserves the behavior of
+ /// implementations that only provide [`SigningCredential::is_valid`].
fn is_valid_at(&self, _ts: Timestamp) -> bool {
self.is_valid()
}
@@ -44,6 +49,14 @@ impl<T: SigningCredential> SigningCredential for Option<T> {
ctx.is_valid()
}
+
+ fn is_valid_at(&self, ts: Timestamp) -> bool {
+ let Some(ctx) = self else {
+ return false;
+ };
+
+ ctx.is_valid_at(ts)
+ }
}
/// ProvideCredential is the trait used by signer to load the credential from the environment.
@@ -116,6 +129,22 @@ pub trait SignRequest: Debug + Send + Sync + Unpin + 'static {
/// Typically, it will be a credential.
type Credential: Send + Sync + Unpin + 'static;
+ /// Return the timestamp through which the credential must remain usable
+ /// for the requested signing operation.
+ ///
+ /// Implementations own the signing clock, the service-specific meaning of
+ /// `expires_in`, and any transport, RPC, or artifact-lifetime headroom. This
+ /// method must not perform I/O or mutate state. When a deadline depends on an
+ /// artifact's signing time, [`SignRequest::sign_request`] must derive both the
+ /// deadline check and the artifact from the same captured timestamp.
+ fn required_valid_until(
+ &self,
+ _credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ Timestamp::now() + expires_in.unwrap_or_default()
+ }
+
/// Sign a request head.
///
/// On `Err`, an implementation must leave the entire request head unchanged. On
@@ -127,6 +156,10 @@ pub trait SignRequest: Debug + Send + Sync + Unpin + 'static {
/// ## Credential
///
/// The `credential` parameter is the credential required by the signer to sign the request.
+ /// Implementations with expiring credentials must validate it against
+ /// [`SignRequest::required_valid_until`] before mutating the request or performing
+ /// external signing calls. [`crate::Signer`] performs the same validation before
+ /// invoking this method, while direct callers rely on the implementation.
///
/// ## Expires In
///
@@ -148,6 +181,15 @@ pub trait SignRequestDyn: Debug + Send + Sync + Unpin + 'static {
/// Credential used by this builder.
type Credential: Send + Sync + Unpin + 'static;
+ /// Dyn version of [`SignRequest::required_valid_until`].
+ fn required_valid_until_dyn(
+ &self,
+ _credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ Timestamp::now() + expires_in.unwrap_or_default()
+ }
+
/// Dyn version of [`SignRequest::sign_request`].
fn sign_request_dyn<'a>(
&'a self,
@@ -164,6 +206,14 @@ where
{
type Credential = T::Credential;
+ fn required_valid_until_dyn(
+ &self,
+ credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ self.required_valid_until(credential, expires_in)
+ }
+
fn sign_request_dyn<'a>(
&'a self,
ctx: &'a Context,
@@ -181,6 +231,15 @@ where
{
type Credential = T::Credential;
+ fn required_valid_until(
+ &self,
+ credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ self.deref()
+ .required_valid_until_dyn(credential, expires_in)
+ }
+
async fn sign_request(
&self,
ctx: &Context,
@@ -327,3 +386,35 @@ where
Ok(None)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[derive(Clone, Debug)]
+ struct ExactCredential {
+ valid_at: Timestamp,
+ }
+
+ impl SigningCredential for ExactCredential {
+ fn is_valid(&self) -> bool {
+ false
+ }
+
+ fn is_valid_at(&self, timestamp: Timestamp) -> bool {
+ self.valid_at == timestamp
+ }
+ }
+
+ #[test]
+ fn option_forwards_exact_validity_check() {
+ let timestamp = Timestamp::from_second(42).expect("timestamp must be valid");
+ let credential = Some(ExactCredential {
+ valid_at: timestamp,
+ });
+
+ assert!(credential.is_valid_at(timestamp));
+ assert!(!credential.is_valid_at(timestamp + Duration::from_secs(1)));
+ assert!(!None::<ExactCredential>.is_valid_at(timestamp));
+ }
+}
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 247c327..d453ea1 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -50,6 +50,12 @@
//! header and query authentication. The service and credential type determine the
//! authentication mode.
//!
+//! [`SigningCredential::is_valid`] controls whether a cached credential can be reused
+//! without refresh. [`SigningCredential::is_valid_at`] checks exact usability at the
+//! timestamp returned by [`SignRequest::required_valid_until`]. A refreshed credential
+//! only needs to satisfy the exact operation requirement; provider errors are returned
+//! without retrying internally or falling back to the old cached credential.
+//!
//! ## Example
//!
//! ```no_run
diff --git a/core/src/signer.rs b/core/src/signer.rs
index e3c8e61..7a4aa41 100644
--- a/core/src/signer.rs
+++ b/core/src/signer.rs
@@ -23,7 +23,6 @@ use crate::Result;
use crate::SignRequest;
use crate::SignRequestDyn;
use crate::SigningCredential;
-use crate::time::Timestamp;
use std::any::type_name;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -92,30 +91,57 @@ impl<K: SigningCredential> Signer<K> {
/// `expires_in` is a service-specific validity input and does not universally
/// select query authentication. The configured service signer and credential type
/// determine how it is interpreted.
+ ///
+ /// Cached credentials must be fresh according to [`SigningCredential::is_valid`]
+ /// and usable through [`SignRequest::required_valid_until`]. A refreshed credential
+ /// only needs to satisfy the exact operation deadline. Provider errors are returned
+ /// without internal retry or fallback to the previous cached credential.
pub async fn sign(
&self,
req: &mut http::request::Parts,
expires_in: Option<Duration>,
) -> Result<()> {
let credential = self.credential.lock().expect("lock poisoned").clone();
- let credential = if credential.is_valid()
- && expires_in.is_none_or(|d| credential.is_valid_at(Timestamp::now() + d))
- {
- credential
- } else {
- let ctx = self.loader.provide_credential_dyn(&self.ctx).await?;
- *self.credential.lock().expect("lock poisoned") = ctx.clone();
- ctx
- };
+ let credential = match credential {
+ Some(credential)
+ if credential.is_valid()
+ && credential.is_valid_at(
+ self.builder
+ .required_valid_until_dyn(&credential, expires_in),
+ ) =>
+ {
+ credential
+ }
+ _ => {
+ let credential = self
+ .loader
+ .provide_credential_dyn(&self.ctx)
+ .await?
+ .ok_or_else(|| {
+ Error::credential_invalid("failed to load signing credential")
+ .with_context(format!("credential_type: {}", type_name::<K>()))
+ })?;
- let credential_ref = credential.as_ref().ok_or_else(|| {
- Error::credential_invalid("failed to load signing credential")
- .with_context(format!("credential_type: {}", type_name::<K>()))
- })?;
+ *self.credential.lock().expect("lock poisoned") = Some(credential.clone());
+
+ let required_until = self
+ .builder
+ .required_valid_until_dyn(&credential, expires_in);
+ if !credential.is_valid_at(required_until) {
+ return Err(Error::credential_invalid(
+ "signing credential is not valid for the requested operation",
+ )
+ .with_context(format!("credential_type: {}", type_name::<K>()))
+ .with_context(format!("required_valid_until: {required_until}")));
+ }
+
+ credential
+ }
+ };
let mut candidate = req.clone();
self.builder
- .sign_request_dyn(&self.ctx, &mut candidate, Some(credential_ref), expires_in)
+ .sign_request_dyn(&self.ctx, &mut candidate, Some(&credential), expires_in)
.await?;
req.uri = candidate.uri;
@@ -127,8 +153,11 @@ impl<K: SigningCredential> Signer<K> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::{ProvideCredential, SignRequest};
+ use crate::time::Timestamp;
+ use crate::{ErrorKind, ProvideCredential, SignRequest};
use http::{HeaderValue, Method, Request, Version};
+ use std::collections::VecDeque;
+ use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone, Debug)]
struct TestCredential;
@@ -186,6 +215,93 @@ mod tests {
}
}
+ #[derive(Clone, Debug)]
+ struct ExpiringCredential {
+ generation: u8,
+ fresh: bool,
+ expires_at: Timestamp,
+ required_until: Timestamp,
+ }
+
+ impl SigningCredential for ExpiringCredential {
+ fn is_valid(&self) -> bool {
+ self.fresh
+ }
+
+ fn is_valid_at(&self, timestamp: Timestamp) -> bool {
+ self.expires_at > timestamp
+ }
+ }
+
+ #[derive(Debug)]
+ struct SequenceProvider {
+ responses: Mutex<VecDeque<Result<Option<ExpiringCredential>>>>,
+ calls: Arc<AtomicUsize>,
+ }
+
+ impl SequenceProvider {
+ fn new(
+ responses: impl IntoIterator<Item = Result<Option<ExpiringCredential>>>,
+ ) -> (Self, Arc<AtomicUsize>) {
+ let calls = Arc::new(AtomicUsize::new(0));
+ (
+ Self {
+ responses: Mutex::new(responses.into_iter().collect()),
+ calls: calls.clone(),
+ },
+ calls,
+ )
+ }
+ }
+
+ impl ProvideCredential for SequenceProvider {
+ type Credential = ExpiringCredential;
+
+ async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ self.responses
+ .lock()
+ .expect("lock poisoned")
+ .pop_front()
+ .unwrap_or(Ok(None))
+ }
+ }
+
+ #[derive(Debug)]
+ struct OperationSigner;
+
+ impl SignRequest for OperationSigner {
+ type Credential = ExpiringCredential;
+
+ fn required_valid_until(
+ &self,
+ credential: &Self::Credential,
+ _expires_in: Option<Duration>,
+ ) -> Timestamp {
+ credential.required_until
+ }
+
+ async fn sign_request(
+ &self,
+ _ctx: &Context,
+ req: &mut http::request::Parts,
+ credential: Option<&Self::Credential>,
+ expires_in: Option<Duration>,
+ ) -> Result<()> {
+ let credential = credential.expect("credential must be present");
+ if !credential.is_valid_at(self.required_valid_until(credential, expires_in)) {
+ return Err(Error::credential_invalid(
+ "credential is not valid for operation",
+ ));
+ }
+ req.headers.insert(
+ "x-credential-generation",
+ credential.generation.to_string().parse()?,
+ );
+ Ok(())
+ }
+ }
+
fn request_parts() -> http::request::Parts {
let mut parts = Request::get("https://example.com/original?x=%2F")
.version(Version::HTTP_11)
@@ -251,4 +367,134 @@ mod tests {
);
assert!(!parts.headers.contains_key("x-original"));
}
+
+ #[test]
+ fn refreshes_cached_credential_for_operation_requirement() {
+ let base = Timestamp::from_second(1_000).expect("timestamp must be valid");
+ let cached = ExpiringCredential {
+ generation: 1,
+ fresh: true,
+ expires_at: base + Duration::from_secs(20),
+ required_until: base + Duration::from_secs(30),
+ };
+ let refreshed = ExpiringCredential {
+ generation: 2,
+ fresh: true,
+ expires_at: base + Duration::from_secs(20),
+ required_until: base + Duration::from_secs(10),
+ };
+ let (provider, calls) = SequenceProvider::new([Ok(Some(refreshed))]);
+ let signer = Signer::new(Context::new(), provider, OperationSigner);
+ *signer.credential.lock().expect("lock poisoned") = Some(cached);
+
+ let mut parts = request_parts();
+ futures::executor::block_on(signer.sign(&mut parts, None))
+ .expect("refreshed credential must satisfy the recomputed requirement");
+
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ assert_eq!(
+ parts.headers.get("x-credential-generation"),
+ Some(&HeaderValue::from_static("2"))
+ );
+ }
+
+ #[test]
+ fn uses_refreshed_credential_that_is_usable_but_not_fresh() {
+ let base = Timestamp::from_second(2_000).expect("timestamp must be valid");
+ let credential = ExpiringCredential {
+ generation: 1,
+ fresh: false,
+ expires_at: base + Duration::from_secs(30),
+ required_until: base + Duration::from_secs(10),
+ };
+ let (provider, calls) =
+ SequenceProvider::new([Ok(Some(credential.clone())), Ok(Some(credential))]);
+ let signer = Signer::new(Context::new(), provider, OperationSigner);
+
+ for _ in 0..2 {
+ let mut parts = request_parts();
+ futures::executor::block_on(signer.sign(&mut parts, None))
+ .expect("usable refreshed credential must be accepted");
+ }
+
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
+ }
+
+ #[test]
+ fn refresh_error_does_not_fall_back_and_caller_can_retry() {
+ let base = Timestamp::from_second(3_000).expect("timestamp must be valid");
+ let cached = ExpiringCredential {
+ generation: 1,
+ fresh: false,
+ expires_at: base + Duration::from_secs(30),
+ required_until: base + Duration::from_secs(10),
+ };
+ let refreshed = ExpiringCredential {
+ generation: 2,
+ fresh: true,
+ expires_at: base + Duration::from_secs(30),
+ required_until: base + Duration::from_secs(10),
+ };
+ let (provider, calls) = SequenceProvider::new([
+ Err(Error::unexpected("injected refresh failure")),
+ Ok(Some(refreshed)),
+ ]);
+ let signer = Signer::new(Context::new(), provider, OperationSigner);
+ *signer.credential.lock().expect("lock poisoned") = Some(cached);
+
+ let mut parts = request_parts();
+ let original = parts.clone();
+ let err = futures::executor::block_on(signer.sign(&mut parts, None))
+ .expect_err("refresh error must be returned");
+ assert_eq!(err.kind(), ErrorKind::Unexpected);
+ assert_eq!(parts.uri, original.uri);
+ assert_eq!(parts.headers, original.headers);
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+
+ futures::executor::block_on(signer.sign(&mut parts, None))
+ .expect("caller retry must attempt refresh again");
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
+ assert_eq!(
+ parts.headers.get("x-credential-generation"),
+ Some(&HeaderValue::from_static("2"))
+ );
+ }
+
+ #[test]
+ fn missing_refresh_does_not_fall_back_and_caller_can_retry() {
+ let base = Timestamp::from_second(4_000).expect("timestamp must be valid");
+ let cached = ExpiringCredential {
+ generation: 1,
+ fresh: false,
+ expires_at: base + Duration::from_secs(30),
+ required_until: base + Duration::from_secs(10),
+ };
+ let refreshed = ExpiringCredential {
+ generation: 2,
+ fresh: true,
+ expires_at: base + Duration::from_secs(30),
+ required_until: base + Duration::from_secs(10),
+ };
+ let (provider, calls) = SequenceProvider::new([Ok(None), Ok(Some(refreshed))]);
+ let signer = Signer::new(Context::new(), provider, OperationSigner);
+ *signer.credential.lock().expect("lock poisoned") = Some(cached);
+ let mut parts = request_parts();
+ let original = parts.clone();
+
+ let err = futures::executor::block_on(signer.sign(&mut parts, None))
+ .expect_err("missing credential must fail");
+
+ assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ assert_eq!(parts.uri, original.uri);
+ assert_eq!(parts.headers, original.headers);
+
+ futures::executor::block_on(signer.sign(&mut parts, None))
+ .expect("caller retry must attempt refresh again");
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
+ assert_eq!(
+ parts.headers.get("x-credential-generation"),
+ Some(&HeaderValue::from_static("2"))
+ );
+ }
}
diff --git a/services/aliyun-oss/src/credential.rs b/services/aliyun-oss/src/credential.rs
index 495ce51..17ecb70 100644
--- a/services/aliyun-oss/src/credential.rs
+++ b/services/aliyun-oss/src/credential.rs
@@ -47,19 +47,44 @@ impl Debug for Credential {
impl SigningCredential for Credential {
fn is_valid(&self) -> bool {
- if (self.access_key_id.is_empty() || self.access_key_secret.is_empty())
- && self.security_token.is_none()
- {
+ self.is_valid_at(Timestamp::now() + Duration::from_secs(120))
+ }
+
+ fn is_valid_at(&self, timestamp: Timestamp) -> bool {
+ if self.access_key_id.is_empty() || self.access_key_secret.is_empty() {
return false;
}
- // Take 120s as buffer to avoid edge cases.
- if let Some(valid) = self
- .expires_in
- .map(|v| v > Timestamp::now() + Duration::from_secs(120))
- {
- return valid;
- }
- true
+ self.expires_in.is_none_or(|expires| expires > timestamp)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn separates_cache_freshness_from_exact_validity() {
+ let now = Timestamp::now();
+ let credential = Credential {
+ access_key_id: "access-key".to_string(),
+ access_key_secret: "secret-key".to_string(),
+ security_token: Some("token".to_string()),
+ expires_in: Some(now + Duration::from_secs(30)),
+ };
+
+ assert!(!credential.is_valid());
+ assert!(credential.is_valid_at(now + Duration::from_secs(10)));
+ assert!(!credential.is_valid_at(now + Duration::from_secs(30)));
+ }
+
+ #[test]
+ fn security_token_does_not_replace_signing_keys() {
+ let credential = Credential {
+ security_token: Some("token".to_string()),
+ ..Default::default()
+ };
+
+ assert!(!credential.is_valid_at(Timestamp::now()));
}
}
diff --git a/services/aliyun-oss/src/provide_credential/assume_role.rs b/services/aliyun-oss/src/provide_credential/assume_role.rs
index 6c51ebe..97c1c57 100644
--- a/services/aliyun-oss/src/provide_credential/assume_role.rs
+++ b/services/aliyun-oss/src/provide_credential/assume_role.rs
@@ -615,6 +615,7 @@ mod tests {
envs: HashMap::new(),
});
+ let signing_time: Timestamp = "2024-03-05T06:07:08Z".parse().unwrap();
let provider = AssumeRoleCredentialProvider::new()
.with_base_provider(TestBaseCredentialProvider::new(Some(Credential {
access_key_id: "base-ak".to_string(),
@@ -625,9 +626,13 @@ mod tests {
.with_role_arn("acs:ram::123456789012:role/test-role")
.with_role_session_name("test-session")
.with_sts_endpoint("https://sts.example.com")
- .with_time("2024-03-05T06:07:08Z".parse().unwrap())
+ .with_time(signing_time)
.with_signature_nonce("test-nonce");
- let signer = Signer::new(ctx, provider, RequestSigner::new("test-bucket"));
+ let signer = Signer::new(
+ ctx,
+ provider,
+ RequestSigner::new("test-bucket").with_time(signing_time),
+ );
let mut first_req =
http::Request::get("https://test-bucket.oss-cn-beijing.aliyuncs.com/object")
diff --git a/services/aliyun-oss/src/sign_request.rs b/services/aliyun-oss/src/sign_request.rs
index 929635b..b5906df 100644
--- a/services/aliyun-oss/src/sign_request.rs
+++ b/services/aliyun-oss/src/sign_request.rs
@@ -24,13 +24,14 @@ use reqsign_core::hash::{
base64_hmac_sha1, base64_hmac_sha256, hex_hmac_sha256, hex_sha256, hmac_sha256,
};
use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, Error, SignRequest, SigningRequest};
+use reqsign_core::{Context, Error, SignRequest, SigningCredential, SigningRequest};
use std::collections::HashSet;
use std::fmt::Write;
use std::sync::LazyLock;
use std::time::Duration;
const CONTENT_MD5: &str = "content-md5";
+const CREDENTIAL_OPERATION_HEADROOM: Duration = Duration::from_secs(10);
const IF_MODIFIED_SINCE: &str = "if-modified-since";
const OSS_V2_ALGORITHM: &str = "OSS2";
const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
@@ -138,10 +139,26 @@ impl RequestSigner {
fn get_time(&self) -> Timestamp {
self.time.unwrap_or_else(Timestamp::now)
}
+
+ fn required_valid_until_at(
+ &self,
+ signing_time: Timestamp,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ signing_time + expires_in.unwrap_or(CREDENTIAL_OPERATION_HEADROOM)
+ }
}
impl SignRequest for RequestSigner {
type Credential = Credential;
+ fn required_valid_until(
+ &self,
+ _credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ self.required_valid_until_at(self.get_time(), expires_in)
+ }
+
async fn sign_request(
&self,
_ctx: &Context,
@@ -154,6 +171,13 @@ impl SignRequest for RequestSigner {
};
let signing_time = self.get_time();
+ let required_until = self.required_valid_until_at(signing_time, expires_in);
+ if !cred.is_valid_at(required_until) {
+ return Err(Error::credential_invalid(
+ "credential is not valid for the requested signing operation",
+ ));
+ }
+
let mut candidate = req.clone();
match self.signing_version {
@@ -1193,6 +1217,22 @@ mod tests {
Timestamp::from_second(1_717_332_000).expect("timestamp must be valid")
}
+ #[test]
+ fn header_deadline_includes_transport_headroom() {
+ let now: Timestamp = "2026-07-22T00:00:00Z".parse().unwrap();
+ let signer = RequestSigner::new("bucket").with_time(now);
+ let credential = Credential::default();
+
+ assert_eq!(
+ signer.required_valid_until(&credential, None),
+ now + CREDENTIAL_OPERATION_HEADROOM
+ );
+ assert_eq!(
+ signer.required_valid_until(&credential, Some(Duration::from_secs(3600))),
+ now + Duration::from_secs(3600)
+ );
+ }
+
#[test]
fn test_header_string_to_sign_includes_implicit_security_token_header() {
let req = Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object.txt")
diff --git a/services/aws-v4/src/credential.rs b/services/aws-v4/src/credential.rs
index 5068eb9..89e3224 100644
--- a/services/aws-v4/src/credential.rs
+++ b/services/aws-v4/src/credential.rs
@@ -47,21 +47,44 @@ impl Debug for Credential {
impl SigningCredential for Credential {
fn is_valid(&self) -> bool {
- self.is_valid_at(Timestamp::now())
+ self.is_valid_at(Timestamp::now() + Duration::from_secs(120))
}
- fn is_valid_at(&self, ts: Timestamp) -> bool {
- if (self.access_key_id.is_empty() || self.secret_access_key.is_empty())
- && self.session_token.is_none()
- {
+ fn is_valid_at(&self, timestamp: Timestamp) -> bool {
+ if self.access_key_id.is_empty() || self.secret_access_key.is_empty() {
return false;
}
- // Take 120s as buffer to avoid edge cases.
- if let Some(valid) = self.expires_in.map(|v| v > ts + Duration::from_secs(120)) {
- return valid;
- }
+ self.expires_in.is_none_or(|expires| expires > timestamp)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn separates_cache_freshness_from_exact_validity() {
+ let now = Timestamp::now();
+ let credential = Credential {
+ access_key_id: "access-key".to_string(),
+ secret_access_key: "secret-key".to_string(),
+ session_token: Some("token".to_string()),
+ expires_in: Some(now + Duration::from_secs(30)),
+ };
+
+ assert!(!credential.is_valid());
+ assert!(credential.is_valid_at(now + Duration::from_secs(10)));
+ assert!(!credential.is_valid_at(now + Duration::from_secs(30)));
+ }
+
+ #[test]
+ fn session_token_does_not_replace_signing_keys() {
+ let credential = Credential {
+ session_token: Some("token".to_string()),
+ ..Default::default()
+ };
- true
+ assert!(!credential.is_valid_at(Timestamp::now()));
}
}
diff --git a/services/aws-v4/src/sign_request.rs b/services/aws-v4/src/sign_request.rs
index f2c49de..60bc919 100644
--- a/services/aws-v4/src/sign_request.rs
+++ b/services/aws-v4/src/sign_request.rs
@@ -26,10 +26,12 @@ use log::debug;
use percent_encoding::{percent_decode_str, utf8_percent_encode};
use reqsign_core::hash::{hex_hmac_sha256, hex_sha256, hmac_sha256};
use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, Result, SignRequest, SigningRequest};
+use reqsign_core::{Context, Result, SignRequest, SigningCredential, SigningRequest};
use std::fmt::Write;
use std::time::Duration;
+const CREDENTIAL_OPERATION_HEADROOM: Duration = Duration::from_secs(10);
+
/// RequestSigner that implement AWS SigV4.
///
/// - [Signature Version 4 signing process](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html)
@@ -63,10 +65,30 @@ impl RequestSigner {
self.time = Some(time);
self
}
+
+ fn get_time(&self) -> Timestamp {
+ self.time.unwrap_or_else(Timestamp::now)
+ }
+
+ fn required_valid_until_at(
+ &self,
+ signing_time: Timestamp,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ signing_time + expires_in.unwrap_or(CREDENTIAL_OPERATION_HEADROOM)
+ }
}
impl SignRequest for RequestSigner {
type Credential = Credential;
+ fn required_valid_until(
+ &self,
+ _credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ self.required_valid_until_at(self.get_time(), expires_in)
+ }
+
async fn sign_request(
&self,
_: &Context,
@@ -78,7 +100,14 @@ impl SignRequest for RequestSigner {
return Ok(());
};
- let now = self.time.unwrap_or_else(Timestamp::now);
+ let now = self.get_time();
+ let required_until = self.required_valid_until_at(now, expires_in);
+ if !cred.is_valid_at(required_until) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "credential is not valid for the requested signing operation",
+ ));
+ }
+
let original_uri = req.uri.clone();
let mut signed_req = SigningRequest::build(req)?;
@@ -429,12 +458,154 @@ mod tests {
use aws_sigv4::sign::v4;
use http::Request;
use http::header;
- use reqsign_core::ProvideCredential;
+ use reqsign_core::{ErrorKind, ProvideCredential, Signer};
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
+ use std::collections::VecDeque;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::{Arc, Mutex};
const RAW_QUERY: &str = "slash=%2F&hash=%23&=%26&equals=%3D&space=%20&encoded-plus=%2B&literal-plus=+&double=%252F&dup=first&dup=second&=empty-key&empty=&flag&flag=&";
+ #[derive(Debug)]
+ struct SequenceProvider {
+ credentials: Mutex<VecDeque<Credential>>,
+ calls: Arc<AtomicUsize>,
+ }
+
+ impl ProvideCredential for SequenceProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(
+ &self,
+ _ctx: &Context,
+ ) -> reqsign_core::Result<Option<Self::Credential>> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ Ok(self.credentials.lock().unwrap().pop_front())
+ }
+ }
+
+ #[test]
+ fn presign_deadline_uses_signing_time_and_requested_expiry() {
+ let now: Timestamp = "2026-07-22T00:00:00Z"
+ .parse()
+ .expect("timestamp must parse");
+ let signer = RequestSigner::new("s3", "test").with_time(now);
+ let credential = Credential::default();
+
+ assert_eq!(
+ signer.required_valid_until(&credential, Some(Duration::from_secs(3600))),
+ now + Duration::from_secs(3600)
+ );
+ assert_eq!(
+ signer.required_valid_until(&credential, None),
+ now + CREDENTIAL_OPERATION_HEADROOM
+ );
+ }
+
+ #[tokio::test]
+ async fn presign_refreshes_credential_that_cannot_cover_url_lifetime() -> Result<()> {
+ let now = Timestamp::now();
+ let short_lived = Credential {
+ access_key_id: "short-lived-ak".to_string(),
+ secret_access_key: "short-lived-sk".to_string(),
+ session_token: Some("short-lived-token".to_string()),
+ expires_in: Some(now + Duration::from_secs(600)),
+ };
+ let long_lived = Credential {
+ access_key_id: "long-lived-ak".to_string(),
+ secret_access_key: "long-lived-sk".to_string(),
+ session_token: Some("long-lived-token".to_string()),
+ expires_in: Some(now + Duration::from_secs(7200)),
+ };
+ let calls = Arc::new(AtomicUsize::new(0));
+ let provider = SequenceProvider {
+ credentials: Mutex::new(VecDeque::from([short_lived, long_lived])),
+ calls: calls.clone(),
+ };
+ let signer = Signer::new(
+ Context::new(),
+ provider,
+ RequestSigner::new("s3", "test").with_time(now),
+ );
+
+ let mut header_parts = Request::get("https://example.com/object")
+ .body(())?
+ .into_parts()
+ .0;
+ signer.sign(&mut header_parts, None).await?;
+
+ let mut query_parts = Request::get("https://example.com/object")
+ .body(())?
+ .into_parts()
+ .0;
+ signer
+ .sign(&mut query_parts, Some(Duration::from_secs(3600)))
+ .await?;
+
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
+ assert!(
+ query_parts
+ .uri
+ .query()
+ .expect("presigned query must exist")
+ .contains("X-Amz-Credential=long-lived-ak%2F")
+ );
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn presign_rejects_refreshed_credential_that_cannot_cover_url_lifetime() -> Result<()> {
+ let now = Timestamp::now();
+ let short_lived = Credential {
+ access_key_id: "short-lived-ak".to_string(),
+ secret_access_key: "short-lived-sk".to_string(),
+ session_token: Some("short-lived-token".to_string()),
+ expires_in: Some(now + Duration::from_secs(600)),
+ };
+ let calls = Arc::new(AtomicUsize::new(0));
+ let provider = SequenceProvider {
+ credentials: Mutex::new(VecDeque::from([short_lived])),
+ calls: calls.clone(),
+ };
+ let signer = Signer::new(
+ Context::new(),
+ provider,
+ RequestSigner::new("s3", "test").with_time(now),
+ );
+ let mut parts = Request::get("https://example.com/object")
+ .body(())?
+ .into_parts()
+ .0;
+ let original = parts.clone();
+
+ let err = signer
+ .sign(&mut parts, Some(Duration::from_secs(3600)))
+ .await
+ .expect_err("credential must cover the entire presigned URL lifetime");
+
+ assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ assert_eq!(parts.uri, original.uri);
+ assert_eq!(parts.headers, original.headers);
+
+ let mut header_parts = Request::get("https://example.com/object")
+ .body(())?
+ .into_parts()
+ .0;
+ signer.sign(&mut header_parts, None).await?;
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ assert!(
+ header_parts
+ .headers
+ .get(header::AUTHORIZATION)
+ .expect("authorization header must exist")
+ .to_str()?
+ .contains("Credential=short-lived-ak/")
+ );
+ Ok(())
+ }
+
/// (name, request_builder)
type TestCase = (&'static str, fn() -> Request<&'static str>);
diff --git a/services/azure-storage/src/credential.rs b/services/azure-storage/src/credential.rs
index ae64b13..d164491 100644
--- a/services/azure-storage/src/credential.rs
+++ b/services/azure-storage/src/credential.rs
@@ -71,6 +71,10 @@ impl Debug for Credential {
impl SigningCredential for Credential {
fn is_valid(&self) -> bool {
+ self.is_valid_at(Timestamp::now() + Duration::from_secs(20))
+ }
+
+ fn is_valid_at(&self, timestamp: Timestamp) -> bool {
match self {
Credential::SharedKey {
account_name,
@@ -81,12 +85,7 @@ impl SigningCredential for Credential {
if token.is_empty() {
return false;
}
- // Check expiration for bearer tokens (take 20s as buffer to avoid edge cases)
- if let Some(expires) = expires_in {
- *expires > Timestamp::now() + Duration::from_secs(20)
- } else {
- true
- }
+ expires_in.is_none_or(|expires| expires > timestamp)
}
}
}
@@ -116,3 +115,19 @@ impl Credential {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn separates_bearer_cache_freshness_from_exact_validity() {
+ let now = Timestamp::now();
+ let credential =
+ Credential::with_bearer_token("token", Some(now + Duration::from_secs(10)));
+
+ assert!(!credential.is_valid());
+ assert!(credential.is_valid_at(now + Duration::from_secs(5)));
+ assert!(!credential.is_valid_at(now + Duration::from_secs(10)));
+ }
+}
diff --git a/services/azure-storage/src/sign_request.rs b/services/azure-storage/src/sign_request.rs
index 9a773a5..ae18af5 100644
--- a/services/azure-storage/src/sign_request.rs
+++ b/services/azure-storage/src/sign_request.rs
@@ -23,11 +23,15 @@ use log::debug;
use percent_encoding::{percent_decode_str, percent_encode};
use reqsign_core::hash::{base64_decode, base64_hmac_sha256};
use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, Result, SignRequest, SigningMethod, SigningRequest};
+use reqsign_core::{
+ Context, Result, SignRequest, SigningCredential, SigningMethod, SigningRequest,
+};
use std::fmt::Write;
use std::sync::Mutex;
use std::time::Duration;
+const BEARER_TOKEN_OPERATION_HEADROOM: Duration = Duration::from_secs(20);
+
/// Resource kind required by SAS generation.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SasResourceKind {
@@ -173,6 +177,39 @@ impl RequestSigner {
self.time = Some(time);
self
}
+
+ fn get_time(&self) -> Timestamp {
+ self.time.unwrap_or_else(Timestamp::now)
+ }
+
+ fn required_valid_until_at(
+ &self,
+ credential: &Credential,
+ signing_time: Timestamp,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ match credential {
+ Credential::BearerToken { .. } => {
+ let cached_key_covers_operation = expires_in.is_some_and(|expires| {
+ self.user_delegation_key_cache
+ .lock()
+ .expect("lock poisoned")
+ .as_ref()
+ .is_some_and(|key| {
+ key.signed_expiry
+ > signing_time + expires + BEARER_TOKEN_OPERATION_HEADROOM
+ })
+ });
+
+ if cached_key_covers_operation {
+ signing_time
+ } else {
+ signing_time + BEARER_TOKEN_OPERATION_HEADROOM
+ }
+ }
+ Credential::SharedKey { .. } | Credential::SasToken { .. } => signing_time,
+ }
+ }
}
impl Default for RequestSigner {
@@ -183,6 +220,14 @@ impl Default for RequestSigner {
impl SignRequest for RequestSigner {
type Credential = Credential;
+ fn required_valid_until(
+ &self,
+ credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ self.required_valid_until_at(credential, self.get_time(), expires_in)
+ }
+
async fn sign_request(
&self,
context: &Context,
@@ -194,6 +239,13 @@ impl SignRequest for RequestSigner {
return Ok(());
};
+ let signing_time = self.get_time();
+ let required_until = self.required_valid_until_at(cred, signing_time, expires_in);
+ if !cred.is_valid_at(required_until) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "credential is not valid for the requested signing operation",
+ ));
+ }
let method = if let Some(expires_in) = expires_in {
SigningMethod::Query(expires_in)
} else {
@@ -220,8 +272,7 @@ impl SignRequest for RequestSigner {
));
};
- let now_time = self.time.unwrap_or_else(Timestamp::now);
- let expiry = now_time + d;
+ let expiry = signing_time + d;
let resource = service_sas_resource(&sctx.path)?;
match (cfg.resource, &resource) {
@@ -259,10 +310,10 @@ impl SignRequest for RequestSigner {
scheme: sctx.scheme.as_str(),
authority: sctx.authority.as_str(),
bearer_token: token,
- start: now_time,
+ start: signing_time,
expiry,
service_version: version,
- now: now_time,
+ now: signing_time,
},
)
.await?;
@@ -280,10 +331,10 @@ impl SignRequest for RequestSigner {
scheme: sctx.scheme.as_str(),
authority: sctx.authority.as_str(),
bearer_token: token,
- start: now_time,
+ start: signing_time,
expiry,
service_version: version,
- now: now_time,
+ now: signing_time,
},
)
.await?;
@@ -327,7 +378,7 @@ impl SignRequest for RequestSigner {
SigningMethod::Header => {
sctx.headers.insert(
X_MS_DATE,
- Timestamp::now().format_http_date().parse().map_err(|e| {
+ signing_time.format_http_date().parse().map_err(|e| {
reqsign_core::Error::unexpected("failed to parse date header")
.with_source(e)
})?,
@@ -353,7 +404,6 @@ impl SignRequest for RequestSigner {
// Shared key authentication
match method {
SigningMethod::Query(d) => {
- let now_time = self.time.unwrap_or_else(Timestamp::now);
let Some(permissions) = &self.service_sas_permissions else {
return Err(reqsign_core::Error::request_invalid(
"Service SAS permissions are required for presign",
@@ -367,7 +417,7 @@ impl SignRequest for RequestSigner {
account_key.clone(),
resource,
permissions.to_string(),
- now_time + d,
+ signing_time + d,
);
if let Some(start) = self.service_sas_start {
signer = signer.with_start(start);
@@ -389,8 +439,7 @@ impl SignRequest for RequestSigner {
final_uri = Some(append_query_pairs(&original_uri, &signer_token)?);
}
SigningMethod::Header => {
- let now_time = self.time.unwrap_or_else(Timestamp::now);
- let string_to_sign = string_to_sign(&mut sctx, account_name, now_time)?;
+ let string_to_sign = string_to_sign(&mut sctx, account_name, signing_time)?;
let decode_content = base64_decode(account_key).map_err(|e| {
reqsign_core::Error::unexpected("failed to decode account key")
.with_source(e)
@@ -729,10 +778,31 @@ mod tests {
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
use std::str::FromStr;
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
const RAW_QUERY: &str = "slash=%2F&hash=%23&=%26&equals=%3D&space=%20&encoded-plus=%2B&literal-plus=+&double=%252F&dup=first&dup=second&=empty-key&empty=&flag&flag=&";
+ #[test]
+ fn bearer_deadline_covers_rpc_not_generated_sas_lifetime() {
+ let now: Timestamp = "2026-07-22T00:00:00Z"
+ .parse()
+ .expect("timestamp must parse");
+ let signer = RequestSigner::new().with_time(now);
+ let bearer = Credential::with_bearer_token("token", None);
+ let shared_key = Credential::with_shared_key("account", "key");
+
+ assert_eq!(
+ signer.required_valid_until(&bearer, Some(Duration::from_secs(3600))),
+ now + BEARER_TOKEN_OPERATION_HEADROOM
+ );
+ assert_eq!(
+ signer.required_valid_until(&shared_key, Some(Duration::from_secs(3600))),
+ now
+ );
+ }
+
#[test]
fn canonical_resource_decodes_query_once_without_form_semantics() {
let mut parts = Request::get(
@@ -881,13 +951,16 @@ mod tests {
);
}
- #[derive(Debug)]
- struct MockUserDelegationHttpSend;
+ #[derive(Clone, Debug, Default)]
+ struct MockUserDelegationHttpSend {
+ calls: Arc<AtomicUsize>,
+ }
impl HttpSend for MockUserDelegationHttpSend {
async fn http_send(
&self,
req: http::Request<Bytes>,
) -> reqsign_core::Result<http::Response<Bytes>> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
if req.method() != http::Method::POST {
return Err(reqsign_core::Error::unexpected("unexpected request method")
.with_context(req.method().to_string()));
@@ -987,9 +1060,10 @@ mod tests {
async fn test_bearer_token_presign_user_delegation_sas() {
let now = Timestamp::from_str("2022-03-01T08:12:34Z").unwrap();
+ let http_send = MockUserDelegationHttpSend::default();
let ctx = Context::new()
.with_file_read(TokioFileRead)
- .with_http_send(MockUserDelegationHttpSend)
+ .with_http_send(http_send.clone())
.with_env(OsEnv);
let cred = Credential::with_bearer_token("token", None);
@@ -1018,5 +1092,26 @@ mod tests {
"{original_uri}sv=2020-12-06&se=2022-03-01T08%3A17%3A34Z&sp=r&sr=b&skoid=oid&sktid=tid&skt=2022-03-01T08%3A12%3A34Z&ske=2022-03-01T09%3A12%3A34Z&sks=b&skv=2020-12-06&sig=VkI3h/LWkD6qcDzshjQzCuCdMPDCFA3tMEbxM%2BED5Nc%3D"
)
);
+
+ let near_expiry_cred =
+ Credential::with_bearer_token("token", Some(now + Duration::from_secs(5)));
+ assert_eq!(
+ builder.required_valid_until(&near_expiry_cred, Some(Duration::from_secs(300))),
+ now
+ );
+
+ let req = Request::builder().uri(&original_uri).body(()).unwrap();
+ let (mut cached_parts, _) = req.into_parts();
+ builder
+ .sign_request(
+ &ctx,
+ &mut cached_parts,
+ Some(&near_expiry_cred),
+ Some(Duration::from_secs(300)),
+ )
+ .await
+ .unwrap();
+ assert_eq!(cached_parts.uri, parts.uri);
+ assert_eq!(http_send.calls.load(Ordering::SeqCst), 1);
}
}
diff --git a/services/google/src/credential.rs b/services/google/src/credential.rs
index 9a895b6..42440ce 100644
--- a/services/google/src/credential.rs
+++ b/services/google/src/credential.rs
@@ -19,6 +19,8 @@ use reqsign_core::{Result, SigningCredential as KeyTrait, time::Timestamp, utils
use std::fmt::{self, Debug};
use std::time::Duration;
+const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(120);
+
/// ServiceAccount holds the client email and private key for service account authentication.
#[derive(Clone, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -38,6 +40,12 @@ impl Debug for ServiceAccount {
}
}
+impl ServiceAccount {
+ pub(crate) fn is_valid(&self) -> bool {
+ !self.private_key.is_empty() && !self.client_email.is_empty()
+ }
+}
+
/// ImpersonatedServiceAccount holds the source credentials for impersonation.
#[derive(Clone, serde::Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
@@ -238,18 +246,16 @@ impl Debug for Token {
impl KeyTrait for Token {
fn is_valid(&self) -> bool {
+ self.is_valid_at(Timestamp::now() + TOKEN_REFRESH_BUFFER)
+ }
+
+ fn is_valid_at(&self, timestamp: Timestamp) -> bool {
if self.access_token.is_empty() {
return false;
}
- match self.expires_at {
- Some(expires_at) => {
- // Consider token invalid if it expires within 2 minutes
- let buffer = Duration::from_secs(120);
- Timestamp::now() < expires_at - buffer
- }
- None => true, // No expiration means always valid
- }
+ self.expires_at
+ .is_none_or(|expires_at| expires_at > timestamp)
}
}
@@ -309,8 +315,20 @@ impl Credential {
impl KeyTrait for Credential {
fn is_valid(&self) -> bool {
- // A credential is valid if it has a service account or a valid token
- self.service_account.is_some() || self.has_valid_token()
+ self.service_account
+ .as_ref()
+ .is_some_and(ServiceAccount::is_valid)
+ || self.has_valid_token()
+ }
+
+ fn is_valid_at(&self, timestamp: Timestamp) -> bool {
+ self.service_account
+ .as_ref()
+ .is_some_and(ServiceAccount::is_valid)
+ || self
+ .token
+ .as_ref()
+ .is_some_and(|token| token.is_valid_at(timestamp))
}
}
@@ -384,10 +402,12 @@ mod tests {
// Token that expires within 2 minutes
token.expires_at = Some(Timestamp::now() + Duration::from_secs(30));
assert!(!token.is_valid());
+ assert!(token.is_valid_at(Timestamp::now() + Duration::from_secs(10)));
// Expired token
token.expires_at = Some(Timestamp::now() - Duration::from_secs(3600));
assert!(!token.is_valid());
+ assert!(!token.is_valid_at(Timestamp::now()));
// Empty access token
token.access_token = String::new();
@@ -528,6 +548,14 @@ mod tests {
assert!(cred.has_service_account());
assert!(!cred.has_token());
+ // Incomplete service account
+ let cred = Credential::with_service_account(ServiceAccount {
+ client_email: String::new(),
+ private_key: "key".to_string(),
+ });
+ assert!(!cred.is_valid());
+ assert!(!cred.is_valid_at(Timestamp::now()));
+
// Valid token only
let cred = Credential::with_token(Token {
access_token: "test".to_string(),
diff --git a/services/google/src/sign_request.rs b/services/google/src/sign_request.rs
index 5a49240..50be214 100644
--- a/services/google/src/sign_request.rs
+++ b/services/google/src/sign_request.rs
@@ -32,6 +32,8 @@ use reqsign_core::{
use crate::constants::{DEFAULT_SCOPE, GOOG_QUERY_ENCODE_SET, GOOG_URI_ENCODE_SET, GOOGLE_SCOPE};
use crate::credential::{Credential, ServiceAccount, Token};
+const TOKEN_OPERATION_HEADROOM: Duration = Duration::from_secs(10);
+
/// Claims is used to build JWT for Google Cloud.
#[derive(Debug, Serialize)]
struct Claims {
@@ -132,6 +134,10 @@ impl RequestSigner {
self
}
+ fn token_required_until(&self) -> Timestamp {
+ Timestamp::now() + TOKEN_OPERATION_HEADROOM
+ }
+
/// Exchange a service account for an access token.
///
/// This method is used internally when a token is needed but only a service account
@@ -378,6 +384,22 @@ impl RequestSigner {
impl SignRequest for RequestSigner {
type Credential = Credential;
+ fn required_valid_until(
+ &self,
+ credential: &Self::Credential,
+ _expires_in: Option<Duration>,
+ ) -> Timestamp {
+ if credential
+ .service_account
+ .as_ref()
+ .is_some_and(ServiceAccount::is_valid)
+ {
+ Timestamp::now()
+ } else {
+ self.token_required_until()
+ }
+ }
+
async fn sign_request(
&self,
ctx: &Context,
@@ -389,17 +411,28 @@ impl SignRequest for RequestSigner {
return Ok(());
};
+ let required_until = self.required_valid_until(cred, expires_in);
+ if !cred.is_valid_at(required_until) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "credential is not valid for the requested signing operation",
+ ));
+ }
+
let (signing_req, final_uri) = match expires_in {
// Query signing - prefer ServiceAccount, otherwise use IAMCredentials signBlob if possible.
Some(expires) => {
- if let Some(sa) = cred.service_account.as_ref() {
+ if let Some(sa) = cred
+ .service_account
+ .as_ref()
+ .filter(|service_account| service_account.is_valid())
+ {
let (signing_req, uri) =
self.build_signed_query_with_service_account(req, sa, expires)?;
(signing_req, Some(uri))
} else if let (Some(token), Some(signer_email)) =
(cred.token.as_ref(), self.signer_email.as_deref())
{
- if !token.is_valid() {
+ if !token.is_valid_at(required_until) {
return Err(reqsign_core::Error::credential_invalid(
"token required for iamcredentials signBlob query signing",
));
@@ -423,24 +456,42 @@ impl SignRequest for RequestSigner {
}
// Header authentication - prefer valid token, otherwise exchange from SA
None => {
- // Check if we have a valid token
+ let token_required_until = self.token_required_until();
if let Some(token) = &cred.token {
- if token.is_valid() {
+ if token.is_valid_at(token_required_until) {
(self.build_token_auth(req, token)?, None)
- } else if let Some(sa) = &cred.service_account {
+ } else if let Some(sa) = cred
+ .service_account
+ .as_ref()
+ .filter(|service_account| service_account.is_valid())
+ {
// Token expired, but we have SA, exchange for new token
debug!("token expired, exchanging service account for new token");
let new_token = self.exchange_token(ctx, sa).await?;
+ if !new_token.is_valid_at(self.token_required_until()) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "exchanged token is not valid long enough for header authentication",
+ ));
+ }
(self.build_token_auth(req, &new_token)?, None)
} else {
return Err(reqsign_core::Error::credential_invalid(
"token expired and no service account available",
));
}
- } else if let Some(sa) = &cred.service_account {
+ } else if let Some(sa) = cred
+ .service_account
+ .as_ref()
+ .filter(|service_account| service_account.is_valid())
+ {
// No token but have SA, exchange for token
debug!("no token available, exchanging service account for token");
let token = self.exchange_token(ctx, sa).await?;
+ if !token.is_valid_at(self.token_required_until()) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "exchanged token is not valid long enough for header authentication",
+ ));
+ }
(self.build_token_auth(req, &token)?, None)
} else {
return Err(reqsign_core::Error::credential_invalid(
@@ -633,7 +684,8 @@ mod tests {
use super::*;
use bytes::Bytes;
use http::header;
- use reqsign_core::HttpSend;
+ use reqsign_core::{ErrorKind, HttpSend, ProvideCredential, Signer};
+ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
const RAW_QUERY: &str = "slash=%2F&hash=%23&=%26&equals=%3D&space=%20&encoded-plus=%2B&literal-plus=+&double=%252F&dup=first&dup=second&=empty-key&empty=&flag&flag=&";
@@ -690,6 +742,21 @@ mod tests {
}
}
+ #[derive(Debug)]
+ struct RepeatingProvider {
+ credential: Credential,
+ calls: Arc<AtomicUsize>,
+ }
+
+ impl ProvideCredential for RepeatingProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ Ok(Some(self.credential.clone()))
+ }
+ }
+
fn query_get<'a>(query: &'a str, key: &str) -> Option<&'a str> {
query.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
@@ -717,10 +784,11 @@ mod tests {
let cred = Credential::with_token(Token {
access_token: "test-access-token".to_string(),
- expires_at: None,
+ expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
});
+ assert!(!cred.is_valid());
- let expires_in = Duration::from_secs(60);
+ let expires_in = Duration::from_secs(3600);
let original_uri =
format!("https://storage.googleapis.com/test-bucket/test%2Fobject?{RAW_QUERY}");
@@ -784,6 +852,50 @@ mod tests {
Ok(())
}
+ #[tokio::test]
+ async fn signer_refreshes_near_expiry_token_without_binding_it_to_signed_url_lifetime()
+ -> Result<()> {
+ let mock_http = MockHttpSend::default();
+ let ctx = Context::new().with_http_send(mock_http);
+ let credential = Credential::with_token(Token {
+ access_token: "test-access-token".to_string(),
+ expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
+ });
+ assert!(!credential.is_valid());
+
+ let calls = Arc::new(AtomicUsize::new(0));
+ let provider = RepeatingProvider {
+ credential,
+ calls: calls.clone(),
+ };
+ let signer = Signer::new(
+ ctx,
+ provider,
+ RequestSigner::new("storage").with_signer_email("test-signer@example.com"),
+ );
+
+ for _ in 0..2 {
+ let mut parts = http::Request::get("https://storage.googleapis.com/test-bucket/object")
+ .body(())?
+ .into_parts()
+ .0;
+
+ signer
+ .sign(&mut parts, Some(Duration::from_secs(3600)))
+ .await?;
+ assert!(
+ parts
+ .uri
+ .query()
+ .expect("signed URL query must exist")
+ .contains("X-Goog-Signature=010203")
+ );
+ }
+
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
+ Ok(())
+ }
+
#[tokio::test]
async fn bearer_authentication_preserves_uri_and_header_values() -> Result<()> {
let signer = RequestSigner::new("storage");
@@ -810,4 +922,85 @@ mod tests {
);
Ok(())
}
+
+ #[tokio::test]
+ async fn bearer_authentication_uses_token_inside_refresh_window() -> Result<()> {
+ let signer = RequestSigner::new("storage");
+ let credential = Credential::with_token(Token {
+ access_token: "test-access-token".to_string(),
+ expires_at: Some(Timestamp::now() + Duration::from_secs(30)),
+ });
+ assert!(!credential.is_valid());
+
+ let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
+ .body(())?
+ .into_parts()
+ .0;
+
+ signer
+ .sign_request(&Context::new(), &mut parts, Some(&credential), None)
+ .await?;
+
+ assert_eq!(
+ parts.headers[header::AUTHORIZATION],
+ "Bearer test-access-token"
+ );
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn signer_reuses_refreshed_token_for_operation_but_refreshes_next_time() -> Result<()> {
+ let credential = Credential::with_token(Token {
+ access_token: "test-access-token".to_string(),
+ expires_at: Some(Timestamp::now() + Duration::from_secs(30)),
+ });
+ assert!(!credential.is_valid());
+
+ let calls = Arc::new(AtomicUsize::new(0));
+ let provider = RepeatingProvider {
+ credential,
+ calls: calls.clone(),
+ };
+ let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
+
+ for _ in 0..2 {
+ let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
+ .body(())?
+ .into_parts()
+ .0;
+
+ signer.sign(&mut parts, None).await?;
+ assert_eq!(
+ parts.headers[header::AUTHORIZATION],
+ "Bearer test-access-token"
+ );
+ }
+
+ assert_eq!(calls.load(Ordering::SeqCst), 2);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn bearer_authentication_rejects_token_shorter_than_operation_headroom() -> Result<()> {
+ let signer = RequestSigner::new("storage");
+ let credential = Credential::with_token(Token {
+ access_token: "test-access-token".to_string(),
+ expires_at: Some(Timestamp::now() + Duration::from_secs(5)),
+ });
+ let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
+ .body(())?
+ .into_parts()
+ .0;
+ let original = parts.clone();
+
+ let err = signer
+ .sign_request(&Context::new(), &mut parts, Some(&credential), None)
+ .await
+ .expect_err("token must cover the operation headroom");
+
+ assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+ assert_eq!(parts.uri, original.uri);
+ assert_eq!(parts.headers, original.headers);
+ Ok(())
+ }
}
diff --git a/services/huaweicloud-obs/src/credential.rs b/services/huaweicloud-obs/src/credential.rs
index 19999a3..5693372 100644
--- a/services/huaweicloud-obs/src/credential.rs
+++ b/services/huaweicloud-obs/src/credential.rs
@@ -17,7 +17,7 @@
use std::fmt::{Debug, Formatter};
-use reqsign_core::{SigningCredential, utils::Redact};
+use reqsign_core::{SigningCredential, time::Timestamp, utils::Redact};
/// Credential for obs.
#[derive(Clone)]
@@ -60,6 +60,23 @@ impl Debug for Credential {
impl SigningCredential for Credential {
fn is_valid(&self) -> bool {
- true
+ !self.access_key_id.is_empty() && !self.secret_access_key.is_empty()
+ }
+
+ fn is_valid_at(&self, _timestamp: Timestamp) -> bool {
+ self.is_valid()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rejects_incomplete_signing_keys() {
+ let credential = Credential::new(String::new(), String::new(), Some("token".to_string()));
+
+ assert!(!credential.is_valid());
+ assert!(!credential.is_valid_at(Timestamp::now()));
}
}
diff --git a/services/huaweicloud-obs/src/sign_request.rs b/services/huaweicloud-obs/src/sign_request.rs
index a34405b..230efc0 100644
--- a/services/huaweicloud-obs/src/sign_request.rs
+++ b/services/huaweicloud-obs/src/sign_request.rs
@@ -33,7 +33,7 @@ use super::constants::*;
use super::credential::Credential;
use reqsign_core::hash::base64_hmac_sha1;
use reqsign_core::time::Timestamp;
-use reqsign_core::{SignRequest, SigningMethod, SigningRequest};
+use reqsign_core::{SignRequest, SigningCredential, SigningMethod, SigningRequest};
static OBS_QUERY_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
.remove(b'-')
@@ -70,10 +70,30 @@ impl RequestSigner {
self.time = Some(time);
self
}
+
+ fn get_time(&self) -> Timestamp {
+ self.time.unwrap_or_else(Timestamp::now)
+ }
+
+ fn required_valid_until_at(
+ &self,
+ signing_time: Timestamp,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ signing_time + expires_in.unwrap_or_default()
+ }
}
impl SignRequest for RequestSigner {
type Credential = Credential;
+ fn required_valid_until(
+ &self,
+ _credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ self.required_valid_until_at(self.get_time(), expires_in)
+ }
+
async fn sign_request(
&self,
_ctx: &reqsign_core::Context,
@@ -84,7 +104,14 @@ impl SignRequest for RequestSigner {
let Some(cred) = credential else {
return Ok(());
};
- let now = self.time.unwrap_or_else(Timestamp::now);
+
+ let now = self.get_time();
+ let required_until = self.required_valid_until_at(now, expires_in);
+ if !cred.is_valid_at(required_until) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "credential is not valid for the requested signing operation",
+ ));
+ }
let method = if let Some(expires_in) = expires_in {
SigningMethod::Query(expires_in)
diff --git a/services/oracle/src/credential.rs b/services/oracle/src/credential.rs
index d1420f6..0a078f3 100644
--- a/services/oracle/src/credential.rs
+++ b/services/oracle/src/credential.rs
@@ -32,7 +32,7 @@ pub struct Credential {
pub key_file: String,
/// Fingerprint of the API Key.
pub fingerprint: String,
- /// Expiration time for this credential.
+ /// Deadline after which the credential source should be reloaded.
pub expires_in: Option<Timestamp>,
}
@@ -48,23 +48,44 @@ impl Debug for Credential {
}
}
+impl Credential {
+ fn has_required_fields(&self) -> bool {
+ !self.tenancy.is_empty()
+ && !self.user.is_empty()
+ && !self.key_file.is_empty()
+ && !self.fingerprint.is_empty()
+ }
+}
+
impl SigningCredential for Credential {
fn is_valid(&self) -> bool {
- if self.tenancy.is_empty()
- || self.user.is_empty()
- || self.key_file.is_empty()
- || self.fingerprint.is_empty()
- {
- return false;
- }
- // Take 120s as buffer to avoid edge cases.
- if let Some(valid) = self
- .expires_in
- .map(|v| v > Timestamp::now() + Duration::from_secs(120))
- {
- return valid;
- }
+ self.has_required_fields()
+ && self
+ .expires_in
+ .is_none_or(|refresh_at| refresh_at > Timestamp::now() + Duration::from_secs(120))
+ }
+
+ fn is_valid_at(&self, _timestamp: Timestamp) -> bool {
+ self.has_required_fields()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn refresh_deadline_only_controls_cache_freshness() {
+ let now = Timestamp::now();
+ let credential = Credential {
+ tenancy: "tenancy".to_string(),
+ user: "user".to_string(),
+ key_file: "key.pem".to_string(),
+ fingerprint: "fingerprint".to_string(),
+ expires_in: Some(now + Duration::from_secs(30)),
+ };
- true
+ assert!(!credential.is_valid());
+ assert!(credential.is_valid_at(now + Duration::from_secs(3600)));
}
}
diff --git a/services/oracle/src/sign_request.rs b/services/oracle/src/sign_request.rs
index 0477a69..540975d 100644
--- a/services/oracle/src/sign_request.rs
+++ b/services/oracle/src/sign_request.rs
@@ -22,7 +22,7 @@ use http::request::Parts;
use log::debug;
use reqsign_core::Result;
use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, SignRequest, SigningRequest};
+use reqsign_core::{Context, SignRequest, SigningCredential, SigningRequest};
use rsa::pkcs1v15::SigningKey;
use rsa::sha2::Sha256;
use rsa::signature::{SignatureEncoding, Signer};
@@ -51,6 +51,14 @@ impl Default for RequestSigner {
impl SignRequest for RequestSigner {
type Credential = Credential;
+ fn required_valid_until(
+ &self,
+ _credential: &Self::Credential,
+ _expires_in: Option<Duration>,
+ ) -> Timestamp {
+ Timestamp::now()
+ }
+
async fn sign_request(
&self,
ctx: &Context,
@@ -63,6 +71,12 @@ impl SignRequest for RequestSigner {
};
let now = Timestamp::now();
+ if !cred.is_valid_at(now) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "credential is not valid for the requested signing operation",
+ ));
+ }
+
let request_target = req
.uri
.path_and_query()
diff --git a/services/tencent-cos/src/credential.rs b/services/tencent-cos/src/credential.rs
index 98ac83a..0d9a157 100644
--- a/services/tencent-cos/src/credential.rs
+++ b/services/tencent-cos/src/credential.rs
@@ -47,17 +47,34 @@ impl Debug for Credential {
impl SigningCredential for Credential {
fn is_valid(&self) -> bool {
+ self.is_valid_at(Timestamp::now() + Duration::from_secs(120))
+ }
+
+ fn is_valid_at(&self, timestamp: Timestamp) -> bool {
if self.secret_id.is_empty() || self.secret_key.is_empty() {
return false;
}
- // Take 120s as buffer to avoid edge cases.
- if let Some(valid) = self
- .expires_in
- .map(|v| v > Timestamp::now() + Duration::from_secs(120))
- {
- return valid;
- }
- true
+ self.expires_in.is_none_or(|expires| expires > timestamp)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn separates_cache_freshness_from_exact_validity() {
+ let now = Timestamp::now();
+ let credential = Credential {
+ secret_id: "secret-id".to_string(),
+ secret_key: "secret-key".to_string(),
+ security_token: Some("token".to_string()),
+ expires_in: Some(now + Duration::from_secs(30)),
+ };
+
+ assert!(!credential.is_valid());
+ assert!(credential.is_valid_at(now + Duration::from_secs(10)));
+ assert!(!credential.is_valid_at(now + Duration::from_secs(30)));
}
}
diff --git a/services/tencent-cos/src/sign_request.rs b/services/tencent-cos/src/sign_request.rs
index 114e373..0ef7677 100644
--- a/services/tencent-cos/src/sign_request.rs
+++ b/services/tencent-cos/src/sign_request.rs
@@ -24,7 +24,7 @@ use log::debug;
use percent_encoding::{percent_decode_str, utf8_percent_encode};
use reqsign_core::hash::{hex_hmac_sha1, hex_sha1};
use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, Result, SignRequest, SigningRequest};
+use reqsign_core::{Context, Result, SignRequest, SigningCredential, SigningRequest};
use std::time::Duration;
/// RequestSigner that implements Tencent COS signing.
@@ -52,10 +52,31 @@ impl RequestSigner {
self.time = Some(time);
self
}
+
+ fn get_time(&self) -> Timestamp {
+ self.time.unwrap_or_else(Timestamp::now)
+ }
+
+ fn required_valid_until_at(
+ &self,
+ signing_time: Timestamp,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ let signature_lifetime = expires_in.unwrap_or_else(|| Duration::from_secs(3600));
+ signing_time + signature_lifetime
+ }
}
impl SignRequest for RequestSigner {
type Credential = Credential;
+ fn required_valid_until(
+ &self,
+ _credential: &Self::Credential,
+ expires_in: Option<Duration>,
+ ) -> Timestamp {
+ self.required_valid_until_at(self.get_time(), expires_in)
+ }
+
async fn sign_request(
&self,
_ctx: &Context,
@@ -67,7 +88,14 @@ impl SignRequest for RequestSigner {
return Ok(());
};
- let now = self.time.unwrap_or_else(Timestamp::now);
+ let now = self.get_time();
+ let required_until = self.required_valid_until_at(now, expires_in);
+ if !cred.is_valid_at(required_until) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "credential is not valid for the requested signing operation",
+ ));
+ }
+
let original_uri = req.uri.clone();
let mut signing_req = SigningRequest::build(req)?;
@@ -231,6 +259,23 @@ mod tests {
const RAW_QUERY: &str = "versionId=a%2Bb%3Dc%2525%26e&slash=%2F&hash=%23&=%26&equals=%3D&space=%20&encoded-plus=%2B&literal-plus=+&double=%252F&dup=first&dup=second&=empty-key&empty=&flag&flag=&";
+ #[test]
+ fn deadline_matches_actual_signature_lifetime() -> Result<()> {
+ let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
+ let signer = RequestSigner::new().with_time(now);
+ let credential = Credential::default();
+
+ assert_eq!(
+ signer.required_valid_until(&credential, None),
+ now + Duration::from_secs(3600)
+ );
+ assert_eq!(
+ signer.required_valid_until(&credential, Some(Duration::from_secs(60))),
+ now + Duration::from_secs(60)
+ );
+ Ok(())
+ }
+
#[tokio::test]
async fn canonicalization_and_signing_preserve_wire_uri() -> Result<()> {
let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
diff --git a/services/volcengine-tos/src/credential.rs b/services/volcengine-tos/src/credential.rs
index c5fb9b5..f604302 100644
--- a/services/volcengine-tos/src/credential.rs
+++ b/services/volcengine-tos/src/credential.rs
@@ -16,6 +16,7 @@
// under the License.
use reqsign_core::SigningCredential;
+use reqsign_core::time::Timestamp;
use reqsign_core::utils::Redact;
use std::fmt::{Debug, Formatter};
@@ -59,4 +60,20 @@ impl SigningCredential for Credential {
fn is_valid(&self) -> bool {
!self.access_key_id.is_empty() && !self.secret_access_key.is_empty()
}
+
+ fn is_valid_at(&self, _timestamp: Timestamp) -> bool {
+ self.is_valid()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn exact_validity_checks_structure() {
+ let credential = Credential::new("", "");
+
+ assert!(!credential.is_valid_at(Timestamp::now()));
+ }
}
diff --git a/services/volcengine-tos/src/sign_request.rs b/services/volcengine-tos/src/sign_request.rs
index 603288d..5972baa 100644
--- a/services/volcengine-tos/src/sign_request.rs
+++ b/services/volcengine-tos/src/sign_request.rs
@@ -21,7 +21,7 @@ use log::debug;
use percent_encoding::percent_decode_str;
use reqsign_core::hash::{hex_hmac_sha256, hex_sha256, hmac_sha256};
use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, Result, SignRequest, SigningRequest};
+use reqsign_core::{Context, Result, SignRequest, SigningCredential, SigningRequest};
use std::fmt::Write;
use std::sync::LazyLock;
@@ -63,10 +63,22 @@ impl RequestSigner {
self.time = Some(time);
self
}
+
+ fn get_time(&self) -> Timestamp {
+ self.time.unwrap_or_else(Timestamp::now)
+ }
}
impl SignRequest for RequestSigner {
type Credential = Credential;
+ fn required_valid_until(
+ &self,
+ _credential: &Self::Credential,
+ _expires_in: Option<std::time::Duration>,
+ ) -> Timestamp {
+ self.get_time()
+ }
+
async fn sign_request(
&self,
_ctx: &Context,
@@ -78,7 +90,12 @@ impl SignRequest for RequestSigner {
return Ok(());
};
- let now = self.time.unwrap_or_else(Timestamp::now);
+ let now = self.get_time();
+ if !cred.is_valid_at(now) {
+ return Err(reqsign_core::Error::credential_invalid(
+ "credential is not valid for the requested signing operation",
+ ));
+ }
let mut signing_req = SigningRequest::build(req)?;