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/08/27 06:01:50 UTC
(opendal-reqsign) 01/01: feat(google): preserve signer identity in token credentials
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch xuanwo/google-signer-identity
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
commit f4fd1211b9a44f66c22b34ff4984a0ff04e7b208
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Thu Aug 27 14:01:24 2026 +0800
feat(google): preserve signer identity in token credentials
---
services/google/README.md | 6 ++
services/google/src/credential.rs | 66 +++++++++++-
.../src/credential_access_boundary/client_side.rs | 1 +
.../src/provide_credential/external_account.rs | 50 +++------
.../impersonated_service_account.rs | 80 +++++++++++++-
.../google/src/provide_credential/vm_metadata.rs | 18 +++-
services/google/src/sign_request.rs | 115 +++++++++++++++++++--
7 files changed, 285 insertions(+), 51 deletions(-)
diff --git a/services/google/README.md b/services/google/README.md
index 6bfcdcb..327f647 100644
--- a/services/google/README.md
+++ b/services/google/README.md
@@ -31,6 +31,12 @@ The crate also supports server-side Cloud Storage Credential Access Boundary
downscoping. Enable the `credential-access-boundary-client-side` feature for
local client-side token generation.
+For query signing, credential providers preserve a target service account email
+when they can determine it from impersonation or VM metadata configuration. The
+request signer uses that identity with IAMCredentials `signBlob`. An email set
+with `RequestSigner::with_signer_email` takes precedence over the
+provider-discovered identity.
+
## Examples
- [Credential-chain logging](examples/chain_logging.rs)
diff --git a/services/google/src/credential.rs b/services/google/src/credential.rs
index 42440ce..13bc413 100644
--- a/services/google/src/credential.rs
+++ b/services/google/src/credential.rs
@@ -259,13 +259,15 @@ impl KeyTrait for Token {
}
}
-/// Credential represents Google credentials that may contain both service account and token.
+/// Credential represents Google credentials that may contain a service account, token, and
+/// provider-discovered signer identity.
///
/// **IMPORTANT**: This is a specially designed structure that can hold both ServiceAccount
/// and Token simultaneously. This design is intentional and critical for Google's authentication:
///
/// - Service account only: Used for signed URL generation and JWT-based authentication
-/// - Token only: Used for Bearer authentication (e.g., from metadata server, OAuth2)
+/// - Token only: Used for Bearer authentication (e.g., from metadata server, OAuth2)
+/// - Token with signer email: Also supports query signing through IAMCredentials `signBlob`
/// - Both: The RequestSigner is responsible for exchanging service account for tokens when needed,
/// and can use cached tokens when available to avoid unnecessary exchanges
///
@@ -278,6 +280,10 @@ pub struct Credential {
pub service_account: Option<ServiceAccount>,
/// OAuth2 access token, if available.
pub token: Option<Token>,
+ /// Service account email authorized to sign with the token, if known by the provider.
+ ///
+ /// This identity is used only for query signing and does not affect Bearer authentication.
+ pub signer_email: Option<String>,
}
impl Credential {
@@ -286,17 +292,27 @@ impl Credential {
Self {
service_account: Some(service_account),
token: None,
+ signer_email: None,
}
}
- /// Create a credential with only a token.
+ /// Create a credential with a token.
pub fn with_token(token: Token) -> Self {
Self {
service_account: None,
token: Some(token),
+ signer_email: None,
}
}
+ /// Set the service account email authorized to sign with this credential's token.
+ ///
+ /// This identity is used only for query signing and does not affect Bearer authentication.
+ pub fn with_signer_email(mut self, signer_email: impl Into<String>) -> Self {
+ self.signer_email = Some(signer_email.into());
+ self
+ }
+
/// Check if the credential has a service account.
pub fn has_service_account(&self) -> bool {
self.service_account.is_some()
@@ -313,6 +329,37 @@ impl Credential {
}
}
+pub(crate) fn parse_service_account_impersonation_url(url: &str) -> Result<String> {
+ let marker = "/serviceAccounts/";
+ let start = url.find(marker).ok_or_else(|| {
+ reqsign_core::Error::config_invalid(format!(
+ "service_account_impersonation_url missing {marker}: {url}"
+ ))
+ })?;
+ let rest = &url[start + marker.len()..];
+ let end = rest.find(':').ok_or_else(|| {
+ reqsign_core::Error::config_invalid(format!(
+ "service_account_impersonation_url missing action separator: {url}"
+ ))
+ })?;
+
+ let email = percent_encoding::percent_decode_str(&rest[..end])
+ .decode_utf8()
+ .map_err(|e| {
+ reqsign_core::Error::config_invalid(
+ "service_account_impersonation_url contains invalid UTF-8 email",
+ )
+ .with_source(e)
+ })?;
+ if email.is_empty() {
+ return Err(reqsign_core::Error::config_invalid(
+ "service_account_impersonation_url resolved empty service account email",
+ ));
+ }
+
+ Ok(email.into_owned())
+}
+
impl KeyTrait for Credential {
fn is_valid(&self) -> bool {
self.service_account
@@ -387,6 +434,15 @@ mod tests {
assert!(result.is_err());
}
+ #[test]
+ fn test_parse_service_account_impersonation_url() {
+ let url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/signer%40example.com:generateAccessToken";
+ assert_eq!(
+ parse_service_account_impersonation_url(url).unwrap(),
+ "signer@example.com"
+ );
+ }
+
#[test]
fn test_token_is_valid() {
let mut token = Token {
@@ -565,6 +621,10 @@ mod tests {
assert!(!cred.has_service_account());
assert!(cred.has_token());
assert!(cred.has_valid_token());
+ assert!(cred.signer_email.is_none());
+
+ let cred = cred.with_signer_email("signer@example.com");
+ assert_eq!(cred.signer_email.as_deref(), Some("signer@example.com"));
// Invalid token only
let cred = Credential::with_token(Token {
diff --git a/services/google/src/credential_access_boundary/client_side.rs b/services/google/src/credential_access_boundary/client_side.rs
index d1654b6..8bbd3d9 100644
--- a/services/google/src/credential_access_boundary/client_side.rs
+++ b/services/google/src/credential_access_boundary/client_side.rs
@@ -1682,6 +1682,7 @@ mod tests {
client_email: "service@example.com".to_string(),
}),
token: Some(valid_token),
+ signer_email: None,
},
source_token("", Some(now + Duration::from_secs(2 * 60 * 60))),
source_token("source", None),
diff --git a/services/google/src/provide_credential/external_account.rs b/services/google/src/provide_credential/external_account.rs
index 0fe447c..7ff69de 100644
--- a/services/google/src/provide_credential/external_account.rs
+++ b/services/google/src/provide_credential/external_account.rs
@@ -28,7 +28,9 @@ use reqsign_aws_v4::{
};
use serde::{Deserialize, Serialize};
-use crate::credential::{Credential, ExternalAccount, Token, external_account};
+use crate::credential::{
+ Credential, ExternalAccount, Token, external_account, parse_service_account_impersonation_url,
+};
use reqsign_core::time::Timestamp;
use reqsign_core::{Context, ProvideCredential, Result, SignRequest};
@@ -574,7 +576,7 @@ impl ExternalAccountCredentialProvider {
if let Some(url) = &self.external_account.service_account_impersonation_url {
let url = resolve_template(ctx, url)?;
- let email = parse_impersonated_service_account_email(&url)?;
+ let email = parse_service_account_impersonation_url(&url)?;
envs.insert(
GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL.to_string(),
email,
@@ -932,6 +934,12 @@ impl ProvideCredential for ExternalAccountCredentialProvider {
type Credential = Credential;
async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+ let signer_email = self
+ .external_account
+ .service_account_impersonation_url
+ .as_deref()
+ .and_then(|url| parse_service_account_impersonation_url(url).ok());
+
// Load OIDC token from source
let oidc_token = self.load_oidc_token(ctx).await?;
@@ -948,7 +956,11 @@ impl ProvideCredential for ExternalAccountCredentialProvider {
sts_token
};
- Ok(Some(Credential::with_token(final_token)))
+ let credential = Credential::with_token(final_token);
+ Ok(Some(match signer_email {
+ Some(signer_email) => credential.with_signer_email(signer_email),
+ None => credential,
+ }))
}
}
@@ -991,37 +1003,6 @@ fn resolve_template(ctx: &Context, input: &str) -> Result<String> {
}
}
-fn parse_impersonated_service_account_email(url: &str) -> Result<String> {
- let marker = "/serviceAccounts/";
- let start = url.find(marker).ok_or_else(|| {
- reqsign_core::Error::config_invalid(format!(
- "service_account_impersonation_url missing {marker}: {url}"
- ))
- })?;
- let rest = &url[start + marker.len()..];
- let end = rest.find(':').ok_or_else(|| {
- reqsign_core::Error::config_invalid(format!(
- "service_account_impersonation_url missing action separator: {url}"
- ))
- })?;
-
- let email = percent_encoding::percent_decode_str(&rest[..end])
- .decode_utf8()
- .map_err(|e| {
- reqsign_core::Error::config_invalid(
- "service_account_impersonation_url contains invalid UTF-8 email",
- )
- .with_source(e)
- })?;
- if email.is_empty() {
- return Err(reqsign_core::Error::config_invalid(
- "service_account_impersonation_url resolved empty service account email",
- ));
- }
-
- Ok(email.into_owned())
-}
-
struct ResolvedAwsSource {
region_url: Option<String>,
url: Option<String>,
@@ -1591,6 +1572,7 @@ mod tests {
.await?
.expect("credential must exist");
assert!(cred.has_valid_token());
+ assert_eq!(cred.signer_email.as_deref(), Some("sa@example.com"));
assert_eq!(
sts_scope.lock().expect("lock").as_deref(),
diff --git a/services/google/src/provide_credential/impersonated_service_account.rs b/services/google/src/provide_credential/impersonated_service_account.rs
index c5e4725..d42c039 100644
--- a/services/google/src/provide_credential/impersonated_service_account.rs
+++ b/services/google/src/provide_credential/impersonated_service_account.rs
@@ -21,7 +21,9 @@ use http::header::CONTENT_TYPE;
use log::{debug, error};
use serde::{Deserialize, Serialize};
-use crate::credential::{Credential, ImpersonatedServiceAccount, Token};
+use crate::credential::{
+ Credential, ImpersonatedServiceAccount, Token, parse_service_account_impersonation_url,
+};
use reqsign_core::time::Timestamp;
use reqsign_core::{Context, ProvideCredential, Result};
@@ -216,6 +218,80 @@ impl ProvideCredential for ImpersonatedServiceAccountCredentialProvider {
// Then exchange for impersonated access token
let access_token = self.generate_access_token(ctx, &bearer_token).await?;
- Ok(Some(Credential::with_token(access_token)))
+ let credential = Credential::with_token(access_token);
+ let signer_email = parse_service_account_impersonation_url(
+ &self
+ .impersonated_service_account
+ .service_account_impersonation_url,
+ )
+ .ok();
+ Ok(Some(match signer_email {
+ Some(signer_email) => credential.with_signer_email(signer_email),
+ None => credential,
+ }))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bytes::Bytes;
+ use reqsign_core::HttpSend;
+
+ #[derive(Clone, Debug)]
+ struct MockHttpSend;
+
+ impl HttpSend for MockHttpSend {
+ async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+ let body = match req.uri().to_string().as_str() {
+ "https://oauth2.googleapis.com/token" => {
+ br#"{"access_token":"source-token","expires_in":3600}"#.as_slice()
+ }
+ "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target%40example.com:generateAccessToken" => {
+ br#"{"accessToken":"impersonated-token","expireTime":"2100-01-01T00:00:00Z"}"#
+ .as_slice()
+ }
+ uri => panic!("unexpected request: {uri}"),
+ };
+
+ Ok(http::Response::builder()
+ .status(http::StatusCode::OK)
+ .body(body.into())
+ .expect("response must build"))
+ }
+ }
+
+ #[tokio::test]
+ async fn preserves_impersonated_service_account_identity() -> Result<()> {
+ let provider = ImpersonatedServiceAccountCredentialProvider::new(
+ ImpersonatedServiceAccount {
+ service_account_impersonation_url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target%40example.com:generateAccessToken".to_string(),
+ source_credentials: crate::credential::OAuth2Credentials {
+ client_id: "client-id".to_string(),
+ client_secret: "client-secret".to_string(),
+ refresh_token: "refresh-token".to_string(),
+ },
+ delegates: Vec::new(),
+ },
+ );
+
+ let credential = provider
+ .provide_credential(&Context::new().with_http_send(MockHttpSend))
+ .await?
+ .expect("credential must exist");
+
+ assert_eq!(
+ credential.signer_email.as_deref(),
+ Some("target@example.com")
+ );
+ assert_eq!(
+ credential
+ .token
+ .as_ref()
+ .expect("token must exist")
+ .access_token,
+ "impersonated-token"
+ );
+ Ok(())
}
}
diff --git a/services/google/src/provide_credential/vm_metadata.rs b/services/google/src/provide_credential/vm_metadata.rs
index 57af156..c8f73b3 100644
--- a/services/google/src/provide_credential/vm_metadata.rs
+++ b/services/google/src/provide_credential/vm_metadata.rs
@@ -58,7 +58,8 @@ impl VmMetadataCredentialProvider {
/// Set the service account used to retrieve a token from VM metadata service.
///
- /// Defaults to `default` if not configured.
+ /// Defaults to `default` if not configured. A configured value other than `default` is also
+ /// preserved as the token credential's signer email for query signing.
pub fn with_service_account(mut self, service_account: impl Into<String>) -> Self {
self.service_account = Some(service_account.into());
self
@@ -114,10 +115,16 @@ impl ProvideCredential for VmMetadataCredentialProvider {
})?;
let expires_at = Timestamp::now() + Duration::from_secs(token_resp.expires_in);
- Ok(Some(Credential::with_token(Token {
+ let credential = Credential::with_token(Token {
access_token: token_resp.access_token,
expires_at: Some(expires_at),
- })))
+ });
+ Ok(Some(match self.service_account.as_deref() {
+ Some(service_account) if service_account != "default" => {
+ credential.with_signer_email(service_account)
+ }
+ _ => credential,
+ }))
}
}
@@ -160,6 +167,7 @@ mod tests {
.expect("credential must exist");
assert!(cred.has_token());
+ assert!(cred.signer_email.is_none());
assert_eq!(
http.uris.lock().unwrap().as_slice(),
&["http://127.0.0.1:8080/computeMetadata/v1/instance/service-accounts/default/token?scopes=https://www.googleapis.com/auth/cloud-platform".to_string()]
@@ -182,6 +190,10 @@ mod tests {
.expect("credential must exist");
assert!(cred.has_token());
+ assert_eq!(
+ cred.signer_email.as_deref(),
+ Some("custom@test-project.iam.gserviceaccount.com")
+ );
assert_eq!(
http.uris.lock().unwrap().as_slice(),
&["http://127.0.0.1:8080/computeMetadata/v1/instance/service-accounts/custom@test-project.iam.gserviceaccount.com/token?scopes=https://www.googleapis.com/auth/cloud-platform".to_string()]
diff --git a/services/google/src/sign_request.rs b/services/google/src/sign_request.rs
index dbc45a8..750ad05 100644
--- a/services/google/src/sign_request.rs
+++ b/services/google/src/sign_request.rs
@@ -122,8 +122,9 @@ impl RequestSigner {
/// Set the signer service account email used for query signing via IAMCredentials `signBlob`.
///
- /// This is required when generating signed URLs without an embedded service account private key
- /// (e.g. ADC / WIF / impersonation tokens).
+ /// This explicitly configured email takes precedence over a signer email discovered by the
+ /// credential provider. It is required when generating signed URLs from a token whose provider
+ /// cannot determine the signer identity.
pub fn with_signer_email(mut self, signer_email: impl Into<String>) -> Self {
self.signer_email = Some(signer_email.into());
self
@@ -430,9 +431,12 @@ impl SignRequest for RequestSigner {
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())
- {
+ } else if let (Some(token), Some(signer_email)) = (
+ cred.token.as_ref(),
+ self.signer_email
+ .as_deref()
+ .or(cred.signer_email.as_deref()),
+ ) {
if !token.is_valid_at(required_until) {
return Err(reqsign_core::Error::credential_invalid(
"token required for iamcredentials signBlob query signing",
@@ -451,7 +455,7 @@ impl SignRequest for RequestSigner {
(signing_req, Some(uri))
} else {
return Err(reqsign_core::Error::credential_invalid(
- "service account or token + signer_email required for query signing",
+ "service account or token + signer identity required for query signing",
));
}
}
@@ -696,16 +700,37 @@ mod tests {
payload_b64: Option<String>,
}
- #[derive(Clone, Debug, Default)]
+ #[derive(Clone, Debug)]
struct MockHttpSend {
recorded: Arc<Mutex<Recorded>>,
+ expected_signer_email: String,
}
+
+ impl Default for MockHttpSend {
+ fn default() -> Self {
+ Self {
+ recorded: Arc::default(),
+ expected_signer_email: "test-signer@example.com".to_string(),
+ }
+ }
+ }
+
+ impl MockHttpSend {
+ fn with_expected_signer_email(mut self, signer_email: &str) -> Self {
+ self.expected_signer_email = signer_email.to_string();
+ self
+ }
+ }
+
impl HttpSend for MockHttpSend {
async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
assert_eq!(req.method(), http::Method::POST);
assert_eq!(
req.uri().to_string(),
- "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-signer@example.com:signBlob"
+ format!(
+ "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:signBlob",
+ self.expected_signer_email
+ )
);
assert_eq!(
req.headers()
@@ -853,6 +878,77 @@ mod tests {
Ok(())
}
+ #[tokio::test]
+ async fn query_signing_uses_credential_signer_email() -> Result<()> {
+ let signer_email = "inferred-signer@example.com";
+ let ctx = Context::new()
+ .with_http_send(MockHttpSend::default().with_expected_signer_email(signer_email));
+ let credential = Credential::with_token(Token {
+ access_token: "test-access-token".to_string(),
+ expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
+ })
+ .with_signer_email(signer_email);
+ let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
+ .body(())?
+ .into_parts()
+ .0;
+
+ RequestSigner::new("storage")
+ .sign_request(
+ &ctx,
+ &mut parts,
+ Some(&credential),
+ Some(Duration::from_secs(300)),
+ )
+ .await?;
+
+ assert!(
+ query_get(
+ parts.uri.query().expect("signed URL query must exist"),
+ "X-Goog-Credential"
+ )
+ .expect("credential query must exist")
+ .starts_with("inferred-signer%40example.com%2F")
+ );
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn explicit_signer_email_overrides_credential_identity() -> Result<()> {
+ let explicit_email = "explicit-signer@example.com";
+ let ctx = Context::new()
+ .with_http_send(MockHttpSend::default().with_expected_signer_email(explicit_email));
+ let credential = Credential::with_token(Token {
+ access_token: "test-access-token".to_string(),
+ expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
+ })
+ .with_signer_email("inferred-signer@example.com");
+ let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
+ .body(())?
+ .into_parts()
+ .0;
+
+ RequestSigner::new("storage")
+ .with_signer_email(explicit_email)
+ .sign_request(
+ &ctx,
+ &mut parts,
+ Some(&credential),
+ Some(Duration::from_secs(300)),
+ )
+ .await?;
+
+ assert!(
+ query_get(
+ parts.uri.query().expect("signed URL query must exist"),
+ "X-Goog-Credential"
+ )
+ .expect("credential query must exist")
+ .starts_with("explicit-signer%40example.com%2F")
+ );
+ Ok(())
+ }
+
#[tokio::test]
async fn signer_refreshes_near_expiry_token_without_binding_it_to_signed_url_lifetime()
-> Result<()> {
@@ -903,7 +999,8 @@ mod tests {
let credential = Credential::with_token(Token {
access_token: "test-access-token".to_string(),
expires_at: None,
- });
+ })
+ .with_signer_email("signer@example.com");
let original_uri = format!("https://storage.googleapis.com/bucket/object?{RAW_QUERY}");
let mut parts = http::Request::get(&original_uri)
.header("x-custom", " value ")