use crate::{
BuildResult, Result,
credentials::{
CacheableResource, Credentials,
idtoken::{
IDTokenCredentials, dynamic::IDTokenCredentialsProvider, parse_id_token_from_str,
},
impersonated::{
BuilderSource, IMPERSONATED_CREDENTIAL_TYPE, ImpersonationUrl, MSG,
build_components_from_credentials, build_components_from_json,
},
},
errors,
headers_util::{self, ID_TOKEN_REQUEST_TYPE, metrics_header_value},
retry::Builder as RetryTokenProviderBuilder,
token::{CachedTokenProvider, Token, TokenProvider},
token_cache::TokenCache,
};
use async_trait::async_trait;
use google_cloud_gax::backoff_policy::BackoffPolicyArg;
use google_cloud_gax::error::CredentialsError;
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::sync::Arc;
pub struct Builder {
source: BuilderSource,
delegates: Option<Vec<String>>,
pub(crate) include_email: Option<bool>,
target_audience: String,
service_account_impersonation_url: Option<ImpersonationUrl>,
retry_builder: RetryTokenProviderBuilder,
}
impl Builder {
pub fn new<S: Into<String>>(target_audience: S, impersonated_credential: Value) -> Self {
Self {
source: BuilderSource::FromJson(impersonated_credential),
delegates: None,
include_email: None,
target_audience: target_audience.into(),
service_account_impersonation_url: None,
retry_builder: RetryTokenProviderBuilder::default(),
}
}
pub fn from_source_credentials<SA: Into<String>, SP: Into<String>>(
target_audience: SA,
target_principal: SP,
source_credentials: Credentials,
) -> Self {
Self {
source: BuilderSource::FromCredentials(source_credentials),
delegates: None,
include_email: None,
target_audience: target_audience.into(),
service_account_impersonation_url: Some(ImpersonationUrl::target_principal(
target_principal.into(),
)),
retry_builder: RetryTokenProviderBuilder::default(),
}
}
pub fn with_include_email(mut self) -> Self {
self.include_email = Some(true);
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_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<IDTokenCredentials> {
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 token_provider = ImpersonatedTokenProvider {
source_credentials: components.source_credentials,
service_account_impersonation_url: components.service_account_impersonation_url,
delegates: self.delegates.or(components.delegates),
include_email: self.include_email,
target_audience: self.target_audience,
};
let token_provider = self.retry_builder.build(token_provider);
Ok(IDTokenCredentials {
inner: Arc::new(ImpersonatedServiceAccount {
token_provider: TokenCache::new(token_provider),
}),
})
}
}
#[derive(Debug)]
struct ImpersonatedServiceAccount<T>
where
T: CachedTokenProvider,
{
token_provider: T,
}
#[async_trait::async_trait]
impl<T> IDTokenCredentialsProvider for ImpersonatedServiceAccount<T>
where
T: CachedTokenProvider,
{
async fn id_token(&self) -> Result<String> {
let cached_token = self.token_provider.token(Extensions::new()).await?;
match cached_token {
CacheableResource::New { data, .. } => Ok(data.token),
CacheableResource::NotModified => {
Err(CredentialsError::from_msg(false, "failed to fetch token"))
}
}
}
}
#[derive(Debug)]
pub(crate) struct ImpersonatedTokenProvider {
pub(crate) source_credentials: Credentials,
pub(crate) service_account_impersonation_url: ImpersonationUrl,
pub(crate) delegates: Option<Vec<String>>,
pub(crate) target_audience: String,
pub(crate) include_email: Option<bool>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct GenerateIdTokenRequest {
#[serde(skip_serializing_if = "Option::is_none")]
delegates: Option<Vec<String>>,
audience: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "includeEmail")]
include_email: Option<bool>,
}
async fn generate_id_token(
source_headers: HeaderMap,
delegates: Option<Vec<String>>,
audience: String,
include_email: Option<bool>,
service_account_impersonation_url: &str,
) -> Result<Token> {
let client = Client::new();
let body = GenerateIdTokenRequest {
audience,
delegates,
include_email,
};
let response = client
.post(service_account_impersonation_url)
.header("Content-Type", "application/json")
.header(
headers_util::X_GOOG_API_CLIENT,
metrics_header_value(ID_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::<GenerateIdTokenResponse>()
.await
.map_err(|e| {
let retryable = !e.is_decode();
CredentialsError::from_source(retryable, e)
})?;
parse_id_token_from_str(token_response.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")
}
};
let url = self
.service_account_impersonation_url
.id_token_url(&self.source_credentials)
.await;
generate_id_token(
source_headers,
self.delegates.clone(),
self.target_audience.clone(),
self.include_email,
&url,
)
.await
}
}
#[derive(serde::Deserialize)]
struct GenerateIdTokenResponse {
#[serde(rename = "token")]
token: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::idtoken::tests::generate_test_id_token;
use crate::credentials::tests::MockCredentials;
use crate::credentials::tests::{
get_mock_auth_retry_policy, get_mock_backoff_policy, get_mock_retry_throttler,
};
use httptest::{Expectation, Server, matchers::*, responders::*};
use serde_json::json;
type TestResult = anyhow::Result<()>;
impl Builder {
fn with_impersonation_endpoint(mut self, endpoint: &str) -> Self {
self.service_account_impersonation_url = self
.service_account_impersonation_url
.map(|u| u.with_endpoint(endpoint));
self
}
}
#[tokio::test]
async fn test_impersonated_service_account_id_token() -> TestResult {
let audience = "test-audience";
let token_string = generate_test_id_token(audience);
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(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateIdToken"
),
request::headers(contains((
"authorization",
"Bearer test-user-account-token"
))),
request::body(json_decoded(eq(json!({
"audience": audience,
}))))
])
.respond_with(json_encoded(json!({
"token": token_string,
}))),
);
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(audience, impersonated_credential.clone()).build()?;
let token = creds.id_token().await?;
assert_eq!(token, token_string);
Ok(())
}
#[tokio::test]
async fn test_impersonated_id_token_with_delegates_and_email() -> TestResult {
let audience = "test-audience";
let token_string = generate_test_id_token(audience);
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(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateIdToken"
),
request::headers(contains((
"authorization",
"Bearer test-user-account-token"
))),
request::body(json_decoded(eq(json!({
"audience": audience,
"delegates": ["delegate1", "delegate2"],
"includeEmail": true
}))))
])
.respond_with(json_encoded(json!({
"token": token_string,
}))),
);
let impersonated_credential = json!({
"type": "impersonated_service_account",
"service_account_impersonation_url": server.url("/v1/projects/-/serviceAccounts/test-principal:generateIdToken").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("test-audience", impersonated_credential)
.with_delegates(vec!["delegate1", "delegate2"])
.with_include_email()
.build()?;
let token = creds.id_token().await?;
assert_eq!(token, token_string);
Ok(())
}
#[tokio::test]
async fn test_impersonated_id_token_from_source_credentials() -> TestResult {
let audience = "test-audience";
let token_string = generate_test_id_token(audience);
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(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateIdToken"
),
request::headers(contains((
"authorization",
"Bearer test-user-account-token"
))),
request::body(json_decoded(eq(json!({
"audience": audience,
}))))
])
.respond_with(json_encoded(json!({
"token": token_string,
}))),
);
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",
"token_uri": server.url("/token").to_string()
}))
.build()?;
let endpoint = server.url("/").to_string();
let endpoint = endpoint.trim_end_matches('/');
let creds =
Builder::from_source_credentials(audience, "test-principal", source_credentials)
.with_impersonation_endpoint(endpoint)
.build()?;
let token = creds.id_token().await?;
assert_eq!(token, token_string);
Ok(())
}
#[tokio::test]
async fn test_impersonated_id_token_metrics_header() -> TestResult {
let audience = "test-audience";
let token_string = generate_test_id_token(audience);
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(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateIdToken"
),
request::headers(contains(("x-goog-api-client", matches("cred-type/imp")))),
request::headers(contains((
"x-goog-api-client",
matches("auth-request-type/it")
)))
])
.respond_with(json_encoded(json!({
"token": token_string,
}))),
);
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(audience, impersonated_credential).build()?;
let token = creds.id_token().await?;
assert_eq!(token, token_string);
Ok(())
}
#[tokio::test]
async fn test_impersonated_id_token_retries_for_success() -> TestResult {
let audience = "test-audience";
let token_string = generate_test_id_token(audience);
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 impersonation_path = "/v1/projects/-/serviceAccounts/test-principal:generateIdToken";
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"),
json_encoded(json!({
"token": token_string,
})),
]),
);
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(audience, 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()?;
let token = creds.id_token().await?;
assert_eq!(token, token_string);
Ok(())
}
#[tokio::test]
async fn test_impersonated_id_token_does_not_retry_on_non_transient_failures() -> 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 impersonation_path = "/v1/projects/-/serviceAccounts/test-principal:generateIdToken";
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("/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("test-audience", 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()?;
let err = creds.id_token().await.unwrap_err();
assert!(!err.is_transient());
Ok(())
}
#[tokio::test]
async fn test_impersonated_id_token_custom_universe_domain() -> TestResult {
let audience = "test-audience";
let token_string = generate_test_id_token(audience);
let server = Server::run();
let universe_domain = "my-custom-universe.com".to_string();
server.expect(
Expectation::matching(all_of![
request::method_path(
"POST",
"/v1/projects/-/serviceAccounts/test-principal:generateIdToken"
),
request::headers(contains((
"authorization",
"Bearer test-user-account-token"
))),
request::body(json_decoded(eq(json!({
"audience": audience,
}))))
])
.respond_with(json_encoded(json!({
"token": token_string,
}))),
);
let universe_domain_clone = universe_domain.clone();
let mut mock = MockCredentials::new();
mock.expect_universe_domain()
.returning(move || Some(universe_domain_clone.clone()));
mock.expect_headers().returning(move |_| {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
"Bearer test-user-account-token".parse().unwrap(),
);
Ok(CacheableResource::New {
entity_tag: Default::default(),
data: headers,
})
});
let source_credentials = Credentials::from(mock);
let builder = Builder::from_source_credentials(
audience,
"test-principal",
source_credentials.clone(),
);
let url = builder
.service_account_impersonation_url
.as_ref()
.expect("url should be set from the with_target_principal call")
.id_token_url(&source_credentials)
.await;
assert_eq!(
url,
format!(
"https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/test-principal:generateIdToken"
)
);
let endpoint = server.url("/").to_string();
let endpoint = endpoint.trim_end_matches('/');
let creds = builder.with_impersonation_endpoint(endpoint).build()?;
let token = creds.id_token().await?;
assert_eq!(token, token_string);
Ok(())
}
}