Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions services/google/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 63 additions & 3 deletions services/google/src/credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand All @@ -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 {
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
50 changes: 16 additions & 34 deletions services/google/src/provide_credential/external_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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?;

Expand All @@ -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,
}))
}
}

Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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(())
}
}
18 changes: 15 additions & 3 deletions services/google/src/provide_credential/vm_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}))
}
}

Expand Down Expand Up @@ -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()]
Expand All @@ -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()]
Expand Down
Loading
Loading