1use super::dynamic::CredentialsProvider;
111use super::external_account_sources::aws_sourced::AwsSourcedCredentials;
112use super::external_account_sources::executable_sourced::ExecutableSourcedCredentials;
113use super::external_account_sources::file_sourced::FileSourcedCredentials;
114use super::external_account_sources::url_sourced::UrlSourcedCredentials;
115use super::impersonated;
116use super::internal::sts_exchange::{ClientAuthentication, ExchangeTokenRequest, STSHandler};
117use super::{CacheableResource, Credentials};
118use crate::access_boundary::{CredentialsWithAccessBoundary, external_account_lookup_url};
119use crate::build_errors::Error as BuilderError;
120use crate::constants::{DEFAULT_SCOPE, STS_TOKEN_URL};
121use crate::credentials::dynamic::AccessTokenCredentialsProvider;
122use crate::credentials::external_account_sources::programmatic_sourced::ProgrammaticSourcedCredentials;
123use crate::credentials::subject_token::dynamic;
124use crate::credentials::{AccessToken, AccessTokenCredentials};
125use crate::errors::non_retryable;
126use crate::headers_util::AuthHeadersBuilder;
127use crate::retry::Builder as RetryTokenProviderBuilder;
128use crate::token::{CachedTokenProvider, Token, TokenProvider};
129use crate::token_cache::TokenCache;
130use crate::{BuildResult, Result};
131use google_cloud_gax::backoff_policy::BackoffPolicyArg;
132use google_cloud_gax::retry_policy::RetryPolicyArg;
133use google_cloud_gax::retry_throttler::RetryThrottlerArg;
134use http::{Extensions, HeaderMap};
135use serde::{Deserialize, Serialize};
136use serde_json::Value;
137use std::collections::HashMap;
138use std::sync::Arc;
139use tokio::time::{Duration, Instant};
140
141const IAM_SCOPE: &str = "https://www.googleapis.com/auth/iam";
142
143#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
144pub(crate) struct CredentialSourceFormat {
145 #[serde(rename = "type")]
146 pub format_type: String,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub subject_token_field_name: Option<String>,
149}
150
151#[derive(Serialize, Deserialize, Debug, Clone, Default)]
152pub(crate) struct ExecutableConfig {
153 pub command: String,
154 pub timeout_millis: Option<u32>,
155 pub output_file: Option<String>,
156}
157
158#[derive(Serialize, Deserialize, Debug, Clone)]
159#[serde(untagged)]
160enum CredentialSourceFile {
161 Aws {
163 environment_id: String,
164 region_url: Option<String>,
165 url: Option<String>,
166 regional_cred_verification_url: Option<String>,
167 imdsv2_session_token_url: Option<String>,
168 },
169 Executable {
170 executable: ExecutableConfig,
171 },
172 Url {
173 url: String,
174 headers: Option<HashMap<String, String>>,
175 format: Option<CredentialSourceFormat>,
176 },
177 File {
178 file: String,
179 format: Option<CredentialSourceFormat>,
180 },
181}
182
183fn is_valid_workforce_pool_audience(audience: &str) -> bool {
186 let path = audience
187 .strip_prefix("//iam.googleapis.com/")
188 .unwrap_or(audience);
189
190 let mut s = path.split('/');
191 matches!((s.next(), s.next(), s.next(), s.next(), s.next(), s.next(), s.next()), (
192 Some("locations"),
193 Some(_location),
194 Some("workforcePools"),
195 Some(pool),
196 Some("providers"),
197 Some(provider),
198 None,
199 ) if !pool.is_empty() && !provider.is_empty())
200}
201
202#[derive(Serialize, Deserialize, Debug, Clone)]
206struct ExternalAccountFile {
207 audience: String,
208 subject_token_type: String,
209 service_account_impersonation_url: Option<String>,
210 token_url: String,
211 client_id: Option<String>,
212 client_secret: Option<String>,
213 scopes: Option<Vec<String>>,
214 credential_source: CredentialSourceFile,
215 workforce_pool_user_project: Option<String>,
216}
217
218impl From<ExternalAccountFile> for ExternalAccountConfig {
219 fn from(config: ExternalAccountFile) -> Self {
220 let mut scope = config.scopes.unwrap_or_default();
221 if scope.is_empty() {
222 scope.push(DEFAULT_SCOPE.to_string());
223 }
224 Self {
225 audience: config.audience.clone(),
226 client_id: config.client_id,
227 client_secret: config.client_secret,
228 subject_token_type: config.subject_token_type,
229 token_url: config.token_url,
230 service_account_impersonation_url: config.service_account_impersonation_url,
231 credential_source: CredentialSource::from_file(
232 config.credential_source,
233 &config.audience,
234 ),
235 scopes: scope,
236 workforce_pool_user_project: config.workforce_pool_user_project,
237 }
238 }
239}
240
241impl CredentialSource {
242 fn from_file(source: CredentialSourceFile, audience: &str) -> Self {
243 match source {
244 CredentialSourceFile::Url {
245 url,
246 headers,
247 format,
248 } => Self::Url(UrlSourcedCredentials::new(url, headers, format)),
249 CredentialSourceFile::Executable { executable } => {
250 Self::Executable(ExecutableSourcedCredentials::new(executable))
251 }
252 CredentialSourceFile::File { file, format } => {
253 Self::File(FileSourcedCredentials::new(file, format))
254 }
255 CredentialSourceFile::Aws {
256 region_url,
257 url,
258 regional_cred_verification_url,
259 imdsv2_session_token_url,
260 ..
261 } => Self::Aws(AwsSourcedCredentials::new(
262 region_url,
263 url,
264 regional_cred_verification_url,
265 imdsv2_session_token_url,
266 audience.to_string(),
267 )),
268 }
269 }
270}
271
272#[derive(Debug, Clone)]
273struct ExternalAccountConfig {
274 audience: String,
275 subject_token_type: String,
276 token_url: String,
277 service_account_impersonation_url: Option<String>,
278 client_id: Option<String>,
279 client_secret: Option<String>,
280 scopes: Vec<String>,
281 credential_source: CredentialSource,
282 workforce_pool_user_project: Option<String>,
283}
284
285#[derive(Debug, Default)]
286struct ExternalAccountConfigBuilder {
287 audience: Option<String>,
288 subject_token_type: Option<String>,
289 token_url: Option<String>,
290 service_account_impersonation_url: Option<String>,
291 client_id: Option<String>,
292 client_secret: Option<String>,
293 scopes: Option<Vec<String>>,
294 credential_source: Option<CredentialSource>,
295 workforce_pool_user_project: Option<String>,
296}
297
298impl ExternalAccountConfigBuilder {
299 fn with_audience<S: Into<String>>(mut self, audience: S) -> Self {
300 self.audience = Some(audience.into());
301 self
302 }
303
304 fn with_subject_token_type<S: Into<String>>(mut self, subject_token_type: S) -> Self {
305 self.subject_token_type = Some(subject_token_type.into());
306 self
307 }
308
309 fn with_token_url<S: Into<String>>(mut self, token_url: S) -> Self {
310 self.token_url = Some(token_url.into());
311 self
312 }
313
314 fn with_service_account_impersonation_url<S: Into<String>>(mut self, url: S) -> Self {
315 self.service_account_impersonation_url = Some(url.into());
316 self
317 }
318
319 fn with_client_id<S: Into<String>>(mut self, client_id: S) -> Self {
320 self.client_id = Some(client_id.into());
321 self
322 }
323
324 fn with_client_secret<S: Into<String>>(mut self, client_secret: S) -> Self {
325 self.client_secret = Some(client_secret.into());
326 self
327 }
328
329 fn with_scopes(mut self, scopes: Vec<String>) -> Self {
330 self.scopes = Some(scopes);
331 self
332 }
333
334 fn with_credential_source(mut self, source: CredentialSource) -> Self {
335 self.credential_source = Some(source);
336 self
337 }
338
339 fn with_workforce_pool_user_project<S: Into<String>>(mut self, project: S) -> Self {
340 self.workforce_pool_user_project = Some(project.into());
341 self
342 }
343
344 fn build(self) -> BuildResult<ExternalAccountConfig> {
345 let audience = self
346 .audience
347 .clone()
348 .ok_or(BuilderError::missing_field("audience"))?;
349
350 if self.workforce_pool_user_project.is_some()
351 && !is_valid_workforce_pool_audience(&audience)
352 {
353 return Err(BuilderError::parsing(
354 "workforce_pool_user_project should not be set for non-workforce pool credentials",
355 ));
356 }
357
358 Ok(ExternalAccountConfig {
359 audience,
360 subject_token_type: self
361 .subject_token_type
362 .ok_or(BuilderError::missing_field("subject_token_type"))?,
363 token_url: self
364 .token_url
365 .ok_or(BuilderError::missing_field("token_url"))?,
366 scopes: self.scopes.ok_or(BuilderError::missing_field("scopes"))?,
367 credential_source: self
368 .credential_source
369 .ok_or(BuilderError::missing_field("credential_source"))?,
370 service_account_impersonation_url: self.service_account_impersonation_url,
371 client_id: self.client_id,
372 client_secret: self.client_secret,
373 workforce_pool_user_project: self.workforce_pool_user_project,
374 })
375 }
376}
377
378#[derive(Debug, Clone)]
379#[allow(dead_code)]
380enum CredentialSource {
381 Url(UrlSourcedCredentials),
382 Executable(ExecutableSourcedCredentials),
383 File(FileSourcedCredentials),
384 Aws(AwsSourcedCredentials),
385 Programmatic(ProgrammaticSourcedCredentials),
386}
387
388impl ExternalAccountConfig {
389 fn make_credentials(
390 self,
391 quota_project_id: Option<String>,
392 retry_builder: RetryTokenProviderBuilder,
393 ) -> ExternalAccountCredentials<TokenCache> {
394 let config = self.clone();
395 match self.credential_source {
396 CredentialSource::Url(source) => {
397 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
398 }
399 CredentialSource::Executable(source) => {
400 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
401 }
402 CredentialSource::Programmatic(source) => {
403 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
404 }
405 CredentialSource::File(source) => {
406 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
407 }
408 CredentialSource::Aws(source) => {
409 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
410 }
411 }
412 }
413
414 fn make_credentials_from_source<T>(
415 subject_token_provider: T,
416 config: ExternalAccountConfig,
417 quota_project_id: Option<String>,
418 retry_builder: RetryTokenProviderBuilder,
419 ) -> ExternalAccountCredentials<TokenCache>
420 where
421 T: dynamic::SubjectTokenProvider + 'static,
422 {
423 let token_provider = ExternalAccountTokenProvider {
424 subject_token_provider,
425 config,
426 };
427 let token_provider_with_retry = retry_builder.build(token_provider);
428 let cache = TokenCache::new(token_provider_with_retry);
429 ExternalAccountCredentials {
430 token_provider: cache,
431 quota_project_id,
432 }
433 }
434}
435
436#[derive(Debug)]
437struct ExternalAccountTokenProvider<T>
438where
439 T: dynamic::SubjectTokenProvider,
440{
441 subject_token_provider: T,
442 config: ExternalAccountConfig,
443}
444
445#[async_trait::async_trait]
446impl<T> TokenProvider for ExternalAccountTokenProvider<T>
447where
448 T: dynamic::SubjectTokenProvider,
449{
450 async fn token(&self) -> Result<Token> {
451 let subject_token = self.subject_token_provider.subject_token().await?;
452
453 let audience = self.config.audience.clone();
454 let subject_token_type = self.config.subject_token_type.clone();
455 let user_scopes = self.config.scopes.clone();
456 let url = self.config.token_url.clone();
457
458 let extra_options =
459 if self.config.client_id.is_none() && self.config.client_secret.is_none() {
460 let workforce_pool_user_project = self.config.workforce_pool_user_project.clone();
461 workforce_pool_user_project.map(|project| {
462 let mut options = HashMap::new();
463 options.insert("userProject".to_string(), project);
464 options
465 })
466 } else {
467 None
468 };
469
470 let sts_scope = if self.config.service_account_impersonation_url.is_some() {
476 vec![IAM_SCOPE.to_string()]
477 } else {
478 user_scopes.clone()
479 };
480
481 let req = ExchangeTokenRequest {
482 url,
483 audience: Some(audience),
484 subject_token: subject_token.token,
485 subject_token_type,
486 scope: sts_scope,
487 authentication: ClientAuthentication {
488 client_id: self.config.client_id.clone(),
489 client_secret: self.config.client_secret.clone(),
490 },
491 extra_options,
492 ..ExchangeTokenRequest::default()
493 };
494
495 let token_res = STSHandler::exchange_token(req).await?;
496
497 if let Some(impersonation_url) = &self.config.service_account_impersonation_url {
498 let mut headers = HeaderMap::new();
499 headers.insert(
500 http::header::AUTHORIZATION,
501 http::HeaderValue::from_str(&format!("Bearer {}", token_res.access_token))
502 .map_err(non_retryable)?,
503 );
504
505 return impersonated::generate_access_token(
506 headers,
507 None,
508 user_scopes,
509 impersonated::DEFAULT_LIFETIME,
510 impersonation_url,
511 )
512 .await;
513 }
514
515 let token = Token {
516 token: token_res.access_token,
517 token_type: token_res.token_type,
518 expires_at: Some(Instant::now() + Duration::from_secs(token_res.expires_in)),
519 metadata: None,
520 };
521 Ok(token)
522 }
523}
524
525#[derive(Debug)]
526pub(crate) struct ExternalAccountCredentials<T>
527where
528 T: CachedTokenProvider,
529{
530 token_provider: T,
531 quota_project_id: Option<String>,
532}
533
534pub struct Builder {
578 external_account_config: Value,
579 quota_project_id: Option<String>,
580 scopes: Option<Vec<String>>,
581 retry_builder: RetryTokenProviderBuilder,
582 iam_endpoint_override: Option<String>,
583}
584
585impl Builder {
586 pub fn new(external_account_config: Value) -> Self {
590 Self {
591 external_account_config,
592 quota_project_id: None,
593 scopes: None,
594 retry_builder: RetryTokenProviderBuilder::default(),
595 iam_endpoint_override: None,
596 }
597 }
598
599 pub fn with_quota_project_id<S: Into<String>>(mut self, quota_project_id: S) -> Self {
608 self.quota_project_id = Some(quota_project_id.into());
609 self
610 }
611
612 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
616 where
617 I: IntoIterator<Item = S>,
618 S: Into<String>,
619 {
620 self.scopes = Some(scopes.into_iter().map(|s| s.into()).collect());
621 self
622 }
623
624 pub fn with_retry_policy<V: Into<RetryPolicyArg>>(mut self, v: V) -> Self {
646 self.retry_builder = self.retry_builder.with_retry_policy(v.into());
647 self
648 }
649
650 pub fn with_backoff_policy<V: Into<BackoffPolicyArg>>(mut self, v: V) -> Self {
673 self.retry_builder = self.retry_builder.with_backoff_policy(v.into());
674 self
675 }
676
677 pub fn with_retry_throttler<V: Into<RetryThrottlerArg>>(mut self, v: V) -> Self {
705 self.retry_builder = self.retry_builder.with_retry_throttler(v.into());
706 self
707 }
708
709 #[cfg(all(test, google_cloud_unstable_trusted_boundaries))]
710 fn maybe_iam_endpoint_override(mut self, iam_endpoint_override: Option<String>) -> Self {
711 self.iam_endpoint_override = iam_endpoint_override;
712 self
713 }
714
715 pub fn build(self) -> BuildResult<Credentials> {
729 Ok(self.build_credentials()?.into())
730 }
731
732 pub fn build_access_token_credentials(self) -> BuildResult<AccessTokenCredentials> {
746 Ok(self.build_credentials()?.into())
747 }
748
749 fn build_credentials(
750 self,
751 ) -> BuildResult<CredentialsWithAccessBoundary<ExternalAccountCredentials<TokenCache>>> {
752 let mut file: ExternalAccountFile =
753 serde_json::from_value(self.external_account_config).map_err(BuilderError::parsing)?;
754
755 if let Some(scopes) = self.scopes {
756 file.scopes = Some(scopes);
757 }
758
759 if file.workforce_pool_user_project.is_some()
760 && !is_valid_workforce_pool_audience(&file.audience)
761 {
762 return Err(BuilderError::parsing(
763 "workforce_pool_user_project should not be set for non-workforce pool credentials",
764 ));
765 }
766
767 let config: ExternalAccountConfig = file.into();
768
769 let access_boundary_url =
770 external_account_lookup_url(&config.audience, self.iam_endpoint_override.as_deref());
771
772 let creds = config.make_credentials(self.quota_project_id, self.retry_builder);
773
774 Ok(CredentialsWithAccessBoundary::new(
775 creds,
776 access_boundary_url,
777 ))
778 }
779}
780
781pub struct ProgrammaticBuilder {
829 quota_project_id: Option<String>,
830 config: ExternalAccountConfigBuilder,
831 retry_builder: RetryTokenProviderBuilder,
832}
833
834impl ProgrammaticBuilder {
835 pub fn new(subject_token_provider: Arc<dyn dynamic::SubjectTokenProvider>) -> Self {
868 let config = ExternalAccountConfigBuilder::default().with_credential_source(
869 CredentialSource::Programmatic(ProgrammaticSourcedCredentials::new(
870 subject_token_provider,
871 )),
872 );
873 Self {
874 quota_project_id: None,
875 config,
876 retry_builder: RetryTokenProviderBuilder::default(),
877 }
878 }
879
880 pub fn with_quota_project_id<S: Into<String>>(mut self, quota_project_id: S) -> Self {
919 self.quota_project_id = Some(quota_project_id.into());
920 self
921 }
922
923 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
958 where
959 I: IntoIterator<Item = S>,
960 S: Into<String>,
961 {
962 self.config = self.config.with_scopes(
963 scopes
964 .into_iter()
965 .map(|s| s.into())
966 .collect::<Vec<String>>(),
967 );
968 self
969 }
970
971 pub fn with_audience<S: Into<String>>(mut self, audience: S) -> Self {
1006 self.config = self.config.with_audience(audience);
1007 self
1008 }
1009
1010 pub fn with_subject_token_type<S: Into<String>>(mut self, subject_token_type: S) -> Self {
1044 self.config = self.config.with_subject_token_type(subject_token_type);
1045 self
1046 }
1047
1048 pub fn with_token_url<S: Into<String>>(mut self, token_url: S) -> Self {
1081 self.config = self.config.with_token_url(token_url);
1082 self
1083 }
1084
1085 pub fn with_client_id<S: Into<String>>(mut self, client_id: S) -> Self {
1117 self.config = self.config.with_client_id(client_id.into());
1118 self
1119 }
1120
1121 pub fn with_client_secret<S: Into<String>>(mut self, client_secret: S) -> Self {
1153 self.config = self.config.with_client_secret(client_secret.into());
1154 self
1155 }
1156
1157 pub fn with_target_principal<S: Into<String>>(mut self, target_principal: S) -> Self {
1191 let url = format!(
1192 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken",
1193 target_principal.into()
1194 );
1195 self.config = self.config.with_service_account_impersonation_url(url);
1196 self
1197 }
1198
1199 pub fn with_workforce_pool_user_project<S: Into<String>>(mut self, project: S) -> Self {
1233 self.config = self.config.with_workforce_pool_user_project(project);
1234 self
1235 }
1236
1237 pub fn with_retry_policy<V: Into<RetryPolicyArg>>(mut self, v: V) -> Self {
1277 self.retry_builder = self.retry_builder.with_retry_policy(v.into());
1278 self
1279 }
1280
1281 pub fn with_backoff_policy<V: Into<BackoffPolicyArg>>(mut self, v: V) -> Self {
1321 self.retry_builder = self.retry_builder.with_backoff_policy(v.into());
1322 self
1323 }
1324
1325 pub fn with_retry_throttler<V: Into<RetryThrottlerArg>>(mut self, v: V) -> Self {
1371 self.retry_builder = self.retry_builder.with_retry_throttler(v.into());
1372 self
1373 }
1374
1375 pub fn build(self) -> BuildResult<Credentials> {
1382 let (config, quota_project_id, retry_builder) = self.build_components()?;
1383 let creds = config.make_credentials(quota_project_id, retry_builder);
1384 Ok(Credentials {
1385 inner: Arc::new(creds),
1386 })
1387 }
1388
1389 fn build_components(
1391 self,
1392 ) -> BuildResult<(
1393 ExternalAccountConfig,
1394 Option<String>,
1395 RetryTokenProviderBuilder,
1396 )> {
1397 let Self {
1398 quota_project_id,
1399 config,
1400 retry_builder,
1401 } = self;
1402
1403 let mut config_builder = config;
1404 if config_builder.scopes.is_none() {
1405 config_builder = config_builder.with_scopes(vec![DEFAULT_SCOPE.to_string()]);
1406 }
1407 if config_builder.token_url.is_none() {
1408 config_builder = config_builder.with_token_url(STS_TOKEN_URL.to_string());
1409 }
1410 let final_config = config_builder.build()?;
1411
1412 Ok((final_config, quota_project_id, retry_builder))
1413 }
1414}
1415
1416#[async_trait::async_trait]
1417impl<T> CredentialsProvider for ExternalAccountCredentials<T>
1418where
1419 T: CachedTokenProvider,
1420{
1421 async fn headers(&self, extensions: Extensions) -> Result<CacheableResource<HeaderMap>> {
1422 let token = self.token_provider.token(extensions).await?;
1423
1424 AuthHeadersBuilder::new(&token)
1425 .maybe_quota_project_id(self.quota_project_id.as_deref())
1426 .build()
1427 }
1428}
1429
1430#[async_trait::async_trait]
1431impl<T> AccessTokenCredentialsProvider for ExternalAccountCredentials<T>
1432where
1433 T: CachedTokenProvider,
1434{
1435 async fn access_token(&self) -> Result<AccessToken> {
1436 let token = self.token_provider.token(Extensions::new()).await?;
1437 token.into()
1438 }
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443 use super::*;
1444 use crate::constants::{
1445 ACCESS_TOKEN_TYPE, DEFAULT_SCOPE, JWT_TOKEN_TYPE, TOKEN_EXCHANGE_GRANT_TYPE,
1446 };
1447 use crate::credentials::subject_token::{
1448 Builder as SubjectTokenBuilder, SubjectToken, SubjectTokenProvider,
1449 };
1450 use crate::credentials::tests::{
1451 find_source_error, get_mock_auth_retry_policy, get_mock_backoff_policy,
1452 get_mock_retry_throttler, get_token_from_headers,
1453 };
1454 use crate::errors::{CredentialsError, SubjectTokenProviderError};
1455 use httptest::{
1456 Expectation, Server, cycle,
1457 matchers::{all_of, contains, request, url_decoded},
1458 responders::{json_encoded, status_code},
1459 };
1460 use serde_json::*;
1461 use std::collections::HashMap;
1462 use std::error::Error;
1463 use std::fmt;
1464 use test_case::test_case;
1465 use time::OffsetDateTime;
1466
1467 #[derive(Debug)]
1468 struct TestProviderError;
1469 impl fmt::Display for TestProviderError {
1470 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1471 write!(f, "TestProviderError")
1472 }
1473 }
1474 impl Error for TestProviderError {}
1475 impl SubjectTokenProviderError for TestProviderError {
1476 fn is_transient(&self) -> bool {
1477 false
1478 }
1479 }
1480
1481 #[derive(Debug)]
1482 struct TestSubjectTokenProvider;
1483 impl SubjectTokenProvider for TestSubjectTokenProvider {
1484 type Error = TestProviderError;
1485 async fn subject_token(&self) -> std::result::Result<SubjectToken, Self::Error> {
1486 Ok(SubjectTokenBuilder::new("test-subject-token".to_string()).build())
1487 }
1488 }
1489
1490 #[tokio::test]
1491 async fn create_external_account_builder() {
1492 let contents = json!({
1493 "type": "external_account",
1494 "audience": "audience",
1495 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1496 "token_url": "https://sts.googleapis.com/v1beta/token",
1497 "credential_source": {
1498 "url": "https://example.com/token",
1499 "format": {
1500 "type": "json",
1501 "subject_token_field_name": "access_token"
1502 }
1503 }
1504 });
1505
1506 let creds = Builder::new(contents)
1507 .with_quota_project_id("test_project")
1508 .with_scopes(["a", "b"])
1509 .build()
1510 .unwrap();
1511
1512 let fmt = format!("{creds:?}");
1514 assert!(fmt.contains("ExternalAccountCredentials"));
1515 }
1516
1517 #[tokio::test]
1518 async fn create_external_account_detect_url_sourced() {
1519 let contents = json!({
1520 "type": "external_account",
1521 "audience": "audience",
1522 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1523 "token_url": "https://sts.googleapis.com/v1beta/token",
1524 "credential_source": {
1525 "url": "https://example.com/token",
1526 "headers": {
1527 "Metadata": "True"
1528 },
1529 "format": {
1530 "type": "json",
1531 "subject_token_field_name": "access_token"
1532 }
1533 }
1534 });
1535
1536 let file: ExternalAccountFile =
1537 serde_json::from_value(contents).expect("failed to parse external account config");
1538 let config: ExternalAccountConfig = file.into();
1539 let source = config.credential_source;
1540
1541 match source {
1542 CredentialSource::Url(source) => {
1543 assert_eq!(source.url, "https://example.com/token");
1544 assert_eq!(
1545 source.headers,
1546 HashMap::from([("Metadata".to_string(), "True".to_string()),]),
1547 );
1548 assert_eq!(source.format, "json");
1549 assert_eq!(source.subject_token_field_name, "access_token");
1550 }
1551 _ => {
1552 unreachable!("expected Url Sourced credential")
1553 }
1554 }
1555 }
1556
1557 #[tokio::test]
1558 async fn create_external_account_detect_executable_sourced() {
1559 let contents = json!({
1560 "type": "external_account",
1561 "audience": "audience",
1562 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1563 "token_url": "https://sts.googleapis.com/v1beta/token",
1564 "credential_source": {
1565 "executable": {
1566 "command": "cat /some/file",
1567 "output_file": "/some/file",
1568 "timeout_millis": 5000
1569 }
1570 }
1571 });
1572
1573 let file: ExternalAccountFile =
1574 serde_json::from_value(contents).expect("failed to parse external account config");
1575 let config: ExternalAccountConfig = file.into();
1576 let source = config.credential_source;
1577
1578 match source {
1579 CredentialSource::Executable(source) => {
1580 assert_eq!(source.command, "cat");
1581 assert_eq!(source.args, vec!["/some/file"]);
1582 assert_eq!(source.output_file.as_deref(), Some("/some/file"));
1583 assert_eq!(source.timeout, Duration::from_secs(5));
1584 }
1585 _ => {
1586 unreachable!("expected Executable Sourced credential")
1587 }
1588 }
1589 }
1590
1591 #[tokio::test]
1592 async fn create_external_account_detect_file_sourced() {
1593 let contents = json!({
1594 "type": "external_account",
1595 "audience": "audience",
1596 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1597 "token_url": "https://sts.googleapis.com/v1beta/token",
1598 "credential_source": {
1599 "file": "/foo/bar",
1600 "format": {
1601 "type": "json",
1602 "subject_token_field_name": "token"
1603 }
1604 }
1605 });
1606
1607 let file: ExternalAccountFile =
1608 serde_json::from_value(contents).expect("failed to parse external account config");
1609 let config: ExternalAccountConfig = file.into();
1610 let source = config.credential_source;
1611
1612 match source {
1613 CredentialSource::File(source) => {
1614 assert_eq!(source.file, "/foo/bar");
1615 assert_eq!(source.format, "json");
1616 assert_eq!(source.subject_token_field_name, "token");
1617 }
1618 _ => {
1619 unreachable!("expected File Sourced credential")
1620 }
1621 }
1622 }
1623
1624 #[tokio::test]
1625 async fn test_external_account_with_impersonation_success() {
1626 let subject_token_server = Server::run();
1627 let sts_server = Server::run();
1628 let impersonation_server = Server::run();
1629
1630 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
1631 let contents = json!({
1632 "type": "external_account",
1633 "audience": "audience",
1634 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1635 "token_url": sts_server.url("/token").to_string(),
1636 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
1637 "credential_source": {
1638 "url": subject_token_server.url("/subject_token").to_string(),
1639 "format": {
1640 "type": "json",
1641 "subject_token_field_name": "access_token"
1642 }
1643 }
1644 });
1645
1646 subject_token_server.expect(
1647 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1648 json_encoded(json!({
1649 "access_token": "subject_token",
1650 })),
1651 ),
1652 );
1653
1654 sts_server.expect(
1655 Expectation::matching(all_of![
1656 request::method_path("POST", "/token"),
1657 request::body(url_decoded(contains((
1658 "grant_type",
1659 TOKEN_EXCHANGE_GRANT_TYPE
1660 )))),
1661 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1662 request::body(url_decoded(contains((
1663 "requested_token_type",
1664 ACCESS_TOKEN_TYPE
1665 )))),
1666 request::body(url_decoded(contains((
1667 "subject_token_type",
1668 JWT_TOKEN_TYPE
1669 )))),
1670 request::body(url_decoded(contains(("audience", "audience")))),
1671 request::body(url_decoded(contains(("scope", IAM_SCOPE)))),
1672 ])
1673 .respond_with(json_encoded(json!({
1674 "access_token": "sts-token",
1675 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1676 "token_type": "Bearer",
1677 "expires_in": 3600,
1678 }))),
1679 );
1680
1681 let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
1682 .format(&time::format_description::well_known::Rfc3339)
1683 .unwrap();
1684 impersonation_server.expect(
1685 Expectation::matching(all_of![
1686 request::method_path("POST", impersonation_path),
1687 request::headers(contains(("authorization", "Bearer sts-token"))),
1688 ])
1689 .respond_with(json_encoded(json!({
1690 "accessToken": "final-impersonated-token",
1691 "expireTime": expire_time
1692 }))),
1693 );
1694
1695 let creds = Builder::new(contents).build().unwrap();
1696 let headers = creds.headers(Extensions::new()).await.unwrap();
1697 match headers {
1698 CacheableResource::New { data, .. } => {
1699 let token = data.get("authorization").unwrap().to_str().unwrap();
1700 assert_eq!(token, "Bearer final-impersonated-token");
1701 }
1702 CacheableResource::NotModified => panic!("Expected new headers"),
1703 }
1704 }
1705
1706 #[tokio::test]
1707 async fn test_external_account_without_impersonation_success() {
1708 let subject_token_server = Server::run();
1709 let sts_server = Server::run();
1710
1711 let contents = json!({
1712 "type": "external_account",
1713 "audience": "audience",
1714 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1715 "token_url": sts_server.url("/token").to_string(),
1716 "credential_source": {
1717 "url": subject_token_server.url("/subject_token").to_string(),
1718 "format": {
1719 "type": "json",
1720 "subject_token_field_name": "access_token"
1721 }
1722 }
1723 });
1724
1725 subject_token_server.expect(
1726 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1727 json_encoded(json!({
1728 "access_token": "subject_token",
1729 })),
1730 ),
1731 );
1732
1733 sts_server.expect(
1734 Expectation::matching(all_of![
1735 request::method_path("POST", "/token"),
1736 request::body(url_decoded(contains((
1737 "grant_type",
1738 TOKEN_EXCHANGE_GRANT_TYPE
1739 )))),
1740 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1741 request::body(url_decoded(contains((
1742 "requested_token_type",
1743 ACCESS_TOKEN_TYPE
1744 )))),
1745 request::body(url_decoded(contains((
1746 "subject_token_type",
1747 JWT_TOKEN_TYPE
1748 )))),
1749 request::body(url_decoded(contains(("audience", "audience")))),
1750 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
1751 ])
1752 .respond_with(json_encoded(json!({
1753 "access_token": "sts-only-token",
1754 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1755 "token_type": "Bearer",
1756 "expires_in": 3600,
1757 }))),
1758 );
1759
1760 let creds = Builder::new(contents).build().unwrap();
1761 let headers = creds.headers(Extensions::new()).await.unwrap();
1762 match headers {
1763 CacheableResource::New { data, .. } => {
1764 let token = data.get("authorization").unwrap().to_str().unwrap();
1765 assert_eq!(token, "Bearer sts-only-token");
1766 }
1767 CacheableResource::NotModified => panic!("Expected new headers"),
1768 }
1769 }
1770
1771 #[tokio::test]
1772 async fn test_external_account_access_token_credentials_success() {
1773 let server = Server::run();
1774
1775 let contents = json!({
1776 "type": "external_account",
1777 "audience": "audience",
1778 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1779 "token_url": server.url("/token").to_string(),
1780 "credential_source": {
1781 "url": server.url("/subject_token").to_string(),
1782 "format": {
1783 "type": "json",
1784 "subject_token_field_name": "access_token"
1785 }
1786 }
1787 });
1788
1789 server.expect(
1790 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1791 json_encoded(json!({
1792 "access_token": "subject_token",
1793 })),
1794 ),
1795 );
1796
1797 server.expect(
1798 Expectation::matching(all_of![
1799 request::method_path("POST", "/token"),
1800 request::body(url_decoded(contains((
1801 "grant_type",
1802 TOKEN_EXCHANGE_GRANT_TYPE
1803 )))),
1804 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1805 request::body(url_decoded(contains((
1806 "requested_token_type",
1807 ACCESS_TOKEN_TYPE
1808 )))),
1809 request::body(url_decoded(contains((
1810 "subject_token_type",
1811 JWT_TOKEN_TYPE
1812 )))),
1813 request::body(url_decoded(contains(("audience", "audience")))),
1814 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
1815 ])
1816 .respond_with(json_encoded(json!({
1817 "access_token": "sts-only-token",
1818 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1819 "token_type": "Bearer",
1820 "expires_in": 3600,
1821 }))),
1822 );
1823
1824 let creds = Builder::new(contents)
1825 .build_access_token_credentials()
1826 .unwrap();
1827 let access_token = creds.access_token().await.unwrap();
1828 assert_eq!(access_token.token, "sts-only-token");
1829 }
1830
1831 #[tokio::test]
1832 async fn test_impersonation_flow_sts_call_fails() {
1833 let subject_token_server = Server::run();
1834 let sts_server = Server::run();
1835 let impersonation_server = Server::run();
1836
1837 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
1838 let contents = json!({
1839 "type": "external_account",
1840 "audience": "audience",
1841 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1842 "token_url": sts_server.url("/token").to_string(),
1843 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
1844 "credential_source": {
1845 "url": subject_token_server.url("/subject_token").to_string(),
1846 "format": {
1847 "type": "json",
1848 "subject_token_field_name": "access_token"
1849 }
1850 }
1851 });
1852
1853 subject_token_server.expect(
1854 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1855 json_encoded(json!({
1856 "access_token": "subject_token",
1857 })),
1858 ),
1859 );
1860
1861 sts_server.expect(
1862 Expectation::matching(request::method_path("POST", "/token"))
1863 .respond_with(status_code(500)),
1864 );
1865
1866 let creds = Builder::new(contents).build().unwrap();
1867 let err = creds.headers(Extensions::new()).await.unwrap_err();
1868 let original_err = find_source_error::<CredentialsError>(&err).unwrap();
1869 assert!(
1870 original_err
1871 .to_string()
1872 .contains("failed to exchange token")
1873 );
1874 assert!(original_err.is_transient());
1875 }
1876
1877 #[tokio::test]
1878 async fn test_impersonation_flow_iam_call_fails() {
1879 let subject_token_server = Server::run();
1880 let sts_server = Server::run();
1881 let impersonation_server = Server::run();
1882
1883 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
1884 let contents = json!({
1885 "type": "external_account",
1886 "audience": "audience",
1887 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1888 "token_url": sts_server.url("/token").to_string(),
1889 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
1890 "credential_source": {
1891 "url": subject_token_server.url("/subject_token").to_string(),
1892 "format": {
1893 "type": "json",
1894 "subject_token_field_name": "access_token"
1895 }
1896 }
1897 });
1898
1899 subject_token_server.expect(
1900 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1901 json_encoded(json!({
1902 "access_token": "subject_token",
1903 })),
1904 ),
1905 );
1906
1907 sts_server.expect(
1908 Expectation::matching(request::method_path("POST", "/token")).respond_with(
1909 json_encoded(json!({
1910 "access_token": "sts-token",
1911 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1912 "token_type": "Bearer",
1913 "expires_in": 3600,
1914 })),
1915 ),
1916 );
1917
1918 impersonation_server.expect(
1919 Expectation::matching(request::method_path("POST", impersonation_path))
1920 .respond_with(status_code(403)),
1921 );
1922
1923 let creds = Builder::new(contents).build().unwrap();
1924 let err = creds.headers(Extensions::new()).await.unwrap_err();
1925 let original_err = find_source_error::<CredentialsError>(&err).unwrap();
1926 assert!(original_err.to_string().contains("failed to fetch token"));
1927 assert!(!original_err.is_transient());
1928 }
1929
1930 #[test_case(Some(vec!["scope1", "scope2"]), Some("http://custom.com/token") ; "with custom scopes and token_url")]
1931 #[test_case(None, Some("http://custom.com/token") ; "with default scopes and custom token_url")]
1932 #[test_case(Some(vec!["scope1", "scope2"]), None ; "with custom scopes and default token_url")]
1933 #[test_case(None, None ; "with default scopes and default token_url")]
1934 #[tokio::test]
1935 async fn create_programmatic_builder(scopes: Option<Vec<&str>>, token_url: Option<&str>) {
1936 let provider = Arc::new(TestSubjectTokenProvider);
1937 let mut builder = ProgrammaticBuilder::new(provider)
1938 .with_audience("test-audience")
1939 .with_subject_token_type("test-token-type")
1940 .with_client_id("test-client-id")
1941 .with_client_secret("test-client-secret")
1942 .with_target_principal("test-principal");
1943
1944 let expected_scopes = if let Some(scopes) = scopes.clone() {
1945 scopes.iter().map(|s| s.to_string()).collect()
1946 } else {
1947 vec![DEFAULT_SCOPE.to_string()]
1948 };
1949
1950 let expected_token_url = token_url.unwrap_or(STS_TOKEN_URL).to_string();
1951
1952 if let Some(scopes) = scopes {
1953 builder = builder.with_scopes(scopes);
1954 }
1955 if let Some(token_url) = token_url {
1956 builder = builder.with_token_url(token_url);
1957 }
1958
1959 let (config, _, _) = builder.build_components().unwrap();
1960
1961 assert_eq!(config.audience, "test-audience");
1962 assert_eq!(config.subject_token_type, "test-token-type");
1963 assert_eq!(config.client_id, Some("test-client-id".to_string()));
1964 assert_eq!(config.client_secret, Some("test-client-secret".to_string()));
1965 assert_eq!(config.scopes, expected_scopes);
1966 assert_eq!(config.token_url, expected_token_url);
1967 assert_eq!(
1968 config.service_account_impersonation_url,
1969 Some("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken".to_string())
1970 );
1971 }
1972
1973 #[tokio::test]
1974 async fn create_programmatic_builder_with_quota_project_id() {
1975 let provider = Arc::new(TestSubjectTokenProvider);
1976 let builder = ProgrammaticBuilder::new(provider)
1977 .with_audience("test-audience")
1978 .with_subject_token_type("test-token-type")
1979 .with_token_url(STS_TOKEN_URL)
1980 .with_quota_project_id("test-quota-project");
1981
1982 let creds = builder.build().unwrap();
1983
1984 let fmt = format!("{creds:?}");
1985 assert!(
1986 fmt.contains("ExternalAccountCredentials"),
1987 "Expected 'ExternalAccountCredentials', got: {fmt}"
1988 );
1989 assert!(
1990 fmt.contains("test-quota-project"),
1991 "Expected 'test-quota-project', got: {fmt}"
1992 );
1993 }
1994
1995 #[tokio::test]
1996 async fn programmatic_builder_returns_correct_headers() {
1997 let provider = Arc::new(TestSubjectTokenProvider);
1998 let sts_server = Server::run();
1999 let builder = ProgrammaticBuilder::new(provider)
2000 .with_audience("test-audience")
2001 .with_subject_token_type("test-token-type")
2002 .with_token_url(sts_server.url("/token").to_string())
2003 .with_quota_project_id("test-quota-project");
2004
2005 let creds = builder.build().unwrap();
2006
2007 sts_server.expect(
2008 Expectation::matching(all_of![
2009 request::method_path("POST", "/token"),
2010 request::body(url_decoded(contains((
2011 "grant_type",
2012 TOKEN_EXCHANGE_GRANT_TYPE
2013 )))),
2014 request::body(url_decoded(contains((
2015 "subject_token",
2016 "test-subject-token"
2017 )))),
2018 request::body(url_decoded(contains((
2019 "requested_token_type",
2020 ACCESS_TOKEN_TYPE
2021 )))),
2022 request::body(url_decoded(contains((
2023 "subject_token_type",
2024 "test-token-type"
2025 )))),
2026 request::body(url_decoded(contains(("audience", "test-audience")))),
2027 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
2028 ])
2029 .respond_with(json_encoded(json!({
2030 "access_token": "sts-only-token",
2031 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2032 "token_type": "Bearer",
2033 "expires_in": 3600,
2034 }))),
2035 );
2036
2037 let headers = creds.headers(Extensions::new()).await.unwrap();
2038 match headers {
2039 CacheableResource::New { data, .. } => {
2040 let token = data.get("authorization").unwrap().to_str().unwrap();
2041 assert_eq!(token, "Bearer sts-only-token");
2042 let quota_project = data.get("x-goog-user-project").unwrap().to_str().unwrap();
2043 assert_eq!(quota_project, "test-quota-project");
2044 }
2045 CacheableResource::NotModified => panic!("Expected new headers"),
2046 }
2047 }
2048
2049 #[tokio::test]
2050 async fn create_programmatic_builder_fails_on_missing_required_field() {
2051 let provider = Arc::new(TestSubjectTokenProvider);
2052 let result = ProgrammaticBuilder::new(provider)
2053 .with_subject_token_type("test-token-type")
2054 .with_token_url("http://test.com/token")
2056 .build();
2057
2058 assert!(result.is_err(), "{result:?}");
2059 let error_string = result.unwrap_err().to_string();
2060 assert!(
2061 error_string.contains("missing required field: audience"),
2062 "Expected error about missing 'audience', got: {error_string}"
2063 );
2064 }
2065
2066 #[tokio::test]
2067 async fn test_external_account_retries_on_transient_failures() {
2068 let mut subject_token_server = Server::run();
2069 let mut sts_server = Server::run();
2070
2071 let contents = json!({
2072 "type": "external_account",
2073 "audience": "audience",
2074 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2075 "token_url": sts_server.url("/token").to_string(),
2076 "credential_source": {
2077 "url": subject_token_server.url("/subject_token").to_string(),
2078 }
2079 });
2080
2081 subject_token_server.expect(
2082 Expectation::matching(request::method_path("GET", "/subject_token"))
2083 .times(3)
2084 .respond_with(json_encoded(json!({
2085 "access_token": "subject_token",
2086 }))),
2087 );
2088
2089 sts_server.expect(
2090 Expectation::matching(request::method_path("POST", "/token"))
2091 .times(3)
2092 .respond_with(status_code(503)),
2093 );
2094
2095 let creds = Builder::new(contents)
2096 .with_retry_policy(get_mock_auth_retry_policy(3))
2097 .with_backoff_policy(get_mock_backoff_policy())
2098 .with_retry_throttler(get_mock_retry_throttler())
2099 .build()
2100 .unwrap();
2101
2102 let err = creds.headers(Extensions::new()).await.unwrap_err();
2103 assert!(err.is_transient(), "{err:?}");
2104 sts_server.verify_and_clear();
2105 subject_token_server.verify_and_clear();
2106 }
2107
2108 #[tokio::test]
2109 async fn test_external_account_does_not_retry_on_non_transient_failures() {
2110 let subject_token_server = Server::run();
2111 let mut sts_server = Server::run();
2112
2113 let contents = json!({
2114 "type": "external_account",
2115 "audience": "audience",
2116 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2117 "token_url": sts_server.url("/token").to_string(),
2118 "credential_source": {
2119 "url": subject_token_server.url("/subject_token").to_string(),
2120 }
2121 });
2122
2123 subject_token_server.expect(
2124 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2125 json_encoded(json!({
2126 "access_token": "subject_token",
2127 })),
2128 ),
2129 );
2130
2131 sts_server.expect(
2132 Expectation::matching(request::method_path("POST", "/token"))
2133 .times(1)
2134 .respond_with(status_code(401)),
2135 );
2136
2137 let creds = Builder::new(contents)
2138 .with_retry_policy(get_mock_auth_retry_policy(1))
2139 .with_backoff_policy(get_mock_backoff_policy())
2140 .with_retry_throttler(get_mock_retry_throttler())
2141 .build()
2142 .unwrap();
2143
2144 let err = creds.headers(Extensions::new()).await.unwrap_err();
2145 assert!(!err.is_transient());
2146 sts_server.verify_and_clear();
2147 }
2148
2149 #[tokio::test]
2150 async fn test_external_account_retries_for_success() {
2151 let mut subject_token_server = Server::run();
2152 let mut sts_server = Server::run();
2153
2154 let contents = json!({
2155 "type": "external_account",
2156 "audience": "audience",
2157 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2158 "token_url": sts_server.url("/token").to_string(),
2159 "credential_source": {
2160 "url": subject_token_server.url("/subject_token").to_string(),
2161 }
2162 });
2163
2164 subject_token_server.expect(
2165 Expectation::matching(request::method_path("GET", "/subject_token"))
2166 .times(3)
2167 .respond_with(json_encoded(json!({
2168 "access_token": "subject_token",
2169 }))),
2170 );
2171
2172 sts_server.expect(
2173 Expectation::matching(request::method_path("POST", "/token"))
2174 .times(3)
2175 .respond_with(cycle![
2176 status_code(503).body("try-again"),
2177 status_code(503).body("try-again"),
2178 json_encoded(json!({
2179 "access_token": "sts-only-token",
2180 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2181 "token_type": "Bearer",
2182 "expires_in": 3600,
2183 }))
2184 ]),
2185 );
2186
2187 let creds = Builder::new(contents)
2188 .with_retry_policy(get_mock_auth_retry_policy(3))
2189 .with_backoff_policy(get_mock_backoff_policy())
2190 .with_retry_throttler(get_mock_retry_throttler())
2191 .build()
2192 .unwrap();
2193
2194 let headers = creds.headers(Extensions::new()).await.unwrap();
2195 match headers {
2196 CacheableResource::New { data, .. } => {
2197 let token = data.get("authorization").unwrap().to_str().unwrap();
2198 assert_eq!(token, "Bearer sts-only-token");
2199 }
2200 CacheableResource::NotModified => panic!("Expected new headers"),
2201 }
2202 sts_server.verify_and_clear();
2203 subject_token_server.verify_and_clear();
2204 }
2205
2206 #[tokio::test]
2207 async fn test_programmatic_builder_retries_on_transient_failures() {
2208 let provider = Arc::new(TestSubjectTokenProvider);
2209 let mut sts_server = Server::run();
2210
2211 sts_server.expect(
2212 Expectation::matching(request::method_path("POST", "/token"))
2213 .times(3)
2214 .respond_with(status_code(503)),
2215 );
2216
2217 let creds = ProgrammaticBuilder::new(provider)
2218 .with_audience("test-audience")
2219 .with_subject_token_type("test-token-type")
2220 .with_token_url(sts_server.url("/token").to_string())
2221 .with_retry_policy(get_mock_auth_retry_policy(3))
2222 .with_backoff_policy(get_mock_backoff_policy())
2223 .with_retry_throttler(get_mock_retry_throttler())
2224 .build()
2225 .unwrap();
2226
2227 let err = creds.headers(Extensions::new()).await.unwrap_err();
2228 assert!(err.is_transient(), "{err:?}");
2229 sts_server.verify_and_clear();
2230 }
2231
2232 #[tokio::test]
2233 async fn test_programmatic_builder_does_not_retry_on_non_transient_failures() {
2234 let provider = Arc::new(TestSubjectTokenProvider);
2235 let mut sts_server = Server::run();
2236
2237 sts_server.expect(
2238 Expectation::matching(request::method_path("POST", "/token"))
2239 .times(1)
2240 .respond_with(status_code(401)),
2241 );
2242
2243 let creds = ProgrammaticBuilder::new(provider)
2244 .with_audience("test-audience")
2245 .with_subject_token_type("test-token-type")
2246 .with_token_url(sts_server.url("/token").to_string())
2247 .with_retry_policy(get_mock_auth_retry_policy(1))
2248 .with_backoff_policy(get_mock_backoff_policy())
2249 .with_retry_throttler(get_mock_retry_throttler())
2250 .build()
2251 .unwrap();
2252
2253 let err = creds.headers(Extensions::new()).await.unwrap_err();
2254 assert!(!err.is_transient());
2255 sts_server.verify_and_clear();
2256 }
2257
2258 #[tokio::test]
2259 async fn test_programmatic_builder_retries_for_success() {
2260 let provider = Arc::new(TestSubjectTokenProvider);
2261 let mut sts_server = Server::run();
2262
2263 sts_server.expect(
2264 Expectation::matching(request::method_path("POST", "/token"))
2265 .times(3)
2266 .respond_with(cycle![
2267 status_code(503).body("try-again"),
2268 status_code(503).body("try-again"),
2269 json_encoded(json!({
2270 "access_token": "sts-only-token",
2271 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2272 "token_type": "Bearer",
2273 "expires_in": 3600,
2274 }))
2275 ]),
2276 );
2277
2278 let creds = ProgrammaticBuilder::new(provider)
2279 .with_audience("test-audience")
2280 .with_subject_token_type("test-token-type")
2281 .with_token_url(sts_server.url("/token").to_string())
2282 .with_retry_policy(get_mock_auth_retry_policy(3))
2283 .with_backoff_policy(get_mock_backoff_policy())
2284 .with_retry_throttler(get_mock_retry_throttler())
2285 .build()
2286 .unwrap();
2287
2288 let headers = creds.headers(Extensions::new()).await.unwrap();
2289 match headers {
2290 CacheableResource::New { data, .. } => {
2291 let token = data.get("authorization").unwrap().to_str().unwrap();
2292 assert_eq!(token, "Bearer sts-only-token");
2293 }
2294 CacheableResource::NotModified => panic!("Expected new headers"),
2295 }
2296 sts_server.verify_and_clear();
2297 }
2298
2299 #[test_case(
2300 "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider",
2301 "/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations";
2302 "workload_identity_pool"
2303 )]
2304 #[test_case(
2305 "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider",
2306 "/v1/locations/global/workforcePools/my-pool/allowedLocations";
2307 "workforce_pool"
2308 )]
2309 #[tokio::test]
2310 #[cfg(google_cloud_unstable_trusted_boundaries)]
2311 async fn e2e_access_boundary(audience: &str, iam_path: &str) -> anyhow::Result<()> {
2312 use crate::credentials::tests::get_access_boundary_from_headers;
2313
2314 let audience = audience.to_string();
2315 let iam_path = iam_path.to_string();
2316
2317 let server = Server::run();
2318
2319 server.expect(
2320 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2321 json_encoded(json!({
2322 "access_token": "subject_token",
2323 })),
2324 ),
2325 );
2326
2327 server.expect(
2328 Expectation::matching(all_of![
2329 request::method_path("POST", "/token"),
2330 request::body(url_decoded(contains(("subject_token", "subject_token")))),
2331 request::body(url_decoded(contains(("audience", audience.clone())))),
2332 ])
2333 .respond_with(json_encoded(json!({
2334 "access_token": "sts-only-token",
2335 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2336 "token_type": "Bearer",
2337 "expires_in": 3600,
2338 }))),
2339 );
2340
2341 server.expect(
2342 Expectation::matching(all_of![request::method_path("GET", iam_path.clone()),])
2343 .respond_with(json_encoded(json!({
2344 "locations": ["us-central1"],
2345 "encodedLocations": "0x1234"
2346 }))),
2347 );
2348
2349 let contents = json!({
2350 "type": "external_account",
2351 "audience": audience.to_string(),
2352 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2353 "token_url": server.url("/token").to_string(),
2354 "credential_source": {
2355 "url": server.url("/subject_token").to_string(),
2356 "format": {
2357 "type": "json",
2358 "subject_token_field_name": "access_token"
2359 }
2360 }
2361 });
2362
2363 let iam_endpoint = server.url("").to_string().trim_end_matches('/').to_string();
2364
2365 let creds = Builder::new(contents)
2366 .maybe_iam_endpoint_override(Some(iam_endpoint))
2367 .build_credentials()?;
2368
2369 creds.wait_for_boundary().await;
2371
2372 let headers = creds.headers(Extensions::new()).await?;
2373 let token = get_token_from_headers(headers.clone());
2374 let access_boundary = get_access_boundary_from_headers(headers);
2375
2376 assert!(token.is_some(), "should have some token");
2377 assert_eq!(access_boundary.as_deref(), Some("0x1234"));
2378
2379 Ok(())
2380 }
2381
2382 #[tokio::test]
2383 async fn test_kubernetes_wif_direct_identity_parsing() {
2384 let contents = json!({
2385 "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool-name/providers/k8s-cluster",
2386 "credential_source": {
2387 "file": "/var/run/service-account/token"
2388 },
2389 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2390 "token_url": "https://sts.googleapis.com/v1/token",
2391 "type": "external_account"
2392 });
2393
2394 let file: ExternalAccountFile = serde_json::from_value(contents)
2395 .expect("failed to parse kubernetes WIF direct identity config");
2396 let config: ExternalAccountConfig = file.into();
2397
2398 match config.credential_source {
2399 CredentialSource::File(source) => {
2400 assert_eq!(source.file, "/var/run/service-account/token");
2401 assert_eq!(source.format, "text"); assert_eq!(source.subject_token_field_name, ""); }
2404 _ => {
2405 unreachable!("expected File sourced credential")
2406 }
2407 }
2408 }
2409
2410 #[tokio::test]
2411 async fn test_kubernetes_wif_impersonation_parsing() {
2412 let contents = json!({
2413 "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool-name/providers/k8s-cluster",
2414 "credential_source": {
2415 "file": "/var/run/service-account/token",
2416 "format": {
2417 "type": "text"
2418 }
2419 },
2420 "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-sa@test-project.iam.gserviceaccount.com:generateAccessToken",
2421 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2422 "token_url": "https://sts.googleapis.com/v1/token",
2423 "type": "external_account",
2424 "universe_domain": "googleapis.com"
2425 });
2426
2427 let file: ExternalAccountFile = serde_json::from_value(contents)
2428 .expect("failed to parse kubernetes WIF impersonation config");
2429 let config: ExternalAccountConfig = file.into();
2430
2431 match config.credential_source {
2432 CredentialSource::File(source) => {
2433 assert_eq!(source.file, "/var/run/service-account/token");
2434 assert_eq!(source.format, "text");
2435 assert_eq!(source.subject_token_field_name, ""); }
2437 _ => {
2438 unreachable!("expected File sourced credential")
2439 }
2440 }
2441
2442 assert_eq!(
2443 config.service_account_impersonation_url,
2444 Some("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-sa@test-project.iam.gserviceaccount.com:generateAccessToken".to_string())
2445 );
2446 }
2447
2448 #[tokio::test]
2449 async fn test_aws_parsing() {
2450 let contents = json!({
2451 "audience": "audience",
2452 "credential_source": {
2453 "environment_id": "aws1",
2454 "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
2455 "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
2456 "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
2457 "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
2458 },
2459 "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
2460 "token_url": "https://sts.googleapis.com/v1/token",
2461 "type": "external_account"
2462 });
2463
2464 let file: ExternalAccountFile =
2465 serde_json::from_value(contents).expect("failed to parse AWS config");
2466 let config: ExternalAccountConfig = file.into();
2467
2468 match config.credential_source {
2469 CredentialSource::Aws(source) => {
2470 assert_eq!(
2471 source.region_url,
2472 Some(
2473 "http://169.254.169.254/latest/meta-data/placement/availability-zone"
2474 .to_string()
2475 )
2476 );
2477 assert_eq!(
2478 source.regional_cred_verification_url,
2479 Some(
2480 "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
2481 .to_string()
2482 )
2483 );
2484 assert_eq!(
2485 source.imdsv2_session_token_url,
2486 Some("http://169.254.169.254/latest/api/token".to_string())
2487 );
2488 }
2489 _ => {
2490 unreachable!("expected Aws sourced credential")
2491 }
2492 }
2493 }
2494
2495 #[tokio::test]
2496 async fn builder_workforce_pool_user_project_fails_without_workforce_pool_audience() {
2497 let contents = json!({
2498 "type": "external_account",
2499 "audience": "not-a-workforce-pool",
2500 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2501 "token_url": "http://test.com/token",
2502 "credential_source": {
2503 "url": "http://test.com/subject_token",
2504 },
2505 "workforce_pool_user_project": "test-project"
2506 });
2507
2508 let result = Builder::new(contents).build();
2509
2510 assert!(result.is_err(), "{result:?}");
2511 let err = result.unwrap_err();
2512 assert!(err.is_parsing(), "{err:?}");
2513 }
2514
2515 #[tokio::test]
2516 async fn sts_handler_ignores_workforce_pool_user_project_with_client_auth()
2517 -> std::result::Result<(), Box<dyn std::error::Error>> {
2518 let subject_token_server = Server::run();
2519 let sts_server = Server::run();
2520
2521 let contents = json!({
2522 "type": "external_account",
2523 "audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
2524 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2525 "token_url": sts_server.url("/token").to_string(),
2526 "client_id": "client-id",
2527 "credential_source": {
2528 "url": subject_token_server.url("/subject_token").to_string(),
2529 },
2530 "workforce_pool_user_project": "test-project"
2531 });
2532
2533 subject_token_server.expect(
2534 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2535 json_encoded(json!({
2536 "access_token": "subject_token",
2537 })),
2538 ),
2539 );
2540
2541 sts_server.expect(
2542 Expectation::matching(all_of![request::method_path("POST", "/token"),]).respond_with(
2543 json_encoded(json!({
2544 "access_token": "sts-only-token",
2545 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2546 "token_type": "Bearer",
2547 "expires_in": 3600,
2548 })),
2549 ),
2550 );
2551
2552 let creds = Builder::new(contents).build()?;
2553 let headers = creds.headers(Extensions::new()).await?;
2554 let token = get_token_from_headers(headers);
2555 assert_eq!(token.as_deref(), Some("sts-only-token"));
2556
2557 Ok(())
2558 }
2559
2560 #[tokio::test]
2561 async fn sts_handler_receives_workforce_pool_user_project()
2562 -> std::result::Result<(), Box<dyn std::error::Error>> {
2563 let subject_token_server = Server::run();
2564 let sts_server = Server::run();
2565
2566 let contents = json!({
2567 "type": "external_account",
2568 "audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
2569 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2570 "token_url": sts_server.url("/token").to_string(),
2571 "credential_source": {
2572 "url": subject_token_server.url("/subject_token").to_string(),
2573 },
2574 "workforce_pool_user_project": "test-project-123"
2575 });
2576
2577 subject_token_server.expect(
2578 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2579 json_encoded(json!({
2580 "access_token": "subject_token",
2581 })),
2582 ),
2583 );
2584
2585 sts_server.expect(
2586 Expectation::matching(all_of![
2587 request::method_path("POST", "/token"),
2588 request::body(url_decoded(contains((
2589 "options",
2590 "{\"userProject\":\"test-project-123\"}"
2591 )))),
2592 ])
2593 .respond_with(json_encoded(json!({
2594 "access_token": "sts-only-token",
2595 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2596 "token_type": "Bearer",
2597 "expires_in": 3600,
2598 }))),
2599 );
2600
2601 let creds = Builder::new(contents).build()?;
2602 let headers = creds.headers(Extensions::new()).await?;
2603 let token = get_token_from_headers(headers);
2604 assert_eq!(token.as_deref(), Some("sts-only-token"));
2605
2606 Ok(())
2607 }
2608}