Skip to main content

google_cloud_auth/credentials/
external_account.rs

1// Copyright 2025 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! [Workload Identity Federation] (or External Account) credentials.
16//!
17//! Workload identity federation allows applications running outside of Google Cloud
18//! to access Google Cloud resources without using a [Service Account Key]. Instead of
19//! a long-lived credential (like a service account key), you can exchange a credential
20//! from your workload's identity provider for a short-lived Google Cloud access token.
21//! As per [best practices], Workload Identity Federation is the recommended method of
22//! authentication if your workload is using an external identity provider.
23//!
24//! Refer to [Obtain short-lived tokens for Workforce Identity Federation] for
25//! creating configurations that can be used with this library for loading credentials
26//! using various external toke provider sources such as file, URL, or an executable.
27//!
28//! ## Example: Creating credentials from a JSON object
29//!
30//! ```
31//! # use google_cloud_auth::credentials::external_account;
32//! # use http::Extensions;
33//! #
34//! # async fn sample() -> anyhow::Result<()> {
35//! let project_id = "your-gcp-project-id";
36//! let pool_id = "your-workload-identity-pool-id";
37//! let provider_id = "your-provider-id";
38//!
39//! let audience = format!(
40//!     "//iam.googleapis.com/projects/{}/locations/global/workloadIdentityPools/{}/providers/{}",
41//!     project_id, pool_id, provider_id
42//! );
43//!
44//! // This is an example of a configuration for a file-sourced credential.
45//! // The actual configuration will depend on your identity provider.
46//! let external_account_config = serde_json::json!({
47//!     "type": "external_account",
48//!     "audience": audience,
49//!     "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
50//!     "token_url": "https://sts.googleapis.com/v1/token",
51//!     "credential_source": {
52//!         "file": "/path/to/your/oidc/token.jwt"
53//!     }
54//! });
55//!
56//! let credentials = external_account::Builder::new(external_account_config)
57//!     .build()
58//!     .unwrap();
59//! let headers = credentials.headers(Extensions::new()).await?;
60//! println!("Headers: {headers:?}");
61//! # Ok(()) }
62//! ```
63//!
64//! ## Example: Creating credentials with custom retry behavior
65//!
66//! ```
67//! # use google_cloud_auth::credentials::external_account;
68//! # use http::Extensions;
69//! # use std::time::Duration;
70//! # async fn sample() -> anyhow::Result<()> {
71//! use google_cloud_gax::retry_policy::{AlwaysRetry, RetryPolicyExt};
72//! use google_cloud_gax::exponential_backoff::ExponentialBackoff;
73//! # let project_id = "your-gcp-project-id";
74//! # let pool_id = "your-workload-identity-pool-id";
75//! # let provider_id = "your-provider-id";
76//! #
77//! # let audience = format!(
78//! #     "//iam.googleapis.com/projects/{}/locations/global/workloadIdentityPools/{}/providers/{}",
79//! #     project_id, pool_id, provider_id
80//! # );
81//! #
82//! # let external_account_config = serde_json::json!({
83//! #     "type": "external_account",
84//! #     "audience": audience,
85//! #     "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
86//! #     "token_url": "https://sts.googleapis.com/v1/token",
87//! #     "credential_source": {
88//! #         "file": "/path/to/your/oidc/token.jwt"
89//! #     }
90//! # });
91//! let backoff = ExponentialBackoff::default();
92//! let credentials = external_account::Builder::new(external_account_config)
93//!     .with_retry_policy(AlwaysRetry.with_attempt_limit(3))
94//!     .with_backoff_policy(backoff)
95//!     .build()
96//!     .unwrap();
97//! let headers = credentials.headers(Extensions::new()).await?;
98//! println!("Headers: {headers:?}");
99//! # Ok(()) }
100//! ```
101//!
102//! [Workload Identity Federation]: https://cloud.google.com/iam/docs/workload-identity-federation
103//! [AIP-4117]: https://google.aip.dev/auth/4117
104//! [Service Account Key]: https://cloud.google.com/iam/docs/service-account-creds#key-types
105//! [best practices]: https://cloud.google.com/docs/authentication#auth-decision-tree
106//! [Obtain short-lived tokens for Workforce Identity Federation]: https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#use_configuration_files_for_sign-in
107
108use 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    // Most specific variants first for untagged enum
160    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
181/// Check if the audience parameter matches the pattern for a valid workforce pool.
182/// Example: https://iam.googleapis.com/locations/global/workforcePools/pool/providers/provider
183fn 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/// A representation of a [external account config file].
201///
202/// [external account config file]: https://google.aip.dev/auth/4117#configuration-file-generation-and-usage
203#[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        // User provides the scopes to be set on the final token they receive. The scopes they
480        // provide does not necessarily have to include the IAM scope or cloud-platform scope
481        // which are needed to make the call to `iamcredentials` endpoint. So when minting the
482        // STS token, we use the user provided or IAM scope depending on whether the STS provided
483        // token is to be used with impersonation or directly.
484        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
544/// A builder for external account [Credentials] instances.
545///
546/// # Example
547/// ```
548/// # use google_cloud_auth::credentials::external_account::Builder;
549/// # async fn sample() -> anyhow::Result<()> {
550/// let project_id = project_id();
551/// let workload_identity_pool_id = workload_identity_pool();
552/// let provider_id = workload_identity_provider();
553/// let provider_name = format!(
554///     "//iam.googleapis.com/projects/{project_id}/locations/global/workloadIdentityPools/{workload_identity_pool_id}/providers/{provider_id}"
555/// );
556/// let config = serde_json::json!({
557///     "type": "external_account",
558///     "audience": provider_name,
559///     "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
560///     "token_url": "https://sts.googleapis.com/v1beta/token",
561///     "credential_source": {
562///         "url": format!("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={provider_name}"),
563///         "headers": {
564///           "Metadata": "True"
565///         },
566///         "format": {
567///           "type": "json",
568///           "subject_token_field_name": "access_token"
569///         }
570///     }
571/// });
572/// let credentials = Builder::new(config)
573///     .with_quota_project_id("quota_project")
574///     .build()?;
575/// # Ok(()) }
576///
577/// # fn project_id() -> String {
578/// #     "test-only".to_string()
579/// # }
580/// # fn workload_identity_pool() -> String {
581/// #     "test-only".to_string()
582/// # }
583/// # fn workload_identity_provider() -> String {
584/// #     "test-only".to_string()
585/// # }
586/// ```
587pub 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    /// Creates a new builder using [external_account_credentials] JSON value.
598    ///
599    /// [external_account_credentials]: https://google.aip.dev/auth/4117#configuration-file-generation-and-usage
600    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    /// Sets the [quota project] for this credentials.
612    ///
613    /// In some services, you can use a service account in
614    /// one project for authentication and authorization, and charge
615    /// the usage to a different project. This requires that the
616    /// service account has `serviceusage.services.use` permissions on the quota project.
617    ///
618    /// [quota project]: https://cloud.google.com/docs/quotas/quota-project
619    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    /// Sets the Google Cloud universe domain for these credentials.
625    ///
626    /// Any value provided here overrides a `universe_domain` value from the input service account JSON.
627    // TODO(#3646): Make this public and let example run when universe domain support is done.
628    #[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    /// Overrides the [scopes] for this credentials.
635    ///
636    /// [scopes]: https://developers.google.com/identity/protocols/oauth2/scopes
637    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    /// Configure the retry policy for fetching tokens.
647    ///
648    /// The retry policy controls how to handle retries, and sets limits on
649    /// the number of attempts or the total time spent retrying.
650    ///
651    /// ```
652    /// # use google_cloud_auth::credentials::external_account::Builder;
653    /// # async fn sample() -> anyhow::Result<()> {
654    /// use google_cloud_gax::retry_policy::{AlwaysRetry, RetryPolicyExt};
655    /// let config = serde_json::json!({
656    ///     "type": "external_account",
657    ///     "audience": "audience",
658    ///     "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
659    ///     "token_url": "https://sts.googleapis.com/v1beta/token",
660    ///     "credential_source": { "file": "/path/to/your/oidc/token.jwt" }
661    /// });
662    /// let credentials = Builder::new(config)
663    ///     .with_retry_policy(AlwaysRetry.with_attempt_limit(3))
664    ///     .build()?;
665    /// # Ok(()) }
666    /// ```
667    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    /// Configure the retry backoff policy.
673    ///
674    /// The backoff policy controls how long to wait in between retry attempts.
675    ///
676    /// ```
677    /// # use google_cloud_auth::credentials::external_account::Builder;
678    /// # use std::time::Duration;
679    /// # async fn sample() -> anyhow::Result<()> {
680    /// use google_cloud_gax::exponential_backoff::ExponentialBackoff;
681    /// let config = serde_json::json!({
682    ///     "type": "external_account",
683    ///     "audience": "audience",
684    ///     "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
685    ///     "token_url": "https://sts.googleapis.com/v1beta/token",
686    ///     "credential_source": { "file": "/path/to/your/oidc/token.jwt" }
687    /// });
688    /// let policy = ExponentialBackoff::default();
689    /// let credentials = Builder::new(config)
690    ///     .with_backoff_policy(policy)
691    ///     .build()?;
692    /// # Ok(()) }
693    /// ```
694    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    /// Configure the retry throttler.
700    ///
701    /// Advanced applications may want to configure a retry throttler to
702    /// [Address Cascading Failures] and when [Handling Overload] conditions.
703    /// The authentication library throttles its retry loop, using a policy to
704    /// control the throttling algorithm. Use this method to fine tune or
705    /// customize the default retry throttler.
706    ///
707    /// [Handling Overload]: https://sre.google/sre-book/handling-overload/
708    /// [Address Cascading Failures]: https://sre.google/sre-book/addressing-cascading-failures/
709    ///
710    /// ```
711    /// # use google_cloud_auth::credentials::external_account::Builder;
712    /// # async fn sample() -> anyhow::Result<()> {
713    /// use google_cloud_gax::retry_throttler::AdaptiveThrottler;
714    /// let config = serde_json::json!({
715    ///     "type": "external_account",
716    ///     "audience": "audience",
717    ///     "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
718    ///     "token_url": "https://sts.googleapis.com/v1beta/token",
719    ///     "credential_source": { "file": "/path/to/your/oidc/token.jwt" }
720    /// });
721    /// let credentials = Builder::new(config)
722    ///     .with_retry_throttler(AdaptiveThrottler::default())
723    ///     .build()?;
724    /// # Ok(()) }
725    /// ```
726    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    /// Returns a [Credentials] instance with the configured settings.
738    ///
739    /// # Errors
740    ///
741    /// Returns a [BuilderError] if the `external_account_config`
742    /// provided to [`Builder::new`] cannot be successfully deserialized into the
743    /// expected format for an external account configuration. This typically happens if the
744    /// JSON value is malformed or missing required fields.
745    ///
746    /// For more information, on the expected format, consult the relevant
747    /// section in the [external_account_credentials] guide.
748    ///
749    /// [external_account_credentials]: https://google.aip.dev/auth/4117#configuration-file-generation-and-usage
750    pub fn build(self) -> BuildResult<Credentials> {
751        Ok(self.build_credentials()?.into())
752    }
753
754    /// Returns an [AccessTokenCredentials] instance with the configured settings.
755    ///
756    /// # Errors
757    ///
758    /// Returns a [BuilderError] if the `external_account_config`
759    /// provided to [`Builder::new`] cannot be successfully deserialized into the
760    /// expected format for an external account configuration. This typically happens if the
761    /// JSON value is malformed or missing required fields.
762    ///
763    /// For more information, on the expected format, consult the relevant
764    /// section in the [external_account_credentials] guide.
765    ///
766    /// [external_account_credentials]: https://google.aip.dev/auth/4117#configuration-file-generation-and-usage
767    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
809/// A builder for external account [Credentials] that uses a user provided subject
810/// token provider.
811///
812/// This builder is designed for advanced use cases where the subject token is
813/// provided directly by the application through a custom implementation of the
814/// [SubjectTokenProvider] trait.
815///
816/// # Example
817///
818/// ```
819/// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
820/// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
821/// # use google_cloud_auth::errors::SubjectTokenProviderError;
822/// # use std::error::Error;
823/// # use std::fmt;
824/// # use std::sync::Arc;
825/// #
826/// # #[derive(Debug)]
827/// # struct MyTokenProvider;
828/// #
829/// # #[derive(Debug)]
830/// # struct MyProviderError;
831/// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
832/// # impl Error for MyProviderError {}
833/// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
834/// #
835/// # impl SubjectTokenProvider for MyTokenProvider {
836/// #     type Error = MyProviderError;
837/// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
838/// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
839/// #     }
840/// # }
841/// #
842/// # async fn sample() -> anyhow::Result<()> {
843/// let provider = Arc::new(MyTokenProvider);
844///
845/// let credentials = ProgrammaticBuilder::new(provider)
846///     .with_audience("//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/my-pool/providers/my-provider".to_string())
847///     .with_subject_token_type("urn:ietf:params:oauth:token-type:jwt".to_string())
848///     .with_token_url("https://sts.googleapis.com/v1beta/token".to_string())
849///     .with_quota_project_id("my-quota-project")
850///     .with_scopes(vec!["https://www.googleapis.com/auth/devstorage.read_only".to_string()])
851///     .build()
852///     .unwrap();
853/// # Ok(()) }
854/// ```
855/// [SubjectTokenProvider]: crate::credentials::subject_token::SubjectTokenProvider
856pub struct ProgrammaticBuilder {
857    quota_project_id: Option<String>,
858    config: ExternalAccountConfigBuilder,
859    retry_builder: RetryTokenProviderBuilder,
860}
861
862impl ProgrammaticBuilder {
863    /// Creates a new builder that uses the provided [SubjectTokenProvider] to
864    /// fetch the third-party subject token.
865    ///
866    /// # Example
867    ///
868    /// ```
869    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
870    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
871    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
872    /// # use std::error::Error;
873    /// # use std::fmt;
874    /// # use std::sync::Arc;
875    /// #
876    /// # #[derive(Debug)]
877    /// # struct MyTokenProvider;
878    /// #
879    /// # #[derive(Debug)]
880    /// # struct MyProviderError;
881    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
882    /// # impl Error for MyProviderError {}
883    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
884    /// #
885    /// # impl SubjectTokenProvider for MyTokenProvider {
886    /// #     type Error = MyProviderError;
887    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
888    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
889    /// #     }
890    /// # }
891    /// let provider = Arc::new(MyTokenProvider);
892    /// let builder = ProgrammaticBuilder::new(provider);
893    /// ```
894    /// [SubjectTokenProvider]: crate::credentials::subject_token::SubjectTokenProvider
895    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    /// Sets the optional [quota project] for this credentials.
909    ///
910    /// In some services, you can use a service account in
911    /// one project for authentication and authorization, and charge
912    /// the usage to a different project. This requires that the
913    /// service account has `serviceusage.services.use` permissions on the quota project.
914    ///
915    /// # Example
916    ///
917    /// ```
918    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
919    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
920    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
921    /// # use std::error::Error;
922    /// # use std::fmt;
923    /// # use std::sync::Arc;
924    /// #
925    /// # #[derive(Debug)]
926    /// # struct MyTokenProvider;
927    /// #
928    /// # #[derive(Debug)]
929    /// # struct MyProviderError;
930    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
931    /// # impl Error for MyProviderError {}
932    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
933    /// #
934    /// # impl SubjectTokenProvider for MyTokenProvider {
935    /// #     type Error = MyProviderError;
936    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
937    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
938    /// #     }
939    /// # }
940    /// # let provider = Arc::new(MyTokenProvider);
941    /// let builder = ProgrammaticBuilder::new(provider)
942    ///     .with_quota_project_id("my-quota-project");
943    /// ```
944    ///
945    /// [quota project]: https://cloud.google.com/docs/quotas/quota-project
946    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    /// Overrides the optional [scopes] for this credentials. If this method is not
952    /// called, a default scope will be used.
953    ///
954    /// # Example
955    ///
956    /// ```
957    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
958    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
959    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
960    /// # use std::error::Error;
961    /// # use std::fmt;
962    /// # use std::sync::Arc;
963    /// #
964    /// # #[derive(Debug)]
965    /// # struct MyTokenProvider;
966    /// #
967    /// # #[derive(Debug)]
968    /// # struct MyProviderError;
969    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
970    /// # impl Error for MyProviderError {}
971    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
972    /// #
973    /// # impl SubjectTokenProvider for MyTokenProvider {
974    /// #     type Error = MyProviderError;
975    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
976    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
977    /// #     }
978    /// # }
979    /// # let provider = Arc::new(MyTokenProvider);
980    /// let builder = ProgrammaticBuilder::new(provider)
981    ///     .with_scopes(vec!["scope1", "scope2"]);
982    /// ```
983    ///
984    /// [scopes]: https://developers.google.com/identity/protocols/oauth2/scopes
985    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    /// Sets the Google Cloud universe domain for these credentials.
1000    ///
1001    /// Any value provided here overrides a `universe_domain` value from the input service account JSON.
1002    // TODO(#3646): Make this public and let example run when universe domain support is done.
1003    #[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    /// Sets the required audience for the token exchange.
1010    ///
1011    /// This is the resource name for the workload identity pool and the provider
1012    /// identifier in that pool.
1013    ///
1014    /// # Example
1015    ///
1016    /// ```
1017    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1018    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1019    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1020    /// # use std::error::Error;
1021    /// # use std::fmt;
1022    /// # use std::sync::Arc;
1023    /// #
1024    /// # #[derive(Debug)]
1025    /// # struct MyTokenProvider;
1026    /// #
1027    /// # #[derive(Debug)]
1028    /// # struct MyProviderError;
1029    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1030    /// # impl Error for MyProviderError {}
1031    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1032    /// #
1033    /// # impl SubjectTokenProvider for MyTokenProvider {
1034    /// #     type Error = MyProviderError;
1035    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1036    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1037    /// #     }
1038    /// # }
1039    /// # let provider = Arc::new(MyTokenProvider);
1040    /// let builder = ProgrammaticBuilder::new(provider)
1041    ///     .with_audience("my-audience");
1042    /// ```
1043    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    /// Sets the required subject token type.
1049    ///
1050    /// This is the STS subject token type based on the OAuth 2.0 token exchange spec.
1051    ///
1052    /// # Example
1053    ///
1054    /// ```
1055    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1056    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1057    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1058    /// # use std::error::Error;
1059    /// # use std::fmt;
1060    /// # use std::sync::Arc;
1061    /// #
1062    /// # #[derive(Debug)]
1063    /// # struct MyTokenProvider;
1064    /// #
1065    /// # #[derive(Debug)]
1066    /// # struct MyProviderError;
1067    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1068    /// # impl Error for MyProviderError {}
1069    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1070    /// #
1071    /// # impl SubjectTokenProvider for MyTokenProvider {
1072    /// #     type Error = MyProviderError;
1073    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1074    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1075    /// #     }
1076    /// # }
1077    /// # let provider = Arc::new(MyTokenProvider);
1078    /// let builder = ProgrammaticBuilder::new(provider)
1079    ///     .with_subject_token_type("my-token-type");
1080    /// ```
1081    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    /// Sets the optional token URL for the STS token exchange. If not provided,
1087    /// `https://sts.googleapis.com/v1/token` is used.
1088    ///
1089    /// # Example
1090    ///
1091    /// ```
1092    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1093    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1094    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1095    /// # use std::error::Error;
1096    /// # use std::fmt;
1097    /// # use std::sync::Arc;
1098    /// #
1099    /// # #[derive(Debug)]
1100    /// # struct MyTokenProvider;
1101    /// #
1102    /// # #[derive(Debug)]
1103    /// # struct MyProviderError;
1104    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1105    /// # impl Error for MyProviderError {}
1106    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1107    /// #
1108    /// # impl SubjectTokenProvider for MyTokenProvider {
1109    /// #     type Error = MyProviderError;
1110    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1111    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1112    /// #     }
1113    /// # }
1114    /// # let provider = Arc::new(MyTokenProvider);
1115    /// let builder = ProgrammaticBuilder::new(provider)
1116    ///     .with_token_url("http://my-token-url.com");
1117    /// ```
1118    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    /// Sets the optional client ID for client authentication.
1124    ///
1125    /// # Example
1126    ///
1127    /// ```
1128    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1129    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1130    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1131    /// # use std::error::Error;
1132    /// # use std::fmt;
1133    /// # use std::sync::Arc;
1134    /// #
1135    /// # #[derive(Debug)]
1136    /// # struct MyTokenProvider;
1137    /// #
1138    /// # #[derive(Debug)]
1139    /// # struct MyProviderError;
1140    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1141    /// # impl Error for MyProviderError {}
1142    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1143    /// #
1144    /// # impl SubjectTokenProvider for MyTokenProvider {
1145    /// #     type Error = MyProviderError;
1146    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1147    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1148    /// #     }
1149    /// # }
1150    /// # let provider = Arc::new(MyTokenProvider);
1151    /// let builder = ProgrammaticBuilder::new(provider)
1152    ///     .with_client_id("my-client-id");
1153    /// ```
1154    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    /// Sets the optional client secret for client authentication.
1160    ///
1161    /// # Example
1162    ///
1163    /// ```
1164    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1165    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1166    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1167    /// # use std::error::Error;
1168    /// # use std::fmt;
1169    /// # use std::sync::Arc;
1170    /// #
1171    /// # #[derive(Debug)]
1172    /// # struct MyTokenProvider;
1173    /// #
1174    /// # #[derive(Debug)]
1175    /// # struct MyProviderError;
1176    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1177    /// # impl Error for MyProviderError {}
1178    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1179    /// #
1180    /// # impl SubjectTokenProvider for MyTokenProvider {
1181    /// #     type Error = MyProviderError;
1182    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1183    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1184    /// #     }
1185    /// # }
1186    /// # let provider = Arc::new(MyTokenProvider);
1187    /// let builder = ProgrammaticBuilder::new(provider)
1188    ///     .with_client_secret("my-client-secret");
1189    /// ```
1190    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    /// Sets the optional target principal.
1196    ///
1197    /// Target principal is the email of the service account to impersonate.
1198    ///
1199    /// # Example
1200    ///
1201    /// ```
1202    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1203    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1204    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1205    /// # use std::error::Error;
1206    /// # use std::fmt;
1207    /// # use std::sync::Arc;
1208    /// #
1209    /// # #[derive(Debug)]
1210    /// # struct MyTokenProvider;
1211    /// #
1212    /// # #[derive(Debug)]
1213    /// # struct MyProviderError;
1214    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1215    /// # impl Error for MyProviderError {}
1216    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1217    /// #
1218    /// # impl SubjectTokenProvider for MyTokenProvider {
1219    /// #     type Error = MyProviderError;
1220    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1221    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1222    /// #     }
1223    /// # }
1224    /// # let provider = Arc::new(MyTokenProvider);
1225    /// let builder = ProgrammaticBuilder::new(provider)
1226    ///     .with_target_principal("test-principal");
1227    /// ```
1228    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    /// Sets the optional workforce pool user project.
1238    ///
1239    /// This is the project ID used for quota and billing in the workforce pool context.
1240    ///
1241    /// # Example
1242    ///
1243    /// ```
1244    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1245    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1246    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1247    /// # use std::error::Error;
1248    /// # use std::fmt;
1249    /// # use std::sync::Arc;
1250    /// #
1251    /// # #[derive(Debug)]
1252    /// # struct MyTokenProvider;
1253    /// #
1254    /// # #[derive(Debug)]
1255    /// # struct MyProviderError;
1256    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1257    /// # impl Error for MyProviderError {}
1258    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1259    /// #
1260    /// # impl SubjectTokenProvider for MyTokenProvider {
1261    /// #     type Error = MyProviderError;
1262    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1263    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1264    /// #     }
1265    /// # }
1266    /// # let provider = Arc::new(MyTokenProvider);
1267    /// let builder = ProgrammaticBuilder::new(provider)
1268    ///     .with_workforce_pool_user_project("my-project-id");
1269    /// ```
1270    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    /// Configure the retry policy for fetching tokens.
1276    ///
1277    /// The retry policy controls how to handle retries, and sets limits on
1278    /// the number of attempts or the total time spent retrying.
1279    ///
1280    /// ```
1281    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1282    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1283    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1284    /// # use std::error::Error;
1285    /// # use std::fmt;
1286    /// # use std::sync::Arc;
1287    /// #
1288    /// # #[derive(Debug)]
1289    /// # struct MyTokenProvider;
1290    /// #
1291    /// # #[derive(Debug)]
1292    /// # struct MyProviderError;
1293    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1294    /// # impl Error for MyProviderError {}
1295    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1296    /// #
1297    /// # impl SubjectTokenProvider for MyTokenProvider {
1298    /// #     type Error = MyProviderError;
1299    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1300    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1301    /// #     }
1302    /// # }
1303    /// #
1304    /// # async fn sample() -> anyhow::Result<()> {
1305    /// use google_cloud_gax::retry_policy::{AlwaysRetry, RetryPolicyExt};
1306    /// let provider = Arc::new(MyTokenProvider);
1307    /// let credentials = ProgrammaticBuilder::new(provider)
1308    ///     .with_audience("test-audience")
1309    ///     .with_subject_token_type("test-token-type")
1310    ///     .with_retry_policy(AlwaysRetry.with_attempt_limit(3))
1311    ///     .build()?;
1312    /// # Ok(()) }
1313    /// ```
1314    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    /// Configure the retry backoff policy.
1320    ///
1321    /// The backoff policy controls how long to wait in between retry attempts.
1322    ///
1323    /// ```
1324    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1325    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1326    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1327    /// # use std::error::Error;
1328    /// # use std::fmt;
1329    /// # use std::sync::Arc;
1330    /// #
1331    /// # #[derive(Debug)]
1332    /// # struct MyTokenProvider;
1333    /// #
1334    /// # #[derive(Debug)]
1335    /// # struct MyProviderError;
1336    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1337    /// # impl Error for MyProviderError {}
1338    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1339    /// #
1340    /// # impl SubjectTokenProvider for MyTokenProvider {
1341    /// #     type Error = MyProviderError;
1342    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1343    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1344    /// #     }
1345    /// # }
1346    /// #
1347    /// # async fn sample() -> anyhow::Result<()> {
1348    /// use google_cloud_gax::exponential_backoff::ExponentialBackoff;
1349    /// let provider = Arc::new(MyTokenProvider);
1350    /// let policy = ExponentialBackoff::default();
1351    /// let credentials = ProgrammaticBuilder::new(provider)
1352    ///     .with_audience("test-audience")
1353    ///     .with_subject_token_type("test-token-type")
1354    ///     .with_backoff_policy(policy)
1355    ///     .build()?;
1356    /// # Ok(()) }
1357    /// ```
1358    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    /// Configure the retry throttler.
1364    ///
1365    /// Advanced applications may want to configure a retry throttler to
1366    /// [Address Cascading Failures] and when [Handling Overload] conditions.
1367    /// The authentication library throttles its retry loop, using a policy to
1368    /// control the throttling algorithm. Use this method to fine tune or
1369    /// customize the default retry throttler.
1370    ///
1371    /// [Handling Overload]: https://sre.google/sre-book/handling-overload/
1372    /// [Address Cascading Failures]: https://sre.google/sre-book/addressing-cascading-failures/
1373    ///
1374    /// ```
1375    /// # use google_cloud_auth::credentials::external_account::ProgrammaticBuilder;
1376    /// # use google_cloud_auth::credentials::subject_token::{SubjectTokenProvider, SubjectToken, Builder as SubjectTokenBuilder};
1377    /// # use google_cloud_auth::errors::SubjectTokenProviderError;
1378    /// # use std::error::Error;
1379    /// # use std::fmt;
1380    /// # use std::sync::Arc;
1381    /// #
1382    /// # #[derive(Debug)]
1383    /// # struct MyTokenProvider;
1384    /// #
1385    /// # #[derive(Debug)]
1386    /// # struct MyProviderError;
1387    /// # impl fmt::Display for MyProviderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "MyProviderError") } }
1388    /// # impl Error for MyProviderError {}
1389    /// # impl SubjectTokenProviderError for MyProviderError { fn is_transient(&self) -> bool { false } }
1390    /// #
1391    /// # impl SubjectTokenProvider for MyTokenProvider {
1392    /// #     type Error = MyProviderError;
1393    /// #     async fn subject_token(&self) -> Result<SubjectToken, Self::Error> {
1394    /// #         Ok(SubjectTokenBuilder::new("my-programmatic-token".to_string()).build())
1395    /// #     }
1396    /// # }
1397    /// #
1398    /// # async fn sample() -> anyhow::Result<()> {
1399    /// use google_cloud_gax::retry_throttler::AdaptiveThrottler;
1400    /// let provider = Arc::new(MyTokenProvider);
1401    /// let credentials = ProgrammaticBuilder::new(provider)
1402    ///     .with_audience("test-audience")
1403    ///     .with_subject_token_type("test-token-type")
1404    ///     .with_retry_throttler(AdaptiveThrottler::default())
1405    ///     .build()?;
1406    /// # Ok(()) }
1407    /// ```
1408    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    /// Returns a [Credentials] instance with the configured settings.
1414    ///
1415    /// # Errors
1416    ///
1417    /// Returns a [BuilderError] if any of the required fields (such as
1418    /// `audience` or `subject_token_type`) have not been set.
1419    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    /// Consumes the builder and returns its configured components.
1428    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        // Use the debug output to verify the right kind of credentials are created.
1561        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            // Missing .with_audience(...)
2189            .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        // wait for the access boundary background thread to update
2504        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"); // Default format
2536                assert_eq!(source.subject_token_field_name, ""); // Default empty
2537            }
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, ""); // Empty for text format
2570            }
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}