use super::dynamic::CredentialsProvider;
use super::external_account_sources::aws_sourced::AwsSourcedCredentials;
use super::external_account_sources::executable_sourced::ExecutableSourcedCredentials;
use super::external_account_sources::file_sourced::FileSourcedCredentials;
use super::external_account_sources::url_sourced::UrlSourcedCredentials;
use super::impersonated;
use super::internal::sts_exchange::{ClientAuthentication, ExchangeTokenRequest, STSHandler};
use super::{CacheableResource, Credentials};
use crate::access_boundary::{CredentialsWithAccessBoundary, external_account_lookup_url};
use crate::build_errors::Error as BuilderError;
use crate::constants::{DEFAULT_SCOPE, STS_TOKEN_URL};
use crate::credentials::dynamic::AccessTokenCredentialsProvider;
use crate::credentials::external_account_sources::programmatic_sourced::ProgrammaticSourcedCredentials;
use crate::credentials::subject_token::dynamic;
use crate::credentials::{AccessToken, AccessTokenCredentials};
use crate::errors::non_retryable;
use crate::headers_util::AuthHeadersBuilder;
use crate::retry::Builder as RetryTokenProviderBuilder;
use crate::token::{CachedTokenProvider, Token, TokenProvider};
use crate::token_cache::TokenCache;
use crate::{BuildResult, Result};
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 serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::time::{Duration, Instant};
const IAM_SCOPE: &str = "https://www.googleapis.com/auth/iam";
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub(crate) struct CredentialSourceFormat {
#[serde(rename = "type")]
pub format_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_token_field_name: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub(crate) struct ExecutableConfig {
pub command: String,
pub timeout_millis: Option<u32>,
pub output_file: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
enum CredentialSourceFile {
Aws {
environment_id: String,
region_url: Option<String>,
url: Option<String>,
regional_cred_verification_url: Option<String>,
imdsv2_session_token_url: Option<String>,
},
Executable {
executable: ExecutableConfig,
},
Url {
url: String,
headers: Option<HashMap<String, String>>,
format: Option<CredentialSourceFormat>,
},
File {
file: String,
format: Option<CredentialSourceFormat>,
},
}
fn is_valid_workforce_pool_audience(audience: &str) -> bool {
let path = audience
.strip_prefix("//iam.googleapis.com/")
.unwrap_or(audience);
let mut s = path.split('/');
matches!((s.next(), s.next(), s.next(), s.next(), s.next(), s.next(), s.next()), (
Some("locations"),
Some(_location),
Some("workforcePools"),
Some(pool),
Some("providers"),
Some(provider),
None,
) if !pool.is_empty() && !provider.is_empty())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct ExternalAccountFile {
audience: String,
subject_token_type: String,
service_account_impersonation_url: Option<String>,
token_url: String,
client_id: Option<String>,
client_secret: Option<String>,
scopes: Option<Vec<String>>,
credential_source: CredentialSourceFile,
workforce_pool_user_project: Option<String>,
}
impl From<ExternalAccountFile> for ExternalAccountConfig {
fn from(config: ExternalAccountFile) -> Self {
let mut scope = config.scopes.unwrap_or_default();
if scope.is_empty() {
scope.push(DEFAULT_SCOPE.to_string());
}
Self {
audience: config.audience.clone(),
client_id: config.client_id,
client_secret: config.client_secret,
subject_token_type: config.subject_token_type,
token_url: config.token_url,
service_account_impersonation_url: config.service_account_impersonation_url,
credential_source: CredentialSource::from_file(
config.credential_source,
&config.audience,
),
scopes: scope,
workforce_pool_user_project: config.workforce_pool_user_project,
}
}
}
impl CredentialSource {
fn from_file(source: CredentialSourceFile, audience: &str) -> Self {
match source {
CredentialSourceFile::Url {
url,
headers,
format,
} => Self::Url(UrlSourcedCredentials::new(url, headers, format)),
CredentialSourceFile::Executable { executable } => {
Self::Executable(ExecutableSourcedCredentials::new(executable))
}
CredentialSourceFile::File { file, format } => {
Self::File(FileSourcedCredentials::new(file, format))
}
CredentialSourceFile::Aws {
region_url,
url,
regional_cred_verification_url,
imdsv2_session_token_url,
..
} => Self::Aws(AwsSourcedCredentials::new(
region_url,
url,
regional_cred_verification_url,
imdsv2_session_token_url,
audience.to_string(),
)),
}
}
}
#[derive(Debug, Clone)]
struct ExternalAccountConfig {
audience: String,
subject_token_type: String,
token_url: String,
service_account_impersonation_url: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
scopes: Vec<String>,
credential_source: CredentialSource,
workforce_pool_user_project: Option<String>,
}
#[derive(Debug, Default)]
struct ExternalAccountConfigBuilder {
audience: Option<String>,
subject_token_type: Option<String>,
token_url: Option<String>,
service_account_impersonation_url: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
scopes: Option<Vec<String>>,
credential_source: Option<CredentialSource>,
workforce_pool_user_project: Option<String>,
}
impl ExternalAccountConfigBuilder {
fn with_audience<S: Into<String>>(mut self, audience: S) -> Self {
self.audience = Some(audience.into());
self
}
fn with_subject_token_type<S: Into<String>>(mut self, subject_token_type: S) -> Self {
self.subject_token_type = Some(subject_token_type.into());
self
}
fn with_token_url<S: Into<String>>(mut self, token_url: S) -> Self {
self.token_url = Some(token_url.into());
self
}
fn with_service_account_impersonation_url<S: Into<String>>(mut self, url: S) -> Self {
self.service_account_impersonation_url = Some(url.into());
self
}
fn with_client_id<S: Into<String>>(mut self, client_id: S) -> Self {
self.client_id = Some(client_id.into());
self
}
fn with_client_secret<S: Into<String>>(mut self, client_secret: S) -> Self {
self.client_secret = Some(client_secret.into());
self
}
fn with_scopes(mut self, scopes: Vec<String>) -> Self {
self.scopes = Some(scopes);
self
}
fn with_credential_source(mut self, source: CredentialSource) -> Self {
self.credential_source = Some(source);
self
}
fn with_workforce_pool_user_project<S: Into<String>>(mut self, project: S) -> Self {
self.workforce_pool_user_project = Some(project.into());
self
}
fn build(self) -> BuildResult<ExternalAccountConfig> {
let audience = self
.audience
.clone()
.ok_or(BuilderError::missing_field("audience"))?;
if self.workforce_pool_user_project.is_some()
&& !is_valid_workforce_pool_audience(&audience)
{
return Err(BuilderError::parsing(
"workforce_pool_user_project should not be set for non-workforce pool credentials",
));
}
Ok(ExternalAccountConfig {
audience,
subject_token_type: self
.subject_token_type
.ok_or(BuilderError::missing_field("subject_token_type"))?,
token_url: self
.token_url
.ok_or(BuilderError::missing_field("token_url"))?,
scopes: self.scopes.ok_or(BuilderError::missing_field("scopes"))?,
credential_source: self
.credential_source
.ok_or(BuilderError::missing_field("credential_source"))?,
service_account_impersonation_url: self.service_account_impersonation_url,
client_id: self.client_id,
client_secret: self.client_secret,
workforce_pool_user_project: self.workforce_pool_user_project,
})
}
}
#[derive(Debug, Clone)]
enum CredentialSource {
Url(UrlSourcedCredentials),
Executable(ExecutableSourcedCredentials),
File(FileSourcedCredentials),
Aws(AwsSourcedCredentials),
Programmatic(ProgrammaticSourcedCredentials),
}
impl ExternalAccountConfig {
fn make_credentials(
self,
quota_project_id: Option<String>,
retry_builder: RetryTokenProviderBuilder,
) -> ExternalAccountCredentials<TokenCache> {
let config = self.clone();
match self.credential_source {
CredentialSource::Url(source) => {
Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
}
CredentialSource::Executable(source) => {
Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
}
CredentialSource::Programmatic(source) => {
Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
}
CredentialSource::File(source) => {
Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
}
CredentialSource::Aws(source) => {
Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
}
}
}
fn make_credentials_from_source<T>(
subject_token_provider: T,
config: ExternalAccountConfig,
quota_project_id: Option<String>,
retry_builder: RetryTokenProviderBuilder,
) -> ExternalAccountCredentials<TokenCache>
where
T: dynamic::SubjectTokenProvider + 'static,
{
let token_provider = ExternalAccountTokenProvider {
subject_token_provider,
config,
};
let token_provider_with_retry = retry_builder.build(token_provider);
let cache = TokenCache::new(token_provider_with_retry);
ExternalAccountCredentials {
token_provider: cache,
quota_project_id,
}
}
}
#[derive(Debug)]
struct ExternalAccountTokenProvider<T>
where
T: dynamic::SubjectTokenProvider,
{
subject_token_provider: T,
config: ExternalAccountConfig,
}
#[async_trait::async_trait]
impl<T> TokenProvider for ExternalAccountTokenProvider<T>
where
T: dynamic::SubjectTokenProvider,
{
async fn token(&self) -> Result<Token> {
let subject_token = self.subject_token_provider.subject_token().await?;
let audience = self.config.audience.clone();
let subject_token_type = self.config.subject_token_type.clone();
let user_scopes = self.config.scopes.clone();
let url = self.config.token_url.clone();
let extra_options =
if self.config.client_id.is_none() && self.config.client_secret.is_none() {
let workforce_pool_user_project = self.config.workforce_pool_user_project.clone();
workforce_pool_user_project.map(|project| {
let mut options = HashMap::new();
options.insert("userProject".to_string(), project);
options
})
} else {
None
};
let sts_scope = if self.config.service_account_impersonation_url.is_some() {
vec![IAM_SCOPE.to_string()]
} else {
user_scopes.clone()
};
let req = ExchangeTokenRequest {
url,
audience: Some(audience),
subject_token: subject_token.token,
subject_token_type,
scope: sts_scope,
authentication: ClientAuthentication {
client_id: self.config.client_id.clone(),
client_secret: self.config.client_secret.clone(),
},
extra_options,
..ExchangeTokenRequest::default()
};
let token_res = STSHandler::exchange_token(req).await?;
if let Some(impersonation_url) = &self.config.service_account_impersonation_url {
let mut headers = HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
http::HeaderValue::from_str(&format!("Bearer {}", token_res.access_token))
.map_err(non_retryable)?,
);
return impersonated::generate_access_token(
headers,
None,
user_scopes,
impersonated::DEFAULT_LIFETIME,
impersonation_url,
)
.await;
}
let token = Token {
token: token_res.access_token,
token_type: token_res.token_type,
expires_at: Some(Instant::now() + Duration::from_secs(token_res.expires_in)),
metadata: None,
};
Ok(token)
}
}
#[derive(Debug)]
pub(crate) struct ExternalAccountCredentials<T>
where
T: CachedTokenProvider,
{
token_provider: T,
quota_project_id: Option<String>,
}
pub struct Builder {
external_account_config: Value,
quota_project_id: Option<String>,
scopes: Option<Vec<String>>,
retry_builder: RetryTokenProviderBuilder,
iam_endpoint_override: Option<String>,
}
impl Builder {
pub fn new(external_account_config: Value) -> Self {
Self {
external_account_config,
quota_project_id: None,
scopes: None,
retry_builder: RetryTokenProviderBuilder::default(),
iam_endpoint_override: None,
}
}
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_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_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
}
#[cfg(all(test, google_cloud_unstable_trusted_boundaries))]
fn maybe_iam_endpoint_override(mut self, iam_endpoint_override: Option<String>) -> Self {
self.iam_endpoint_override = iam_endpoint_override;
self
}
pub fn build(self) -> BuildResult<Credentials> {
Ok(self.build_credentials()?.into())
}
pub fn build_access_token_credentials(self) -> BuildResult<AccessTokenCredentials> {
Ok(self.build_credentials()?.into())
}
fn build_credentials(
self,
) -> BuildResult<CredentialsWithAccessBoundary<ExternalAccountCredentials<TokenCache>>> {
let mut file: ExternalAccountFile =
serde_json::from_value(self.external_account_config).map_err(BuilderError::parsing)?;
if let Some(scopes) = self.scopes {
file.scopes = Some(scopes);
}
if file.workforce_pool_user_project.is_some()
&& !is_valid_workforce_pool_audience(&file.audience)
{
return Err(BuilderError::parsing(
"workforce_pool_user_project should not be set for non-workforce pool credentials",
));
}
let config: ExternalAccountConfig = file.into();
let access_boundary_url =
external_account_lookup_url(&config.audience, self.iam_endpoint_override.as_deref());
let creds = config.make_credentials(self.quota_project_id, self.retry_builder);
Ok(CredentialsWithAccessBoundary::new(
creds,
access_boundary_url,
))
}
}
pub struct ProgrammaticBuilder {
quota_project_id: Option<String>,
config: ExternalAccountConfigBuilder,
retry_builder: RetryTokenProviderBuilder,
}
impl ProgrammaticBuilder {
pub fn new(subject_token_provider: Arc<dyn dynamic::SubjectTokenProvider>) -> Self {
let config = ExternalAccountConfigBuilder::default().with_credential_source(
CredentialSource::Programmatic(ProgrammaticSourcedCredentials::new(
subject_token_provider,
)),
);
Self {
quota_project_id: None,
config,
retry_builder: RetryTokenProviderBuilder::default(),
}
}
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_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.config = self.config.with_scopes(
scopes
.into_iter()
.map(|s| s.into())
.collect::<Vec<String>>(),
);
self
}
pub fn with_audience<S: Into<String>>(mut self, audience: S) -> Self {
self.config = self.config.with_audience(audience);
self
}
pub fn with_subject_token_type<S: Into<String>>(mut self, subject_token_type: S) -> Self {
self.config = self.config.with_subject_token_type(subject_token_type);
self
}
pub fn with_token_url<S: Into<String>>(mut self, token_url: S) -> Self {
self.config = self.config.with_token_url(token_url);
self
}
pub fn with_client_id<S: Into<String>>(mut self, client_id: S) -> Self {
self.config = self.config.with_client_id(client_id.into());
self
}
pub fn with_client_secret<S: Into<String>>(mut self, client_secret: S) -> Self {
self.config = self.config.with_client_secret(client_secret.into());
self
}
pub fn with_target_principal<S: Into<String>>(mut self, target_principal: S) -> Self {
let url = format!(
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken",
target_principal.into()
);
self.config = self.config.with_service_account_impersonation_url(url);
self
}
pub fn with_workforce_pool_user_project<S: Into<String>>(mut self, project: S) -> Self {
self.config = self.config.with_workforce_pool_user_project(project);
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> {
let (config, quota_project_id, retry_builder) = self.build_components()?;
let creds = config.make_credentials(quota_project_id, retry_builder);
Ok(Credentials {
inner: Arc::new(creds),
})
}
fn build_components(
self,
) -> BuildResult<(
ExternalAccountConfig,
Option<String>,
RetryTokenProviderBuilder,
)> {
let Self {
quota_project_id,
config,
retry_builder,
} = self;
let mut config_builder = config;
if config_builder.scopes.is_none() {
config_builder = config_builder.with_scopes(vec![DEFAULT_SCOPE.to_string()]);
}
if config_builder.token_url.is_none() {
config_builder = config_builder.with_token_url(STS_TOKEN_URL.to_string());
}
let final_config = config_builder.build()?;
Ok((final_config, quota_project_id, retry_builder))
}
}
#[async_trait::async_trait]
impl<T> CredentialsProvider for ExternalAccountCredentials<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 ExternalAccountCredentials<T>
where
T: CachedTokenProvider,
{
async fn access_token(&self) -> Result<AccessToken> {
let token = self.token_provider.token(Extensions::new()).await?;
token.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::{
ACCESS_TOKEN_TYPE, DEFAULT_SCOPE, JWT_TOKEN_TYPE, TOKEN_EXCHANGE_GRANT_TYPE,
};
use crate::credentials::subject_token::{
Builder as SubjectTokenBuilder, SubjectToken, SubjectTokenProvider,
};
use crate::credentials::tests::{
find_source_error, get_mock_auth_retry_policy, get_mock_backoff_policy,
get_mock_retry_throttler, get_token_from_headers,
};
use crate::errors::{CredentialsError, SubjectTokenProviderError};
use httptest::{
Expectation, Server, cycle,
matchers::{all_of, contains, request, url_decoded},
responders::{json_encoded, status_code},
};
use serde_json::*;
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use test_case::test_case;
use time::OffsetDateTime;
#[derive(Debug)]
struct TestProviderError;
impl fmt::Display for TestProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TestProviderError")
}
}
impl Error for TestProviderError {}
impl SubjectTokenProviderError for TestProviderError {
fn is_transient(&self) -> bool {
false
}
}
#[derive(Debug)]
struct TestSubjectTokenProvider;
impl SubjectTokenProvider for TestSubjectTokenProvider {
type Error = TestProviderError;
async fn subject_token(&self) -> std::result::Result<SubjectToken, Self::Error> {
Ok(SubjectTokenBuilder::new("test-subject-token".to_string()).build())
}
}
#[tokio::test]
async fn create_external_account_builder() {
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1beta/token",
"credential_source": {
"url": "https://example.com/token",
"format": {
"type": "json",
"subject_token_field_name": "access_token"
}
}
});
let creds = Builder::new(contents)
.with_quota_project_id("test_project")
.with_scopes(["a", "b"])
.build()
.unwrap();
let fmt = format!("{creds:?}");
assert!(fmt.contains("ExternalAccountCredentials"));
}
#[tokio::test]
async fn create_external_account_detect_url_sourced() {
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1beta/token",
"credential_source": {
"url": "https://example.com/token",
"headers": {
"Metadata": "True"
},
"format": {
"type": "json",
"subject_token_field_name": "access_token"
}
}
});
let file: ExternalAccountFile =
serde_json::from_value(contents).expect("failed to parse external account config");
let config: ExternalAccountConfig = file.into();
let source = config.credential_source;
match source {
CredentialSource::Url(source) => {
assert_eq!(source.url, "https://example.com/token");
assert_eq!(
source.headers,
HashMap::from([("Metadata".to_string(), "True".to_string()),]),
);
assert_eq!(source.format, "json");
assert_eq!(source.subject_token_field_name, "access_token");
}
_ => {
unreachable!("expected Url Sourced credential")
}
}
}
#[tokio::test]
async fn create_external_account_detect_executable_sourced() {
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1beta/token",
"credential_source": {
"executable": {
"command": "cat /some/file",
"output_file": "/some/file",
"timeout_millis": 5000
}
}
});
let file: ExternalAccountFile =
serde_json::from_value(contents).expect("failed to parse external account config");
let config: ExternalAccountConfig = file.into();
let source = config.credential_source;
match source {
CredentialSource::Executable(source) => {
assert_eq!(source.command, "cat");
assert_eq!(source.args, vec!["/some/file"]);
assert_eq!(source.output_file.as_deref(), Some("/some/file"));
assert_eq!(source.timeout, Duration::from_secs(5));
}
_ => {
unreachable!("expected Executable Sourced credential")
}
}
}
#[tokio::test]
async fn create_external_account_detect_file_sourced() {
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1beta/token",
"credential_source": {
"file": "/foo/bar",
"format": {
"type": "json",
"subject_token_field_name": "token"
}
}
});
let file: ExternalAccountFile =
serde_json::from_value(contents).expect("failed to parse external account config");
let config: ExternalAccountConfig = file.into();
let source = config.credential_source;
match source {
CredentialSource::File(source) => {
assert_eq!(source.file, "/foo/bar");
assert_eq!(source.format, "json");
assert_eq!(source.subject_token_field_name, "token");
}
_ => {
unreachable!("expected File Sourced credential")
}
}
}
#[tokio::test]
async fn test_external_account_with_impersonation_success() {
let subject_token_server = Server::run();
let sts_server = Server::run();
let impersonation_server = Server::run();
let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
"format": {
"type": "json",
"subject_token_field_name": "access_token"
}
}
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
sts_server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/token"),
request::body(url_decoded(contains((
"grant_type",
TOKEN_EXCHANGE_GRANT_TYPE
)))),
request::body(url_decoded(contains(("subject_token", "subject_token")))),
request::body(url_decoded(contains((
"requested_token_type",
ACCESS_TOKEN_TYPE
)))),
request::body(url_decoded(contains((
"subject_token_type",
JWT_TOKEN_TYPE
)))),
request::body(url_decoded(contains(("audience", "audience")))),
request::body(url_decoded(contains(("scope", IAM_SCOPE)))),
])
.respond_with(json_encoded(json!({
"access_token": "sts-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
}))),
);
let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
impersonation_server.expect(
Expectation::matching(all_of![
request::method_path("POST", impersonation_path),
request::headers(contains(("authorization", "Bearer sts-token"))),
])
.respond_with(json_encoded(json!({
"accessToken": "final-impersonated-token",
"expireTime": expire_time
}))),
);
let creds = Builder::new(contents).build().unwrap();
let headers = creds.headers(Extensions::new()).await.unwrap();
match headers {
CacheableResource::New { data, .. } => {
let token = data.get("authorization").unwrap().to_str().unwrap();
assert_eq!(token, "Bearer final-impersonated-token");
}
CacheableResource::NotModified => panic!("Expected new headers"),
}
}
#[tokio::test]
async fn test_external_account_without_impersonation_success() {
let subject_token_server = Server::run();
let sts_server = Server::run();
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
"format": {
"type": "json",
"subject_token_field_name": "access_token"
}
}
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
sts_server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/token"),
request::body(url_decoded(contains((
"grant_type",
TOKEN_EXCHANGE_GRANT_TYPE
)))),
request::body(url_decoded(contains(("subject_token", "subject_token")))),
request::body(url_decoded(contains((
"requested_token_type",
ACCESS_TOKEN_TYPE
)))),
request::body(url_decoded(contains((
"subject_token_type",
JWT_TOKEN_TYPE
)))),
request::body(url_decoded(contains(("audience", "audience")))),
request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
])
.respond_with(json_encoded(json!({
"access_token": "sts-only-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
}))),
);
let creds = Builder::new(contents).build().unwrap();
let headers = creds.headers(Extensions::new()).await.unwrap();
match headers {
CacheableResource::New { data, .. } => {
let token = data.get("authorization").unwrap().to_str().unwrap();
assert_eq!(token, "Bearer sts-only-token");
}
CacheableResource::NotModified => panic!("Expected new headers"),
}
}
#[tokio::test]
async fn test_external_account_access_token_credentials_success() {
let server = Server::run();
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": server.url("/token").to_string(),
"credential_source": {
"url": server.url("/subject_token").to_string(),
"format": {
"type": "json",
"subject_token_field_name": "access_token"
}
}
});
server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/token"),
request::body(url_decoded(contains((
"grant_type",
TOKEN_EXCHANGE_GRANT_TYPE
)))),
request::body(url_decoded(contains(("subject_token", "subject_token")))),
request::body(url_decoded(contains((
"requested_token_type",
ACCESS_TOKEN_TYPE
)))),
request::body(url_decoded(contains((
"subject_token_type",
JWT_TOKEN_TYPE
)))),
request::body(url_decoded(contains(("audience", "audience")))),
request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
])
.respond_with(json_encoded(json!({
"access_token": "sts-only-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
}))),
);
let creds = Builder::new(contents)
.build_access_token_credentials()
.unwrap();
let access_token = creds.access_token().await.unwrap();
assert_eq!(access_token.token, "sts-only-token");
}
#[tokio::test]
async fn test_impersonation_flow_sts_call_fails() {
let subject_token_server = Server::run();
let sts_server = Server::run();
let impersonation_server = Server::run();
let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
"format": {
"type": "json",
"subject_token_field_name": "access_token"
}
}
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
sts_server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.respond_with(status_code(500)),
);
let creds = Builder::new(contents).build().unwrap();
let err = creds.headers(Extensions::new()).await.unwrap_err();
let original_err = find_source_error::<CredentialsError>(&err).unwrap();
assert!(
original_err
.to_string()
.contains("failed to exchange token")
);
assert!(original_err.is_transient());
}
#[tokio::test]
async fn test_impersonation_flow_iam_call_fails() {
let subject_token_server = Server::run();
let sts_server = Server::run();
let impersonation_server = Server::run();
let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
"format": {
"type": "json",
"subject_token_field_name": "access_token"
}
}
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
sts_server.expect(
Expectation::matching(request::method_path("POST", "/token")).respond_with(
json_encoded(json!({
"access_token": "sts-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
})),
),
);
impersonation_server.expect(
Expectation::matching(request::method_path("POST", impersonation_path))
.respond_with(status_code(403)),
);
let creds = Builder::new(contents).build().unwrap();
let err = creds.headers(Extensions::new()).await.unwrap_err();
let original_err = find_source_error::<CredentialsError>(&err).unwrap();
assert!(original_err.to_string().contains("failed to fetch token"));
assert!(!original_err.is_transient());
}
#[test_case(Some(vec!["scope1", "scope2"]), Some("http://custom.com/token") ; "with custom scopes and token_url")]
#[test_case(None, Some("http://custom.com/token") ; "with default scopes and custom token_url")]
#[test_case(Some(vec!["scope1", "scope2"]), None ; "with custom scopes and default token_url")]
#[test_case(None, None ; "with default scopes and default token_url")]
#[tokio::test]
async fn create_programmatic_builder(scopes: Option<Vec<&str>>, token_url: Option<&str>) {
let provider = Arc::new(TestSubjectTokenProvider);
let mut builder = ProgrammaticBuilder::new(provider)
.with_audience("test-audience")
.with_subject_token_type("test-token-type")
.with_client_id("test-client-id")
.with_client_secret("test-client-secret")
.with_target_principal("test-principal");
let expected_scopes = if let Some(scopes) = scopes.clone() {
scopes.iter().map(|s| s.to_string()).collect()
} else {
vec![DEFAULT_SCOPE.to_string()]
};
let expected_token_url = token_url.unwrap_or(STS_TOKEN_URL).to_string();
if let Some(scopes) = scopes {
builder = builder.with_scopes(scopes);
}
if let Some(token_url) = token_url {
builder = builder.with_token_url(token_url);
}
let (config, _, _) = builder.build_components().unwrap();
assert_eq!(config.audience, "test-audience");
assert_eq!(config.subject_token_type, "test-token-type");
assert_eq!(config.client_id, Some("test-client-id".to_string()));
assert_eq!(config.client_secret, Some("test-client-secret".to_string()));
assert_eq!(config.scopes, expected_scopes);
assert_eq!(config.token_url, expected_token_url);
assert_eq!(
config.service_account_impersonation_url,
Some("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken".to_string())
);
}
#[tokio::test]
async fn create_programmatic_builder_with_quota_project_id() {
let provider = Arc::new(TestSubjectTokenProvider);
let builder = ProgrammaticBuilder::new(provider)
.with_audience("test-audience")
.with_subject_token_type("test-token-type")
.with_token_url(STS_TOKEN_URL)
.with_quota_project_id("test-quota-project");
let creds = builder.build().unwrap();
let fmt = format!("{creds:?}");
assert!(
fmt.contains("ExternalAccountCredentials"),
"Expected 'ExternalAccountCredentials', got: {fmt}"
);
assert!(
fmt.contains("test-quota-project"),
"Expected 'test-quota-project', got: {fmt}"
);
}
#[tokio::test]
async fn programmatic_builder_returns_correct_headers() {
let provider = Arc::new(TestSubjectTokenProvider);
let sts_server = Server::run();
let builder = ProgrammaticBuilder::new(provider)
.with_audience("test-audience")
.with_subject_token_type("test-token-type")
.with_token_url(sts_server.url("/token").to_string())
.with_quota_project_id("test-quota-project");
let creds = builder.build().unwrap();
sts_server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/token"),
request::body(url_decoded(contains((
"grant_type",
TOKEN_EXCHANGE_GRANT_TYPE
)))),
request::body(url_decoded(contains((
"subject_token",
"test-subject-token"
)))),
request::body(url_decoded(contains((
"requested_token_type",
ACCESS_TOKEN_TYPE
)))),
request::body(url_decoded(contains((
"subject_token_type",
"test-token-type"
)))),
request::body(url_decoded(contains(("audience", "test-audience")))),
request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
])
.respond_with(json_encoded(json!({
"access_token": "sts-only-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
}))),
);
let headers = creds.headers(Extensions::new()).await.unwrap();
match headers {
CacheableResource::New { data, .. } => {
let token = data.get("authorization").unwrap().to_str().unwrap();
assert_eq!(token, "Bearer sts-only-token");
let quota_project = data.get("x-goog-user-project").unwrap().to_str().unwrap();
assert_eq!(quota_project, "test-quota-project");
}
CacheableResource::NotModified => panic!("Expected new headers"),
}
}
#[tokio::test]
async fn create_programmatic_builder_fails_on_missing_required_field() {
let provider = Arc::new(TestSubjectTokenProvider);
let result = ProgrammaticBuilder::new(provider)
.with_subject_token_type("test-token-type")
.with_token_url("http://test.com/token")
.build();
assert!(result.is_err(), "{result:?}");
let error_string = result.unwrap_err().to_string();
assert!(
error_string.contains("missing required field: audience"),
"Expected error about missing 'audience', got: {error_string}"
);
}
#[tokio::test]
async fn test_external_account_retries_on_transient_failures() {
let mut subject_token_server = Server::run();
let mut sts_server = Server::run();
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
}
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token"))
.times(3)
.respond_with(json_encoded(json!({
"access_token": "subject_token",
}))),
);
sts_server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.times(3)
.respond_with(status_code(503)),
);
let creds = Builder::new(contents)
.with_retry_policy(get_mock_auth_retry_policy(3))
.with_backoff_policy(get_mock_backoff_policy())
.with_retry_throttler(get_mock_retry_throttler())
.build()
.unwrap();
let err = creds.headers(Extensions::new()).await.unwrap_err();
assert!(err.is_transient(), "{err:?}");
sts_server.verify_and_clear();
subject_token_server.verify_and_clear();
}
#[tokio::test]
async fn test_external_account_does_not_retry_on_non_transient_failures() {
let subject_token_server = Server::run();
let mut sts_server = Server::run();
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
}
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
sts_server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.times(1)
.respond_with(status_code(401)),
);
let creds = Builder::new(contents)
.with_retry_policy(get_mock_auth_retry_policy(1))
.with_backoff_policy(get_mock_backoff_policy())
.with_retry_throttler(get_mock_retry_throttler())
.build()
.unwrap();
let err = creds.headers(Extensions::new()).await.unwrap_err();
assert!(!err.is_transient());
sts_server.verify_and_clear();
}
#[tokio::test]
async fn test_external_account_retries_for_success() {
let mut subject_token_server = Server::run();
let mut sts_server = Server::run();
let contents = json!({
"type": "external_account",
"audience": "audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
}
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token"))
.times(3)
.respond_with(json_encoded(json!({
"access_token": "subject_token",
}))),
);
sts_server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.times(3)
.respond_with(cycle![
status_code(503).body("try-again"),
status_code(503).body("try-again"),
json_encoded(json!({
"access_token": "sts-only-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
}))
]),
);
let creds = Builder::new(contents)
.with_retry_policy(get_mock_auth_retry_policy(3))
.with_backoff_policy(get_mock_backoff_policy())
.with_retry_throttler(get_mock_retry_throttler())
.build()
.unwrap();
let headers = creds.headers(Extensions::new()).await.unwrap();
match headers {
CacheableResource::New { data, .. } => {
let token = data.get("authorization").unwrap().to_str().unwrap();
assert_eq!(token, "Bearer sts-only-token");
}
CacheableResource::NotModified => panic!("Expected new headers"),
}
sts_server.verify_and_clear();
subject_token_server.verify_and_clear();
}
#[tokio::test]
async fn test_programmatic_builder_retries_on_transient_failures() {
let provider = Arc::new(TestSubjectTokenProvider);
let mut sts_server = Server::run();
sts_server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.times(3)
.respond_with(status_code(503)),
);
let creds = ProgrammaticBuilder::new(provider)
.with_audience("test-audience")
.with_subject_token_type("test-token-type")
.with_token_url(sts_server.url("/token").to_string())
.with_retry_policy(get_mock_auth_retry_policy(3))
.with_backoff_policy(get_mock_backoff_policy())
.with_retry_throttler(get_mock_retry_throttler())
.build()
.unwrap();
let err = creds.headers(Extensions::new()).await.unwrap_err();
assert!(err.is_transient(), "{err:?}");
sts_server.verify_and_clear();
}
#[tokio::test]
async fn test_programmatic_builder_does_not_retry_on_non_transient_failures() {
let provider = Arc::new(TestSubjectTokenProvider);
let mut sts_server = Server::run();
sts_server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.times(1)
.respond_with(status_code(401)),
);
let creds = ProgrammaticBuilder::new(provider)
.with_audience("test-audience")
.with_subject_token_type("test-token-type")
.with_token_url(sts_server.url("/token").to_string())
.with_retry_policy(get_mock_auth_retry_policy(1))
.with_backoff_policy(get_mock_backoff_policy())
.with_retry_throttler(get_mock_retry_throttler())
.build()
.unwrap();
let err = creds.headers(Extensions::new()).await.unwrap_err();
assert!(!err.is_transient());
sts_server.verify_and_clear();
}
#[tokio::test]
async fn test_programmatic_builder_retries_for_success() {
let provider = Arc::new(TestSubjectTokenProvider);
let mut sts_server = Server::run();
sts_server.expect(
Expectation::matching(request::method_path("POST", "/token"))
.times(3)
.respond_with(cycle![
status_code(503).body("try-again"),
status_code(503).body("try-again"),
json_encoded(json!({
"access_token": "sts-only-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
}))
]),
);
let creds = ProgrammaticBuilder::new(provider)
.with_audience("test-audience")
.with_subject_token_type("test-token-type")
.with_token_url(sts_server.url("/token").to_string())
.with_retry_policy(get_mock_auth_retry_policy(3))
.with_backoff_policy(get_mock_backoff_policy())
.with_retry_throttler(get_mock_retry_throttler())
.build()
.unwrap();
let headers = creds.headers(Extensions::new()).await.unwrap();
match headers {
CacheableResource::New { data, .. } => {
let token = data.get("authorization").unwrap().to_str().unwrap();
assert_eq!(token, "Bearer sts-only-token");
}
CacheableResource::NotModified => panic!("Expected new headers"),
}
sts_server.verify_and_clear();
}
#[test_case(
"//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider",
"/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations";
"workload_identity_pool"
)]
#[test_case(
"//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider",
"/v1/locations/global/workforcePools/my-pool/allowedLocations";
"workforce_pool"
)]
#[tokio::test]
#[cfg(google_cloud_unstable_trusted_boundaries)]
async fn e2e_access_boundary(audience: &str, iam_path: &str) -> anyhow::Result<()> {
use crate::credentials::tests::get_access_boundary_from_headers;
let audience = audience.to_string();
let iam_path = iam_path.to_string();
let server = Server::run();
server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/token"),
request::body(url_decoded(contains(("subject_token", "subject_token")))),
request::body(url_decoded(contains(("audience", audience.clone())))),
])
.respond_with(json_encoded(json!({
"access_token": "sts-only-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
}))),
);
server.expect(
Expectation::matching(all_of![request::method_path("GET", iam_path.clone()),])
.respond_with(json_encoded(json!({
"locations": ["us-central1"],
"encodedLocations": "0x1234"
}))),
);
let contents = json!({
"type": "external_account",
"audience": audience.to_string(),
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": server.url("/token").to_string(),
"credential_source": {
"url": server.url("/subject_token").to_string(),
"format": {
"type": "json",
"subject_token_field_name": "access_token"
}
}
});
let iam_endpoint = server.url("").to_string().trim_end_matches('/').to_string();
let creds = Builder::new(contents)
.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");
assert_eq!(access_boundary.as_deref(), Some("0x1234"));
Ok(())
}
#[tokio::test]
async fn test_kubernetes_wif_direct_identity_parsing() {
let contents = json!({
"audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool-name/providers/k8s-cluster",
"credential_source": {
"file": "/var/run/service-account/token"
},
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"type": "external_account"
});
let file: ExternalAccountFile = serde_json::from_value(contents)
.expect("failed to parse kubernetes WIF direct identity config");
let config: ExternalAccountConfig = file.into();
match config.credential_source {
CredentialSource::File(source) => {
assert_eq!(source.file, "/var/run/service-account/token");
assert_eq!(source.format, "text"); assert_eq!(source.subject_token_field_name, ""); }
_ => {
unreachable!("expected File sourced credential")
}
}
}
#[tokio::test]
async fn test_kubernetes_wif_impersonation_parsing() {
let contents = json!({
"audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool-name/providers/k8s-cluster",
"credential_source": {
"file": "/var/run/service-account/token",
"format": {
"type": "text"
}
},
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-sa@test-project.iam.gserviceaccount.com:generateAccessToken",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"type": "external_account",
"universe_domain": "googleapis.com"
});
let file: ExternalAccountFile = serde_json::from_value(contents)
.expect("failed to parse kubernetes WIF impersonation config");
let config: ExternalAccountConfig = file.into();
match config.credential_source {
CredentialSource::File(source) => {
assert_eq!(source.file, "/var/run/service-account/token");
assert_eq!(source.format, "text");
assert_eq!(source.subject_token_field_name, ""); }
_ => {
unreachable!("expected File sourced credential")
}
}
assert_eq!(
config.service_account_impersonation_url,
Some("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-sa@test-project.iam.gserviceaccount.com:generateAccessToken".to_string())
);
}
#[tokio::test]
async fn test_aws_parsing() {
let contents = json!({
"audience": "audience",
"credential_source": {
"environment_id": "aws1",
"region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
"imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
},
"subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
"token_url": "https://sts.googleapis.com/v1/token",
"type": "external_account"
});
let file: ExternalAccountFile =
serde_json::from_value(contents).expect("failed to parse AWS config");
let config: ExternalAccountConfig = file.into();
match config.credential_source {
CredentialSource::Aws(source) => {
assert_eq!(
source.region_url,
Some(
"http://169.254.169.254/latest/meta-data/placement/availability-zone"
.to_string()
)
);
assert_eq!(
source.regional_cred_verification_url,
Some(
"https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
.to_string()
)
);
assert_eq!(
source.imdsv2_session_token_url,
Some("http://169.254.169.254/latest/api/token".to_string())
);
}
_ => {
unreachable!("expected Aws sourced credential")
}
}
}
#[tokio::test]
async fn builder_workforce_pool_user_project_fails_without_workforce_pool_audience() {
let contents = json!({
"type": "external_account",
"audience": "not-a-workforce-pool",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "http://test.com/token",
"credential_source": {
"url": "http://test.com/subject_token",
},
"workforce_pool_user_project": "test-project"
});
let result = Builder::new(contents).build();
assert!(result.is_err(), "{result:?}");
let err = result.unwrap_err();
assert!(err.is_parsing(), "{err:?}");
}
#[tokio::test]
async fn sts_handler_ignores_workforce_pool_user_project_with_client_auth()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let subject_token_server = Server::run();
let sts_server = Server::run();
let contents = json!({
"type": "external_account",
"audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"client_id": "client-id",
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
},
"workforce_pool_user_project": "test-project"
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
sts_server.expect(
Expectation::matching(all_of![request::method_path("POST", "/token"),]).respond_with(
json_encoded(json!({
"access_token": "sts-only-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
})),
),
);
let creds = Builder::new(contents).build()?;
let headers = creds.headers(Extensions::new()).await?;
let token = get_token_from_headers(headers);
assert_eq!(token.as_deref(), Some("sts-only-token"));
Ok(())
}
#[tokio::test]
async fn sts_handler_receives_workforce_pool_user_project()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let subject_token_server = Server::run();
let sts_server = Server::run();
let contents = json!({
"type": "external_account",
"audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": sts_server.url("/token").to_string(),
"credential_source": {
"url": subject_token_server.url("/subject_token").to_string(),
},
"workforce_pool_user_project": "test-project-123"
});
subject_token_server.expect(
Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
json_encoded(json!({
"access_token": "subject_token",
})),
),
);
sts_server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/token"),
request::body(url_decoded(contains((
"options",
"{\"userProject\":\"test-project-123\"}"
)))),
])
.respond_with(json_encoded(json!({
"access_token": "sts-only-token",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
}))),
);
let creds = Builder::new(contents).build()?;
let headers = creds.headers(Extensions::new()).await?;
let token = get_token_from_headers(headers);
assert_eq!(token.as_deref(), Some("sts-only-token"));
Ok(())
}
}