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, DEFAULT_UNIVERSE_DOMAIN, 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 universe_domain: Option<String>,
215}
216
217impl From<ExternalAccountFile> for ExternalAccountConfig {
218 fn from(config: ExternalAccountFile) -> Self {
219 let mut scope = config.scopes.unwrap_or_default();
220 if scope.is_empty() {
221 scope.push(DEFAULT_SCOPE.to_string());
222 }
223 Self {
224 audience: config.audience.clone(),
225 client_id: config.client_id,
226 client_secret: config.client_secret,
227 subject_token_type: config.subject_token_type,
228 token_url: config.token_url,
229 service_account_impersonation_url: config.service_account_impersonation_url,
230 credential_source: CredentialSource::from_file(
231 config.credential_source,
232 &config.audience,
233 ),
234 scopes: scope,
235 workforce_pool_user_project: config.workforce_pool_user_project,
236 universe_domain: config.universe_domain,
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 universe_domain: Option<String>,
284}
285
286#[derive(Debug, Default)]
287struct ExternalAccountConfigBuilder {
288 audience: Option<String>,
289 subject_token_type: Option<String>,
290 token_url: Option<String>,
291 service_account_impersonation_url: Option<String>,
292 client_id: Option<String>,
293 client_secret: Option<String>,
294 scopes: Option<Vec<String>>,
295 credential_source: Option<CredentialSource>,
296 workforce_pool_user_project: Option<String>,
297 universe_domain: Option<String>,
298}
299
300impl ExternalAccountConfigBuilder {
301 fn with_audience<S: Into<String>>(mut self, audience: S) -> Self {
302 self.audience = Some(audience.into());
303 self
304 }
305
306 fn with_subject_token_type<S: Into<String>>(mut self, subject_token_type: S) -> Self {
307 self.subject_token_type = Some(subject_token_type.into());
308 self
309 }
310
311 fn with_token_url<S: Into<String>>(mut self, token_url: S) -> Self {
312 self.token_url = Some(token_url.into());
313 self
314 }
315
316 fn with_service_account_impersonation_url<S: Into<String>>(mut self, url: S) -> Self {
317 self.service_account_impersonation_url = Some(url.into());
318 self
319 }
320
321 fn with_client_id<S: Into<String>>(mut self, client_id: S) -> Self {
322 self.client_id = Some(client_id.into());
323 self
324 }
325
326 fn with_client_secret<S: Into<String>>(mut self, client_secret: S) -> Self {
327 self.client_secret = Some(client_secret.into());
328 self
329 }
330
331 fn with_scopes(mut self, scopes: Vec<String>) -> Self {
332 self.scopes = Some(scopes);
333 self
334 }
335
336 fn with_credential_source(mut self, source: CredentialSource) -> Self {
337 self.credential_source = Some(source);
338 self
339 }
340
341 fn with_workforce_pool_user_project<S: Into<String>>(mut self, project: S) -> Self {
342 self.workforce_pool_user_project = Some(project.into());
343 self
344 }
345
346 fn with_universe_domain<S: Into<String>>(mut self, universe_domain: S) -> Self {
347 self.universe_domain = Some(universe_domain.into());
348 self
349 }
350
351 fn build(self) -> BuildResult<ExternalAccountConfig> {
352 let audience = self
353 .audience
354 .clone()
355 .ok_or(BuilderError::missing_field("audience"))?;
356
357 if self.workforce_pool_user_project.is_some()
358 && !is_valid_workforce_pool_audience(&audience)
359 {
360 return Err(BuilderError::parsing(
361 "workforce_pool_user_project should not be set for non-workforce pool credentials",
362 ));
363 }
364
365 Ok(ExternalAccountConfig {
366 audience,
367 subject_token_type: self
368 .subject_token_type
369 .ok_or(BuilderError::missing_field("subject_token_type"))?,
370 token_url: self
371 .token_url
372 .ok_or(BuilderError::missing_field("token_url"))?,
373 scopes: self.scopes.ok_or(BuilderError::missing_field("scopes"))?,
374 credential_source: self
375 .credential_source
376 .ok_or(BuilderError::missing_field("credential_source"))?,
377 service_account_impersonation_url: self.service_account_impersonation_url,
378 client_id: self.client_id,
379 client_secret: self.client_secret,
380 workforce_pool_user_project: self.workforce_pool_user_project,
381 universe_domain: self.universe_domain,
382 })
383 }
384}
385
386#[derive(Debug, Clone)]
387enum CredentialSource {
388 Url(UrlSourcedCredentials),
389 Executable(ExecutableSourcedCredentials),
390 File(FileSourcedCredentials),
391 Aws(AwsSourcedCredentials),
392 Programmatic(ProgrammaticSourcedCredentials),
393}
394
395impl ExternalAccountConfig {
396 fn make_credentials(
397 self,
398 quota_project_id: Option<String>,
399 retry_builder: RetryTokenProviderBuilder,
400 ) -> ExternalAccountCredentials<TokenCache> {
401 let config = self.clone();
402 match self.credential_source {
403 CredentialSource::Url(source) => {
404 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
405 }
406 CredentialSource::Executable(source) => {
407 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
408 }
409 CredentialSource::Programmatic(source) => {
410 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
411 }
412 CredentialSource::File(source) => {
413 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
414 }
415 CredentialSource::Aws(source) => {
416 Self::make_credentials_from_source(source, config, quota_project_id, retry_builder)
417 }
418 }
419 }
420
421 fn make_credentials_from_source<T>(
422 subject_token_provider: T,
423 config: ExternalAccountConfig,
424 quota_project_id: Option<String>,
425 retry_builder: RetryTokenProviderBuilder,
426 ) -> ExternalAccountCredentials<TokenCache>
427 where
428 T: dynamic::SubjectTokenProvider + 'static,
429 {
430 let universe_domain = config.universe_domain.clone();
431 let token_provider = ExternalAccountTokenProvider {
432 subject_token_provider,
433 config,
434 };
435 let token_provider_with_retry = retry_builder.build(token_provider);
436 let cache = TokenCache::new(token_provider_with_retry);
437 ExternalAccountCredentials {
438 token_provider: cache,
439 quota_project_id,
440 universe_domain,
441 }
442 }
443}
444
445#[derive(Debug)]
446struct ExternalAccountTokenProvider<T>
447where
448 T: dynamic::SubjectTokenProvider,
449{
450 subject_token_provider: T,
451 config: ExternalAccountConfig,
452}
453
454#[async_trait::async_trait]
455impl<T> TokenProvider for ExternalAccountTokenProvider<T>
456where
457 T: dynamic::SubjectTokenProvider,
458{
459 async fn token(&self) -> Result<Token> {
460 let subject_token = self.subject_token_provider.subject_token().await?;
461
462 let audience = self.config.audience.clone();
463 let subject_token_type = self.config.subject_token_type.clone();
464 let user_scopes = self.config.scopes.clone();
465 let url = self.config.token_url.clone();
466
467 let extra_options =
468 if self.config.client_id.is_none() && self.config.client_secret.is_none() {
469 let workforce_pool_user_project = self.config.workforce_pool_user_project.clone();
470 workforce_pool_user_project.map(|project| {
471 let mut options = HashMap::new();
472 options.insert("userProject".to_string(), project);
473 options
474 })
475 } else {
476 None
477 };
478
479 let sts_scope = if self.config.service_account_impersonation_url.is_some() {
485 vec![IAM_SCOPE.to_string()]
486 } else {
487 user_scopes.clone()
488 };
489
490 let req = ExchangeTokenRequest {
491 url,
492 audience: Some(audience),
493 subject_token: subject_token.token,
494 subject_token_type,
495 scope: sts_scope,
496 authentication: ClientAuthentication {
497 client_id: self.config.client_id.clone(),
498 client_secret: self.config.client_secret.clone(),
499 },
500 extra_options,
501 ..ExchangeTokenRequest::default()
502 };
503
504 let token_res = STSHandler::default().exchange_token(req).await?;
505
506 if let Some(impersonation_url) = &self.config.service_account_impersonation_url {
507 let mut headers = HeaderMap::new();
508 headers.insert(
509 http::header::AUTHORIZATION,
510 http::HeaderValue::from_str(&format!("Bearer {}", token_res.access_token))
511 .map_err(non_retryable)?,
512 );
513
514 return impersonated::generate_access_token(
515 headers,
516 None,
517 user_scopes,
518 impersonated::DEFAULT_LIFETIME,
519 impersonation_url,
520 )
521 .await;
522 }
523
524 let token = Token {
525 token: token_res.access_token,
526 token_type: token_res.token_type,
527 expires_at: Some(Instant::now() + Duration::from_secs(token_res.expires_in)),
528 metadata: None,
529 };
530 Ok(token)
531 }
532}
533
534#[derive(Debug)]
535pub(crate) struct ExternalAccountCredentials<T>
536where
537 T: CachedTokenProvider,
538{
539 token_provider: T,
540 quota_project_id: Option<String>,
541 universe_domain: Option<String>,
542}
543
544pub struct Builder {
588 external_account_config: Value,
589 quota_project_id: Option<String>,
590 scopes: Option<Vec<String>>,
591 universe_domain: Option<String>,
592 retry_builder: RetryTokenProviderBuilder,
593 iam_endpoint_override: Option<String>,
594}
595
596impl Builder {
597 pub fn new(external_account_config: Value) -> Self {
601 Self {
602 external_account_config,
603 quota_project_id: None,
604 scopes: None,
605 universe_domain: None,
606 retry_builder: RetryTokenProviderBuilder::default(),
607 iam_endpoint_override: None,
608 }
609 }
610
611 pub fn with_quota_project_id<S: Into<String>>(mut self, quota_project_id: S) -> Self {
620 self.quota_project_id = Some(quota_project_id.into());
621 self
622 }
623
624 pub fn with_universe_domain<S: Into<String>>(mut self, universe_domain: S) -> Self {
648 self.universe_domain = Some(universe_domain.into());
649 self
650 }
651
652 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
656 where
657 I: IntoIterator<Item = S>,
658 S: Into<String>,
659 {
660 self.scopes = Some(scopes.into_iter().map(|s| s.into()).collect());
661 self
662 }
663
664 pub fn with_retry_policy<V: Into<RetryPolicyArg>>(mut self, v: V) -> Self {
686 self.retry_builder = self.retry_builder.with_retry_policy(v.into());
687 self
688 }
689
690 pub fn with_backoff_policy<V: Into<BackoffPolicyArg>>(mut self, v: V) -> Self {
713 self.retry_builder = self.retry_builder.with_backoff_policy(v.into());
714 self
715 }
716
717 pub fn with_retry_throttler<V: Into<RetryThrottlerArg>>(mut self, v: V) -> Self {
745 self.retry_builder = self.retry_builder.with_retry_throttler(v.into());
746 self
747 }
748
749 #[cfg(all(test, google_cloud_unstable_trusted_boundaries))]
750 fn maybe_iam_endpoint_override(mut self, iam_endpoint_override: Option<String>) -> Self {
751 self.iam_endpoint_override = iam_endpoint_override;
752 self
753 }
754
755 pub fn build(self) -> BuildResult<Credentials> {
769 Ok(self.build_credentials()?.into())
770 }
771
772 pub fn build_access_token_credentials(self) -> BuildResult<AccessTokenCredentials> {
786 Ok(self.build_credentials()?.into())
787 }
788
789 fn build_credentials(
790 self,
791 ) -> BuildResult<CredentialsWithAccessBoundary<ExternalAccountCredentials<TokenCache>>> {
792 let mut file: ExternalAccountFile =
793 serde_json::from_value(self.external_account_config).map_err(BuilderError::parsing)?;
794
795 if let Some(scopes) = self.scopes {
796 file.scopes = Some(scopes);
797 }
798
799 if let Some(ref ud) = self.universe_domain {
800 file.universe_domain = Some(ud.clone());
801 }
802
803 if file.workforce_pool_user_project.is_some()
804 && !is_valid_workforce_pool_audience(&file.audience)
805 {
806 return Err(BuilderError::parsing(
807 "workforce_pool_user_project should not be set for non-workforce pool credentials",
808 ));
809 }
810
811 let config: ExternalAccountConfig = file.into();
812
813 let access_boundary_url =
814 external_account_lookup_url(&config.audience, self.iam_endpoint_override.as_deref());
815
816 let creds = config.make_credentials(self.quota_project_id, self.retry_builder);
817
818 Ok(CredentialsWithAccessBoundary::new(
819 creds,
820 access_boundary_url,
821 ))
822 }
823}
824
825pub struct ProgrammaticBuilder {
873 quota_project_id: Option<String>,
874 config: ExternalAccountConfigBuilder,
875 retry_builder: RetryTokenProviderBuilder,
876}
877
878impl ProgrammaticBuilder {
879 pub fn new(subject_token_provider: Arc<dyn dynamic::SubjectTokenProvider>) -> Self {
912 let config = ExternalAccountConfigBuilder::default().with_credential_source(
913 CredentialSource::Programmatic(ProgrammaticSourcedCredentials::new(
914 subject_token_provider,
915 )),
916 );
917 Self {
918 quota_project_id: None,
919 config,
920 retry_builder: RetryTokenProviderBuilder::default(),
921 }
922 }
923
924 pub fn with_quota_project_id<S: Into<String>>(mut self, quota_project_id: S) -> Self {
963 self.quota_project_id = Some(quota_project_id.into());
964 self
965 }
966
967 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
1002 where
1003 I: IntoIterator<Item = S>,
1004 S: Into<String>,
1005 {
1006 self.config = self.config.with_scopes(
1007 scopes
1008 .into_iter()
1009 .map(|s| s.into())
1010 .collect::<Vec<String>>(),
1011 );
1012 self
1013 }
1014
1015 pub fn with_universe_domain<S: Into<String>>(mut self, universe_domain: S) -> Self {
1040 self.config = self.config.with_universe_domain(universe_domain);
1041 self
1042 }
1043
1044 pub fn with_audience<S: Into<String>>(mut self, audience: S) -> Self {
1079 self.config = self.config.with_audience(audience);
1080 self
1081 }
1082
1083 pub fn with_subject_token_type<S: Into<String>>(mut self, subject_token_type: S) -> Self {
1117 self.config = self.config.with_subject_token_type(subject_token_type);
1118 self
1119 }
1120
1121 pub fn with_token_url<S: Into<String>>(mut self, token_url: S) -> Self {
1154 self.config = self.config.with_token_url(token_url);
1155 self
1156 }
1157
1158 pub fn with_client_id<S: Into<String>>(mut self, client_id: S) -> Self {
1190 self.config = self.config.with_client_id(client_id.into());
1191 self
1192 }
1193
1194 pub fn with_client_secret<S: Into<String>>(mut self, client_secret: S) -> Self {
1226 self.config = self.config.with_client_secret(client_secret.into());
1227 self
1228 }
1229
1230 pub fn with_target_principal<S: Into<String>>(mut self, target_principal: S) -> Self {
1264 let url = format!(
1265 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken",
1266 target_principal.into()
1267 );
1268 self.config = self.config.with_service_account_impersonation_url(url);
1269 self
1270 }
1271
1272 pub fn with_workforce_pool_user_project<S: Into<String>>(mut self, project: S) -> Self {
1306 self.config = self.config.with_workforce_pool_user_project(project);
1307 self
1308 }
1309
1310 pub fn with_retry_policy<V: Into<RetryPolicyArg>>(mut self, v: V) -> Self {
1350 self.retry_builder = self.retry_builder.with_retry_policy(v.into());
1351 self
1352 }
1353
1354 pub fn with_backoff_policy<V: Into<BackoffPolicyArg>>(mut self, v: V) -> Self {
1394 self.retry_builder = self.retry_builder.with_backoff_policy(v.into());
1395 self
1396 }
1397
1398 pub fn with_retry_throttler<V: Into<RetryThrottlerArg>>(mut self, v: V) -> Self {
1444 self.retry_builder = self.retry_builder.with_retry_throttler(v.into());
1445 self
1446 }
1447
1448 pub fn build(self) -> BuildResult<Credentials> {
1455 let (config, quota_project_id, retry_builder) = self.build_components()?;
1456 let creds = config.make_credentials(quota_project_id, retry_builder);
1457 Ok(Credentials {
1458 inner: Arc::new(creds),
1459 })
1460 }
1461
1462 fn build_components(
1464 self,
1465 ) -> BuildResult<(
1466 ExternalAccountConfig,
1467 Option<String>,
1468 RetryTokenProviderBuilder,
1469 )> {
1470 let Self {
1471 quota_project_id,
1472 config,
1473 retry_builder,
1474 } = self;
1475
1476 let mut config_builder = config;
1477 if config_builder.scopes.is_none() {
1478 config_builder = config_builder.with_scopes(vec![DEFAULT_SCOPE.to_string()]);
1479 }
1480 if config_builder.token_url.is_none() {
1481 let mut token_url = STS_TOKEN_URL.to_string();
1482 if let Some(ref ud) = config_builder.universe_domain {
1483 if ud != DEFAULT_UNIVERSE_DOMAIN {
1484 token_url = token_url.replace(DEFAULT_UNIVERSE_DOMAIN, ud);
1485 }
1486 }
1487 config_builder = config_builder.with_token_url(token_url);
1488 }
1489 let final_config = config_builder.build()?;
1490
1491 Ok((final_config, quota_project_id, retry_builder))
1492 }
1493}
1494
1495#[async_trait::async_trait]
1496impl<T> CredentialsProvider for ExternalAccountCredentials<T>
1497where
1498 T: CachedTokenProvider,
1499{
1500 async fn headers(&self, extensions: Extensions) -> Result<CacheableResource<HeaderMap>> {
1501 let token = self.token_provider.token(extensions).await?;
1502
1503 AuthHeadersBuilder::new(&token)
1504 .maybe_quota_project_id(self.quota_project_id.as_deref())
1505 .build()
1506 }
1507
1508 async fn universe_domain(&self) -> Option<String> {
1509 self.universe_domain.clone()
1510 }
1511}
1512
1513#[async_trait::async_trait]
1514impl<T> AccessTokenCredentialsProvider for ExternalAccountCredentials<T>
1515where
1516 T: CachedTokenProvider,
1517{
1518 async fn access_token(&self) -> Result<AccessToken> {
1519 let token = self.token_provider.token(Extensions::new()).await?;
1520 token.into()
1521 }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526 use super::*;
1527 use crate::constants::{
1528 ACCESS_TOKEN_TYPE, DEFAULT_SCOPE, JWT_TOKEN_TYPE, TOKEN_EXCHANGE_GRANT_TYPE,
1529 };
1530 use crate::credentials::subject_token::{
1531 Builder as SubjectTokenBuilder, SubjectToken, SubjectTokenProvider,
1532 };
1533 use crate::credentials::tests::{
1534 find_source_error, get_mock_auth_retry_policy, get_mock_backoff_policy,
1535 get_mock_retry_throttler, get_token_from_headers,
1536 };
1537 use crate::errors::{CredentialsError, SubjectTokenProviderError};
1538 use httptest::{
1539 Expectation, Server, cycle,
1540 matchers::{all_of, contains, request, url_decoded},
1541 responders::{json_encoded, status_code},
1542 };
1543 use serde_json::*;
1544 use std::collections::HashMap;
1545 use std::error::Error;
1546 use std::fmt;
1547 use test_case::test_case;
1548 use time::OffsetDateTime;
1549
1550 #[derive(Debug)]
1551 struct TestProviderError;
1552 impl fmt::Display for TestProviderError {
1553 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1554 write!(f, "TestProviderError")
1555 }
1556 }
1557 impl Error for TestProviderError {}
1558 impl SubjectTokenProviderError for TestProviderError {
1559 fn is_transient(&self) -> bool {
1560 false
1561 }
1562 }
1563
1564 #[derive(Debug)]
1565 struct TestSubjectTokenProvider;
1566 impl SubjectTokenProvider for TestSubjectTokenProvider {
1567 type Error = TestProviderError;
1568 async fn subject_token(&self) -> std::result::Result<SubjectToken, Self::Error> {
1569 Ok(SubjectTokenBuilder::new("test-subject-token".to_string()).build())
1570 }
1571 }
1572
1573 #[tokio::test]
1574 async fn create_external_account_builder() {
1575 let contents = json!({
1576 "type": "external_account",
1577 "audience": "audience",
1578 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1579 "token_url": "https://sts.googleapis.com/v1beta/token",
1580 "credential_source": {
1581 "url": "https://example.com/token",
1582 "format": {
1583 "type": "json",
1584 "subject_token_field_name": "access_token"
1585 }
1586 }
1587 });
1588
1589 let creds = Builder::new(contents)
1590 .with_quota_project_id("test_project")
1591 .with_scopes(["a", "b"])
1592 .build()
1593 .unwrap();
1594
1595 let fmt = format!("{creds:?}");
1597 assert!(fmt.contains("ExternalAccountCredentials"));
1598 }
1599
1600 #[tokio::test]
1601 async fn create_external_account_with_universe_domain() {
1602 let contents = json!({
1603 "type": "external_account",
1604 "audience": "audience",
1605 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1606 "token_url": "https://sts.my-custom-universe.com/v1beta/token",
1607 "credential_source": {
1608 "url": "https://example.com/token",
1609 "format": {
1610 "type": "json",
1611 "subject_token_field_name": "access_token"
1612 }
1613 },
1614 "universe_domain": "my-custom-universe.com"
1615 });
1616
1617 let creds = Builder::new(contents).build().unwrap();
1618
1619 assert_eq!(
1620 creds.universe_domain().await,
1621 Some("my-custom-universe.com".to_string())
1622 );
1623 }
1624
1625 #[tokio::test]
1626 async fn create_external_account_with_universe_domain_override() {
1627 let contents = json!({
1628 "type": "external_account",
1629 "audience": "audience",
1630 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1631 "token_url": "https://sts.my-custom-universe.com/v1beta/token",
1632 "credential_source": {
1633 "url": "https://example.com/token",
1634 "format": {
1635 "type": "json",
1636 "subject_token_field_name": "access_token"
1637 }
1638 },
1639 "universe_domain": "my-custom-universe.com"
1640 });
1641
1642 let creds = Builder::new(contents)
1643 .with_universe_domain("my-overridden-universe.com")
1644 .build()
1645 .unwrap();
1646
1647 assert_eq!(
1648 creds.universe_domain().await,
1649 Some("my-overridden-universe.com".to_string())
1650 );
1651 }
1652
1653 #[tokio::test]
1654 async fn test_programmatic_builder_with_universe_domain() {
1655 let provider = Arc::new(TestSubjectTokenProvider);
1656 let builder = ProgrammaticBuilder::new(provider)
1657 .with_audience("test-audience")
1658 .with_subject_token_type("test-token-type")
1659 .with_token_url(STS_TOKEN_URL)
1660 .with_universe_domain("my-custom-universe.com");
1661
1662 let creds = builder.build().unwrap();
1663
1664 assert_eq!(
1665 creds.universe_domain().await,
1666 Some("my-custom-universe.com".to_string())
1667 );
1668 }
1669
1670 #[tokio::test]
1671 async fn test_programmatic_builder_sts_url_updates_with_universe_domain() {
1672 let provider = Arc::new(TestSubjectTokenProvider);
1673 let builder = ProgrammaticBuilder::new(provider)
1674 .with_audience("test-audience")
1675 .with_subject_token_type("test-token-type")
1676 .with_universe_domain("my-custom-universe.com");
1677
1678 let (config, _, _) = builder.build_components().unwrap();
1679
1680 assert_eq!(
1681 config.token_url,
1682 "https://sts.my-custom-universe.com/v1/token"
1683 );
1684 }
1685
1686 #[tokio::test]
1687 async fn create_external_account_detect_url_sourced() {
1688 let contents = json!({
1689 "type": "external_account",
1690 "audience": "audience",
1691 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1692 "token_url": "https://sts.googleapis.com/v1beta/token",
1693 "credential_source": {
1694 "url": "https://example.com/token",
1695 "headers": {
1696 "Metadata": "True"
1697 },
1698 "format": {
1699 "type": "json",
1700 "subject_token_field_name": "access_token"
1701 }
1702 }
1703 });
1704
1705 let file: ExternalAccountFile =
1706 serde_json::from_value(contents).expect("failed to parse external account config");
1707 let config: ExternalAccountConfig = file.into();
1708 let source = config.credential_source;
1709
1710 match source {
1711 CredentialSource::Url(source) => {
1712 assert_eq!(source.url, "https://example.com/token");
1713 assert_eq!(
1714 source.headers,
1715 HashMap::from([("Metadata".to_string(), "True".to_string()),]),
1716 );
1717 assert_eq!(source.format, "json");
1718 assert_eq!(source.subject_token_field_name, "access_token");
1719 }
1720 _ => {
1721 unreachable!("expected Url Sourced credential")
1722 }
1723 }
1724 }
1725
1726 #[tokio::test]
1727 async fn create_external_account_detect_executable_sourced() {
1728 let contents = json!({
1729 "type": "external_account",
1730 "audience": "audience",
1731 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1732 "token_url": "https://sts.googleapis.com/v1beta/token",
1733 "credential_source": {
1734 "executable": {
1735 "command": "cat /some/file",
1736 "output_file": "/some/file",
1737 "timeout_millis": 5000
1738 }
1739 }
1740 });
1741
1742 let file: ExternalAccountFile =
1743 serde_json::from_value(contents).expect("failed to parse external account config");
1744 let config: ExternalAccountConfig = file.into();
1745 let source = config.credential_source;
1746
1747 match source {
1748 CredentialSource::Executable(source) => {
1749 assert_eq!(source.command, "cat");
1750 assert_eq!(source.args, vec!["/some/file"]);
1751 assert_eq!(source.output_file.as_deref(), Some("/some/file"));
1752 assert_eq!(source.timeout, Duration::from_secs(5));
1753 }
1754 _ => {
1755 unreachable!("expected Executable Sourced credential")
1756 }
1757 }
1758 }
1759
1760 #[tokio::test]
1761 async fn create_external_account_detect_file_sourced() {
1762 let contents = json!({
1763 "type": "external_account",
1764 "audience": "audience",
1765 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1766 "token_url": "https://sts.googleapis.com/v1beta/token",
1767 "credential_source": {
1768 "file": "/foo/bar",
1769 "format": {
1770 "type": "json",
1771 "subject_token_field_name": "token"
1772 }
1773 }
1774 });
1775
1776 let file: ExternalAccountFile =
1777 serde_json::from_value(contents).expect("failed to parse external account config");
1778 let config: ExternalAccountConfig = file.into();
1779 let source = config.credential_source;
1780
1781 match source {
1782 CredentialSource::File(source) => {
1783 assert_eq!(source.file, "/foo/bar");
1784 assert_eq!(source.format, "json");
1785 assert_eq!(source.subject_token_field_name, "token");
1786 }
1787 _ => {
1788 unreachable!("expected File Sourced credential")
1789 }
1790 }
1791 }
1792
1793 #[tokio::test]
1794 async fn test_external_account_with_impersonation_success() {
1795 let subject_token_server = Server::run();
1796 let sts_server = Server::run();
1797 let impersonation_server = Server::run();
1798
1799 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
1800 let contents = json!({
1801 "type": "external_account",
1802 "audience": "audience",
1803 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1804 "token_url": sts_server.url("/token").to_string(),
1805 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
1806 "credential_source": {
1807 "url": subject_token_server.url("/subject_token").to_string(),
1808 "format": {
1809 "type": "json",
1810 "subject_token_field_name": "access_token"
1811 }
1812 }
1813 });
1814
1815 subject_token_server.expect(
1816 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1817 json_encoded(json!({
1818 "access_token": "subject_token",
1819 })),
1820 ),
1821 );
1822
1823 sts_server.expect(
1824 Expectation::matching(all_of![
1825 request::method_path("POST", "/token"),
1826 request::body(url_decoded(contains((
1827 "grant_type",
1828 TOKEN_EXCHANGE_GRANT_TYPE
1829 )))),
1830 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1831 request::body(url_decoded(contains((
1832 "requested_token_type",
1833 ACCESS_TOKEN_TYPE
1834 )))),
1835 request::body(url_decoded(contains((
1836 "subject_token_type",
1837 JWT_TOKEN_TYPE
1838 )))),
1839 request::body(url_decoded(contains(("audience", "audience")))),
1840 request::body(url_decoded(contains(("scope", IAM_SCOPE)))),
1841 ])
1842 .respond_with(json_encoded(json!({
1843 "access_token": "sts-token",
1844 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1845 "token_type": "Bearer",
1846 "expires_in": 3600,
1847 }))),
1848 );
1849
1850 let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
1851 .format(&time::format_description::well_known::Rfc3339)
1852 .unwrap();
1853 impersonation_server.expect(
1854 Expectation::matching(all_of![
1855 request::method_path("POST", impersonation_path),
1856 request::headers(contains(("authorization", "Bearer sts-token"))),
1857 ])
1858 .respond_with(json_encoded(json!({
1859 "accessToken": "final-impersonated-token",
1860 "expireTime": expire_time
1861 }))),
1862 );
1863
1864 let creds = Builder::new(contents).build().unwrap();
1865 let headers = creds.headers(Extensions::new()).await.unwrap();
1866 match headers {
1867 CacheableResource::New { data, .. } => {
1868 let token = data.get("authorization").unwrap().to_str().unwrap();
1869 assert_eq!(token, "Bearer final-impersonated-token");
1870 }
1871 CacheableResource::NotModified => panic!("Expected new headers"),
1872 }
1873 }
1874
1875 #[tokio::test]
1876 async fn test_external_account_without_impersonation_success() {
1877 let subject_token_server = Server::run();
1878 let sts_server = Server::run();
1879
1880 let contents = json!({
1881 "type": "external_account",
1882 "audience": "audience",
1883 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1884 "token_url": sts_server.url("/token").to_string(),
1885 "credential_source": {
1886 "url": subject_token_server.url("/subject_token").to_string(),
1887 "format": {
1888 "type": "json",
1889 "subject_token_field_name": "access_token"
1890 }
1891 }
1892 });
1893
1894 subject_token_server.expect(
1895 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1896 json_encoded(json!({
1897 "access_token": "subject_token",
1898 })),
1899 ),
1900 );
1901
1902 sts_server.expect(
1903 Expectation::matching(all_of![
1904 request::method_path("POST", "/token"),
1905 request::body(url_decoded(contains((
1906 "grant_type",
1907 TOKEN_EXCHANGE_GRANT_TYPE
1908 )))),
1909 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1910 request::body(url_decoded(contains((
1911 "requested_token_type",
1912 ACCESS_TOKEN_TYPE
1913 )))),
1914 request::body(url_decoded(contains((
1915 "subject_token_type",
1916 JWT_TOKEN_TYPE
1917 )))),
1918 request::body(url_decoded(contains(("audience", "audience")))),
1919 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
1920 ])
1921 .respond_with(json_encoded(json!({
1922 "access_token": "sts-only-token",
1923 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1924 "token_type": "Bearer",
1925 "expires_in": 3600,
1926 }))),
1927 );
1928
1929 let creds = Builder::new(contents).build().unwrap();
1930 let headers = creds.headers(Extensions::new()).await.unwrap();
1931 match headers {
1932 CacheableResource::New { data, .. } => {
1933 let token = data.get("authorization").unwrap().to_str().unwrap();
1934 assert_eq!(token, "Bearer sts-only-token");
1935 }
1936 CacheableResource::NotModified => panic!("Expected new headers"),
1937 }
1938 }
1939
1940 #[tokio::test]
1941 async fn test_external_account_access_token_credentials_success() {
1942 let server = Server::run();
1943
1944 let contents = json!({
1945 "type": "external_account",
1946 "audience": "audience",
1947 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1948 "token_url": server.url("/token").to_string(),
1949 "credential_source": {
1950 "url": server.url("/subject_token").to_string(),
1951 "format": {
1952 "type": "json",
1953 "subject_token_field_name": "access_token"
1954 }
1955 }
1956 });
1957
1958 server.expect(
1959 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1960 json_encoded(json!({
1961 "access_token": "subject_token",
1962 })),
1963 ),
1964 );
1965
1966 server.expect(
1967 Expectation::matching(all_of![
1968 request::method_path("POST", "/token"),
1969 request::body(url_decoded(contains((
1970 "grant_type",
1971 TOKEN_EXCHANGE_GRANT_TYPE
1972 )))),
1973 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1974 request::body(url_decoded(contains((
1975 "requested_token_type",
1976 ACCESS_TOKEN_TYPE
1977 )))),
1978 request::body(url_decoded(contains((
1979 "subject_token_type",
1980 JWT_TOKEN_TYPE
1981 )))),
1982 request::body(url_decoded(contains(("audience", "audience")))),
1983 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
1984 ])
1985 .respond_with(json_encoded(json!({
1986 "access_token": "sts-only-token",
1987 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1988 "token_type": "Bearer",
1989 "expires_in": 3600,
1990 }))),
1991 );
1992
1993 let creds = Builder::new(contents)
1994 .build_access_token_credentials()
1995 .unwrap();
1996 let access_token = creds.access_token().await.unwrap();
1997 assert_eq!(access_token.token, "sts-only-token");
1998 }
1999
2000 #[tokio::test]
2001 async fn test_impersonation_flow_sts_call_fails() {
2002 let subject_token_server = Server::run();
2003 let sts_server = Server::run();
2004 let impersonation_server = Server::run();
2005
2006 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
2007 let contents = json!({
2008 "type": "external_account",
2009 "audience": "audience",
2010 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2011 "token_url": sts_server.url("/token").to_string(),
2012 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
2013 "credential_source": {
2014 "url": subject_token_server.url("/subject_token").to_string(),
2015 "format": {
2016 "type": "json",
2017 "subject_token_field_name": "access_token"
2018 }
2019 }
2020 });
2021
2022 subject_token_server.expect(
2023 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2024 json_encoded(json!({
2025 "access_token": "subject_token",
2026 })),
2027 ),
2028 );
2029
2030 sts_server.expect(
2031 Expectation::matching(request::method_path("POST", "/token"))
2032 .respond_with(status_code(500)),
2033 );
2034
2035 let creds = Builder::new(contents).build().unwrap();
2036 let err = creds.headers(Extensions::new()).await.unwrap_err();
2037 let original_err = find_source_error::<CredentialsError>(&err).unwrap();
2038 assert!(
2039 original_err
2040 .to_string()
2041 .contains("failed to exchange token")
2042 );
2043 assert!(original_err.is_transient());
2044 }
2045
2046 #[tokio::test]
2047 async fn test_impersonation_flow_iam_call_fails() {
2048 let subject_token_server = Server::run();
2049 let sts_server = Server::run();
2050 let impersonation_server = Server::run();
2051
2052 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
2053 let contents = json!({
2054 "type": "external_account",
2055 "audience": "audience",
2056 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2057 "token_url": sts_server.url("/token").to_string(),
2058 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
2059 "credential_source": {
2060 "url": subject_token_server.url("/subject_token").to_string(),
2061 "format": {
2062 "type": "json",
2063 "subject_token_field_name": "access_token"
2064 }
2065 }
2066 });
2067
2068 subject_token_server.expect(
2069 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2070 json_encoded(json!({
2071 "access_token": "subject_token",
2072 })),
2073 ),
2074 );
2075
2076 sts_server.expect(
2077 Expectation::matching(request::method_path("POST", "/token")).respond_with(
2078 json_encoded(json!({
2079 "access_token": "sts-token",
2080 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2081 "token_type": "Bearer",
2082 "expires_in": 3600,
2083 })),
2084 ),
2085 );
2086
2087 impersonation_server.expect(
2088 Expectation::matching(request::method_path("POST", impersonation_path))
2089 .respond_with(status_code(403)),
2090 );
2091
2092 let creds = Builder::new(contents).build().unwrap();
2093 let err = creds.headers(Extensions::new()).await.unwrap_err();
2094 let original_err = find_source_error::<CredentialsError>(&err).unwrap();
2095 assert!(original_err.to_string().contains("failed to fetch token"));
2096 assert!(!original_err.is_transient());
2097 }
2098
2099 #[test_case(Some(vec!["scope1", "scope2"]), Some("http://custom.com/token") ; "with custom scopes and token_url")]
2100 #[test_case(None, Some("http://custom.com/token") ; "with default scopes and custom token_url")]
2101 #[test_case(Some(vec!["scope1", "scope2"]), None ; "with custom scopes and default token_url")]
2102 #[test_case(None, None ; "with default scopes and default token_url")]
2103 #[tokio::test]
2104 async fn create_programmatic_builder(scopes: Option<Vec<&str>>, token_url: Option<&str>) {
2105 let provider = Arc::new(TestSubjectTokenProvider);
2106 let mut builder = ProgrammaticBuilder::new(provider)
2107 .with_audience("test-audience")
2108 .with_subject_token_type("test-token-type")
2109 .with_client_id("test-client-id")
2110 .with_client_secret("test-client-secret")
2111 .with_target_principal("test-principal");
2112
2113 let expected_scopes = if let Some(scopes) = scopes.clone() {
2114 scopes.iter().map(|s| s.to_string()).collect()
2115 } else {
2116 vec![DEFAULT_SCOPE.to_string()]
2117 };
2118
2119 let expected_token_url = token_url.unwrap_or(STS_TOKEN_URL).to_string();
2120
2121 if let Some(scopes) = scopes {
2122 builder = builder.with_scopes(scopes);
2123 }
2124 if let Some(token_url) = token_url {
2125 builder = builder.with_token_url(token_url);
2126 }
2127
2128 let (config, _, _) = builder.build_components().unwrap();
2129
2130 assert_eq!(config.audience, "test-audience");
2131 assert_eq!(config.subject_token_type, "test-token-type");
2132 assert_eq!(config.client_id, Some("test-client-id".to_string()));
2133 assert_eq!(config.client_secret, Some("test-client-secret".to_string()));
2134 assert_eq!(config.scopes, expected_scopes);
2135 assert_eq!(config.token_url, expected_token_url);
2136 assert_eq!(
2137 config.service_account_impersonation_url,
2138 Some("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken".to_string())
2139 );
2140 }
2141
2142 #[tokio::test]
2143 async fn create_programmatic_builder_with_quota_project_id() {
2144 let provider = Arc::new(TestSubjectTokenProvider);
2145 let builder = ProgrammaticBuilder::new(provider)
2146 .with_audience("test-audience")
2147 .with_subject_token_type("test-token-type")
2148 .with_token_url(STS_TOKEN_URL)
2149 .with_quota_project_id("test-quota-project");
2150
2151 let creds = builder.build().unwrap();
2152
2153 let fmt = format!("{creds:?}");
2154 assert!(
2155 fmt.contains("ExternalAccountCredentials"),
2156 "Expected 'ExternalAccountCredentials', got: {fmt}"
2157 );
2158 assert!(
2159 fmt.contains("test-quota-project"),
2160 "Expected 'test-quota-project', got: {fmt}"
2161 );
2162 }
2163
2164 #[tokio::test]
2165 async fn programmatic_builder_returns_correct_headers() {
2166 let provider = Arc::new(TestSubjectTokenProvider);
2167 let sts_server = Server::run();
2168 let builder = ProgrammaticBuilder::new(provider)
2169 .with_audience("test-audience")
2170 .with_subject_token_type("test-token-type")
2171 .with_token_url(sts_server.url("/token").to_string())
2172 .with_quota_project_id("test-quota-project");
2173
2174 let creds = builder.build().unwrap();
2175
2176 sts_server.expect(
2177 Expectation::matching(all_of![
2178 request::method_path("POST", "/token"),
2179 request::body(url_decoded(contains((
2180 "grant_type",
2181 TOKEN_EXCHANGE_GRANT_TYPE
2182 )))),
2183 request::body(url_decoded(contains((
2184 "subject_token",
2185 "test-subject-token"
2186 )))),
2187 request::body(url_decoded(contains((
2188 "requested_token_type",
2189 ACCESS_TOKEN_TYPE
2190 )))),
2191 request::body(url_decoded(contains((
2192 "subject_token_type",
2193 "test-token-type"
2194 )))),
2195 request::body(url_decoded(contains(("audience", "test-audience")))),
2196 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
2197 ])
2198 .respond_with(json_encoded(json!({
2199 "access_token": "sts-only-token",
2200 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2201 "token_type": "Bearer",
2202 "expires_in": 3600,
2203 }))),
2204 );
2205
2206 let headers = creds.headers(Extensions::new()).await.unwrap();
2207 match headers {
2208 CacheableResource::New { data, .. } => {
2209 let token = data.get("authorization").unwrap().to_str().unwrap();
2210 assert_eq!(token, "Bearer sts-only-token");
2211 let quota_project = data.get("x-goog-user-project").unwrap().to_str().unwrap();
2212 assert_eq!(quota_project, "test-quota-project");
2213 }
2214 CacheableResource::NotModified => panic!("Expected new headers"),
2215 }
2216 }
2217
2218 #[tokio::test]
2219 async fn create_programmatic_builder_fails_on_missing_required_field() {
2220 let provider = Arc::new(TestSubjectTokenProvider);
2221 let result = ProgrammaticBuilder::new(provider)
2222 .with_subject_token_type("test-token-type")
2223 .with_token_url("http://test.com/token")
2225 .build();
2226
2227 assert!(result.is_err(), "{result:?}");
2228 let error_string = result.unwrap_err().to_string();
2229 assert!(
2230 error_string.contains("missing required field: audience"),
2231 "Expected error about missing 'audience', got: {error_string}"
2232 );
2233 }
2234
2235 #[tokio::test]
2236 async fn test_external_account_retries_on_transient_failures() {
2237 let mut subject_token_server = Server::run();
2238 let mut sts_server = Server::run();
2239
2240 let contents = json!({
2241 "type": "external_account",
2242 "audience": "audience",
2243 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2244 "token_url": sts_server.url("/token").to_string(),
2245 "credential_source": {
2246 "url": subject_token_server.url("/subject_token").to_string(),
2247 }
2248 });
2249
2250 subject_token_server.expect(
2251 Expectation::matching(request::method_path("GET", "/subject_token"))
2252 .times(3)
2253 .respond_with(json_encoded(json!({
2254 "access_token": "subject_token",
2255 }))),
2256 );
2257
2258 sts_server.expect(
2259 Expectation::matching(request::method_path("POST", "/token"))
2260 .times(3)
2261 .respond_with(status_code(503)),
2262 );
2263
2264 let creds = Builder::new(contents)
2265 .with_retry_policy(get_mock_auth_retry_policy(3))
2266 .with_backoff_policy(get_mock_backoff_policy())
2267 .with_retry_throttler(get_mock_retry_throttler())
2268 .build()
2269 .unwrap();
2270
2271 let err = creds.headers(Extensions::new()).await.unwrap_err();
2272 assert!(err.is_transient(), "{err:?}");
2273 sts_server.verify_and_clear();
2274 subject_token_server.verify_and_clear();
2275 }
2276
2277 #[tokio::test]
2278 async fn test_external_account_does_not_retry_on_non_transient_failures() {
2279 let subject_token_server = Server::run();
2280 let mut sts_server = Server::run();
2281
2282 let contents = json!({
2283 "type": "external_account",
2284 "audience": "audience",
2285 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2286 "token_url": sts_server.url("/token").to_string(),
2287 "credential_source": {
2288 "url": subject_token_server.url("/subject_token").to_string(),
2289 }
2290 });
2291
2292 subject_token_server.expect(
2293 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2294 json_encoded(json!({
2295 "access_token": "subject_token",
2296 })),
2297 ),
2298 );
2299
2300 sts_server.expect(
2301 Expectation::matching(request::method_path("POST", "/token"))
2302 .times(1)
2303 .respond_with(status_code(401)),
2304 );
2305
2306 let creds = Builder::new(contents)
2307 .with_retry_policy(get_mock_auth_retry_policy(1))
2308 .with_backoff_policy(get_mock_backoff_policy())
2309 .with_retry_throttler(get_mock_retry_throttler())
2310 .build()
2311 .unwrap();
2312
2313 let err = creds.headers(Extensions::new()).await.unwrap_err();
2314 assert!(!err.is_transient());
2315 sts_server.verify_and_clear();
2316 }
2317
2318 #[tokio::test]
2319 async fn test_external_account_retries_for_success() {
2320 let mut subject_token_server = Server::run();
2321 let mut sts_server = Server::run();
2322
2323 let contents = json!({
2324 "type": "external_account",
2325 "audience": "audience",
2326 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2327 "token_url": sts_server.url("/token").to_string(),
2328 "credential_source": {
2329 "url": subject_token_server.url("/subject_token").to_string(),
2330 }
2331 });
2332
2333 subject_token_server.expect(
2334 Expectation::matching(request::method_path("GET", "/subject_token"))
2335 .times(3)
2336 .respond_with(json_encoded(json!({
2337 "access_token": "subject_token",
2338 }))),
2339 );
2340
2341 sts_server.expect(
2342 Expectation::matching(request::method_path("POST", "/token"))
2343 .times(3)
2344 .respond_with(cycle![
2345 status_code(503).body("try-again"),
2346 status_code(503).body("try-again"),
2347 json_encoded(json!({
2348 "access_token": "sts-only-token",
2349 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2350 "token_type": "Bearer",
2351 "expires_in": 3600,
2352 }))
2353 ]),
2354 );
2355
2356 let creds = Builder::new(contents)
2357 .with_retry_policy(get_mock_auth_retry_policy(3))
2358 .with_backoff_policy(get_mock_backoff_policy())
2359 .with_retry_throttler(get_mock_retry_throttler())
2360 .build()
2361 .unwrap();
2362
2363 let headers = creds.headers(Extensions::new()).await.unwrap();
2364 match headers {
2365 CacheableResource::New { data, .. } => {
2366 let token = data.get("authorization").unwrap().to_str().unwrap();
2367 assert_eq!(token, "Bearer sts-only-token");
2368 }
2369 CacheableResource::NotModified => panic!("Expected new headers"),
2370 }
2371 sts_server.verify_and_clear();
2372 subject_token_server.verify_and_clear();
2373 }
2374
2375 #[tokio::test]
2376 async fn test_programmatic_builder_retries_on_transient_failures() {
2377 let provider = Arc::new(TestSubjectTokenProvider);
2378 let mut sts_server = Server::run();
2379
2380 sts_server.expect(
2381 Expectation::matching(request::method_path("POST", "/token"))
2382 .times(3)
2383 .respond_with(status_code(503)),
2384 );
2385
2386 let creds = ProgrammaticBuilder::new(provider)
2387 .with_audience("test-audience")
2388 .with_subject_token_type("test-token-type")
2389 .with_token_url(sts_server.url("/token").to_string())
2390 .with_retry_policy(get_mock_auth_retry_policy(3))
2391 .with_backoff_policy(get_mock_backoff_policy())
2392 .with_retry_throttler(get_mock_retry_throttler())
2393 .build()
2394 .unwrap();
2395
2396 let err = creds.headers(Extensions::new()).await.unwrap_err();
2397 assert!(err.is_transient(), "{err:?}");
2398 sts_server.verify_and_clear();
2399 }
2400
2401 #[tokio::test]
2402 async fn test_programmatic_builder_does_not_retry_on_non_transient_failures() {
2403 let provider = Arc::new(TestSubjectTokenProvider);
2404 let mut sts_server = Server::run();
2405
2406 sts_server.expect(
2407 Expectation::matching(request::method_path("POST", "/token"))
2408 .times(1)
2409 .respond_with(status_code(401)),
2410 );
2411
2412 let creds = ProgrammaticBuilder::new(provider)
2413 .with_audience("test-audience")
2414 .with_subject_token_type("test-token-type")
2415 .with_token_url(sts_server.url("/token").to_string())
2416 .with_retry_policy(get_mock_auth_retry_policy(1))
2417 .with_backoff_policy(get_mock_backoff_policy())
2418 .with_retry_throttler(get_mock_retry_throttler())
2419 .build()
2420 .unwrap();
2421
2422 let err = creds.headers(Extensions::new()).await.unwrap_err();
2423 assert!(!err.is_transient());
2424 sts_server.verify_and_clear();
2425 }
2426
2427 #[tokio::test]
2428 async fn test_programmatic_builder_retries_for_success() {
2429 let provider = Arc::new(TestSubjectTokenProvider);
2430 let mut sts_server = Server::run();
2431
2432 sts_server.expect(
2433 Expectation::matching(request::method_path("POST", "/token"))
2434 .times(3)
2435 .respond_with(cycle![
2436 status_code(503).body("try-again"),
2437 status_code(503).body("try-again"),
2438 json_encoded(json!({
2439 "access_token": "sts-only-token",
2440 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2441 "token_type": "Bearer",
2442 "expires_in": 3600,
2443 }))
2444 ]),
2445 );
2446
2447 let creds = ProgrammaticBuilder::new(provider)
2448 .with_audience("test-audience")
2449 .with_subject_token_type("test-token-type")
2450 .with_token_url(sts_server.url("/token").to_string())
2451 .with_retry_policy(get_mock_auth_retry_policy(3))
2452 .with_backoff_policy(get_mock_backoff_policy())
2453 .with_retry_throttler(get_mock_retry_throttler())
2454 .build()
2455 .unwrap();
2456
2457 let headers = creds.headers(Extensions::new()).await.unwrap();
2458 match headers {
2459 CacheableResource::New { data, .. } => {
2460 let token = data.get("authorization").unwrap().to_str().unwrap();
2461 assert_eq!(token, "Bearer sts-only-token");
2462 }
2463 CacheableResource::NotModified => panic!("Expected new headers"),
2464 }
2465 sts_server.verify_and_clear();
2466 }
2467
2468 #[test_case(
2469 "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider",
2470 "/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations";
2471 "workload_identity_pool"
2472 )]
2473 #[test_case(
2474 "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider",
2475 "/v1/locations/global/workforcePools/my-pool/allowedLocations";
2476 "workforce_pool"
2477 )]
2478 #[tokio::test]
2479 #[cfg(google_cloud_unstable_trusted_boundaries)]
2480 async fn e2e_access_boundary(audience: &str, iam_path: &str) -> anyhow::Result<()> {
2481 use crate::credentials::tests::get_access_boundary_from_headers;
2482
2483 let audience = audience.to_string();
2484 let iam_path = iam_path.to_string();
2485
2486 let server = Server::run();
2487
2488 server.expect(
2489 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2490 json_encoded(json!({
2491 "access_token": "subject_token",
2492 })),
2493 ),
2494 );
2495
2496 server.expect(
2497 Expectation::matching(all_of![
2498 request::method_path("POST", "/token"),
2499 request::body(url_decoded(contains(("subject_token", "subject_token")))),
2500 request::body(url_decoded(contains(("audience", audience.clone())))),
2501 ])
2502 .respond_with(json_encoded(json!({
2503 "access_token": "sts-only-token",
2504 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2505 "token_type": "Bearer",
2506 "expires_in": 3600,
2507 }))),
2508 );
2509
2510 server.expect(
2511 Expectation::matching(all_of![request::method_path("GET", iam_path.clone()),])
2512 .respond_with(json_encoded(json!({
2513 "locations": ["us-central1"],
2514 "encodedLocations": "0x1234"
2515 }))),
2516 );
2517
2518 let contents = json!({
2519 "type": "external_account",
2520 "audience": audience.to_string(),
2521 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2522 "token_url": server.url("/token").to_string(),
2523 "credential_source": {
2524 "url": server.url("/subject_token").to_string(),
2525 "format": {
2526 "type": "json",
2527 "subject_token_field_name": "access_token"
2528 }
2529 }
2530 });
2531
2532 let iam_endpoint = server.url("").to_string().trim_end_matches('/').to_string();
2533
2534 let creds = Builder::new(contents)
2535 .maybe_iam_endpoint_override(Some(iam_endpoint))
2536 .build_credentials()?;
2537
2538 creds.wait_for_boundary().await;
2540
2541 let headers = creds.headers(Extensions::new()).await?;
2542 let token = get_token_from_headers(headers.clone());
2543 let access_boundary = get_access_boundary_from_headers(headers);
2544
2545 assert!(token.is_some(), "should have some token");
2546 assert_eq!(access_boundary.as_deref(), Some("0x1234"));
2547
2548 Ok(())
2549 }
2550
2551 #[tokio::test]
2552 async fn test_kubernetes_wif_direct_identity_parsing() {
2553 let contents = json!({
2554 "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool-name/providers/k8s-cluster",
2555 "credential_source": {
2556 "file": "/var/run/service-account/token"
2557 },
2558 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2559 "token_url": "https://sts.googleapis.com/v1/token",
2560 "type": "external_account"
2561 });
2562
2563 let file: ExternalAccountFile = serde_json::from_value(contents)
2564 .expect("failed to parse kubernetes WIF direct identity config");
2565 let config: ExternalAccountConfig = file.into();
2566
2567 match config.credential_source {
2568 CredentialSource::File(source) => {
2569 assert_eq!(source.file, "/var/run/service-account/token");
2570 assert_eq!(source.format, "text"); assert_eq!(source.subject_token_field_name, ""); }
2573 _ => {
2574 unreachable!("expected File sourced credential")
2575 }
2576 }
2577 }
2578
2579 #[tokio::test]
2580 async fn test_kubernetes_wif_impersonation_parsing() {
2581 let contents = json!({
2582 "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool-name/providers/k8s-cluster",
2583 "credential_source": {
2584 "file": "/var/run/service-account/token",
2585 "format": {
2586 "type": "text"
2587 }
2588 },
2589 "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-sa@test-project.iam.gserviceaccount.com:generateAccessToken",
2590 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2591 "token_url": "https://sts.googleapis.com/v1/token",
2592 "type": "external_account",
2593 "universe_domain": "googleapis.com"
2594 });
2595
2596 let file: ExternalAccountFile = serde_json::from_value(contents)
2597 .expect("failed to parse kubernetes WIF impersonation config");
2598 let config: ExternalAccountConfig = file.into();
2599
2600 match config.credential_source {
2601 CredentialSource::File(source) => {
2602 assert_eq!(source.file, "/var/run/service-account/token");
2603 assert_eq!(source.format, "text");
2604 assert_eq!(source.subject_token_field_name, ""); }
2606 _ => {
2607 unreachable!("expected File sourced credential")
2608 }
2609 }
2610
2611 assert_eq!(
2612 config.service_account_impersonation_url,
2613 Some("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-sa@test-project.iam.gserviceaccount.com:generateAccessToken".to_string())
2614 );
2615 }
2616
2617 #[tokio::test]
2618 async fn test_aws_parsing() {
2619 let contents = json!({
2620 "audience": "audience",
2621 "credential_source": {
2622 "environment_id": "aws1",
2623 "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
2624 "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
2625 "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
2626 "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
2627 },
2628 "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
2629 "token_url": "https://sts.googleapis.com/v1/token",
2630 "type": "external_account"
2631 });
2632
2633 let file: ExternalAccountFile =
2634 serde_json::from_value(contents).expect("failed to parse AWS config");
2635 let config: ExternalAccountConfig = file.into();
2636
2637 match config.credential_source {
2638 CredentialSource::Aws(source) => {
2639 assert_eq!(
2640 source.region_url,
2641 Some(
2642 "http://169.254.169.254/latest/meta-data/placement/availability-zone"
2643 .to_string()
2644 )
2645 );
2646 assert_eq!(
2647 source.regional_cred_verification_url,
2648 Some(
2649 "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
2650 .to_string()
2651 )
2652 );
2653 assert_eq!(
2654 source.imdsv2_session_token_url,
2655 Some("http://169.254.169.254/latest/api/token".to_string())
2656 );
2657 }
2658 _ => {
2659 unreachable!("expected Aws sourced credential")
2660 }
2661 }
2662 }
2663
2664 #[tokio::test]
2665 async fn builder_workforce_pool_user_project_fails_without_workforce_pool_audience() {
2666 let contents = json!({
2667 "type": "external_account",
2668 "audience": "not-a-workforce-pool",
2669 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2670 "token_url": "http://test.com/token",
2671 "credential_source": {
2672 "url": "http://test.com/subject_token",
2673 },
2674 "workforce_pool_user_project": "test-project"
2675 });
2676
2677 let result = Builder::new(contents).build();
2678
2679 assert!(result.is_err(), "{result:?}");
2680 let err = result.unwrap_err();
2681 assert!(err.is_parsing(), "{err:?}");
2682 }
2683
2684 #[tokio::test]
2685 async fn sts_handler_ignores_workforce_pool_user_project_with_client_auth()
2686 -> std::result::Result<(), Box<dyn std::error::Error>> {
2687 let subject_token_server = Server::run();
2688 let sts_server = Server::run();
2689
2690 let contents = json!({
2691 "type": "external_account",
2692 "audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
2693 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2694 "token_url": sts_server.url("/token").to_string(),
2695 "client_id": "client-id",
2696 "credential_source": {
2697 "url": subject_token_server.url("/subject_token").to_string(),
2698 },
2699 "workforce_pool_user_project": "test-project"
2700 });
2701
2702 subject_token_server.expect(
2703 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2704 json_encoded(json!({
2705 "access_token": "subject_token",
2706 })),
2707 ),
2708 );
2709
2710 sts_server.expect(
2711 Expectation::matching(all_of![request::method_path("POST", "/token"),]).respond_with(
2712 json_encoded(json!({
2713 "access_token": "sts-only-token",
2714 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2715 "token_type": "Bearer",
2716 "expires_in": 3600,
2717 })),
2718 ),
2719 );
2720
2721 let creds = Builder::new(contents).build()?;
2722 let headers = creds.headers(Extensions::new()).await?;
2723 let token = get_token_from_headers(headers);
2724 assert_eq!(token.as_deref(), Some("sts-only-token"));
2725
2726 Ok(())
2727 }
2728
2729 #[tokio::test]
2730 async fn sts_handler_receives_workforce_pool_user_project()
2731 -> std::result::Result<(), Box<dyn std::error::Error>> {
2732 let subject_token_server = Server::run();
2733 let sts_server = Server::run();
2734
2735 let contents = json!({
2736 "type": "external_account",
2737 "audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
2738 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2739 "token_url": sts_server.url("/token").to_string(),
2740 "credential_source": {
2741 "url": subject_token_server.url("/subject_token").to_string(),
2742 },
2743 "workforce_pool_user_project": "test-project-123"
2744 });
2745
2746 subject_token_server.expect(
2747 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2748 json_encoded(json!({
2749 "access_token": "subject_token",
2750 })),
2751 ),
2752 );
2753
2754 sts_server.expect(
2755 Expectation::matching(all_of![
2756 request::method_path("POST", "/token"),
2757 request::body(url_decoded(contains((
2758 "options",
2759 "{\"userProject\":\"test-project-123\"}"
2760 )))),
2761 ])
2762 .respond_with(json_encoded(json!({
2763 "access_token": "sts-only-token",
2764 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2765 "token_type": "Bearer",
2766 "expires_in": 3600,
2767 }))),
2768 );
2769
2770 let creds = Builder::new(contents).build()?;
2771 let headers = creds.headers(Extensions::new()).await?;
2772 let token = get_token_from_headers(headers);
2773 assert_eq!(token.as_deref(), Some("sts-only-token"));
2774
2775 Ok(())
2776 }
2777}