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::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 #[allow(dead_code)]
629 pub(crate) fn with_universe_domain<S: Into<String>>(mut self, universe_domain: S) -> Self {
630 self.universe_domain = Some(universe_domain.into());
631 self
632 }
633
634 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
638 where
639 I: IntoIterator<Item = S>,
640 S: Into<String>,
641 {
642 self.scopes = Some(scopes.into_iter().map(|s| s.into()).collect());
643 self
644 }
645
646 pub fn with_retry_policy<V: Into<RetryPolicyArg>>(mut self, v: V) -> Self {
668 self.retry_builder = self.retry_builder.with_retry_policy(v.into());
669 self
670 }
671
672 pub fn with_backoff_policy<V: Into<BackoffPolicyArg>>(mut self, v: V) -> Self {
695 self.retry_builder = self.retry_builder.with_backoff_policy(v.into());
696 self
697 }
698
699 pub fn with_retry_throttler<V: Into<RetryThrottlerArg>>(mut self, v: V) -> Self {
727 self.retry_builder = self.retry_builder.with_retry_throttler(v.into());
728 self
729 }
730
731 #[cfg(all(test, google_cloud_unstable_trusted_boundaries))]
732 fn maybe_iam_endpoint_override(mut self, iam_endpoint_override: Option<String>) -> Self {
733 self.iam_endpoint_override = iam_endpoint_override;
734 self
735 }
736
737 pub fn build(self) -> BuildResult<Credentials> {
751 Ok(self.build_credentials()?.into())
752 }
753
754 pub fn build_access_token_credentials(self) -> BuildResult<AccessTokenCredentials> {
768 Ok(self.build_credentials()?.into())
769 }
770
771 fn build_credentials(
772 self,
773 ) -> BuildResult<CredentialsWithAccessBoundary<ExternalAccountCredentials<TokenCache>>> {
774 let mut file: ExternalAccountFile =
775 serde_json::from_value(self.external_account_config).map_err(BuilderError::parsing)?;
776
777 if let Some(scopes) = self.scopes {
778 file.scopes = Some(scopes);
779 }
780
781 if let Some(ref ud) = self.universe_domain {
782 file.universe_domain = Some(ud.clone());
783 }
784
785 if file.workforce_pool_user_project.is_some()
786 && !is_valid_workforce_pool_audience(&file.audience)
787 {
788 return Err(BuilderError::parsing(
789 "workforce_pool_user_project should not be set for non-workforce pool credentials",
790 ));
791 }
792
793 let universe_domain = file.universe_domain.clone();
794 let config: ExternalAccountConfig = file.into();
795
796 let access_boundary_url =
797 external_account_lookup_url(&config.audience, self.iam_endpoint_override.as_deref());
798
799 let creds = config.make_credentials(self.quota_project_id, self.retry_builder);
800
801 Ok(CredentialsWithAccessBoundary::new(
802 creds,
803 access_boundary_url,
804 universe_domain,
805 ))
806 }
807}
808
809pub struct ProgrammaticBuilder {
857 quota_project_id: Option<String>,
858 config: ExternalAccountConfigBuilder,
859 retry_builder: RetryTokenProviderBuilder,
860}
861
862impl ProgrammaticBuilder {
863 pub fn new(subject_token_provider: Arc<dyn dynamic::SubjectTokenProvider>) -> Self {
896 let config = ExternalAccountConfigBuilder::default().with_credential_source(
897 CredentialSource::Programmatic(ProgrammaticSourcedCredentials::new(
898 subject_token_provider,
899 )),
900 );
901 Self {
902 quota_project_id: None,
903 config,
904 retry_builder: RetryTokenProviderBuilder::default(),
905 }
906 }
907
908 pub fn with_quota_project_id<S: Into<String>>(mut self, quota_project_id: S) -> Self {
947 self.quota_project_id = Some(quota_project_id.into());
948 self
949 }
950
951 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
986 where
987 I: IntoIterator<Item = S>,
988 S: Into<String>,
989 {
990 self.config = self.config.with_scopes(
991 scopes
992 .into_iter()
993 .map(|s| s.into())
994 .collect::<Vec<String>>(),
995 );
996 self
997 }
998
999 #[allow(dead_code)]
1004 pub(crate) fn with_universe_domain<S: Into<String>>(mut self, universe_domain: S) -> Self {
1005 self.config = self.config.with_universe_domain(universe_domain);
1006 self
1007 }
1008
1009 pub fn with_audience<S: Into<String>>(mut self, audience: S) -> Self {
1044 self.config = self.config.with_audience(audience);
1045 self
1046 }
1047
1048 pub fn with_subject_token_type<S: Into<String>>(mut self, subject_token_type: S) -> Self {
1082 self.config = self.config.with_subject_token_type(subject_token_type);
1083 self
1084 }
1085
1086 pub fn with_token_url<S: Into<String>>(mut self, token_url: S) -> Self {
1119 self.config = self.config.with_token_url(token_url);
1120 self
1121 }
1122
1123 pub fn with_client_id<S: Into<String>>(mut self, client_id: S) -> Self {
1155 self.config = self.config.with_client_id(client_id.into());
1156 self
1157 }
1158
1159 pub fn with_client_secret<S: Into<String>>(mut self, client_secret: S) -> Self {
1191 self.config = self.config.with_client_secret(client_secret.into());
1192 self
1193 }
1194
1195 pub fn with_target_principal<S: Into<String>>(mut self, target_principal: S) -> Self {
1229 let url = format!(
1230 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken",
1231 target_principal.into()
1232 );
1233 self.config = self.config.with_service_account_impersonation_url(url);
1234 self
1235 }
1236
1237 pub fn with_workforce_pool_user_project<S: Into<String>>(mut self, project: S) -> Self {
1271 self.config = self.config.with_workforce_pool_user_project(project);
1272 self
1273 }
1274
1275 pub fn with_retry_policy<V: Into<RetryPolicyArg>>(mut self, v: V) -> Self {
1315 self.retry_builder = self.retry_builder.with_retry_policy(v.into());
1316 self
1317 }
1318
1319 pub fn with_backoff_policy<V: Into<BackoffPolicyArg>>(mut self, v: V) -> Self {
1359 self.retry_builder = self.retry_builder.with_backoff_policy(v.into());
1360 self
1361 }
1362
1363 pub fn with_retry_throttler<V: Into<RetryThrottlerArg>>(mut self, v: V) -> Self {
1409 self.retry_builder = self.retry_builder.with_retry_throttler(v.into());
1410 self
1411 }
1412
1413 pub fn build(self) -> BuildResult<Credentials> {
1420 let (config, quota_project_id, retry_builder) = self.build_components()?;
1421 let creds = config.make_credentials(quota_project_id, retry_builder);
1422 Ok(Credentials {
1423 inner: Arc::new(creds),
1424 })
1425 }
1426
1427 fn build_components(
1429 self,
1430 ) -> BuildResult<(
1431 ExternalAccountConfig,
1432 Option<String>,
1433 RetryTokenProviderBuilder,
1434 )> {
1435 let Self {
1436 quota_project_id,
1437 config,
1438 retry_builder,
1439 } = self;
1440
1441 let mut config_builder = config;
1442 if config_builder.scopes.is_none() {
1443 config_builder = config_builder.with_scopes(vec![DEFAULT_SCOPE.to_string()]);
1444 }
1445 if config_builder.token_url.is_none() {
1446 let mut token_url = STS_TOKEN_URL.to_string();
1447 if let Some(ref ud) = config_builder.universe_domain {
1448 if ud != DEFAULT_UNIVERSE_DOMAIN {
1449 token_url = token_url.replace(DEFAULT_UNIVERSE_DOMAIN, ud);
1450 }
1451 }
1452 config_builder = config_builder.with_token_url(token_url);
1453 }
1454 let final_config = config_builder.build()?;
1455
1456 Ok((final_config, quota_project_id, retry_builder))
1457 }
1458}
1459
1460#[async_trait::async_trait]
1461impl<T> CredentialsProvider for ExternalAccountCredentials<T>
1462where
1463 T: CachedTokenProvider,
1464{
1465 async fn headers(&self, extensions: Extensions) -> Result<CacheableResource<HeaderMap>> {
1466 let token = self.token_provider.token(extensions).await?;
1467
1468 AuthHeadersBuilder::new(&token)
1469 .maybe_quota_project_id(self.quota_project_id.as_deref())
1470 .build()
1471 }
1472
1473 async fn universe_domain(&self) -> Option<String> {
1474 self.universe_domain.clone()
1475 }
1476}
1477
1478#[async_trait::async_trait]
1479impl<T> AccessTokenCredentialsProvider for ExternalAccountCredentials<T>
1480where
1481 T: CachedTokenProvider,
1482{
1483 async fn access_token(&self) -> Result<AccessToken> {
1484 let token = self.token_provider.token(Extensions::new()).await?;
1485 token.into()
1486 }
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491 use super::*;
1492 use crate::constants::{
1493 ACCESS_TOKEN_TYPE, DEFAULT_SCOPE, JWT_TOKEN_TYPE, TOKEN_EXCHANGE_GRANT_TYPE,
1494 };
1495 use crate::credentials::subject_token::{
1496 Builder as SubjectTokenBuilder, SubjectToken, SubjectTokenProvider,
1497 };
1498 use crate::credentials::tests::{
1499 find_source_error, get_mock_auth_retry_policy, get_mock_backoff_policy,
1500 get_mock_retry_throttler, get_token_from_headers,
1501 };
1502 use crate::errors::{CredentialsError, SubjectTokenProviderError};
1503 use httptest::{
1504 Expectation, Server, cycle,
1505 matchers::{all_of, contains, request, url_decoded},
1506 responders::{json_encoded, status_code},
1507 };
1508 use serde_json::*;
1509 use std::collections::HashMap;
1510 use std::error::Error;
1511 use std::fmt;
1512 use test_case::test_case;
1513 use time::OffsetDateTime;
1514
1515 #[derive(Debug)]
1516 struct TestProviderError;
1517 impl fmt::Display for TestProviderError {
1518 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1519 write!(f, "TestProviderError")
1520 }
1521 }
1522 impl Error for TestProviderError {}
1523 impl SubjectTokenProviderError for TestProviderError {
1524 fn is_transient(&self) -> bool {
1525 false
1526 }
1527 }
1528
1529 #[derive(Debug)]
1530 struct TestSubjectTokenProvider;
1531 impl SubjectTokenProvider for TestSubjectTokenProvider {
1532 type Error = TestProviderError;
1533 async fn subject_token(&self) -> std::result::Result<SubjectToken, Self::Error> {
1534 Ok(SubjectTokenBuilder::new("test-subject-token".to_string()).build())
1535 }
1536 }
1537
1538 #[tokio::test]
1539 async fn create_external_account_builder() {
1540 let contents = json!({
1541 "type": "external_account",
1542 "audience": "audience",
1543 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1544 "token_url": "https://sts.googleapis.com/v1beta/token",
1545 "credential_source": {
1546 "url": "https://example.com/token",
1547 "format": {
1548 "type": "json",
1549 "subject_token_field_name": "access_token"
1550 }
1551 }
1552 });
1553
1554 let creds = Builder::new(contents)
1555 .with_quota_project_id("test_project")
1556 .with_scopes(["a", "b"])
1557 .build()
1558 .unwrap();
1559
1560 let fmt = format!("{creds:?}");
1562 assert!(fmt.contains("ExternalAccountCredentials"));
1563 }
1564
1565 #[tokio::test]
1566 async fn create_external_account_with_universe_domain() {
1567 let contents = json!({
1568 "type": "external_account",
1569 "audience": "audience",
1570 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1571 "token_url": "https://sts.my-custom-universe.com/v1beta/token",
1572 "credential_source": {
1573 "url": "https://example.com/token",
1574 "format": {
1575 "type": "json",
1576 "subject_token_field_name": "access_token"
1577 }
1578 },
1579 "universe_domain": "my-custom-universe.com"
1580 });
1581
1582 let creds = Builder::new(contents).build().unwrap();
1583
1584 assert_eq!(
1585 creds.universe_domain().await,
1586 Some("my-custom-universe.com".to_string())
1587 );
1588 }
1589
1590 #[tokio::test]
1591 async fn create_external_account_with_universe_domain_override() {
1592 let contents = json!({
1593 "type": "external_account",
1594 "audience": "audience",
1595 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1596 "token_url": "https://sts.my-custom-universe.com/v1beta/token",
1597 "credential_source": {
1598 "url": "https://example.com/token",
1599 "format": {
1600 "type": "json",
1601 "subject_token_field_name": "access_token"
1602 }
1603 },
1604 "universe_domain": "my-custom-universe.com"
1605 });
1606
1607 let creds = Builder::new(contents)
1608 .with_universe_domain("my-overridden-universe.com")
1609 .build()
1610 .unwrap();
1611
1612 assert_eq!(
1613 creds.universe_domain().await,
1614 Some("my-overridden-universe.com".to_string())
1615 );
1616 }
1617
1618 #[tokio::test]
1619 async fn test_programmatic_builder_with_universe_domain() {
1620 let provider = Arc::new(TestSubjectTokenProvider);
1621 let builder = ProgrammaticBuilder::new(provider)
1622 .with_audience("test-audience")
1623 .with_subject_token_type("test-token-type")
1624 .with_token_url(STS_TOKEN_URL)
1625 .with_universe_domain("my-custom-universe.com");
1626
1627 let creds = builder.build().unwrap();
1628
1629 assert_eq!(
1630 creds.universe_domain().await,
1631 Some("my-custom-universe.com".to_string())
1632 );
1633 }
1634
1635 #[tokio::test]
1636 async fn test_programmatic_builder_sts_url_updates_with_universe_domain() {
1637 let provider = Arc::new(TestSubjectTokenProvider);
1638 let builder = ProgrammaticBuilder::new(provider)
1639 .with_audience("test-audience")
1640 .with_subject_token_type("test-token-type")
1641 .with_universe_domain("my-custom-universe.com");
1642
1643 let (config, _, _) = builder.build_components().unwrap();
1644
1645 assert_eq!(
1646 config.token_url,
1647 "https://sts.my-custom-universe.com/v1/token"
1648 );
1649 }
1650
1651 #[tokio::test]
1652 async fn create_external_account_detect_url_sourced() {
1653 let contents = json!({
1654 "type": "external_account",
1655 "audience": "audience",
1656 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1657 "token_url": "https://sts.googleapis.com/v1beta/token",
1658 "credential_source": {
1659 "url": "https://example.com/token",
1660 "headers": {
1661 "Metadata": "True"
1662 },
1663 "format": {
1664 "type": "json",
1665 "subject_token_field_name": "access_token"
1666 }
1667 }
1668 });
1669
1670 let file: ExternalAccountFile =
1671 serde_json::from_value(contents).expect("failed to parse external account config");
1672 let config: ExternalAccountConfig = file.into();
1673 let source = config.credential_source;
1674
1675 match source {
1676 CredentialSource::Url(source) => {
1677 assert_eq!(source.url, "https://example.com/token");
1678 assert_eq!(
1679 source.headers,
1680 HashMap::from([("Metadata".to_string(), "True".to_string()),]),
1681 );
1682 assert_eq!(source.format, "json");
1683 assert_eq!(source.subject_token_field_name, "access_token");
1684 }
1685 _ => {
1686 unreachable!("expected Url Sourced credential")
1687 }
1688 }
1689 }
1690
1691 #[tokio::test]
1692 async fn create_external_account_detect_executable_sourced() {
1693 let contents = json!({
1694 "type": "external_account",
1695 "audience": "audience",
1696 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1697 "token_url": "https://sts.googleapis.com/v1beta/token",
1698 "credential_source": {
1699 "executable": {
1700 "command": "cat /some/file",
1701 "output_file": "/some/file",
1702 "timeout_millis": 5000
1703 }
1704 }
1705 });
1706
1707 let file: ExternalAccountFile =
1708 serde_json::from_value(contents).expect("failed to parse external account config");
1709 let config: ExternalAccountConfig = file.into();
1710 let source = config.credential_source;
1711
1712 match source {
1713 CredentialSource::Executable(source) => {
1714 assert_eq!(source.command, "cat");
1715 assert_eq!(source.args, vec!["/some/file"]);
1716 assert_eq!(source.output_file.as_deref(), Some("/some/file"));
1717 assert_eq!(source.timeout, Duration::from_secs(5));
1718 }
1719 _ => {
1720 unreachable!("expected Executable Sourced credential")
1721 }
1722 }
1723 }
1724
1725 #[tokio::test]
1726 async fn create_external_account_detect_file_sourced() {
1727 let contents = json!({
1728 "type": "external_account",
1729 "audience": "audience",
1730 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1731 "token_url": "https://sts.googleapis.com/v1beta/token",
1732 "credential_source": {
1733 "file": "/foo/bar",
1734 "format": {
1735 "type": "json",
1736 "subject_token_field_name": "token"
1737 }
1738 }
1739 });
1740
1741 let file: ExternalAccountFile =
1742 serde_json::from_value(contents).expect("failed to parse external account config");
1743 let config: ExternalAccountConfig = file.into();
1744 let source = config.credential_source;
1745
1746 match source {
1747 CredentialSource::File(source) => {
1748 assert_eq!(source.file, "/foo/bar");
1749 assert_eq!(source.format, "json");
1750 assert_eq!(source.subject_token_field_name, "token");
1751 }
1752 _ => {
1753 unreachable!("expected File Sourced credential")
1754 }
1755 }
1756 }
1757
1758 #[tokio::test]
1759 async fn test_external_account_with_impersonation_success() {
1760 let subject_token_server = Server::run();
1761 let sts_server = Server::run();
1762 let impersonation_server = Server::run();
1763
1764 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
1765 let contents = json!({
1766 "type": "external_account",
1767 "audience": "audience",
1768 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1769 "token_url": sts_server.url("/token").to_string(),
1770 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
1771 "credential_source": {
1772 "url": subject_token_server.url("/subject_token").to_string(),
1773 "format": {
1774 "type": "json",
1775 "subject_token_field_name": "access_token"
1776 }
1777 }
1778 });
1779
1780 subject_token_server.expect(
1781 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1782 json_encoded(json!({
1783 "access_token": "subject_token",
1784 })),
1785 ),
1786 );
1787
1788 sts_server.expect(
1789 Expectation::matching(all_of![
1790 request::method_path("POST", "/token"),
1791 request::body(url_decoded(contains((
1792 "grant_type",
1793 TOKEN_EXCHANGE_GRANT_TYPE
1794 )))),
1795 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1796 request::body(url_decoded(contains((
1797 "requested_token_type",
1798 ACCESS_TOKEN_TYPE
1799 )))),
1800 request::body(url_decoded(contains((
1801 "subject_token_type",
1802 JWT_TOKEN_TYPE
1803 )))),
1804 request::body(url_decoded(contains(("audience", "audience")))),
1805 request::body(url_decoded(contains(("scope", IAM_SCOPE)))),
1806 ])
1807 .respond_with(json_encoded(json!({
1808 "access_token": "sts-token",
1809 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1810 "token_type": "Bearer",
1811 "expires_in": 3600,
1812 }))),
1813 );
1814
1815 let expire_time = (OffsetDateTime::now_utc() + time::Duration::hours(1))
1816 .format(&time::format_description::well_known::Rfc3339)
1817 .unwrap();
1818 impersonation_server.expect(
1819 Expectation::matching(all_of![
1820 request::method_path("POST", impersonation_path),
1821 request::headers(contains(("authorization", "Bearer sts-token"))),
1822 ])
1823 .respond_with(json_encoded(json!({
1824 "accessToken": "final-impersonated-token",
1825 "expireTime": expire_time
1826 }))),
1827 );
1828
1829 let creds = Builder::new(contents).build().unwrap();
1830 let headers = creds.headers(Extensions::new()).await.unwrap();
1831 match headers {
1832 CacheableResource::New { data, .. } => {
1833 let token = data.get("authorization").unwrap().to_str().unwrap();
1834 assert_eq!(token, "Bearer final-impersonated-token");
1835 }
1836 CacheableResource::NotModified => panic!("Expected new headers"),
1837 }
1838 }
1839
1840 #[tokio::test]
1841 async fn test_external_account_without_impersonation_success() {
1842 let subject_token_server = Server::run();
1843 let sts_server = Server::run();
1844
1845 let contents = json!({
1846 "type": "external_account",
1847 "audience": "audience",
1848 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1849 "token_url": sts_server.url("/token").to_string(),
1850 "credential_source": {
1851 "url": subject_token_server.url("/subject_token").to_string(),
1852 "format": {
1853 "type": "json",
1854 "subject_token_field_name": "access_token"
1855 }
1856 }
1857 });
1858
1859 subject_token_server.expect(
1860 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1861 json_encoded(json!({
1862 "access_token": "subject_token",
1863 })),
1864 ),
1865 );
1866
1867 sts_server.expect(
1868 Expectation::matching(all_of![
1869 request::method_path("POST", "/token"),
1870 request::body(url_decoded(contains((
1871 "grant_type",
1872 TOKEN_EXCHANGE_GRANT_TYPE
1873 )))),
1874 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1875 request::body(url_decoded(contains((
1876 "requested_token_type",
1877 ACCESS_TOKEN_TYPE
1878 )))),
1879 request::body(url_decoded(contains((
1880 "subject_token_type",
1881 JWT_TOKEN_TYPE
1882 )))),
1883 request::body(url_decoded(contains(("audience", "audience")))),
1884 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
1885 ])
1886 .respond_with(json_encoded(json!({
1887 "access_token": "sts-only-token",
1888 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1889 "token_type": "Bearer",
1890 "expires_in": 3600,
1891 }))),
1892 );
1893
1894 let creds = Builder::new(contents).build().unwrap();
1895 let headers = creds.headers(Extensions::new()).await.unwrap();
1896 match headers {
1897 CacheableResource::New { data, .. } => {
1898 let token = data.get("authorization").unwrap().to_str().unwrap();
1899 assert_eq!(token, "Bearer sts-only-token");
1900 }
1901 CacheableResource::NotModified => panic!("Expected new headers"),
1902 }
1903 }
1904
1905 #[tokio::test]
1906 async fn test_external_account_access_token_credentials_success() {
1907 let server = Server::run();
1908
1909 let contents = json!({
1910 "type": "external_account",
1911 "audience": "audience",
1912 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1913 "token_url": server.url("/token").to_string(),
1914 "credential_source": {
1915 "url": server.url("/subject_token").to_string(),
1916 "format": {
1917 "type": "json",
1918 "subject_token_field_name": "access_token"
1919 }
1920 }
1921 });
1922
1923 server.expect(
1924 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1925 json_encoded(json!({
1926 "access_token": "subject_token",
1927 })),
1928 ),
1929 );
1930
1931 server.expect(
1932 Expectation::matching(all_of![
1933 request::method_path("POST", "/token"),
1934 request::body(url_decoded(contains((
1935 "grant_type",
1936 TOKEN_EXCHANGE_GRANT_TYPE
1937 )))),
1938 request::body(url_decoded(contains(("subject_token", "subject_token")))),
1939 request::body(url_decoded(contains((
1940 "requested_token_type",
1941 ACCESS_TOKEN_TYPE
1942 )))),
1943 request::body(url_decoded(contains((
1944 "subject_token_type",
1945 JWT_TOKEN_TYPE
1946 )))),
1947 request::body(url_decoded(contains(("audience", "audience")))),
1948 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
1949 ])
1950 .respond_with(json_encoded(json!({
1951 "access_token": "sts-only-token",
1952 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
1953 "token_type": "Bearer",
1954 "expires_in": 3600,
1955 }))),
1956 );
1957
1958 let creds = Builder::new(contents)
1959 .build_access_token_credentials()
1960 .unwrap();
1961 let access_token = creds.access_token().await.unwrap();
1962 assert_eq!(access_token.token, "sts-only-token");
1963 }
1964
1965 #[tokio::test]
1966 async fn test_impersonation_flow_sts_call_fails() {
1967 let subject_token_server = Server::run();
1968 let sts_server = Server::run();
1969 let impersonation_server = Server::run();
1970
1971 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
1972 let contents = json!({
1973 "type": "external_account",
1974 "audience": "audience",
1975 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
1976 "token_url": sts_server.url("/token").to_string(),
1977 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
1978 "credential_source": {
1979 "url": subject_token_server.url("/subject_token").to_string(),
1980 "format": {
1981 "type": "json",
1982 "subject_token_field_name": "access_token"
1983 }
1984 }
1985 });
1986
1987 subject_token_server.expect(
1988 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
1989 json_encoded(json!({
1990 "access_token": "subject_token",
1991 })),
1992 ),
1993 );
1994
1995 sts_server.expect(
1996 Expectation::matching(request::method_path("POST", "/token"))
1997 .respond_with(status_code(500)),
1998 );
1999
2000 let creds = Builder::new(contents).build().unwrap();
2001 let err = creds.headers(Extensions::new()).await.unwrap_err();
2002 let original_err = find_source_error::<CredentialsError>(&err).unwrap();
2003 assert!(
2004 original_err
2005 .to_string()
2006 .contains("failed to exchange token")
2007 );
2008 assert!(original_err.is_transient());
2009 }
2010
2011 #[tokio::test]
2012 async fn test_impersonation_flow_iam_call_fails() {
2013 let subject_token_server = Server::run();
2014 let sts_server = Server::run();
2015 let impersonation_server = Server::run();
2016
2017 let impersonation_path = "/projects/-/serviceAccounts/sa@test.com:generateAccessToken";
2018 let contents = json!({
2019 "type": "external_account",
2020 "audience": "audience",
2021 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2022 "token_url": sts_server.url("/token").to_string(),
2023 "service_account_impersonation_url": impersonation_server.url(impersonation_path).to_string(),
2024 "credential_source": {
2025 "url": subject_token_server.url("/subject_token").to_string(),
2026 "format": {
2027 "type": "json",
2028 "subject_token_field_name": "access_token"
2029 }
2030 }
2031 });
2032
2033 subject_token_server.expect(
2034 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2035 json_encoded(json!({
2036 "access_token": "subject_token",
2037 })),
2038 ),
2039 );
2040
2041 sts_server.expect(
2042 Expectation::matching(request::method_path("POST", "/token")).respond_with(
2043 json_encoded(json!({
2044 "access_token": "sts-token",
2045 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2046 "token_type": "Bearer",
2047 "expires_in": 3600,
2048 })),
2049 ),
2050 );
2051
2052 impersonation_server.expect(
2053 Expectation::matching(request::method_path("POST", impersonation_path))
2054 .respond_with(status_code(403)),
2055 );
2056
2057 let creds = Builder::new(contents).build().unwrap();
2058 let err = creds.headers(Extensions::new()).await.unwrap_err();
2059 let original_err = find_source_error::<CredentialsError>(&err).unwrap();
2060 assert!(original_err.to_string().contains("failed to fetch token"));
2061 assert!(!original_err.is_transient());
2062 }
2063
2064 #[test_case(Some(vec!["scope1", "scope2"]), Some("http://custom.com/token") ; "with custom scopes and token_url")]
2065 #[test_case(None, Some("http://custom.com/token") ; "with default scopes and custom token_url")]
2066 #[test_case(Some(vec!["scope1", "scope2"]), None ; "with custom scopes and default token_url")]
2067 #[test_case(None, None ; "with default scopes and default token_url")]
2068 #[tokio::test]
2069 async fn create_programmatic_builder(scopes: Option<Vec<&str>>, token_url: Option<&str>) {
2070 let provider = Arc::new(TestSubjectTokenProvider);
2071 let mut builder = ProgrammaticBuilder::new(provider)
2072 .with_audience("test-audience")
2073 .with_subject_token_type("test-token-type")
2074 .with_client_id("test-client-id")
2075 .with_client_secret("test-client-secret")
2076 .with_target_principal("test-principal");
2077
2078 let expected_scopes = if let Some(scopes) = scopes.clone() {
2079 scopes.iter().map(|s| s.to_string()).collect()
2080 } else {
2081 vec![DEFAULT_SCOPE.to_string()]
2082 };
2083
2084 let expected_token_url = token_url.unwrap_or(STS_TOKEN_URL).to_string();
2085
2086 if let Some(scopes) = scopes {
2087 builder = builder.with_scopes(scopes);
2088 }
2089 if let Some(token_url) = token_url {
2090 builder = builder.with_token_url(token_url);
2091 }
2092
2093 let (config, _, _) = builder.build_components().unwrap();
2094
2095 assert_eq!(config.audience, "test-audience");
2096 assert_eq!(config.subject_token_type, "test-token-type");
2097 assert_eq!(config.client_id, Some("test-client-id".to_string()));
2098 assert_eq!(config.client_secret, Some("test-client-secret".to_string()));
2099 assert_eq!(config.scopes, expected_scopes);
2100 assert_eq!(config.token_url, expected_token_url);
2101 assert_eq!(
2102 config.service_account_impersonation_url,
2103 Some("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-principal:generateAccessToken".to_string())
2104 );
2105 }
2106
2107 #[tokio::test]
2108 async fn create_programmatic_builder_with_quota_project_id() {
2109 let provider = Arc::new(TestSubjectTokenProvider);
2110 let builder = ProgrammaticBuilder::new(provider)
2111 .with_audience("test-audience")
2112 .with_subject_token_type("test-token-type")
2113 .with_token_url(STS_TOKEN_URL)
2114 .with_quota_project_id("test-quota-project");
2115
2116 let creds = builder.build().unwrap();
2117
2118 let fmt = format!("{creds:?}");
2119 assert!(
2120 fmt.contains("ExternalAccountCredentials"),
2121 "Expected 'ExternalAccountCredentials', got: {fmt}"
2122 );
2123 assert!(
2124 fmt.contains("test-quota-project"),
2125 "Expected 'test-quota-project', got: {fmt}"
2126 );
2127 }
2128
2129 #[tokio::test]
2130 async fn programmatic_builder_returns_correct_headers() {
2131 let provider = Arc::new(TestSubjectTokenProvider);
2132 let sts_server = Server::run();
2133 let builder = ProgrammaticBuilder::new(provider)
2134 .with_audience("test-audience")
2135 .with_subject_token_type("test-token-type")
2136 .with_token_url(sts_server.url("/token").to_string())
2137 .with_quota_project_id("test-quota-project");
2138
2139 let creds = builder.build().unwrap();
2140
2141 sts_server.expect(
2142 Expectation::matching(all_of![
2143 request::method_path("POST", "/token"),
2144 request::body(url_decoded(contains((
2145 "grant_type",
2146 TOKEN_EXCHANGE_GRANT_TYPE
2147 )))),
2148 request::body(url_decoded(contains((
2149 "subject_token",
2150 "test-subject-token"
2151 )))),
2152 request::body(url_decoded(contains((
2153 "requested_token_type",
2154 ACCESS_TOKEN_TYPE
2155 )))),
2156 request::body(url_decoded(contains((
2157 "subject_token_type",
2158 "test-token-type"
2159 )))),
2160 request::body(url_decoded(contains(("audience", "test-audience")))),
2161 request::body(url_decoded(contains(("scope", DEFAULT_SCOPE)))),
2162 ])
2163 .respond_with(json_encoded(json!({
2164 "access_token": "sts-only-token",
2165 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2166 "token_type": "Bearer",
2167 "expires_in": 3600,
2168 }))),
2169 );
2170
2171 let headers = creds.headers(Extensions::new()).await.unwrap();
2172 match headers {
2173 CacheableResource::New { data, .. } => {
2174 let token = data.get("authorization").unwrap().to_str().unwrap();
2175 assert_eq!(token, "Bearer sts-only-token");
2176 let quota_project = data.get("x-goog-user-project").unwrap().to_str().unwrap();
2177 assert_eq!(quota_project, "test-quota-project");
2178 }
2179 CacheableResource::NotModified => panic!("Expected new headers"),
2180 }
2181 }
2182
2183 #[tokio::test]
2184 async fn create_programmatic_builder_fails_on_missing_required_field() {
2185 let provider = Arc::new(TestSubjectTokenProvider);
2186 let result = ProgrammaticBuilder::new(provider)
2187 .with_subject_token_type("test-token-type")
2188 .with_token_url("http://test.com/token")
2190 .build();
2191
2192 assert!(result.is_err(), "{result:?}");
2193 let error_string = result.unwrap_err().to_string();
2194 assert!(
2195 error_string.contains("missing required field: audience"),
2196 "Expected error about missing 'audience', got: {error_string}"
2197 );
2198 }
2199
2200 #[tokio::test]
2201 async fn test_external_account_retries_on_transient_failures() {
2202 let mut subject_token_server = Server::run();
2203 let mut sts_server = Server::run();
2204
2205 let contents = json!({
2206 "type": "external_account",
2207 "audience": "audience",
2208 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2209 "token_url": sts_server.url("/token").to_string(),
2210 "credential_source": {
2211 "url": subject_token_server.url("/subject_token").to_string(),
2212 }
2213 });
2214
2215 subject_token_server.expect(
2216 Expectation::matching(request::method_path("GET", "/subject_token"))
2217 .times(3)
2218 .respond_with(json_encoded(json!({
2219 "access_token": "subject_token",
2220 }))),
2221 );
2222
2223 sts_server.expect(
2224 Expectation::matching(request::method_path("POST", "/token"))
2225 .times(3)
2226 .respond_with(status_code(503)),
2227 );
2228
2229 let creds = Builder::new(contents)
2230 .with_retry_policy(get_mock_auth_retry_policy(3))
2231 .with_backoff_policy(get_mock_backoff_policy())
2232 .with_retry_throttler(get_mock_retry_throttler())
2233 .build()
2234 .unwrap();
2235
2236 let err = creds.headers(Extensions::new()).await.unwrap_err();
2237 assert!(err.is_transient(), "{err:?}");
2238 sts_server.verify_and_clear();
2239 subject_token_server.verify_and_clear();
2240 }
2241
2242 #[tokio::test]
2243 async fn test_external_account_does_not_retry_on_non_transient_failures() {
2244 let subject_token_server = Server::run();
2245 let mut sts_server = Server::run();
2246
2247 let contents = json!({
2248 "type": "external_account",
2249 "audience": "audience",
2250 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2251 "token_url": sts_server.url("/token").to_string(),
2252 "credential_source": {
2253 "url": subject_token_server.url("/subject_token").to_string(),
2254 }
2255 });
2256
2257 subject_token_server.expect(
2258 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2259 json_encoded(json!({
2260 "access_token": "subject_token",
2261 })),
2262 ),
2263 );
2264
2265 sts_server.expect(
2266 Expectation::matching(request::method_path("POST", "/token"))
2267 .times(1)
2268 .respond_with(status_code(401)),
2269 );
2270
2271 let creds = Builder::new(contents)
2272 .with_retry_policy(get_mock_auth_retry_policy(1))
2273 .with_backoff_policy(get_mock_backoff_policy())
2274 .with_retry_throttler(get_mock_retry_throttler())
2275 .build()
2276 .unwrap();
2277
2278 let err = creds.headers(Extensions::new()).await.unwrap_err();
2279 assert!(!err.is_transient());
2280 sts_server.verify_and_clear();
2281 }
2282
2283 #[tokio::test]
2284 async fn test_external_account_retries_for_success() {
2285 let mut subject_token_server = Server::run();
2286 let mut sts_server = Server::run();
2287
2288 let contents = json!({
2289 "type": "external_account",
2290 "audience": "audience",
2291 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2292 "token_url": sts_server.url("/token").to_string(),
2293 "credential_source": {
2294 "url": subject_token_server.url("/subject_token").to_string(),
2295 }
2296 });
2297
2298 subject_token_server.expect(
2299 Expectation::matching(request::method_path("GET", "/subject_token"))
2300 .times(3)
2301 .respond_with(json_encoded(json!({
2302 "access_token": "subject_token",
2303 }))),
2304 );
2305
2306 sts_server.expect(
2307 Expectation::matching(request::method_path("POST", "/token"))
2308 .times(3)
2309 .respond_with(cycle![
2310 status_code(503).body("try-again"),
2311 status_code(503).body("try-again"),
2312 json_encoded(json!({
2313 "access_token": "sts-only-token",
2314 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2315 "token_type": "Bearer",
2316 "expires_in": 3600,
2317 }))
2318 ]),
2319 );
2320
2321 let creds = Builder::new(contents)
2322 .with_retry_policy(get_mock_auth_retry_policy(3))
2323 .with_backoff_policy(get_mock_backoff_policy())
2324 .with_retry_throttler(get_mock_retry_throttler())
2325 .build()
2326 .unwrap();
2327
2328 let headers = creds.headers(Extensions::new()).await.unwrap();
2329 match headers {
2330 CacheableResource::New { data, .. } => {
2331 let token = data.get("authorization").unwrap().to_str().unwrap();
2332 assert_eq!(token, "Bearer sts-only-token");
2333 }
2334 CacheableResource::NotModified => panic!("Expected new headers"),
2335 }
2336 sts_server.verify_and_clear();
2337 subject_token_server.verify_and_clear();
2338 }
2339
2340 #[tokio::test]
2341 async fn test_programmatic_builder_retries_on_transient_failures() {
2342 let provider = Arc::new(TestSubjectTokenProvider);
2343 let mut sts_server = Server::run();
2344
2345 sts_server.expect(
2346 Expectation::matching(request::method_path("POST", "/token"))
2347 .times(3)
2348 .respond_with(status_code(503)),
2349 );
2350
2351 let creds = ProgrammaticBuilder::new(provider)
2352 .with_audience("test-audience")
2353 .with_subject_token_type("test-token-type")
2354 .with_token_url(sts_server.url("/token").to_string())
2355 .with_retry_policy(get_mock_auth_retry_policy(3))
2356 .with_backoff_policy(get_mock_backoff_policy())
2357 .with_retry_throttler(get_mock_retry_throttler())
2358 .build()
2359 .unwrap();
2360
2361 let err = creds.headers(Extensions::new()).await.unwrap_err();
2362 assert!(err.is_transient(), "{err:?}");
2363 sts_server.verify_and_clear();
2364 }
2365
2366 #[tokio::test]
2367 async fn test_programmatic_builder_does_not_retry_on_non_transient_failures() {
2368 let provider = Arc::new(TestSubjectTokenProvider);
2369 let mut sts_server = Server::run();
2370
2371 sts_server.expect(
2372 Expectation::matching(request::method_path("POST", "/token"))
2373 .times(1)
2374 .respond_with(status_code(401)),
2375 );
2376
2377 let creds = ProgrammaticBuilder::new(provider)
2378 .with_audience("test-audience")
2379 .with_subject_token_type("test-token-type")
2380 .with_token_url(sts_server.url("/token").to_string())
2381 .with_retry_policy(get_mock_auth_retry_policy(1))
2382 .with_backoff_policy(get_mock_backoff_policy())
2383 .with_retry_throttler(get_mock_retry_throttler())
2384 .build()
2385 .unwrap();
2386
2387 let err = creds.headers(Extensions::new()).await.unwrap_err();
2388 assert!(!err.is_transient());
2389 sts_server.verify_and_clear();
2390 }
2391
2392 #[tokio::test]
2393 async fn test_programmatic_builder_retries_for_success() {
2394 let provider = Arc::new(TestSubjectTokenProvider);
2395 let mut sts_server = Server::run();
2396
2397 sts_server.expect(
2398 Expectation::matching(request::method_path("POST", "/token"))
2399 .times(3)
2400 .respond_with(cycle![
2401 status_code(503).body("try-again"),
2402 status_code(503).body("try-again"),
2403 json_encoded(json!({
2404 "access_token": "sts-only-token",
2405 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2406 "token_type": "Bearer",
2407 "expires_in": 3600,
2408 }))
2409 ]),
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(3))
2417 .with_backoff_policy(get_mock_backoff_policy())
2418 .with_retry_throttler(get_mock_retry_throttler())
2419 .build()
2420 .unwrap();
2421
2422 let headers = creds.headers(Extensions::new()).await.unwrap();
2423 match headers {
2424 CacheableResource::New { data, .. } => {
2425 let token = data.get("authorization").unwrap().to_str().unwrap();
2426 assert_eq!(token, "Bearer sts-only-token");
2427 }
2428 CacheableResource::NotModified => panic!("Expected new headers"),
2429 }
2430 sts_server.verify_and_clear();
2431 }
2432
2433 #[test_case(
2434 "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider",
2435 "/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations";
2436 "workload_identity_pool"
2437 )]
2438 #[test_case(
2439 "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider",
2440 "/v1/locations/global/workforcePools/my-pool/allowedLocations";
2441 "workforce_pool"
2442 )]
2443 #[tokio::test]
2444 #[cfg(google_cloud_unstable_trusted_boundaries)]
2445 async fn e2e_access_boundary(audience: &str, iam_path: &str) -> anyhow::Result<()> {
2446 use crate::credentials::tests::get_access_boundary_from_headers;
2447
2448 let audience = audience.to_string();
2449 let iam_path = iam_path.to_string();
2450
2451 let server = Server::run();
2452
2453 server.expect(
2454 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2455 json_encoded(json!({
2456 "access_token": "subject_token",
2457 })),
2458 ),
2459 );
2460
2461 server.expect(
2462 Expectation::matching(all_of![
2463 request::method_path("POST", "/token"),
2464 request::body(url_decoded(contains(("subject_token", "subject_token")))),
2465 request::body(url_decoded(contains(("audience", audience.clone())))),
2466 ])
2467 .respond_with(json_encoded(json!({
2468 "access_token": "sts-only-token",
2469 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2470 "token_type": "Bearer",
2471 "expires_in": 3600,
2472 }))),
2473 );
2474
2475 server.expect(
2476 Expectation::matching(all_of![request::method_path("GET", iam_path.clone()),])
2477 .respond_with(json_encoded(json!({
2478 "locations": ["us-central1"],
2479 "encodedLocations": "0x1234"
2480 }))),
2481 );
2482
2483 let contents = json!({
2484 "type": "external_account",
2485 "audience": audience.to_string(),
2486 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2487 "token_url": server.url("/token").to_string(),
2488 "credential_source": {
2489 "url": server.url("/subject_token").to_string(),
2490 "format": {
2491 "type": "json",
2492 "subject_token_field_name": "access_token"
2493 }
2494 }
2495 });
2496
2497 let iam_endpoint = server.url("").to_string().trim_end_matches('/').to_string();
2498
2499 let creds = Builder::new(contents)
2500 .maybe_iam_endpoint_override(Some(iam_endpoint))
2501 .build_credentials()?;
2502
2503 creds.wait_for_boundary().await;
2505
2506 let headers = creds.headers(Extensions::new()).await?;
2507 let token = get_token_from_headers(headers.clone());
2508 let access_boundary = get_access_boundary_from_headers(headers);
2509
2510 assert!(token.is_some(), "should have some token");
2511 assert_eq!(access_boundary.as_deref(), Some("0x1234"));
2512
2513 Ok(())
2514 }
2515
2516 #[tokio::test]
2517 async fn test_kubernetes_wif_direct_identity_parsing() {
2518 let contents = json!({
2519 "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool-name/providers/k8s-cluster",
2520 "credential_source": {
2521 "file": "/var/run/service-account/token"
2522 },
2523 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2524 "token_url": "https://sts.googleapis.com/v1/token",
2525 "type": "external_account"
2526 });
2527
2528 let file: ExternalAccountFile = serde_json::from_value(contents)
2529 .expect("failed to parse kubernetes WIF direct identity config");
2530 let config: ExternalAccountConfig = file.into();
2531
2532 match config.credential_source {
2533 CredentialSource::File(source) => {
2534 assert_eq!(source.file, "/var/run/service-account/token");
2535 assert_eq!(source.format, "text"); assert_eq!(source.subject_token_field_name, ""); }
2538 _ => {
2539 unreachable!("expected File sourced credential")
2540 }
2541 }
2542 }
2543
2544 #[tokio::test]
2545 async fn test_kubernetes_wif_impersonation_parsing() {
2546 let contents = json!({
2547 "audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/pool-name/providers/k8s-cluster",
2548 "credential_source": {
2549 "file": "/var/run/service-account/token",
2550 "format": {
2551 "type": "text"
2552 }
2553 },
2554 "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-sa@test-project.iam.gserviceaccount.com:generateAccessToken",
2555 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2556 "token_url": "https://sts.googleapis.com/v1/token",
2557 "type": "external_account",
2558 "universe_domain": "googleapis.com"
2559 });
2560
2561 let file: ExternalAccountFile = serde_json::from_value(contents)
2562 .expect("failed to parse kubernetes WIF impersonation config");
2563 let config: ExternalAccountConfig = file.into();
2564
2565 match config.credential_source {
2566 CredentialSource::File(source) => {
2567 assert_eq!(source.file, "/var/run/service-account/token");
2568 assert_eq!(source.format, "text");
2569 assert_eq!(source.subject_token_field_name, ""); }
2571 _ => {
2572 unreachable!("expected File sourced credential")
2573 }
2574 }
2575
2576 assert_eq!(
2577 config.service_account_impersonation_url,
2578 Some("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-sa@test-project.iam.gserviceaccount.com:generateAccessToken".to_string())
2579 );
2580 }
2581
2582 #[tokio::test]
2583 async fn test_aws_parsing() {
2584 let contents = json!({
2585 "audience": "audience",
2586 "credential_source": {
2587 "environment_id": "aws1",
2588 "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
2589 "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
2590 "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
2591 "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
2592 },
2593 "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
2594 "token_url": "https://sts.googleapis.com/v1/token",
2595 "type": "external_account"
2596 });
2597
2598 let file: ExternalAccountFile =
2599 serde_json::from_value(contents).expect("failed to parse AWS config");
2600 let config: ExternalAccountConfig = file.into();
2601
2602 match config.credential_source {
2603 CredentialSource::Aws(source) => {
2604 assert_eq!(
2605 source.region_url,
2606 Some(
2607 "http://169.254.169.254/latest/meta-data/placement/availability-zone"
2608 .to_string()
2609 )
2610 );
2611 assert_eq!(
2612 source.regional_cred_verification_url,
2613 Some(
2614 "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
2615 .to_string()
2616 )
2617 );
2618 assert_eq!(
2619 source.imdsv2_session_token_url,
2620 Some("http://169.254.169.254/latest/api/token".to_string())
2621 );
2622 }
2623 _ => {
2624 unreachable!("expected Aws sourced credential")
2625 }
2626 }
2627 }
2628
2629 #[tokio::test]
2630 async fn builder_workforce_pool_user_project_fails_without_workforce_pool_audience() {
2631 let contents = json!({
2632 "type": "external_account",
2633 "audience": "not-a-workforce-pool",
2634 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2635 "token_url": "http://test.com/token",
2636 "credential_source": {
2637 "url": "http://test.com/subject_token",
2638 },
2639 "workforce_pool_user_project": "test-project"
2640 });
2641
2642 let result = Builder::new(contents).build();
2643
2644 assert!(result.is_err(), "{result:?}");
2645 let err = result.unwrap_err();
2646 assert!(err.is_parsing(), "{err:?}");
2647 }
2648
2649 #[tokio::test]
2650 async fn sts_handler_ignores_workforce_pool_user_project_with_client_auth()
2651 -> std::result::Result<(), Box<dyn std::error::Error>> {
2652 let subject_token_server = Server::run();
2653 let sts_server = Server::run();
2654
2655 let contents = json!({
2656 "type": "external_account",
2657 "audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
2658 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2659 "token_url": sts_server.url("/token").to_string(),
2660 "client_id": "client-id",
2661 "credential_source": {
2662 "url": subject_token_server.url("/subject_token").to_string(),
2663 },
2664 "workforce_pool_user_project": "test-project"
2665 });
2666
2667 subject_token_server.expect(
2668 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2669 json_encoded(json!({
2670 "access_token": "subject_token",
2671 })),
2672 ),
2673 );
2674
2675 sts_server.expect(
2676 Expectation::matching(all_of![request::method_path("POST", "/token"),]).respond_with(
2677 json_encoded(json!({
2678 "access_token": "sts-only-token",
2679 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2680 "token_type": "Bearer",
2681 "expires_in": 3600,
2682 })),
2683 ),
2684 );
2685
2686 let creds = Builder::new(contents).build()?;
2687 let headers = creds.headers(Extensions::new()).await?;
2688 let token = get_token_from_headers(headers);
2689 assert_eq!(token.as_deref(), Some("sts-only-token"));
2690
2691 Ok(())
2692 }
2693
2694 #[tokio::test]
2695 async fn sts_handler_receives_workforce_pool_user_project()
2696 -> std::result::Result<(), Box<dyn std::error::Error>> {
2697 let subject_token_server = Server::run();
2698 let sts_server = Server::run();
2699
2700 let contents = json!({
2701 "type": "external_account",
2702 "audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
2703 "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
2704 "token_url": sts_server.url("/token").to_string(),
2705 "credential_source": {
2706 "url": subject_token_server.url("/subject_token").to_string(),
2707 },
2708 "workforce_pool_user_project": "test-project-123"
2709 });
2710
2711 subject_token_server.expect(
2712 Expectation::matching(request::method_path("GET", "/subject_token")).respond_with(
2713 json_encoded(json!({
2714 "access_token": "subject_token",
2715 })),
2716 ),
2717 );
2718
2719 sts_server.expect(
2720 Expectation::matching(all_of![
2721 request::method_path("POST", "/token"),
2722 request::body(url_decoded(contains((
2723 "options",
2724 "{\"userProject\":\"test-project-123\"}"
2725 )))),
2726 ])
2727 .respond_with(json_encoded(json!({
2728 "access_token": "sts-only-token",
2729 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
2730 "token_type": "Bearer",
2731 "expires_in": 3600,
2732 }))),
2733 );
2734
2735 let creds = Builder::new(contents).build()?;
2736 let headers = creds.headers(Extensions::new()).await?;
2737 let token = get_token_from_headers(headers);
2738 assert_eq!(token.as_deref(), Some("sts-only-token"));
2739
2740 Ok(())
2741 }
2742}