use crate::access_boundary::CredentialsWithAccessBoundary;
use crate::build_errors::Error as BuilderError;
use crate::constants::DEFAULT_SCOPE;
use crate::credentials::dynamic::{AccessTokenCredentialsProvider, CredentialsProvider};
use crate::credentials::{
AccessToken, AccessTokenCredentials, CacheableResource, Credentials, build_credentials,
extract_credential_type,
};
use crate::errors::{self, CredentialsError};
use crate::headers_util::{
self, ACCESS_TOKEN_REQUEST_TYPE, AuthHeadersBuilder, metrics_header_value,
};
use crate::retry::{Builder as RetryTokenProviderBuilder, TokenProviderWithRetry};
use crate::token::{CachedTokenProvider, Token, TokenProvider};
use crate::token_cache::TokenCache;
use crate::{BuildResult, Result};
use async_trait::async_trait;
use google_cloud_gax::backoff_policy::BackoffPolicyArg;
use google_cloud_gax::retry_policy::RetryPolicyArg;
use google_cloud_gax::retry_throttler::RetryThrottlerArg;
use http::{Extensions, HeaderMap};
use reqwest::Client;
use serde_json::Value;
use std::fmt::Debug;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::time::Instant;
pub(crate) const IMPERSONATED_CREDENTIAL_TYPE: &str = "imp";
pub(crate) const DEFAULT_LIFETIME: Duration = Duration::from_secs(3600);
pub(crate) const MSG: &str = "failed to fetch token";
#[derive(Debug, Clone)]
pub(crate) enum BuilderSource {
FromJson(Value),
FromCredentials(Credentials),
}
pub struct Builder {
source: BuilderSource,
service_account_impersonation_url: Option<String>,
delegates: Option<Vec<String>>,
scopes: Option<Vec<String>>,
quota_project_id: Option<String>,
lifetime: Option<Duration>,
retry_builder: RetryTokenProviderBuilder,
iam_endpoint_override: Option<String>,
is_access_boundary_enabled: bool,
}
impl Builder {
pub fn new(impersonated_credential: Value) -> Self {
Self {
source: BuilderSource::FromJson(impersonated_credential),
service_account_impersonation_url: None,
delegates: None,
scopes: None,
quota_project_id: None,
lifetime: None,
retry_builder: RetryTokenProviderBuilder::default(),
iam_endpoint_override: None,
is_access_boundary_enabled: true,
}
}
pub fn from_source_credentials(source_credentials: Credentials) -> Self {
Self {
source: BuilderSource::FromCredentials(source_credentials),
service_account_impersonation_url: None,
delegates: None,
scopes: None,
quota_project_id: None,
lifetime: None,
retry_builder: RetryTokenProviderBuilder::default(),
iam_endpoint_override: None,
is_access_boundary_enabled: true,
}
}
pub fn with_target_principal<S: Into<String>>(mut self, target_principal: S) -> Self {
self.service_account_impersonation_url = Some(format!(
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken",
target_principal.into()
));
self
}
pub fn with_delegates<I, S>(mut self, delegates: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.delegates = Some(delegates.into_iter().map(|s| s.into()).collect());
self
}
pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes = Some(scopes.into_iter().map(|s| s.into()).collect());
self
}
pub fn with_quota_project_id<S: Into<String>>(mut self, quota_project_id: S) -> Self {
self.quota_project_id = Some(quota_project_id.into());
self
}
pub fn with_lifetime(mut self, lifetime: Duration) -> Self {
self.lifetime = Some(lifetime);
self
}
pub fn with_retry_policy<V: Into<RetryPolicyArg>>(mut self, v: V) -> Self {
self.retry_builder = self.retry_builder.with_retry_policy(v.into());
self
}
pub fn with_backoff_policy<V: Into<BackoffPolicyArg>>(mut self, v: V) -> Self {
self.retry_builder = self.retry_builder.with_backoff_policy(v.into());
self
}
pub fn with_retry_throttler<V: Into<RetryThrottlerArg>>(mut self, v: V) -> Self {
self.retry_builder = self.retry_builder.with_retry_throttler(v.into());
self
}
pub fn build(self) -> BuildResult<Credentials> {
Ok(self.build_credentials()?.into())
}
#[cfg(test)]
fn maybe_iam_endpoint_override(mut self, iam_endpoint_override: Option<String>) -> Self {
self.iam_endpoint_override = iam_endpoint_override;
self
}
#[cfg(test)]
fn without_access_boundary(mut self) -> Self {
self.is_access_boundary_enabled = false;
self
}
pub fn build_access_token_credentials(self) -> BuildResult<AccessTokenCredentials> {
Ok(self.build_credentials()?.into())
}
fn build_credentials(
self,
) -> BuildResult<CredentialsWithAccessBoundary<ImpersonatedServiceAccount<TokenCache>>> {
let is_access_boundary_enabled = self.is_access_boundary_enabled;
let service_account_impersonation_url = self.resolve_impersonation_url()?;
let client_email = extract_client_email(&service_account_impersonation_url)?;
let iam_endpoint_override = self.iam_endpoint_override.clone();
let (token_provider, quota_project_id) = self.build_components()?;
let access_boundary_url = crate::access_boundary::service_account_lookup_url(
&client_email,
iam_endpoint_override.as_deref(),
);
let creds = ImpersonatedServiceAccount {
token_provider: TokenCache::new(token_provider),
quota_project_id,
};
if !is_access_boundary_enabled {
return Ok(CredentialsWithAccessBoundary::new_no_op(creds));
}
Ok(CredentialsWithAccessBoundary::new(
creds,
Some(access_boundary_url),
))
}
pub fn build_signer(self) -> BuildResult<crate::signer::Signer> {
let iam_endpoint = self.iam_endpoint_override.clone();
let source = self.source.clone();
if let BuilderSource::FromJson(json) = source {
let signer = build_signer_from_json(json.clone())?;
if let Some(signer) = signer {
return Ok(signer);
}
}
let service_account_impersonation_url = self.resolve_impersonation_url()?;
let client_email = extract_client_email(&service_account_impersonation_url)?;
let creds = self.build()?;
let signer = crate::signer::iam::IamSigner::new(client_email, creds, iam_endpoint);
Ok(crate::signer::Signer {
inner: Arc::new(signer),
})
}
fn build_components(
self,
) -> BuildResult<(
TokenProviderWithRetry<ImpersonatedTokenProvider>,
Option<String>,
)> {
let components = match self.source {
BuilderSource::FromJson(json) => build_components_from_json(json)?,
BuilderSource::FromCredentials(source_credentials) => {
build_components_from_credentials(
source_credentials,
self.service_account_impersonation_url,
)?
}
};
let scopes = self
.scopes
.or(components.scopes)
.unwrap_or_else(|| vec![DEFAULT_SCOPE.to_string()]);
let quota_project_id = self.quota_project_id.or(components.quota_project_id);
let delegates = self.delegates.or(components.delegates);
let token_provider = ImpersonatedTokenProvider {
source_credentials: components.source_credentials,
service_account_impersonation_url: components.service_account_impersonation_url,
delegates,
scopes,
lifetime: self.lifetime.unwrap_or(DEFAULT_LIFETIME),
};
let token_provider = self.retry_builder.build(token_provider);
Ok((token_provider, quota_project_id))
}
fn resolve_impersonation_url(&self) -> BuildResult<String> {
match self.source.clone() {
BuilderSource::FromJson(json) => {
let config = config_from_json(json)?;
Ok(config.service_account_impersonation_url)
}
BuilderSource::FromCredentials(_) => {
self.service_account_impersonation_url.clone().ok_or_else(|| {
BuilderError::parsing(
"`service_account_impersonation_url` is required when building from source credentials",
)
})
}
}
}
}
pub(crate) struct ImpersonatedCredentialComponents {
pub(crate) source_credentials: Credentials,
pub(crate) service_account_impersonation_url: String,
pub(crate) delegates: Option<Vec<String>>,
pub(crate) quota_project_id: Option<String>,
pub(crate) scopes: Option<Vec<String>>,
}
fn config_from_json(json: Value) -> BuildResult<ImpersonatedConfig> {
serde_json::from_value::<ImpersonatedConfig>(json).map_err(BuilderError::parsing)
}
pub(crate) fn build_components_from_json(
json: Value,
) -> BuildResult<ImpersonatedCredentialComponents> {
let config = config_from_json(json)?;
let source_credential_type = extract_credential_type(&config.source_credentials)?;
if source_credential_type == "impersonated_service_account" {
return Err(BuilderError::parsing(
"source credential of type `impersonated_service_account` is not supported. \
Use the `delegates` field to specify a delegation chain.",
));
}
let source_credentials = build_credentials(Some(config.source_credentials), None, None)?.into();
Ok(ImpersonatedCredentialComponents {
source_credentials,
service_account_impersonation_url: config.service_account_impersonation_url,
delegates: config.delegates,
quota_project_id: config.quota_project_id,
scopes: config.scopes,
})
}
fn build_signer_from_json(json: Value) -> BuildResult<Option<crate::signer::Signer>> {
use crate::credentials::service_account::ServiceAccountKey;
use crate::signer::service_account::ServiceAccountSigner;
let config = config_from_json(json)?;
let client_email = extract_client_email(&config.service_account_impersonation_url)?;
let source_credential_type = extract_credential_type(&config.source_credentials)?;
if source_credential_type == "service_account" {
let service_account_key =
serde_json::from_value::<ServiceAccountKey>(config.source_credentials)
.map_err(BuilderError::parsing)?;
let signing_provider = ServiceAccountSigner::from_impersonated_service_account(
service_account_key,
client_email,
);
let signer = crate::signer::Signer {
inner: Arc::new(signing_provider),
};
return Ok(Some(signer));
}
Ok(None)
}
fn extract_client_email(service_account_impersonation_url: &str) -> BuildResult<String> {
let mut parts = service_account_impersonation_url.split("/serviceAccounts/");
match (parts.nth(1), parts.next()) {
(Some(email), None) => Ok(email.trim_end_matches(":generateAccessToken").to_string()),
_ => Err(BuilderError::parsing(
"invalid service account impersonation URL",
)),
}
}
pub(crate) fn build_components_from_credentials(
source_credentials: Credentials,
service_account_impersonation_url: Option<String>,
) -> BuildResult<ImpersonatedCredentialComponents> {
let url = service_account_impersonation_url.ok_or_else(|| {
BuilderError::parsing(
"`service_account_impersonation_url` is required when building from source credentials",
)
})?;
Ok(ImpersonatedCredentialComponents {
source_credentials,
service_account_impersonation_url: url,
delegates: None,
quota_project_id: None,
scopes: None,
})
}
#[derive(serde::Deserialize, Debug, PartialEq)]
struct ImpersonatedConfig {
service_account_impersonation_url: String,
source_credentials: Value,
delegates: Option<Vec<String>>,
quota_project_id: Option<String>,
scopes: Option<Vec<String>>,
}
#[derive(Debug)]
struct ImpersonatedServiceAccount<T>
where
T: CachedTokenProvider,
{
token_provider: T,
quota_project_id: Option<String>,
}
#[async_trait::async_trait]
impl<T> CredentialsProvider for ImpersonatedServiceAccount<T>
where
T: CachedTokenProvider,
{
async fn headers(&self, extensions: Extensions) -> Result<CacheableResource<HeaderMap>> {
let token = self.token_provider.token(extensions).await?;
AuthHeadersBuilder::new(&token)
.maybe_quota_project_id(self.quota_project_id.as_deref())
.build()
}
}
#[async_trait::async_trait]
impl<T> AccessTokenCredentialsProvider for ImpersonatedServiceAccount<T>
where
T: CachedTokenProvider,
{
async fn access_token(&self) -> Result<AccessToken> {
let token = self.token_provider.token(Extensions::new()).await?;
token.into()
}
}
struct ImpersonatedTokenProvider {
source_credentials: Credentials,
service_account_impersonation_url: String,
delegates: Option<Vec<String>>,
scopes: Vec<String>,
lifetime: Duration,
}
impl Debug for ImpersonatedTokenProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ImpersonatedTokenProvider")
.field("source_credentials", &self.source_credentials)
.field(
"service_account_impersonation_url",
&self.service_account_impersonation_url,
)
.field("delegates", &self.delegates)
.field("scopes", &self.scopes)
.field("lifetime", &self.lifetime)
.finish()
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct GenerateAccessTokenRequest {
#[serde(skip_serializing_if = "Option::is_none")]
delegates: Option<Vec<String>>,
scope: Vec<String>,
lifetime: String,
}
pub(crate) async fn generate_access_token(
source_headers: HeaderMap,
delegates: Option<Vec<String>>,
scopes: Vec<String>,
lifetime: Duration,
service_account_impersonation_url: &str,
) -> Result<Token> {
let client = Client::new();
let body = GenerateAccessTokenRequest {
delegates,
scope: scopes,
lifetime: format!("{}s", lifetime.as_secs_f64()),
};
let response = client
.post(service_account_impersonation_url)
.header("Content-Type", "application/json")
.header(
headers_util::X_GOOG_API_CLIENT,
metrics_header_value(ACCESS_TOKEN_REQUEST_TYPE, IMPERSONATED_CREDENTIAL_TYPE),
)
.headers(source_headers)
.json(&body)
.send()
.await
.map_err(|e| errors::from_http_error(e, MSG))?;
if !response.status().is_success() {
let err = errors::from_http_response(response, MSG).await;
return Err(err);
}
let token_response = response
.json::<GenerateAccessTokenResponse>()
.await
.map_err(|e| {
let retryable = !e.is_decode();
CredentialsError::from_source(retryable, e)
})?;
let parsed_dt = OffsetDateTime::parse(
&token_response.expire_time,
&time::format_description::well_known::Rfc3339,
)
.map_err(errors::non_retryable)?;
let remaining_duration = parsed_dt - OffsetDateTime::now_utc();
let expires_at = Instant::now() + remaining_duration.try_into().unwrap();
let token = Token {
token: token_response.access_token,
token_type: "Bearer".to_string(),
expires_at: Some(expires_at),
metadata: None,
};
Ok(token)
}
#[async_trait]
impl TokenProvider for ImpersonatedTokenProvider {
async fn token(&self) -> Result<Token> {
let source_headers = self.source_credentials.headers(Extensions::new()).await?;
let source_headers = match source_headers {
CacheableResource::New { data, .. } => data,
CacheableResource::NotModified => {
unreachable!("requested source credentials without a caching etag")
}
};
generate_access_token(
source_headers,
self.delegates.clone(),
self.scopes.clone(),
self.lifetime,
&self.service_account_impersonation_url,
)
.await
}
}
#[derive(serde::Deserialize)]
struct GenerateAccessTokenResponse {
#[serde(rename = "accessToken")]
access_token: String,
#[serde(rename = "expireTime")]
expire_time: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::service_account::ServiceAccountKey;
use crate::credentials::tests::PKCS8_PK;
use crate::credentials::tests::{
find_source_error, get_mock_auth_retry_policy, get_mock_backoff_policy,
get_mock_retry_throttler,
};
use crate::errors::CredentialsError;
use base64::{Engine, prelude::BASE64_STANDARD};
use httptest::cycle;
use httptest::{Expectation, Server, matchers::*, responders::*};
use serde_json::Value;
use serde_json::json;
use serial_test::parallel;
type TestResult = anyhow::Result<()>;
#[tokio::test]
#[parallel]
async fn test_generate_access_token_success() -> TestResult {
let server = Server::run();
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::headers(contains(("authorization", "Bearer test-token"))),
])
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer test-token".parse().unwrap());
let token = generate_access_token(
headers,
None,
vec!["scope".to_string()],
DEFAULT_LIFETIME,
&server
.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken")
.to_string(),
)
.await?;
assert_eq!(token.token, "test-impersonated-token");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_generate_access_token_403() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::headers(contains(("authorization", "Bearer test-token"))),
])
.respond_with(status_code(403)),
);
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer test-token".parse().unwrap());
let err = generate_access_token(
headers,
None,
vec!["scope".to_string()],
DEFAULT_LIFETIME,
&server
.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken")
.to_string(),
)
.await
.unwrap_err();
assert!(!err.is_transient());
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_generate_access_token_no_auth_header() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(status_code(401)),
);
let err = generate_access_token(
HeaderMap::new(),
None,
vec!["scope".to_string()],
DEFAULT_LIFETIME,
&server
.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken")
.to_string(),
)
.await
.unwrap_err();
assert!(!err.is_transient());
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_service_account() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::headers(contains((
"authorization",
"Bearer test-user-account-token"
))),
request::body(json_decoded(eq(json!({
"scope": ["scope1", "scope2"],
"lifetime": "3600s"
}))))
])
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential)
.with_scopes(vec!["scope1", "scope2"])
.build_components()?;
let token = token_provider.token().await?;
assert_eq!(token.token, "test-impersonated-token");
assert_eq!(token.token_type, "Bearer");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_service_account_default_scope() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::headers(contains((
"authorization",
"Bearer test-user-account-token"
))),
request::body(json_decoded(eq(json!({
"scope": [DEFAULT_SCOPE],
"lifetime": "3600s"
}))))
])
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential).build_components()?;
let token = token_provider.token().await?;
assert_eq!(token.token, "test-impersonated-token");
assert_eq!(token.token_type, "Bearer");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_service_account_with_custom_lifetime() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::seconds(500))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::headers(contains((
"authorization",
"Bearer test-user-account-token"
))),
request::body(json_decoded(eq(json!({
"scope": ["scope1", "scope2"],
"lifetime": "3.5s"
}))))
])
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential)
.with_scopes(vec!["scope1", "scope2"])
.with_lifetime(Duration::from_secs_f32(3.5))
.build_components()?;
let token = token_provider.token().await?;
assert_eq!(token.token, "test-impersonated-token");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_with_delegates() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::headers(contains((
"authorization",
"Bearer test-user-account-token"
))),
request::body(json_decoded(eq(json!({
"scope": [DEFAULT_SCOPE],
"lifetime": "3600s",
"delegates": ["delegate1", "delegate2"]
}))))
])
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential)
.with_delegates(vec!["delegate1", "delegate2"])
.build_components()?;
let token = token_provider.token().await?;
assert_eq!(token.token, "test-impersonated-token");
assert_eq!(token.token_type, "Bearer");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_service_account_fail() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(status_code(500)),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential).build_components()?;
let err = token_provider.token().await.unwrap_err();
let original_err = find_source_error::<CredentialsError>(&err).unwrap();
assert!(original_err.is_transient());
Ok(())
}
#[tokio::test]
#[parallel]
async fn debug_token_provider() {
let source_credentials = crate::credentials::user_account::Builder::new(json!({
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token"
}))
.build()
.unwrap();
let expected = ImpersonatedTokenProvider {
source_credentials,
service_account_impersonation_url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken".to_string(),
delegates: Some(vec!["delegate1".to_string()]),
scopes: vec!["scope1".to_string()],
lifetime: Duration::from_secs(3600),
};
let fmt = format!("{expected:?}");
assert!(fmt.contains("UserCredentials"), "{fmt}");
assert!(fmt.contains("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"), "{fmt}");
assert!(fmt.contains("delegate1"), "{fmt}");
assert!(fmt.contains("scope1"), "{fmt}");
assert!(fmt.contains("3600s"), "{fmt}");
}
#[test]
fn impersonated_config_full_from_json_success() {
let source_credentials_json = json!({
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token"
});
let json = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
"source_credentials": source_credentials_json,
"delegates": ["delegate1"],
"quota_project_id": "test-project-id",
"scopes": ["scope1"],
});
let expected = ImpersonatedConfig {
service_account_impersonation_url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken".to_string(),
source_credentials: source_credentials_json,
delegates: Some(vec!["delegate1".to_string()]),
quota_project_id: Some("test-project-id".to_string()),
scopes: Some(vec!["scope1".to_string()]),
};
let actual: ImpersonatedConfig = serde_json::from_value(json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn impersonated_config_partial_from_json_success() {
let source_credentials_json = json!({
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token"
});
let json = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
"source_credentials": source_credentials_json
});
let config: ImpersonatedConfig = serde_json::from_value(json).unwrap();
assert_eq!(
config.service_account_impersonation_url,
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
);
assert_eq!(config.source_credentials, source_credentials_json);
assert_eq!(config.delegates, None);
assert_eq!(config.quota_project_id, None);
assert_eq!(config.scopes, None);
}
#[tokio::test]
#[parallel]
async fn test_impersonated_service_account_source_fail() -> TestResult {
#[derive(Debug)]
struct MockSourceCredentialsFail;
#[async_trait]
impl CredentialsProvider for MockSourceCredentialsFail {
async fn headers(
&self,
_extensions: Extensions,
) -> Result<CacheableResource<HeaderMap>> {
Err(errors::non_retryable_from_str("source failed"))
}
}
let source_credentials = Credentials {
inner: Arc::new(MockSourceCredentialsFail),
};
let token_provider = ImpersonatedTokenProvider {
source_credentials,
service_account_impersonation_url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken".to_string(),
delegates: Some(vec!["delegate1".to_string()]),
scopes: vec!["scope1".to_string()],
lifetime: DEFAULT_LIFETIME,
};
let err = token_provider.token().await.unwrap_err();
assert!(err.to_string().contains("source failed"));
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_missing_impersonation_url_fail() {
let source_credentials = crate::credentials::user_account::Builder::new(json!({
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token"
}))
.build()
.unwrap();
let result = Builder::from_source_credentials(source_credentials).build();
assert!(result.is_err(), "{result:?}");
let err = result.unwrap_err();
assert!(err.is_parsing());
assert!(
err.to_string()
.contains("`service_account_impersonation_url` is required")
);
}
#[tokio::test]
#[parallel]
async fn test_nested_impersonated_credentials_fail() {
let nested_impersonated = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
"source_credentials": {
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token"
}
}
});
let result = Builder::new(nested_impersonated).build();
assert!(result.is_err(), "{result:?}");
let err = result.unwrap_err();
assert!(err.is_parsing());
assert!(
err.to_string().contains(
"source credential of type `impersonated_service_account` is not supported"
)
);
}
#[tokio::test]
#[parallel]
async fn test_malformed_impersonated_credentials_fail() {
let malformed_impersonated = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
});
let result = Builder::new(malformed_impersonated).build();
assert!(result.is_err(), "{result:?}");
let err = result.unwrap_err();
assert!(err.is_parsing());
assert!(
err.to_string()
.contains("missing field `source_credentials`")
);
}
#[tokio::test]
#[parallel]
async fn test_invalid_source_credential_type_fail() {
let invalid_source = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
"source_credentials": {
"type": "invalid_type",
}
});
let result = Builder::new(invalid_source).build();
assert!(result.is_err(), "{result:?}");
let err = result.unwrap_err();
assert!(err.is_unknown_type());
}
#[tokio::test]
#[parallel]
async fn test_missing_expiry() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential).build_components()?;
let err = token_provider.token().await.unwrap_err();
assert!(!err.is_transient());
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_invalid_expiry_format() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": "invalid-format"
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential).build_components()?;
let err = token_provider.token().await.unwrap_err();
assert!(!err.is_transient());
Ok(())
}
#[tokio::test]
#[parallel]
async fn token_provider_malformed_response_is_nonretryable() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(json_encoded(json!("bad json"))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential).build_components()?;
let e = token_provider.token().await.err().unwrap();
assert!(!e.is_transient(), "{e}");
Ok(())
}
#[tokio::test]
#[parallel]
async fn token_provider_nonretryable_error() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(status_code(401)),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential).build_components()?;
let err = token_provider.token().await.unwrap_err();
assert!(!err.is_transient());
Ok(())
}
#[tokio::test]
#[parallel]
async fn credential_full_with_quota_project_from_builder() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let creds = Builder::new(impersonated_credential)
.with_quota_project_id("test-project")
.build()?;
let headers = creds.headers(Extensions::new()).await?;
match headers {
CacheableResource::New { data, .. } => {
assert_eq!(data.get("x-goog-user-project").unwrap(), "test-project");
}
CacheableResource::NotModified => panic!("Expected new headers, but got NotModified"),
}
Ok(())
}
#[tokio::test]
#[parallel]
async fn access_token_credentials_success() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let creds = Builder::new(impersonated_credential).build_access_token_credentials()?;
let access_token = creds.access_token().await?;
assert_eq!(access_token.token, "test-impersonated-token");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_with_target_principal() {
let source_credentials = crate::credentials::user_account::Builder::new(json!({
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token"
}))
.build()
.unwrap();
let (token_provider, _) = Builder::from_source_credentials(source_credentials)
.with_target_principal("test-principal@example.iam.gserviceaccount.com")
.build_components()
.unwrap();
assert_eq!(
token_provider.inner.service_account_impersonation_url,
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal@example.iam.gserviceaccount.com:generateAccessToken"
);
}
#[tokio::test]
#[parallel]
async fn credential_full_with_quota_project_from_json() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
},
"quota_project_id": "test-project-from-json",
});
let creds = Builder::new(impersonated_credential).build()?;
let headers = creds.headers(Extensions::new()).await?;
println!("headers: {:#?}", headers);
match headers {
CacheableResource::New { data, .. } => {
assert_eq!(
data.get("x-goog-user-project").unwrap(),
"test-project-from-json"
);
}
CacheableResource::NotModified => panic!("Expected new headers, but got NotModified"),
}
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_does_not_propagate_settings_to_source() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/source_token"),
request::body(json_decoded(
|body: &serde_json::Value| body["scopes"].is_null()
))
])
.respond_with(json_encoded(json!({
"access_token": "source-token",
"expires_in": 3600,
"token_type": "Bearer",
}))),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::headers(contains(("authorization", "Bearer source-token"))),
request::body(json_decoded(eq(json!({
"scope": ["impersonated-scope"],
"lifetime": "3600s"
}))))
])
.respond_with(json_encoded(json!({
"accessToken": "impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/source_token").to_string()
}
});
let creds = Builder::new(impersonated_credential)
.with_scopes(vec!["impersonated-scope"])
.with_quota_project_id("impersonated-quota-project")
.build()?;
let fmt = format!("{creds:?}");
assert!(fmt.contains("impersonated-quota-project"));
let _token = creds.headers(Extensions::new()).await?;
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_metrics_header() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::headers(contains(("x-goog-api-client", matches("cred-type/imp")))),
request::headers(contains((
"x-goog-api-client",
matches("auth-request-type/at")
)))
])
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential).build_components()?;
let token = token_provider.token().await?;
assert_eq!(token.token, "test-impersonated-token");
assert_eq!(token.token_type, "Bearer");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_retries_for_success() -> TestResult {
let mut server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
let impersonation_path =
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken";
server.expect(
Expectation::matching(request::method_path("POST", impersonation_path))
.times(3)
.respond_with(cycle![
status_code(503).body("try-again"),
status_code(503).body("try-again"),
status_code(200)
.append_header("Content-Type", "application/json")
.body(
json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
})
.to_string()
),
]),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url(impersonation_path).to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential)
.with_retry_policy(get_mock_auth_retry_policy(3))
.with_backoff_policy(get_mock_backoff_policy())
.with_retry_throttler(get_mock_retry_throttler())
.build_components()?;
let token = token_provider.token().await?;
assert_eq!(token.token, "test-impersonated-token");
server.verify_and_clear();
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_scopes_from_json() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::body(json_decoded(eq(json!({
"scope": ["scope-from-json"],
"lifetime": "3600s"
}))))
])
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"scopes": ["scope-from-json"],
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential).build_components()?;
let token = token_provider.token().await?;
assert_eq!(token.token, "test-impersonated-token");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_with_scopes_overrides_json_scopes() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken"
),
request::body(json_decoded(eq(json!({
"scope": ["scope-from-with-scopes"],
"lifetime": "3600s"
}))))
])
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken").to_string(),
"scopes": ["scope-from-json"],
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential)
.with_scopes(vec!["scope-from-with-scopes"])
.build_components()?;
let token = token_provider.token().await?;
assert_eq!(token.token, "test-impersonated-token");
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_does_not_retry_on_non_transient_failures() -> TestResult {
let mut server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
})),
),
);
let impersonation_path =
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken";
server.expect(
Expectation::matching(request::method_path("POST", impersonation_path))
.times(1)
.respond_with(status_code(401)),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url(impersonation_path).to_string(),
"source_credentials": {
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
}
});
let (token_provider, _) = Builder::new(impersonated_credential)
.with_retry_policy(get_mock_auth_retry_policy(3))
.with_backoff_policy(get_mock_backoff_policy())
.with_retry_throttler(get_mock_retry_throttler())
.build_components()?;
let err = token_provider.token().await.unwrap_err();
assert!(!err.is_transient());
server.verify_and_clear();
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_remote_signer() -> TestResult {
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.times(2..)
.respond_with(json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
}))),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.times(2)
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:signBlob"
),
request::headers(contains((
"authorization",
"Bearer test-impersonated-token"
))),
])
.times(2)
.respond_with(json_encoded(json!({
"signedBlob": BASE64_STANDARD.encode("signed_blob"),
}))),
);
let impersonation_url = server
.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken")
.to_string();
let user_credential = json!({
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
});
let source_credential =
crate::credentials::user_account::Builder::new(user_credential.clone()).build()?;
let mut builder_from_source = Builder::from_source_credentials(source_credential)
.with_target_principal("test-principal");
builder_from_source.service_account_impersonation_url = Some(impersonation_url.clone());
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": impersonation_url,
"source_credentials": user_credential,
});
let builder_from_json = Builder::new(impersonated_credential);
for builder in [builder_from_source, builder_from_json] {
let iam_endpoint = server.url("").to_string().trim_end_matches('/').to_string();
let signer = builder
.maybe_iam_endpoint_override(Some(iam_endpoint))
.without_access_boundary()
.build_signer()?;
let client_email = signer.client_email().await?;
assert_eq!(client_email, "test-principal");
let result = signer.sign(b"test").await?;
assert_eq!(result.as_ref(), b"signed_blob");
}
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_sa_signer() -> TestResult {
let service_account = json!({
"type": "service_account",
"client_email": "test-client-email",
"private_key_id": "test-private-key-id",
"private_key": Value::from(PKCS8_PK.clone()),
"project_id": "test-project-id",
});
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
"source_credentials": service_account.clone(),
});
let signer = Builder::new(impersonated_credential).build_signer()?;
let client_email = signer.client_email().await?;
assert_eq!(client_email, "test-principal");
let result = signer.sign(b"test").await?;
let service_account_key = serde_json::from_value::<ServiceAccountKey>(service_account)?;
let inner_signer = service_account_key.signer().unwrap();
let inner_result = inner_signer.sign(b"test")?;
assert_eq!(result.as_ref(), inner_result);
Ok(())
}
#[tokio::test]
#[parallel]
async fn test_impersonated_signer_with_invalid_email() -> TestResult {
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": "http://example.com/test-principal:generateIdToken",
"source_credentials": json!({
"type": "service_account",
"client_email": "test-client-email",
"private_key_id": "test-private-key-id",
"private_key": "test-private-key",
"project_id": "test-project-id",
}),
});
let error = Builder::new(impersonated_credential)
.build_signer()
.unwrap_err();
assert!(error.is_parsing());
assert!(
error
.to_string()
.contains("invalid service account impersonation URL"),
"error: {}",
error
);
Ok(())
}
#[tokio::test]
#[parallel]
#[cfg(google_cloud_unstable_trusted_boundaries)]
async fn e2e_access_boundary() -> TestResult {
use crate::credentials::tests::{get_access_boundary_from_headers, get_token_from_headers};
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.times(2..)
.respond_with(json_encoded(json!({
"access_token": "test-user-account-token",
"expires_in": 3600,
"token_type": "Bearer",
}))),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
server.expect(
Expectation::matching(request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateAccessToken",
))
.times(2)
.respond_with(json_encoded(json!({
"accessToken": "test-impersonated-token",
"expireTime": expire_time
}))),
);
server.expect(
Expectation::matching(all_of![
request::method_path(
"GET",
"/v1/projects/-/serviceAccounts/test-principal/allowedLocations"
),
request::headers(contains((
"authorization",
"Bearer test-impersonated-token"
))),
])
.times(2)
.respond_with(json_encoded(json!({
"locations": ["us-central1", "us-east1"],
"encodedLocations": "0x1234"
}))),
);
let impersonation_url = server
.url("/v1/projects/-/serviceAccounts/test-principal:generateAccessToken")
.to_string();
let user_credential = json!({
"type": "authorized_user",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"refresh_token": "test-refresh-token",
"token_uri": server.url("/token").to_string()
});
let source_credential =
crate::credentials::user_account::Builder::new(user_credential.clone()).build()?;
let mut builder_from_source = Builder::from_source_credentials(source_credential)
.with_target_principal("test-principal");
builder_from_source.service_account_impersonation_url = Some(impersonation_url.clone());
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": impersonation_url,
"source_credentials": user_credential,
});
let builder_from_json = Builder::new(impersonated_credential);
for builder in [builder_from_source, builder_from_json] {
let iam_endpoint = server.url("").to_string().trim_end_matches('/').to_string();
let creds = builder
.maybe_iam_endpoint_override(Some(iam_endpoint))
.build_credentials()?;
creds.wait_for_boundary().await;
let headers = creds.headers(Extensions::new()).await?;
let token = get_token_from_headers(headers.clone());
let access_boundary = get_access_boundary_from_headers(headers);
assert!(token.is_some(), "should have some token: {token:?}");
assert_eq!(
access_boundary.as_deref(),
Some("0x1234"),
"should be 0x1234 but found: {access_boundary:?}"
);
}
Ok(())
}
}