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